feat: add aigc record, video preview & custom recharge

- create useAigcRecordStore to centralize current AIGC generation record state
- add video media type support to ResultPreview component
- update RecordCard to only display view button for completed tasks
- implement custom recharge amount with input validation and 9999 max limit
- refactor detail page to load record from Pinia store instead of mock data
- clean up TemplateCarousel markup and remove unused badge styles
- fix minor formatting and code style issues
This commit is contained in:
duanshuwen
2026-07-13 21:25:17 +08:00
parent b5952961fe
commit 10638e0806
10 changed files with 184 additions and 112 deletions

View File

@@ -1,6 +1,13 @@
<template> <template>
<view class="result-preview" :style="{ '--result-accent': result.accent }"> <view class="result-preview" :style="{ '--result-accent': result.accent }">
<image class="result-preview-image" :src="result.cover" mode="aspectFill" /> <video
v-if="result.mediaType === 'video'"
class="result-preview-image"
:src="result.cover"
controls
object-fit="cover"
/>
<image v-else class="result-preview-image" :src="result.cover" mode="aspectFill" />
<view class="result-preview-shade" /> <view class="result-preview-shade" />
<view class="result-preview-caption"> <view class="result-preview-caption">

View File

@@ -5,7 +5,7 @@
<view class="aigc-detail-content"> <view class="aigc-detail-content">
<ResultPreview :result="result" /> <ResultPreview :result="result" />
<ResultMeta :items="metaItems" /> <ResultMeta :items="metaItems" />
<ContinuationCard :info="continuationInfo" @upgrade="handleUpgradeToVideo" /> <ContinuationCard v-if="continuationInfo" :info="continuationInfo" @upgrade="handleUpgradeToVideo" />
<ResultActions @save="handleSave" @share="handleShare" /> <ResultActions @save="handleSave" @share="handleShare" />
<view class="aigc-detail-text-actions"> <view class="aigc-detail-text-actions">
@@ -20,8 +20,10 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import { onShow } from "@dcloudio/uni-app"; import { onShow } from "@dcloudio/uni-app";
import { storeToRefs } from "pinia";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue"; import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js"; import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import { useAigcRecordStore } from "@/store";
import ContinuationCard from "./components/ContinuationCard/index.vue"; import ContinuationCard from "./components/ContinuationCard/index.vue";
import ResultActions from "./components/ResultActions/index.vue"; import ResultActions from "./components/ResultActions/index.vue";
import ResultMeta from "./components/ResultMeta/index.vue"; import ResultMeta from "./components/ResultMeta/index.vue";
@@ -29,33 +31,94 @@ import ResultPreview from "./components/ResultPreview/index.vue";
import { aigcDetailResult } from "./data/result.js"; import { aigcDetailResult } from "./data/result.js";
const { pointBalance, fetchCurrentCredit } = useCurrentCredit(); const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
const result = aigcDetailResult; const aigcRecordStore = useAigcRecordStore();
const { currentRecord } = storeToRefs(aigcRecordStore);
const GENERATOR_TYPE_TEXT = {
0: "图片版",
1: "视频版",
};
const result = computed(() => {
const record = currentRecord.value;
if (!record) return aigcDetailResult;
const imageUrl = record.imageResultUrl || "";
const videoUrl = record.videoResultUrl || "";
const isVideo = Boolean(videoUrl) && (record.generatorType === 1 || !imageUrl);
const typeText =
GENERATOR_TYPE_TEXT[record.generatorType] ||
(record.generatorType === undefined ? "生成结果" : `类型${record.generatorType}`);
const consumedText =
record.generatorCost === undefined || record.generatorCost === null || record.generatorCost === ""
? "-"
: `${record.generatorCost}积分`;
return {
id: record.taskId,
title: record.itemTitle || record.taskId || "生成结果",
cover: isVideo ? videoUrl : imageUrl || videoUrl,
mediaType: isVideo ? "video" : "image",
accent: "#0a4d46",
variantLabel: typeText,
resultLabel: typeText,
consumedText,
desc: record.itemSubTitle || "生成任务已完成",
};
});
onShow(() => { onShow(() => {
fetchCurrentCredit(); fetchCurrentCredit();
}); });
const metaItems = computed(() => [ const metaItems = computed(() => {
if (currentRecord.value) {
const record = currentRecord.value;
const completeTime =
record.videoGeneratorCompleteTime || record.imageGeneratorCompleteTime || record.createTime || "-";
return [
{ {
label: "结果", label: "结果",
value: result.resultLabel, value: result.value.resultLabel,
}, },
{ {
label: "已消耗", label: "已消耗",
value: result.consumedText, value: result.value.consumedText,
},
{
label: "完成时间",
value: completeTime,
},
];
}
return [
{
label: "结果",
value: result.value.resultLabel,
},
{
label: "已消耗",
value: result.value.consumedText,
}, },
{ {
label: "余额", label: "余额",
value: `${pointBalance.value}积分`, value: `${pointBalance.value}积分`,
}, },
]); ];
});
const continuationInfo = computed(() => ({ const continuationInfo = computed(() => {
title: result.continuationTitle, if (currentRecord.value) return null;
desc: result.continuationDesc,
cost: result.continuationCost, return {
buttonText: result.continuationButtonText, title: result.value.continuationTitle,
})); desc: result.value.continuationDesc,
cost: result.value.continuationCost,
buttonText: result.value.continuationButtonText,
};
});
const showPlaceholderToast = (title) => { const showPlaceholderToast = (title) => {
uni.showToast({ uni.showToast({

View File

@@ -5,38 +5,20 @@
<text class="template-heading-title">选择模板</text> <text class="template-heading-title">选择模板</text>
<text class="template-heading-subtitle">左右滑动预览效果</text> <text class="template-heading-subtitle">左右滑动预览效果</text>
</view> </view>
<text class="template-heading-badge">同源双版本</text>
</view> </view>
<scroll-view <scroll-view class="template-rail" scroll-x show-scrollbar="false" scroll-with-animation
class="template-rail" :scroll-left="railScrollLeft" @scroll="handleScroll" @touchstart="handleTouchStart" @touchend="handleTouchEnd"
scroll-x @touchcancel="handleTouchEnd">
show-scrollbar="false"
scroll-with-animation
:scroll-left="railScrollLeft"
@scroll="handleScroll"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
@touchcancel="handleTouchEnd"
>
<view class="template-track"> <view class="template-track">
<TemplateCard <TemplateCard v-for="(template, index) in templates" :key="template.templateId || index" :template="template"
v-for="(template, index) in templates" :active="index === modelValue" @select="handleSelect(template, index)" />
:key="template.templateId || index"
:template="template"
:active="index === modelValue"
@select="handleSelect(template, index)"
/>
</view> </view>
</scroll-view> </scroll-view>
<view class="template-dots"> <view class="template-dots">
<view <view v-for="(template, index) in templates" :key="`${template.templateId || index}-dot`" class="template-dot"
v-for="(template, index) in templates" :class="{ 'is-active': index === modelValue }" />
:key="`${template.templateId || index}-dot`"
class="template-dot"
:class="{ 'is-active': index === modelValue }"
/>
</view> </view>
</view> </view>
</template> </template>

View File

@@ -31,19 +31,6 @@
font-weight: 800; font-weight: 800;
} }
.template-heading-badge {
height: 26px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: rgba(32, 207, 117, 0.12);
color: #10a765;
font-size: 11px;
line-height: 26px;
font-weight: 900;
}
.template-rail { .template-rail {
max-width: 100vw; max-width: 100vw;
height: var(--template-rail-height, 560px); height: var(--template-rail-height, 560px);
@@ -61,7 +48,8 @@
align-items: flex-start; align-items: flex-start;
gap: var(--template-track-gap, 12px); gap: var(--template-track-gap, 12px);
height: var(--template-rail-height, 560px); height: var(--template-rail-height, 560px);
padding: 0 var(--template-track-end-padding, 41px) 0 var(--template-side-padding, 16px); padding: 0 var(--template-track-end-padding, 41px) 0
var(--template-side-padding, 16px);
box-sizing: border-box; box-sizing: border-box;
} }

View File

@@ -1,13 +1,15 @@
<template> <template>
<view class="custom-card is-selected"> <view :class="['custom-card', { 'is-selected': selected }]" @tap="emit('select')">
<text class="custom-title">充值金额</text> <text class="custom-title">自选金额</text>
<label class="custom-input-wrap"> <label class="custom-input-wrap">
<text class="custom-currency">¥</text> <text class="custom-currency">¥</text>
<input <input
class="custom-input" class="custom-input"
type="number" type="number"
:maxlength="4"
:value="modelValue" :value="modelValue"
@focus="emit('select')"
@input="handleInput" @input="handleInput"
/> />
</label> </label>
@@ -24,13 +26,20 @@ defineProps({
type: String, type: String,
default: "", default: "",
}, },
selected: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(["update:modelValue"]); const emit = defineEmits(["update:modelValue", "select"]);
const MAX_CUSTOM_AMOUNT = 9999;
const handleInput = (event) => { const handleInput = (event) => {
const rawValue = event?.detail?.value ?? event?.target?.value ?? ""; const rawValue = event?.detail?.value ?? event?.target?.value ?? "";
const value = String(rawValue).replace(/\D/g, ""); const digits = String(rawValue).replace(/\D/g, "");
const value = digits ? String(Math.min(Number(digits), MAX_CUSTOM_AMOUNT)) : "";
emit("select");
emit("update:modelValue", value); emit("update:modelValue", value);
}; };
</script> </script>

View File

@@ -23,6 +23,12 @@
@select="handleSelectRechargeOption" @select="handleSelectRechargeOption"
/> />
<CustomAmountCard
v-model="customAmount"
:selected="rechargeMode === 'custom'"
@select="handleSelectCustomAmount"
/>
<RechargeFooter <RechargeFooter
:price="payPrice" :price="payPrice"
:loading="paying" :loading="paying"
@@ -39,12 +45,15 @@ import TopNavBar from "@/components/TopNavBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js"; import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import { getRechargeOptions, rechargeCredit } from "@/request/api/AigcApi.js"; import { getRechargeOptions, rechargeCredit } from "@/request/api/AigcApi.js";
import BalanceCard from "./components/BalanceCard/index.vue"; import BalanceCard from "./components/BalanceCard/index.vue";
import CustomAmountCard from "./components/CustomAmountCard/index.vue";
import RechargeFooter from "./components/RechargeFooter/index.vue"; import RechargeFooter from "./components/RechargeFooter/index.vue";
import RechargePackageList from "./components/RechargePackageList/index.vue"; import RechargePackageList from "./components/RechargePackageList/index.vue";
const { pointBalance: balancePoints, fetchCurrentCredit } = useCurrentCredit(); const { pointBalance: balancePoints, fetchCurrentCredit } = useCurrentCredit();
const rechargeOptions = ref([]); const rechargeOptions = ref([]);
const selectedRechargeOption = ref(null); const selectedRechargeOption = ref(null);
const customAmount = ref("");
const rechargeMode = ref("package");
const paying = ref(false); const paying = ref(false);
const getRechargeOptionKey = (option) => { const getRechargeOptionKey = (option) => {
@@ -53,10 +62,17 @@ const getRechargeOptionKey = (option) => {
return `${option.rechargeAmount ?? ""}-${option.rechargePoints ?? ""}`; return `${option.rechargeAmount ?? ""}-${option.rechargePoints ?? ""}`;
}; };
const selectedRechargeKey = computed(() => getRechargeOptionKey(selectedRechargeOption.value)); const selectedRechargeKey = computed(() => {
if (rechargeMode.value !== "package") return "";
return getRechargeOptionKey(selectedRechargeOption.value);
});
const payPrice = computed(() => { const payPrice = computed(() => {
const amount = Number(selectedRechargeOption.value?.rechargeAmount); const amount =
rechargeMode.value === "custom"
? Number.parseInt(customAmount.value || "0", 10)
: Number(selectedRechargeOption.value?.rechargeAmount);
return Number.isFinite(amount) ? amount : 0; return Number.isFinite(amount) ? amount : 0;
}); });
@@ -72,8 +88,10 @@ const fetchRechargeOptions = async () => {
const res = await getRechargeOptions(); const res = await getRechargeOptions();
if (res?.code === 0 && Array.isArray(res.data)) { if (res?.code === 0 && Array.isArray(res.data)) {
rechargeOptions.value = res.data; rechargeOptions.value = res.data;
if (rechargeMode.value === "package") {
selectedRechargeOption.value = res.data[0] || null; selectedRechargeOption.value = res.data[0] || null;
} }
}
} catch (error) { } catch (error) {
console.error("获取积分充值档位失败", error); console.error("获取积分充值档位失败", error);
} }
@@ -102,9 +120,15 @@ const handleOpenLedger = () => {
}; };
const handleSelectRechargeOption = (option) => { const handleSelectRechargeOption = (option) => {
rechargeMode.value = "package";
selectedRechargeOption.value = option; selectedRechargeOption.value = option;
}; };
const handleSelectCustomAmount = () => {
rechargeMode.value = "custom";
selectedRechargeOption.value = null;
};
const requestWechatPay = (payData) => { const requestWechatPay = (payData) => {
const { nonceStr, packageVal, paySign, signType, timeStamp } = payData || {}; const { nonceStr, packageVal, paySign, signType, timeStamp } = payData || {};
@@ -138,14 +162,15 @@ const requestWechatPay = (payData) => {
const handlePay = async () => { const handlePay = async () => {
if (paying.value) return; if (paying.value) return;
if (!selectedRechargeOption.value) { if (rechargeMode.value === "package" && !selectedRechargeOption.value) {
showToast("请选择充值档位"); showToast("请选择充值档位");
return; return;
} }
const rechargeAmount = payPrice.value; const rechargeAmount = payPrice.value;
if (!Number.isInteger(rechargeAmount) || rechargeAmount <= 0) { if (!Number.isInteger(rechargeAmount) || rechargeAmount <= 0) {
showToast("充值档位金额错误,请重试"); const message = rechargeMode.value === "custom" ? "请输入正确的充值金额" : "充值档位金额错误,请重试";
showToast(message);
return; return;
} }

View File

@@ -1,14 +1,7 @@
<template> <template>
<view class="record-card"> <view class="record-card">
<video <video v-if="isVideoRecord" class="record-card-image" :src="mediaUrl" :controls="false" :show-play-btn="false"
v-if="isVideoRecord" :show-center-play-btn="false" object-fit="cover" />
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" /> <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 v-else class="record-card-image record-card-media-placeholder" />
@@ -18,7 +11,7 @@
<text class="record-card-status ellipsis-1">{{ statusText }}</text> <text class="record-card-status ellipsis-1">{{ statusText }}</text>
</view> </view>
<view class="record-card-action" @tap="emit('view', record)"> <view v-if="record.taskStatus === 2" class="record-card-action" @tap="emit('view', record)">
查看 查看
</view> </view>
</view> </view>

View File

@@ -1,30 +1,14 @@
<template> <template>
<z-paging <z-paging ref="paging" v-model="records" class="aigc-record-page" bg-color="#ffffff" safe-area-inset-bottom
ref="paging" @query="queryList">
v-model="records"
class="aigc-record-page"
bg-color="#ffffff"
safe-area-inset-bottom
@query="queryList"
>
<template #top> <template #top>
<view class="aigc-record-nav"> <view class="aigc-record-nav">
<TopNavBar <TopNavBar title="最近任务" background="transparent" title-color="#162235" back-icon-color="#162235"
title="最近任务" :align-with-menu-button="true" :z-index="1" @back="handleBack" />
background="transparent"
title-color="#162235"
back-icon-color="#162235"
:align-with-menu-button="true"
:z-index="1"
@back="handleBack"
/>
</view> </view>
</template> </template>
<RecordList <RecordList :records="records" @view="handleViewRecord" />
:records="records"
@view="handleViewRecord"
/>
<template #empty> <template #empty>
<CustomEmpty statusText="暂无生成记录" /> <CustomEmpty statusText="暂无生成记录" />
@@ -37,10 +21,12 @@ import { ref } from "vue";
import TopNavBar from "@/components/TopNavBar/index.vue"; import TopNavBar from "@/components/TopNavBar/index.vue";
import CustomEmpty from "@/components/CustomEmpty/index.vue"; import CustomEmpty from "@/components/CustomEmpty/index.vue";
import { getAigcGeneratorTaskList } from "@/request/api/AigcApi.js"; import { getAigcGeneratorTaskList } from "@/request/api/AigcApi.js";
import { useAigcRecordStore } from "@/store";
import RecordList from "./components/RecordList/index.vue"; import RecordList from "./components/RecordList/index.vue";
const records = ref([]); const records = ref([]);
const paging = ref(null); const paging = ref(null);
const aigcRecordStore = useAigcRecordStore();
const queryList = async (pageNum, pageSize) => { const queryList = async (pageNum, pageSize) => {
try { try {
@@ -65,10 +51,13 @@ const handleBack = () => {
}; };
const handleViewRecord = (record) => { const handleViewRecord = (record) => {
uni.showToast({ if (record?.taskStatus === 2) {
title: record.itemTitle || record.taskId || "", aigcRecordStore.setCurrentRecord(record);
icon: "none", uni.navigateTo({
url: "/pages-aigc/detail/detail",
}); });
return;
}
}; };
</script> </script>

View File

@@ -0,0 +1,15 @@
import { defineStore } from "pinia";
export const useAigcRecordStore = defineStore("aigcRecord", {
state() {
return {
currentRecord: null,
};
},
actions: {
setCurrentRecord(record) {
this.currentRecord = record ? { ...record } : null;
},
},
});

View File

@@ -1,6 +1,7 @@
import { useAppStore } from "./app"; import { useAppStore } from "./app";
import { useAigcRecordStore } from "./aigcRecord";
import { useSelectedDateStore } from "./selectedDate"; import { useSelectedDateStore } from "./selectedDate";
import { usePictureStore } from "./picture"; import { usePictureStore } from "./picture";
import { useLocationStore } from "./location"; import { useLocationStore } from "./location";
export { useAppStore, useSelectedDateStore, usePictureStore, useLocationStore }; export { useAppStore, useAigcRecordStore, useSelectedDateStore, usePictureStore, useLocationStore };