feat(aigc): fully integrate backend API and replace mock data

- Replace all static mock data with real backend API calls for templates, generation tasks, credit balance and recharge
- Remove deprecated mock data files including templates.js, versionOptions.js, records.js and packages.js
- Add useCurrentCredit composable for fetching and managing user credit balance
- Update all components to align with new backend data shapes (e.g. templateItemId instead of id)
- Add loading and disabled states for interactive UI elements
- Implement full WeChat mini-program payment flow for credit recharge
- Fix template and record card UI styling and media handling
- Set developVersion flag to true for testing environment
This commit is contained in:
duanshuwen
2026-07-12 21:14:22 +08:00
parent 7db07273af
commit cadddb5c3f
29 changed files with 655 additions and 303 deletions

View File

@@ -8,30 +8,44 @@
:is-mask-click="false"
>
<view class="photo-pick-drawer">
<view class="photo-pick-close" @tap="emit('close')">
<uni-icons type="closeempty" size="30" color="#8fa2ba" />
</view>
<text class="photo-pick-title">选择照片</text>
<view class="photo-pick-card">
<view class="photo-pick-plus">+</view>
<text class="photo-pick-card-title">上传照片或视频素材</text>
<text class="photo-pick-card-desc">
选择后会自动进行清晰度人脸数量和遮挡检测
</text>
</view>
<view class="photo-pick-actions">
<view class="photo-pick-action" hover-class="is-pressed" @tap="emit('camera')">
拍照
<view
class="photo-pick-close"
:class="{ 'is-disabled': loading }"
@tap="handleClose"
>
<uni-icons type="closeempty" size="30" color="#8fa2ba" />
</view>
<view class="photo-pick-action" hover-class="is-pressed" @tap="emit('album')">
相册
</view>
</view>
<text class="photo-pick-hint">建议使用半身照避免墨镜口罩强逆光</text>
<text class="photo-pick-title">选择照片</text>
<view class="photo-pick-card">
<view class="photo-pick-plus">+</view>
<text class="photo-pick-card-title">上传照片素材</text>
<text class="photo-pick-card-desc">
选择后会自动进行清晰度人脸数量和遮挡检测
</text>
</view>
<view class="photo-pick-actions">
<view
class="photo-pick-action"
:class="{ 'is-disabled': loading }"
:hover-class="loading ? 'none' : 'is-pressed'"
@tap="handleCamera"
>
拍照
</view>
<view
class="photo-pick-action"
:class="{ 'is-disabled': loading }"
:hover-class="loading ? 'none' : 'is-pressed'"
@tap="handleAlbum"
>
相册
</view>
</view>
<text class="photo-pick-hint">建议使用半身照避免墨镜口罩强逆光</text>
</view>
</uni-popup>
</template>
@@ -39,9 +53,34 @@
<script setup>
import { nextTick, onMounted, ref } from "vue";
const props = defineProps({
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["close", "camera", "album"]);
const popupRef = ref(null);
const handleClose = () => {
if (props.loading) return;
emit("close");
};
const handleCamera = () => {
if (props.loading) return;
emit("camera");
};
const handleAlbum = () => {
if (props.loading) return;
emit("album");
};
onMounted(async () => {
await nextTick();
popupRef.value?.open();

View File

@@ -23,6 +23,10 @@
border-radius: 50%;
}
.photo-pick-close.is-disabled {
opacity: 0.48;
}
.photo-pick-title {
display: block;
margin: 1px 0 17px;
@@ -111,6 +115,10 @@
opacity: 0.82;
}
.photo-pick-action.is-disabled {
opacity: 0.58;
}
.photo-pick-hint {
display: block;
margin: 10px 0 0;

View File

@@ -2,12 +2,11 @@
<view
class="template-version-hero"
:class="`is-${variant}`"
:style="{ '--template-accent': template.accent || '#0a4d46' }"
>
<video
v-if="isVideoTemplate"
v-if="templateMediaUrl && isVideoTemplate"
class="template-version-video"
:src="template.cover"
:src="templateMediaUrl"
autoplay
muted
loop
@@ -16,13 +15,13 @@
:show-center-play-btn="false"
object-fit="cover"
/>
<image v-else class="template-version-image" :src="template.cover" mode="aspectFill" />
<image v-else-if="templateMediaUrl" class="template-version-image" :src="templateMediaUrl" mode="aspectFill" />
<view class="template-version-shade" />
<view class="template-version-copy">
<text class="template-version-badge">{{ template.badge || "同源双版本" }}</text>
<text class="template-version-title">{{ template.title }}</text>
<text class="template-version-desc">{{ template.desc }}</text>
<text class="template-version-badge">{{ template.templateTag }}</text>
<text class="template-version-title">{{ template.templateTitle || template.templateName }}</text>
<text class="template-version-desc">{{ template.templateSubTitle || template.templateDescription }}</text>
</view>
</view>
</template>
@@ -43,11 +42,12 @@ const props = defineProps({
const VIDEO_EXT_REGEXP = /\.(mp4|mov|webm|m4v)(\?.*)?$/i;
const isVideoTemplate = computed(() => {
const mediaType = props.template.type || props.template.mediaType;
if (mediaType === "video") return true;
const templateMediaUrl = computed(() => {
return props.template.templateContentUrl || props.template.coverPhotoUrl || "";
});
return VIDEO_EXT_REGEXP.test(props.template.cover || "");
const isVideoTemplate = computed(() => {
return VIDEO_EXT_REGEXP.test(templateMediaUrl.value);
});
</script>

View File

@@ -6,16 +6,19 @@
@tap="emit('select', option)"
>
<view class="version-option-copy">
<text class="version-option-title">{{ option.label }}</text>
<text class="version-option-desc">{{ option.desc }}</text>
<text class="version-option-title">{{ option.itemTitle || option.templateItemId }}</text>
<text class="version-option-desc">{{ option.itemSubTitle }}</text>
<text class="version-option-type">{{ generatorTypeText }}</text>
</view>
<text class="version-option-cost">{{ option.cost }}积分</text>
<text class="version-option-cost">{{ option.generatorCost }}积分</text>
</view>
</template>
<script setup>
defineProps({
import { computed } from "vue";
const props = defineProps({
option: {
type: Object,
default: () => ({}),
@@ -27,6 +30,12 @@ defineProps({
});
const emit = defineEmits(["select"]);
const generatorTypeText = computed(() => {
if (props.option.generatorType === 0) return "图片生成";
if (props.option.generatorType === 1) return "视频生成";
return `类型 ${props.option.generatorType}`;
});
</script>
<style scoped lang="scss">

View File

@@ -29,6 +29,7 @@
.version-option-title,
.version-option-desc,
.version-option-type,
.version-option-cost {
display: block;
overflow: hidden;
@@ -51,6 +52,14 @@
font-weight: 800;
}
.version-option-type {
margin-top: 4px;
color: #9aa6b8;
font-size: 10px;
line-height: 14px;
font-weight: 800;
}
.version-option-cost {
color: #12a765;
font-size: 15px;

View File

@@ -2,9 +2,9 @@
<view class="version-option-list">
<VersionOptionCard
v-for="option in options"
:key="option.value"
:key="option.templateItemId"
:option="option"
:selected="option.value === selectedValue"
:selected="option.templateItemId === selectedValue"
@select="emit('select', $event)"
/>
</view>

View File

@@ -1,14 +0,0 @@
export const versionOptions = [
{
value: "image",
label: "图片版",
desc: "生成一张高清旅行图片,适合保存和分享。",
cost: 100,
},
{
value: "video",
label: "动效视频版 · 5秒内",
desc: "先生成图片底图,再升级为 5秒内动效视频。",
cost: 200,
},
];

View File

@@ -14,27 +14,27 @@
<GenerateConfirmPanel
v-if="currentStep === 'generateConfirm'"
:cost="currentVersion.cost || 100"
:cost="currentCost"
:balance="pointBalance"
@generate="handleGenerate"
@recharge="handleRecharge"
/>
<GeneratingProgressPanel
v-else-if="currentStep === 'generating'"
:cost="currentVersion.cost || 100"
:cost="currentCost"
:progress="8"
@complete="handleGenerateComplete"
/>
<template v-else>
<VersionOptionList
:options="versionOptions"
:selected-value="selectedVersion"
@select="handleSelectVersion"
:options="templateItems"
:selected-value="selectedTemplateItemId"
@select="handleSelectTemplateItem"
/>
<text class="aigc-use-template-note">
动效视频版会先生成图片底再升级为 5秒内动效视频
动效视频版会先生图再升级为 5 秒内动效视频
</text>
</template>
</view>
@@ -51,6 +51,7 @@
</view>
<view v-if="currentStep === 'photoPick'" class="aigc-use-template-popup-host">
<PhotoPickDrawer
:loading="isUploading"
@close="handleCloseGuide"
@camera="handlePickCamera"
@album="handlePickAlbum"
@@ -58,14 +59,14 @@
</view>
<view v-if="currentStep === 'photoConfirm'" class="aigc-use-template-popup-host">
<PhotoConfirmDrawer
:avatar-src="guideAvatar"
:avatar-src="selectedImageLocalPath || guideAvatar"
@close="handleClosePhotoConfirm"
@confirm="handleConfirmPhoto"
/>
</view>
<PointInsufficientDialog
v-if="pointDialogVisible"
:cost="currentVersion.cost || 100"
:cost="currentCost"
:balance="pointBalance"
@cancel="handleClosePointDialog"
@recharge="handlePointRecharge"
@@ -75,9 +76,15 @@
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onShow } from "@dcloudio/uni-app";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { aigcTemplates } from "@/pages-aigc/home/data/templates.js";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import {
createAigcGeneratorTask,
getAigcTemplateItemList,
getAigcTemplateList,
} from "@/request/api/AigcApi.js";
import { updateImageFile } from "@/request/api/UpdateFile.js";
import guideAvatar from "./assets/xiaoqi-avatar.png";
import GenerateConfirmPanel from "./components/GenerateConfirmPanel/index.vue";
import GeneratingProgressPanel from "./components/GeneratingProgressPanel/index.vue";
@@ -88,24 +95,34 @@ import PointInsufficientDialog from "./components/PointInsufficientDialog/index.
import TemplateVersionHero from "./components/TemplateVersionHero/index.vue";
import VersionOptionList from "./components/VersionOptionList/index.vue";
import { negativeExamples, positiveExamples } from "./data/photoGuideExamples.js";
import { versionOptions } from "./data/versionOptions.js";
const pointBalance = ref(300);
const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
const templateId = ref("");
const selectedVersion = ref("image");
const templates = ref([]);
const templateItems = ref([]);
const selectedTemplateItemId = ref("");
const selectedImageLocalPath = ref("");
const uploadedImageUrl = ref("");
const createdTaskId = ref("");
const currentStep = ref("version");
const pointDialogVisible = ref(false);
const isUploading = ref(false);
const isCreatingTask = ref(false);
const currentTemplate = computed(() => {
return aigcTemplates.find((item) => item.id === templateId.value) || aigcTemplates[0] || {};
return templates.value.find((item) => item.templateId === templateId.value) || templates.value[0] || {};
});
const currentVersion = computed(() => {
return versionOptions.find((item) => item.value === selectedVersion.value) || versionOptions[0] || {};
const currentTemplateItem = computed(() => {
return (
templateItems.value.find((item) => item.templateItemId === selectedTemplateItemId.value) ||
templateItems.value[0] ||
{}
);
});
onLoad((query = {}) => {
templateId.value = query.templateId || "";
const currentCost = computed(() => {
return Number(currentTemplateItem.value.generatorCost) || 0;
});
const showPlaceholderToast = (title) => {
@@ -115,6 +132,57 @@ const showPlaceholderToast = (title) => {
});
};
const resetSelectedPhoto = () => {
selectedImageLocalPath.value = "";
uploadedImageUrl.value = "";
};
const fetchTemplateList = async () => {
try {
const res = await getAigcTemplateList();
if (res?.code === 0 && Array.isArray(res.data)) {
templates.value = res.data;
if (!templateId.value && res.data[0]?.templateId) {
templateId.value = res.data[0].templateId;
}
}
} catch (error) {
console.warn("获取AIGC模板列表失败", error);
}
};
const fetchTemplateItemList = async () => {
if (!templateId.value) {
templateItems.value = [];
selectedTemplateItemId.value = "";
return;
}
try {
const res = await getAigcTemplateItemList({ templateId: templateId.value });
if (res?.code === 0 && Array.isArray(res.data)) {
templateItems.value = res.data;
selectedTemplateItemId.value = res.data[0]?.templateItemId || "";
}
} catch (error) {
console.warn("获取AIGC模板生成项失败", error);
}
};
const fetchPageData = async () => {
await fetchTemplateList();
await fetchTemplateItemList();
};
onLoad((query = {}) => {
templateId.value = query.templateId || "";
fetchPageData();
});
onShow(() => {
fetchCurrentCredit();
});
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
@@ -136,8 +204,11 @@ const handleRecharge = () => {
});
};
const handleSelectVersion = (option) => {
selectedVersion.value = option.value;
const handleSelectTemplateItem = (option) => {
if (!option?.templateItemId) return;
selectedTemplateItemId.value = option.templateItemId;
resetSelectedPhoto();
currentStep.value = "uploadGuide";
};
@@ -149,12 +220,91 @@ const handleConfirmGuide = () => {
currentStep.value = "photoPick";
};
const getChosenFilePath = (res) => {
return res?.tempFiles?.[0]?.tempFilePath || res?.tempFilePaths?.[0] || res?.tempFiles?.[0]?.path || "";
};
const isChooseCanceled = (error) => {
const message = String(error?.errMsg || error?.message || "");
return message.includes("cancel") || message.includes("取消");
};
const handleChooseImageSuccess = async (res) => {
const filePath = getChosenFilePath(res);
if (!filePath) {
showPlaceholderToast("请选择图片");
return;
}
selectedImageLocalPath.value = filePath;
uploadedImageUrl.value = "";
isUploading.value = true;
uni.showLoading({
title: "上传中",
mask: true,
});
try {
const uploadRes = await updateImageFile(filePath);
if (uploadRes?.code === 0 && uploadRes.data) {
uploadedImageUrl.value = uploadRes.data;
currentStep.value = "photoConfirm";
} else {
resetSelectedPhoto();
showPlaceholderToast(uploadRes?.msg || "图片上传失败");
}
} catch (error) {
resetSelectedPhoto();
console.warn("上传AIGC生成图片失败", error);
showPlaceholderToast("图片上传失败");
} finally {
isUploading.value = false;
uni.hideLoading();
}
};
const handleChooseImageFail = (error) => {
if (!isChooseCanceled(error)) {
console.warn("选择AIGC生成图片失败", error);
showPlaceholderToast("选择图片失败");
}
};
const chooseImageWithSource = (sourceType) => {
// #ifdef MP-WEIXIN
uni.chooseMedia({
count: 1,
mediaType: ["image"],
sourceType: [sourceType],
sizeType: ["compressed"],
success: handleChooseImageSuccess,
fail: handleChooseImageFail,
});
// #endif
// #ifndef MP-WEIXIN
uni.chooseImage({
count: 1,
sizeType: ["compressed"],
sourceType: [sourceType],
success: handleChooseImageSuccess,
fail: handleChooseImageFail,
});
// #endif
};
const chooseAndUploadImage = (sourceType) => {
if (isUploading.value) return;
chooseImageWithSource(sourceType);
};
const handlePickCamera = () => {
currentStep.value = "photoConfirm";
chooseAndUploadImage("camera");
};
const handlePickAlbum = () => {
currentStep.value = "photoConfirm";
chooseAndUploadImage("album");
};
const handleClosePhotoConfirm = () => {
@@ -162,11 +312,31 @@ const handleClosePhotoConfirm = () => {
};
const handleConfirmPhoto = () => {
if (!uploadedImageUrl.value) {
showPlaceholderToast("请先上传图片");
currentStep.value = "photoPick";
return;
}
currentStep.value = "generateConfirm";
};
const handleGenerate = () => {
const cost = Number(currentVersion.value.cost) || 0;
const handleGenerate = async () => {
if (isCreatingTask.value) return;
const templateItemId = currentTemplateItem.value.templateItemId;
if (!templateItemId) {
showPlaceholderToast("请选择生成类型");
return;
}
if (!uploadedImageUrl.value) {
showPlaceholderToast("请先上传图片");
currentStep.value = "photoPick";
return;
}
const cost = currentCost.value;
const balance = Number(pointBalance.value) || 0;
if (balance < cost) {
@@ -174,12 +344,42 @@ const handleGenerate = () => {
return;
}
currentStep.value = "generating";
isCreatingTask.value = true;
uni.showLoading({
title: "创建中",
mask: true,
});
try {
const res = await createAigcGeneratorTask({
templateItemId,
imageUrlList: [uploadedImageUrl.value],
});
if (res?.code === 0 && res.data) {
createdTaskId.value = res.data;
currentStep.value = "generating";
fetchCurrentCredit();
} else {
showPlaceholderToast(res?.msg || "创建任务失败");
}
} catch (error) {
console.warn("创建AIGC生成任务失败", error);
showPlaceholderToast("创建任务失败");
} finally {
isCreatingTask.value = false;
uni.hideLoading();
}
};
const handleGenerateComplete = () => {
if (!createdTaskId.value) {
showPlaceholderToast("任务创建失败");
return;
}
uni.navigateTo({
url: "/pages-aigc/detail/detail",
url: `/pages-aigc/detail/detail?taskId=${encodeURIComponent(createdTaskId.value)}`,
});
};