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

@@ -11,7 +11,7 @@
<view class="aigc-wallet-button" @tap="emit('recharge')">
<text class="aigc-wallet-label">{{ rechargeLabel }}</text>
<text class="aigc-wallet-points">
<text class="aigc-wallet-number">{{ points }}</text>
<text class="aigc-wallet-number">{{ displayPoints }}</text>
积分
</text>
</view>
@@ -30,7 +30,7 @@
import { computed, onMounted, ref } from "vue";
import { zniconsMap } from "@/static/fonts/znicons.js";
defineProps({
const props = defineProps({
points: {
type: [Number, String],
default: 0,
@@ -57,6 +57,8 @@ const topbarStyle = computed(() => ({
"--aigc-capsule-safe-width": `${capsuleSafeWidth.value}px`,
}));
const displayPoints = computed(() => Number(props.points) || 0);
onMounted(() => {
try {
const systemInfo = uni.getSystemInfoSync();

View File

@@ -18,17 +18,23 @@
</template>
<script setup>
import { computed, ref } from "vue";
import { computed } from "vue";
import { onShow } from "@dcloudio/uni-app";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import ContinuationCard from "./components/ContinuationCard/index.vue";
import ResultActions from "./components/ResultActions/index.vue";
import ResultMeta from "./components/ResultMeta/index.vue";
import ResultPreview from "./components/ResultPreview/index.vue";
import { aigcDetailResult } from "./data/result.js";
const pointBalance = ref(300);
const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
const result = aigcDetailResult;
onShow(() => {
fetchCurrentCredit();
});
const metaItems = computed(() => [
{
label: "结果",

View File

@@ -1,17 +1,13 @@
<template>
<view
class="template-card"
:class="[
active ? 'is-active' : '',
template.tone ? `is-${template.tone}` : '',
]"
:style="{ '--memory-accent': template.accent || '#0f7069' }"
:class="{ 'is-active': active }"
>
<view class="template-media">
<video
v-if="isVideoTemplate"
v-if="templateMediaUrl && isVideoTemplate"
class="template-video"
:src="template.cover"
:src="templateMediaUrl"
autoplay
muted
loop
@@ -20,17 +16,17 @@
:show-center-play-btn="false"
object-fit="cover"
/>
<image v-else class="template-image" :src="template.cover" mode="aspectFill" />
<image v-else-if="templateMediaUrl" class="template-image" :src="templateMediaUrl" mode="aspectFill" />
<view class="template-media-shade" />
</view>
<view class="template-copy">
<text class="template-badge">{{ template.badge }}</text>
<text class="template-title ellipsis-1">{{ template.title }}</text>
<text class="template-desc ellipsis-1">{{ template.desc }}</text>
<text class="template-note ellipsis-1">{{ template.note }}</text>
<text class="template-badge">{{ template.templateTag }}</text>
<text class="template-title ellipsis-1">{{ template.templateTitle || template.templateName }}</text>
<text class="template-desc ellipsis-1">{{ template.templateSubTitle }}</text>
<text class="template-note ellipsis-1">{{ template.templateDescription }}</text>
<button class="template-select" type="default" @tap.stop="emit('select')">
{{ template.buttonText }}
选这个模板
</button>
</view>
</view>
@@ -54,11 +50,12 @@ const emit = defineEmits(["select"]);
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

@@ -36,7 +36,7 @@
flex: 0 0 var(--template-card-media-height, 358px);
height: var(--template-card-media-height, 358px);
overflow: hidden;
background: var(--memory-accent);
background: #0f7069;
}
.template-image,

View File

@@ -22,7 +22,7 @@
<view class="template-track">
<TemplateCard
v-for="(template, index) in templates"
:key="template.id || index"
:key="template.templateId || index"
:template="template"
:active="index === modelValue"
@select="handleSelect(template, index)"
@@ -33,7 +33,7 @@
<view class="template-dots">
<view
v-for="(template, index) in templates"
:key="`${template.id || index}-dot`"
:key="`${template.templateId || index}-dot`"
class="template-dot"
:class="{ 'is-active': index === modelValue }"
/>

View File

@@ -1,48 +0,0 @@
export const aigcTemplates = [
{
id: "glass-boat-postcard",
title: "鸳鸯湖明信片",
desc: "适合朋友圈分享的旅行纪念内容",
note: "图片版100积分 · 动效视频版200积分/5秒内",
badge: "同源双版本",
buttonText: "选这个模板",
cover: "https://oss.nianxx.cn/mp/static/xiaoqi/glass-boat-postcard.gif",
tone: "postcard",
accent: "#0a4d46",
},
{
id: "bridge-memory",
title: "秘境探险时光",
desc: "在鸳鸯湖划船时留下美好回忆",
note: "图片版100积分 · 动效视频版200积分/5秒内",
badge: "同源双版本",
buttonText: "选这个模板",
cover: "https://oss.nianxx.cn/config/2f642480fdd79f40a07a56c79317a77c.mp4",
tone: "emerald",
accent: "#0f7069",
},
{
id: "waterfall-story",
title: "卧龙潭粘土拍立得",
desc: "用粘土数字形象,从画框中走向屏幕前",
note: "图片版100积分 · 动效视频版200积分/5秒内",
badge: "同源双版本",
buttonText: "选这个模板",
cover: "https://oss.nianxx.cn/config/70cef08f598ac30bf42c34ed31dda9bd.mp4",
tone: "aqua",
accent: "#155e75",
},
// {
// id: "culture-postcard",
// title: "瑶山古寨印象",
// desc: "把民族风情做成旅行收藏卡",
// note: "图片版100积分 · 动效视频版200积分/5秒内",
// badge: "同源双版本",
// buttonText: "选这个模板",
// cover: "https://oss.nianxx.cn/mp/static/xiaoqi/card-culture-1.png",
// tone: "forest",
// accent: "#36563d",
// },
];
export const aigcFlowSteps = ["选模板", "传素材", "添积分", "出结果"];

View File

@@ -14,18 +14,40 @@
<script setup>
import { ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { getAigcGiftPointsToastClosed, setAigcGiftPointsToastClosed } from "@/constant/aigc.js";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import { getAigcTemplateList } from "@/request/api/AigcApi.js";
import GiftPointsToast from "./components/GiftPointsToast/index.vue";
import TemplateCarousel from "./components/TemplateCarousel/index.vue";
import FlowSteps from "./components/FlowSteps/index.vue";
import { aigcFlowSteps, aigcTemplates } from "./data/templates.js";
const pointBalance = ref(300);
const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
const activeIndex = ref(0);
const giftToastVisible = ref(!getAigcGiftPointsToastClosed());
const templates = aigcTemplates;
const steps = aigcFlowSteps;
const templates = ref([]);
const steps = ["选模板", "传素材", "添积分", "出结果"];
const fetchTemplateList = async () => {
try {
const res = await getAigcTemplateList();
if (res?.code === 0 && Array.isArray(res.data)) {
templates.value = res.data;
activeIndex.value = 0;
}
} catch (error) {
console.warn("获取AIGC模板列表失败", error);
}
};
onLoad(() => {
fetchTemplateList();
});
onShow(() => {
fetchCurrentCredit();
});
const handleBack = () => {
const pages = getCurrentPages();
@@ -52,8 +74,11 @@ const handleCloseGiftToast = () => {
};
const handleSelectTemplate = (template) => {
const templateId = template?.templateId || "";
if (!templateId) return;
uni.navigateTo({
url: `/pages-aigc/useTemplate/useTemplate?templateId=${encodeURIComponent(template.id || "")}`
url: `/pages-aigc/useTemplate/useTemplate?templateId=${encodeURIComponent(templateId)}`
});
};
</script>

View File

@@ -0,0 +1,22 @@
import { ref } from "vue";
import { getCurrentCredit } from "@/request/api/AigcApi.js";
export function useCurrentCredit() {
const pointBalance = ref(0);
const fetchCurrentCredit = async () => {
try {
const res = await getCurrentCredit();
if (res?.code === 0) {
pointBalance.value = Number(res.data) || 0;
}
} catch (error) {
console.error("获取当前积分失败", error);
}
};
return {
pointBalance,
fetchCurrentCredit,
};
}

View File

@@ -2,8 +2,7 @@
<view class="balance-card">
<view class="balance-copy">
<text class="balance-label">当前可用积分</text>
<text class="balance-value">{{ balance.points }}</text>
<text class="balance-rule">{{ balance.ruleText }}</text>
<text class="balance-value">{{ points }}</text>
</view>
<view class="balance-ledger" @tap="emit('ledger')">积分明细</view>
@@ -12,9 +11,9 @@
<script setup>
defineProps({
balance: {
type: Object,
default: () => ({}),
points: {
type: [Number, String],
default: 0,
},
});

View File

@@ -1,6 +1,6 @@
<template>
<view :class="['custom-card', { 'is-selected': selected }]" @tap="emit('select')">
<text class="custom-title">自选金额</text>
<view class="custom-card is-selected">
<text class="custom-title">充值金额</text>
<label class="custom-input-wrap">
<text class="custom-currency">¥</text>
@@ -8,43 +8,29 @@
class="custom-input"
type="number"
:value="modelValue"
@focus="emit('select')"
@input="handleInput"
/>
</label>
<view class="custom-hint">
<text> 1 = 100 积分换算</text>
<text>当前可得 {{ currentPoints }} 积分</text>
<text>请输入整数金额</text>
</view>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
defineProps({
modelValue: {
type: String,
default: "",
},
selected: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["update:modelValue", "select"]);
const currentPoints = computed(() => {
const amount = Number.parseInt(props.modelValue || "0", 10);
return Number.isNaN(amount) ? 0 : amount * 100;
});
const emit = defineEmits(["update:modelValue"]);
const handleInput = (event) => {
const rawValue = event?.detail?.value ?? event?.target?.value ?? "";
const value = String(rawValue).replace(/\D/g, "");
emit("select");
emit("update:modelValue", value);
};
</script>

View File

@@ -1,11 +1,15 @@
<template>
<view class="recharge-footer">
<text class="recharge-gain">
本次获得 <text class="recharge-gain-value">{{ points }}</text> 积分
支付金额 <text class="recharge-gain-value">¥{{ formattedPrice }}</text>
</text>
<view class="recharge-pay-button" @tap="emit('pay')">
支付 ¥{{ formattedPrice }}
<view
class="recharge-pay-button"
:class="{ 'is-disabled': loading || price <= 0 }"
@tap="emit('pay')"
>
{{ loading ? "支付中..." : "立即支付" }}
</view>
</view>
</template>
@@ -14,14 +18,14 @@
import { computed } from "vue";
const props = defineProps({
points: {
type: Number,
default: 0,
},
price: {
type: Number,
default: 0,
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["pay"]);

View File

@@ -42,3 +42,7 @@
font-weight: 900;
white-space: nowrap;
}
.recharge-pay-button.is-disabled {
opacity: 0.48;
}

View File

@@ -1,28 +0,0 @@
export const rechargeBalance = {
points: 0,
ruleText: "图片版 100积分/次 · 动效视频版 200积分/次",
};
export const rechargePackages = [
{
id: "points-1000",
points: 1000,
price: 9.5,
originalPrice: 10,
discount: "9.5折",
},
{
id: "points-3000",
points: 3000,
price: 27,
originalPrice: 30,
discount: "9折",
},
{
id: "points-5000",
points: 5000,
price: 42.5,
originalPrice: 50,
discount: "8.5折",
},
];

View File

@@ -13,25 +13,17 @@
<view class="aigc-recharge-content">
<BalanceCard
:balance="balance"
:points="balancePoints"
@ledger="handleOpenLedger"
/>
<RechargePackageList
:packages="packages"
:selected-id="selectedId"
@select="handleSelectPackage"
/>
<CustomAmountCard
v-model="customAmount"
:selected="selectedId === customId"
@select="handleSelectCustom"
/>
<RechargeFooter
:points="payPoints"
:price="payPrice"
:loading="paying"
@pay="handlePay"
/>
</view>
@@ -40,51 +32,34 @@
<script setup>
import { computed, ref } from "vue";
import { onShow } from "@dcloudio/uni-app";
import TopNavBar from "@/components/TopNavBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import { rechargeCredit } from "@/request/api/AigcApi.js";
import BalanceCard from "./components/BalanceCard/index.vue";
import CustomAmountCard from "./components/CustomAmountCard/index.vue";
import RechargeFooter from "./components/RechargeFooter/index.vue";
import RechargePackageList from "./components/RechargePackageList/index.vue";
import { rechargeBalance, rechargePackages } from "./data/packages.js";
const customId = "custom";
const balance = rechargeBalance;
const packages = rechargePackages;
const selectedId = ref("points-3000");
const customAmount = ref("2");
const { pointBalance: balancePoints, fetchCurrentCredit } = useCurrentCredit();
const customAmount = ref("1");
const paying = ref(false);
const selectedPackage = computed(() =>
packages.find((item) => item.id === selectedId.value)
);
const customPrice = computed(() => {
const payPrice = computed(() => {
const amount = Number.parseInt(customAmount.value || "0", 10);
return Number.isNaN(amount) ? 0 : amount;
});
const payPoints = computed(() => {
if (selectedId.value === customId) {
return customPrice.value * 100;
}
return selectedPackage.value?.points || 0;
});
const payPrice = computed(() => {
if (selectedId.value === customId) {
return customPrice.value;
}
return selectedPackage.value?.price || 0;
});
const showPlaceholderToast = (title) => {
const showToast = (title) => {
uni.showToast({
title,
icon: "none",
});
};
onShow(() => {
fetchCurrentCredit();
});
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
@@ -95,20 +70,74 @@ const handleBack = () => {
const handleOpenLedger = () => {
uni.navigateTo({
url: "/pages-aigc/pointsDetails/pointsDetails",
fail: () => showPlaceholderToast("积分明细"),
fail: () => showToast("积分明细"),
});
};
const handleSelectPackage = (item) => {
selectedId.value = item.id;
const requestWechatPay = (payData) => {
const { nonceStr, packageVal, paySign, signType, timeStamp } = payData || {};
if (!nonceStr || !packageVal || !paySign || !signType || !timeStamp) {
showToast("支付参数错误,请重试");
return;
}
// #ifdef MP-WEIXIN
uni.requestPayment({
provider: "wxpay",
timeStamp: String(timeStamp),
nonceStr: String(nonceStr),
package: String(packageVal),
signType: String(signType),
paySign: String(paySign),
success: () => {
uni.showToast({
title: "支付成功",
icon: "success",
});
fetchCurrentCredit();
},
fail: () => {
showToast("支付失败,请重试");
},
});
// #endif
};
const handleSelectCustom = () => {
selectedId.value = customId;
};
const handlePay = async () => {
if (paying.value) return;
const handlePay = () => {
showPlaceholderToast(`支付 ¥${payPrice.value}`);
const rechargeAmount = payPrice.value;
if (!Number.isInteger(rechargeAmount) || rechargeAmount <= 0) {
showToast("请输入正确的充值金额");
return;
}
paying.value = true;
uni.showLoading({ title: "正在发起支付..." });
try {
const res = await rechargeCredit({
rechargeAmount,
payWay: "0",
paySource: "1",
});
uni.hideLoading();
if (res?.code === 0 && res.data) {
requestWechatPay(res.data);
return;
}
showToast(res?.msg || "充值下单失败,请重试");
} catch (error) {
uni.hideLoading();
console.error("充值下单失败", error);
showToast("充值下单失败,请重试");
} finally {
paying.value = false;
}
};
</script>

View File

@@ -1,11 +1,21 @@
<template>
<view class="record-card">
<image class="record-card-image" :src="record.image" mode="aspectFill" />
<video
v-if="isVideoRecord"
class="record-card-image"
:src="mediaUrl"
:controls="false"
:show-play-btn="false"
:show-center-play-btn="false"
object-fit="cover"
/>
<image v-else-if="mediaUrl" class="record-card-image" :src="mediaUrl" mode="aspectFill" />
<view v-else class="record-card-image record-card-media-placeholder" />
<view class="record-card-copy">
<text class="record-card-title ellipsis-1">{{ record.title }}</text>
<text class="record-card-time ellipsis-1">{{ record.time }}</text>
<text class="record-card-status ellipsis-1">{{ record.status }}</text>
<text class="record-card-title ellipsis-1">{{ title }}</text>
<text class="record-card-time ellipsis-1">{{ record.createTime }}</text>
<text class="record-card-status ellipsis-1">{{ statusText }}</text>
</view>
<view class="record-card-action" @tap="emit('view', record)">
@@ -15,7 +25,9 @@
</template>
<script setup>
defineProps({
import { computed } from "vue";
const props = defineProps({
record: {
type: Object,
default: () => ({}),
@@ -23,6 +35,45 @@ defineProps({
});
const emit = defineEmits(["view"]);
const GENERATOR_TYPE_TEXT = {
0: "图片版",
1: "视频版",
};
const TASK_STATUS_TEXT = {
0: "排队中",
1: "生成中",
2: "已完成",
3: "失败",
4: "生成视频中",
};
const title = computed(() => props.record.itemTitle || props.record.taskId || "");
const mediaUrl = computed(() => {
return props.record.imageResultUrl || props.record.videoResultUrl || "";
});
const isVideoRecord = computed(() => {
return !props.record.imageResultUrl && Boolean(props.record.videoResultUrl);
});
const statusText = computed(() => {
const generatorType = props.record.generatorType;
const taskStatus = props.record.taskStatus;
const generatorCost = props.record.generatorCost;
const typeText =
GENERATOR_TYPE_TEXT[generatorType] || (generatorType === undefined ? "" : `类型${generatorType}`);
const stateText =
TASK_STATUS_TEXT[taskStatus] || (taskStatus === undefined ? "" : `状态${taskStatus}`);
const costText =
generatorCost === undefined || generatorCost === null || generatorCost === ""
? ""
: `消耗${generatorCost}积分`;
return [typeText, stateText, costText].filter(Boolean).join(" · ");
});
</script>
<style scoped lang="scss">

View File

@@ -22,6 +22,11 @@
display: block;
border-radius: 10px;
background: #edf5f1;
overflow: hidden;
}
.record-card-media-placeholder {
background: linear-gradient(135deg, #edf5f1 0%, #f6faf8 100%);
}
.record-card-copy {

View File

@@ -3,13 +3,11 @@
<view class="record-list">
<RecordCard
v-for="record in records"
:key="record.id"
:key="record.taskId"
:record="record"
@view="handleView"
/>
</view>
<text class="record-note">仅保留最近20天生成记录</text>
</view>
</template>

View File

@@ -1,16 +0,0 @@
export const aigcRecords = [
{
id: "record-lake-postcard",
title: "鸳鸯湖明信片",
time: "2026-06-29 15:46:12",
status: "图片版 · 已完成 · 可升级动效视频",
image: "https://oss.nianxx.cn/mp/static/xiaoqi/glass-boat-postcard.gif",
},
{
id: "record-lake-postcard-video",
title: "鸳鸯湖明信片",
time: "2026-06-29 15:58:40",
status: "动效视频版 · 5秒内 · 由图片版升级",
image: "https://oss.nianxx.cn/mp/static/xiaoqi/glass-boat-postcard.gif",
},
];

View File

@@ -1,30 +1,61 @@
<template>
<view class="aigc-record-page">
<view class="aigc-record-nav">
<TopNavBar
title="最近任务"
background="transparent"
title-color="#162235"
back-icon-color="#162235"
:align-with-menu-button="true"
:z-index="1"
@back="handleBack"
/>
</view>
<z-paging
ref="paging"
v-model="records"
class="aigc-record-page"
bg-color="#ffffff"
safe-area-inset-bottom
@query="queryList"
>
<template #top>
<view class="aigc-record-nav">
<TopNavBar
title="最近任务"
background="transparent"
title-color="#162235"
back-icon-color="#162235"
:align-with-menu-button="true"
:z-index="1"
@back="handleBack"
/>
</view>
</template>
<RecordList
:records="records"
@view="handleViewRecord"
/>
</view>
<template #empty>
<CustomEmpty statusText="暂无生成记录" />
</template>
</z-paging>
</template>
<script setup>
import { ref } from "vue";
import TopNavBar from "@/components/TopNavBar/index.vue";
import CustomEmpty from "@/components/CustomEmpty/index.vue";
import { getAigcGeneratorTaskList } from "@/request/api/AigcApi.js";
import RecordList from "./components/RecordList/index.vue";
import { aigcRecords } from "./data/records.js";
const records = aigcRecords;
const records = ref([]);
const paging = ref(null);
const queryList = async (pageNum, pageSize) => {
try {
const res = await getAigcGeneratorTaskList({ pageNum, pageSize });
if (res?.code === 0 && Array.isArray(res.data?.records)) {
paging.value?.complete(res.data.records);
return;
}
paging.value?.complete([]);
} catch (error) {
console.error("获取AIGC生成任务列表失败", error);
paging.value?.complete(false);
}
};
const handleBack = () => {
const pages = getCurrentPages();
@@ -35,7 +66,7 @@ const handleBack = () => {
const handleViewRecord = (record) => {
uni.showToast({
title: record.title,
title: record.itemTitle || record.taskId || "",
icon: "none",
});
};

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)}`,
});
};

View File

@@ -0,0 +1,34 @@
import request from "../base/request";
function getAigcTemplateList() {
return request.get("/hotelBiz/aigc/aigcTemplateList", {});
}
function getAigcTemplateItemList(args) {
return request.post("/hotelBiz/aigc/aigcTemplateItemList", args);
}
function getAigcGeneratorTaskList(args) {
return request.post("/hotelBiz/aigcGeneratorTask/aigcGeneratorTaskList", args);
}
function createAigcGeneratorTask(args) {
return request.post("/hotelBiz/aigcGeneratorTask/createAigcGeneratorTask", args);
}
function getCurrentCredit() {
return request.get("/hotelBiz/credit/current", {});
}
function rechargeCredit(args) {
return request.post("/hotelBiz/credit/recharge", args);
}
export {
getAigcTemplateList,
getAigcTemplateItemList,
getAigcGeneratorTaskList,
createAigcGeneratorTask,
getCurrentCredit,
rechargeCredit,
};

View File

@@ -7,7 +7,7 @@ import { getServiceUrl } from "../api/GetServiceUrlApi";
const versionValue = "1.1.3";
/// 是否是测试版本, 测试版本为true 发布版本为false
const developVersion = false;
const developVersion = true;
// 获取服务地址
const getEvnUrl = async () => {