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>
<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-caption">

View File

@@ -5,7 +5,7 @@
<view class="aigc-detail-content">
<ResultPreview :result="result" />
<ResultMeta :items="metaItems" />
<ContinuationCard :info="continuationInfo" @upgrade="handleUpgradeToVideo" />
<ContinuationCard v-if="continuationInfo" :info="continuationInfo" @upgrade="handleUpgradeToVideo" />
<ResultActions @save="handleSave" @share="handleShare" />
<view class="aigc-detail-text-actions">
@@ -20,8 +20,10 @@
<script setup>
import { computed } from "vue";
import { onShow } from "@dcloudio/uni-app";
import { storeToRefs } from "pinia";
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
import { useAigcRecordStore } from "@/store";
import ContinuationCard from "./components/ContinuationCard/index.vue";
import ResultActions from "./components/ResultActions/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";
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(() => {
fetchCurrentCredit();
});
const metaItems = computed(() => [
{
label: "结果",
value: result.resultLabel,
},
{
label: "已消耗",
value: result.consumedText,
},
{
label: "余额",
value: `${pointBalance.value}积分`,
},
]);
const metaItems = computed(() => {
if (currentRecord.value) {
const record = currentRecord.value;
const completeTime =
record.videoGeneratorCompleteTime || record.imageGeneratorCompleteTime || record.createTime || "-";
const continuationInfo = computed(() => ({
title: result.continuationTitle,
desc: result.continuationDesc,
cost: result.continuationCost,
buttonText: result.continuationButtonText,
}));
return [
{
label: "结果",
value: result.value.resultLabel,
},
{
label: "已消耗",
value: result.value.consumedText,
},
{
label: "完成时间",
value: completeTime,
},
];
}
return [
{
label: "结果",
value: result.value.resultLabel,
},
{
label: "已消耗",
value: result.value.consumedText,
},
{
label: "余额",
value: `${pointBalance.value}积分`,
},
];
});
const continuationInfo = computed(() => {
if (currentRecord.value) return null;
return {
title: result.value.continuationTitle,
desc: result.value.continuationDesc,
cost: result.value.continuationCost,
buttonText: result.value.continuationButtonText,
};
});
const showPlaceholderToast = (title) => {
uni.showToast({

View File

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

View File

@@ -31,19 +31,6 @@
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 {
max-width: 100vw;
height: var(--template-rail-height, 560px);
@@ -61,7 +48,8 @@
align-items: flex-start;
gap: var(--template-track-gap, 12px);
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;
}

View File

@@ -1,13 +1,15 @@
<template>
<view class="custom-card is-selected">
<text class="custom-title">充值金额</text>
<view :class="['custom-card', { 'is-selected': selected }]" @tap="emit('select')">
<text class="custom-title">自选金额</text>
<label class="custom-input-wrap">
<text class="custom-currency">¥</text>
<input
class="custom-input"
type="number"
:maxlength="4"
:value="modelValue"
@focus="emit('select')"
@input="handleInput"
/>
</label>
@@ -24,13 +26,20 @@ defineProps({
type: String,
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 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);
};
</script>

View File

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

View File

@@ -1,14 +1,7 @@
<template>
<view class="record-card">
<video
v-if="isVideoRecord"
class="record-card-image"
:src="mediaUrl"
:controls="false"
:show-play-btn="false"
:show-center-play-btn="false"
object-fit="cover"
/>
<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" />
@@ -18,7 +11,7 @@
<text class="record-card-status ellipsis-1">{{ statusText }}</text>
</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 File

@@ -1,30 +1,14 @@
<template>
<z-paging
ref="paging"
v-model="records"
class="aigc-record-page"
bg-color="#ffffff"
safe-area-inset-bottom
@query="queryList"
>
<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"
/>
<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"
/>
<RecordList :records="records" @view="handleViewRecord" />
<template #empty>
<CustomEmpty statusText="暂无生成记录" />
@@ -37,10 +21,12 @@ 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 { useAigcRecordStore } from "@/store";
import RecordList from "./components/RecordList/index.vue";
const records = ref([]);
const paging = ref(null);
const aigcRecordStore = useAigcRecordStore();
const queryList = async (pageNum, pageSize) => {
try {
@@ -65,10 +51,13 @@ const handleBack = () => {
};
const handleViewRecord = (record) => {
uni.showToast({
title: record.itemTitle || record.taskId || "",
icon: "none",
});
if (record?.taskStatus === 2) {
aigcRecordStore.setCurrentRecord(record);
uni.navigateTo({
url: "/pages-aigc/detail/detail",
});
return;
}
};
</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 { useSelectedDateStore } from "./selectedDate";
import { usePictureStore } from "./picture";
import { useLocationStore } from "./location";
export { useAppStore, useSelectedDateStore, usePictureStore, useLocationStore };
import { useAppStore } from "./app";
import { useAigcRecordStore } from "./aigcRecord";
import { useSelectedDateStore } from "./selectedDate";
import { usePictureStore } from "./picture";
import { useLocationStore } from "./location";
export { useAppStore, useAigcRecordStore, useSelectedDateStore, usePictureStore, useLocationStore };