feat: add aigc regenerate flow and improve task polling

Add reusable RegenerateConfirmPopup component. Implement full regeneration flow in AIGC detail page with balance checks, insufficient point prompt, task creation and progress page navigation. Refine progress page polling logic with lifecycle handling, duplicate request prevention and timer cleanup.
This commit is contained in:
duanshuwen
2026-07-17 21:36:08 +08:00
parent b04e3a4613
commit 1a4b320dc5
5 changed files with 332 additions and 17 deletions

View File

@@ -0,0 +1,92 @@
<template>
<uni-popup
ref="popupRef"
type="center"
background-color="transparent"
mask-background-color="rgba(0, 0, 0, 0.58)"
:safe-area="false"
:is-mask-click="false"
>
<view class="regenerate-dialog">
<text class="regenerate-dialog-title">再生成一版</text>
<text class="regenerate-dialog-desc">
将复用当前素材和模板重新生成本次将扣除 {{ normalizedCost }}
积分开始后先冻结生成成功后正式扣除失败自动退回
</text>
<view class="regenerate-dialog-balance">
当前余额 {{ normalizedBalance }} 积分
</view>
<view class="regenerate-dialog-actions">
<view
class="regenerate-dialog-button is-cancel"
:class="{ 'is-disabled': loading }"
:hover-class="loading ? 'none' : 'is-pressed'"
@tap="handleCancel"
>
取消
</view>
<view
class="regenerate-dialog-button is-primary"
:class="{ 'is-disabled': loading }"
:hover-class="loading ? 'none' : 'is-pressed'"
@tap="handleConfirm"
>
{{ loading ? "处理中..." : "确认生成" }}
</view>
</view>
</view>
</uni-popup>
</template>
<script setup>
import { computed, ref } from "vue";
const props = defineProps({
cost: {
type: [Number, String],
default: 0,
},
balance: {
type: [Number, String],
default: 0,
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["cancel", "confirm"]);
const popupRef = ref(null);
const normalizedCost = computed(() => Math.max(0, Number(props.cost) || 0));
const normalizedBalance = computed(() => Math.max(0, Number(props.balance) || 0));
const open = () => {
popupRef.value?.open();
};
const close = () => {
popupRef.value?.close();
};
const handleCancel = () => {
if (props.loading) return;
close();
emit("cancel");
};
const handleConfirm = () => {
if (!props.loading) {
emit("confirm");
}
};
defineExpose({ open, close });
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>

View File

@@ -0,0 +1,87 @@
.regenerate-dialog {
width: 318px;
max-width: calc(100vw - 56px);
padding: 30px 26px 24px;
border-radius: 24px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.26);
box-sizing: border-box;
}
.regenerate-dialog-title {
display: block;
color: #111827;
font-size: 20px;
line-height: 28px;
font-weight: 900;
text-align: center;
}
.regenerate-dialog-desc {
display: block;
margin-top: 14px;
color: #667386;
font-size: 14px;
line-height: 22px;
font-weight: 800;
text-align: center;
}
.regenerate-dialog-balance {
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 14px;
padding: 8px 12px;
border-radius: 14px;
background: #effaf5;
color: #0fa567;
font-size: 12px;
line-height: 18px;
font-weight: 900;
text-align: center;
box-sizing: border-box;
}
.regenerate-dialog-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
margin-top: 28px;
}
.regenerate-dialog-button {
min-width: 0;
height: 54px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 999px;
font-size: 16px;
line-height: 22px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
box-sizing: border-box;
}
.regenerate-dialog-button.is-cancel {
border: 1px solid #e2e6ea;
background: #ffffff;
color: #172033;
}
.regenerate-dialog-button.is-primary {
background: #061e34;
color: #ffffff;
}
.regenerate-dialog-button.is-pressed {
opacity: 0.86;
}
.regenerate-dialog-button.is-disabled {
opacity: 0.6;
}

View File

@@ -19,6 +19,10 @@
<Privacy :visible="privacyVisible" :contract-name="privacyContractName" @agree="handlePrivacyAgree" <Privacy :visible="privacyVisible" :contract-name="privacyContractName" @agree="handlePrivacyAgree"
@disagree="handlePrivacyDisagree" /> @disagree="handlePrivacyDisagree" />
<RegenerateConfirmPopup ref="regeneratePopupRef" :cost="regenerateCost" :balance="pointBalance"
:loading="isRegenerating" @confirm="handleConfirmRegenerate" />
<PointInsufficientDialog v-if="pointDialogVisible" :cost="regenerateCost" :balance="pointBalance"
@cancel="handleClosePointDialog" @recharge="handlePointRecharge" />
</view> </view>
</template> </template>
@@ -29,9 +33,12 @@ import Privacy from "@/components/Privacy/index.vue";
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 { import {
createAigcGeneratorTask,
createAigcGeneratorTaskShare, createAigcGeneratorTaskShare,
getAigcGeneratorTaskDetail, getAigcGeneratorTaskDetail,
} from "@/request/api/AigcApi.js"; } from "@/request/api/AigcApi.js";
import PointInsufficientDialog from "@/pages-aigc/useTemplate/components/PointInsufficientDialog/index.vue";
import RegenerateConfirmPopup from "./components/RegenerateConfirmPopup/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";
import ResultPreview from "./components/ResultPreview/index.vue"; import ResultPreview from "./components/ResultPreview/index.vue";
@@ -45,6 +52,9 @@ const shareKey = ref("");
const privacyVisible = ref(false); const privacyVisible = ref(false);
const privacyContractName = ref("隐私保护指引"); const privacyContractName = ref("隐私保护指引");
const pendingSaveAfterPrivacy = ref(false); const pendingSaveAfterPrivacy = ref(false);
const regeneratePopupRef = ref(null);
const isRegenerating = ref(false);
const pointDialogVisible = ref(false);
const GENERATOR_TYPE_TEXT = { const GENERATOR_TYPE_TEXT = {
0: "图片版", 0: "图片版",
@@ -124,6 +134,7 @@ const metaItems = computed(() => {
}); });
const sharePreviewImage = computed(() => taskDetail.value?.imageResultUrl || ""); const sharePreviewImage = computed(() => taskDetail.value?.imageResultUrl || "");
const regenerateCost = computed(() => Math.max(0, Number(taskDetail.value?.generatorCost) || 0));
const showPlaceholderToast = (title) => { const showPlaceholderToast = (title) => {
uni.showToast({ uni.showToast({
@@ -425,8 +436,72 @@ const handlePrivacyDisagree = () => {
pendingSaveAfterPrivacy.value = false; pendingSaveAfterPrivacy.value = false;
}; };
const handleRegenerate = () => { const handleRegenerate = async () => {
showPlaceholderToast("再生成一版"); await fetchCurrentCredit();
regeneratePopupRef.value?.open();
};
const handleConfirmRegenerate = async () => {
if (isRegenerating.value) return;
isRegenerating.value = true;
await fetchCurrentCredit();
const balance = Number(pointBalance.value) || 0;
if (balance < regenerateCost.value) {
isRegenerating.value = false;
regeneratePopupRef.value?.close();
pointDialogVisible.value = true;
return;
}
const templateItemId = String(taskDetail.value?.templateItemId || "").trim();
const imageResultUrl = String(taskDetail.value?.imageResultUrl || "").trim();
if (!templateItemId || !imageResultUrl) {
isRegenerating.value = false;
showPlaceholderToast(!templateItemId ? "缺少模板生成项" : "缺少可复用素材");
return;
}
uni.showLoading({
title: "创建中",
mask: true,
});
try {
const res = await createAigcGeneratorTask({
templateItemId,
imageUrlList: [imageResultUrl],
});
const nextTaskId = String(res?.data || "").trim();
if (res?.code === 0 && nextTaskId) {
regeneratePopupRef.value?.close();
fetchCurrentCredit();
uni.redirectTo({
url: `/pages-aigc/progress/progress?taskId=${encodeURIComponent(nextTaskId)}`,
fail: () => showPlaceholderToast("打开任务进度失败"),
});
return;
}
showPlaceholderToast(res?.msg || "创建任务失败");
} catch (error) {
console.error("重新创建AIGC生成任务失败", error);
showPlaceholderToast("创建任务失败");
} finally {
uni.hideLoading();
isRegenerating.value = false;
}
};
const handleClosePointDialog = () => {
pointDialogVisible.value = false;
};
const handlePointRecharge = () => {
pointDialogVisible.value = false;
handleRecharge();
}; };
const handleChangeTemplate = () => { const handleChangeTemplate = () => {

View File

@@ -15,7 +15,7 @@
<script setup> <script setup>
import { ref } from "vue"; import { ref } from "vue";
import { onLoad, onShow } from "@dcloudio/uni-app"; import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
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 { getAigcGeneratorTaskDetail } from "@/request/api/AigcApi.js"; import { getAigcGeneratorTaskDetail } from "@/request/api/AigcApi.js";
@@ -24,6 +24,10 @@ import GeneratingProgressPanel from "./components/GeneratingProgressPanel/index.
const { pointBalance, fetchCurrentCredit } = useCurrentCredit(); const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
const taskId = ref(""); const taskId = ref("");
const taskDetail = ref(null); const taskDetail = ref(null);
const DETAIL_POLL_INTERVAL = 3000;
let detailPollTimer = null;
let isFetchingTaskDetail = false;
let isPageVisible = false;
const showToast = (title) => { const showToast = (title) => {
uni.showToast({ uni.showToast({
@@ -37,42 +41,99 @@ const getPreviousPageRoute = () => {
return pages[pages.length - 2]?.route || ""; return pages[pages.length - 2]?.route || "";
}; };
const fetchTaskDetail = async () => { const stopTaskDetailPolling = () => {
uni.showLoading({ if (detailPollTimer !== null) {
title: "加载中", clearTimeout(detailPollTimer);
mask: true, detailPollTimer = null;
}); }
};
const isTaskCompleted = () => Number(taskDetail.value?.taskStatus) === 2;
const fetchTaskDetail = async ({ showLoading = false } = {}) => {
if (isFetchingTaskDetail) return isTaskCompleted();
isFetchingTaskDetail = true;
if (showLoading) {
uni.showLoading({
title: "加载中",
mask: true,
});
}
try { try {
const res = await getAigcGeneratorTaskDetail({ taskId: taskId.value }); const res = await getAigcGeneratorTaskDetail({ taskId: taskId.value });
if (res?.code === 0 && res.data && typeof res.data === "object" && !Array.isArray(res.data)) { if (res?.code === 0 && res.data && typeof res.data === "object" && !Array.isArray(res.data)) {
taskDetail.value = res.data; taskDetail.value = res.data;
return; if (isTaskCompleted()) {
stopTaskDetailPolling();
return true;
}
return false;
} }
taskDetail.value = null; if (showLoading) {
showToast(res?.msg || "获取任务进度失败"); taskDetail.value = null;
showToast(res?.msg || "获取任务进度失败");
}
return false;
} catch (error) { } catch (error) {
console.error("获取AIGC生成任务进度失败", error); console.error("获取AIGC生成任务进度失败", error);
taskDetail.value = null; if (showLoading) {
showToast("获取任务进度失败"); taskDetail.value = null;
showToast("获取任务进度失败");
}
return false;
} finally { } finally {
uni.hideLoading(); isFetchingTaskDetail = false;
if (showLoading) {
uni.hideLoading();
}
} }
}; };
onLoad((query = {}) => { const scheduleTaskDetailPolling = () => {
if (detailPollTimer !== null || !isPageVisible || !taskId.value || isTaskCompleted()) return;
detailPollTimer = setTimeout(async () => {
detailPollTimer = null;
const completed = await fetchTaskDetail();
if (!completed) {
scheduleTaskDetailPolling();
}
}, DETAIL_POLL_INTERVAL);
};
onLoad(async (query = {}) => {
isPageVisible = true;
taskId.value = String(query.taskId || "").trim(); taskId.value = String(query.taskId || "").trim();
if (!taskId.value) { if (!taskId.value) {
showToast("缺少任务ID"); showToast("缺少任务ID");
return; return;
} }
fetchTaskDetail(); const completed = await fetchTaskDetail({ showLoading: true });
if (!completed) {
scheduleTaskDetailPolling();
}
}); });
onShow(() => { onShow(() => {
isPageVisible = true;
fetchCurrentCredit(); fetchCurrentCredit();
if (taskDetail.value) {
scheduleTaskDetailPolling();
}
});
onHide(() => {
isPageVisible = false;
stopTaskDetailPolling();
});
onUnload(() => {
isPageVisible = false;
stopTaskDetailPolling();
}); });
const handleBack = () => { const handleBack = () => {

View File

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