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:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -19,6 +19,10 @@
|
||||
|
||||
<Privacy :visible="privacyVisible" :contract-name="privacyContractName" @agree="handlePrivacyAgree"
|
||||
@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>
|
||||
</template>
|
||||
|
||||
@@ -29,9 +33,12 @@ import Privacy from "@/components/Privacy/index.vue";
|
||||
import AigcTopBar from "@/pages-aigc/components/AigcTopBar/index.vue";
|
||||
import { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
|
||||
import {
|
||||
createAigcGeneratorTask,
|
||||
createAigcGeneratorTaskShare,
|
||||
getAigcGeneratorTaskDetail,
|
||||
} 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 ResultMeta from "./components/ResultMeta/index.vue";
|
||||
import ResultPreview from "./components/ResultPreview/index.vue";
|
||||
@@ -45,6 +52,9 @@ const shareKey = ref("");
|
||||
const privacyVisible = ref(false);
|
||||
const privacyContractName = ref("隐私保护指引");
|
||||
const pendingSaveAfterPrivacy = ref(false);
|
||||
const regeneratePopupRef = ref(null);
|
||||
const isRegenerating = ref(false);
|
||||
const pointDialogVisible = ref(false);
|
||||
|
||||
const GENERATOR_TYPE_TEXT = {
|
||||
0: "图片版",
|
||||
@@ -124,6 +134,7 @@ const metaItems = computed(() => {
|
||||
});
|
||||
|
||||
const sharePreviewImage = computed(() => taskDetail.value?.imageResultUrl || "");
|
||||
const regenerateCost = computed(() => Math.max(0, Number(taskDetail.value?.generatorCost) || 0));
|
||||
|
||||
const showPlaceholderToast = (title) => {
|
||||
uni.showToast({
|
||||
@@ -425,8 +436,72 @@ const handlePrivacyDisagree = () => {
|
||||
pendingSaveAfterPrivacy.value = false;
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
showPlaceholderToast("再生成一版");
|
||||
const handleRegenerate = async () => {
|
||||
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 = () => {
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<script setup>
|
||||
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 { useCurrentCredit } from "@/pages-aigc/hooks/useCurrentCredit.js";
|
||||
import { getAigcGeneratorTaskDetail } from "@/request/api/AigcApi.js";
|
||||
@@ -24,6 +24,10 @@ import GeneratingProgressPanel from "./components/GeneratingProgressPanel/index.
|
||||
const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
|
||||
const taskId = ref("");
|
||||
const taskDetail = ref(null);
|
||||
const DETAIL_POLL_INTERVAL = 3000;
|
||||
let detailPollTimer = null;
|
||||
let isFetchingTaskDetail = false;
|
||||
let isPageVisible = false;
|
||||
|
||||
const showToast = (title) => {
|
||||
uni.showToast({
|
||||
@@ -37,42 +41,99 @@ const getPreviousPageRoute = () => {
|
||||
return pages[pages.length - 2]?.route || "";
|
||||
};
|
||||
|
||||
const fetchTaskDetail = async () => {
|
||||
uni.showLoading({
|
||||
title: "加载中",
|
||||
mask: true,
|
||||
});
|
||||
const stopTaskDetailPolling = () => {
|
||||
if (detailPollTimer !== null) {
|
||||
clearTimeout(detailPollTimer);
|
||||
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 {
|
||||
const res = await getAigcGeneratorTaskDetail({ taskId: taskId.value });
|
||||
if (res?.code === 0 && res.data && typeof res.data === "object" && !Array.isArray(res.data)) {
|
||||
taskDetail.value = res.data;
|
||||
return;
|
||||
if (isTaskCompleted()) {
|
||||
stopTaskDetailPolling();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
taskDetail.value = null;
|
||||
showToast(res?.msg || "获取任务进度失败");
|
||||
if (showLoading) {
|
||||
taskDetail.value = null;
|
||||
showToast(res?.msg || "获取任务进度失败");
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("获取AIGC生成任务进度失败", error);
|
||||
taskDetail.value = null;
|
||||
showToast("获取任务进度失败");
|
||||
if (showLoading) {
|
||||
taskDetail.value = null;
|
||||
showToast("获取任务进度失败");
|
||||
}
|
||||
return false;
|
||||
} 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();
|
||||
if (!taskId.value) {
|
||||
showToast("缺少任务ID");
|
||||
return;
|
||||
}
|
||||
|
||||
fetchTaskDetail();
|
||||
const completed = await fetchTaskDetail({ showLoading: true });
|
||||
if (!completed) {
|
||||
scheduleTaskDetailPolling();
|
||||
}
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
isPageVisible = true;
|
||||
fetchCurrentCredit();
|
||||
if (taskDetail.value) {
|
||||
scheduleTaskDetailPolling();
|
||||
}
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
isPageVisible = false;
|
||||
stopTaskDetailPolling();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
isPageVisible = false;
|
||||
stopTaskDetailPolling();
|
||||
});
|
||||
|
||||
const handleBack = () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { getServiceUrl } from "../api/GetServiceUrlApi";
|
||||
const versionValue = "1.1.3";
|
||||
|
||||
/// 是否是测试版本, 测试版本为true, 发布版本为false
|
||||
const developVersion = true;
|
||||
const developVersion = false;
|
||||
|
||||
// 获取服务地址
|
||||
const getEvnUrl = async () => {
|
||||
|
||||
Reference in New Issue
Block a user