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:
@@ -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">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
15
src/store/modules/aigcRecord.js
Normal file
15
src/store/modules/aigcRecord.js
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user