Files
YGChatCS/src/pages-aigc/detail/detail.vue
duanshuwen 9f0881a284 feat(aigc): 新增图片生成动效视频的升级功能
- 新增图片转视频相关的API接口
- 调整AIGC详情页英雄区块高度至380px
- 添加结果详情页升级动效视频入口
- 完善升级流程的逻辑与费用计算
- 适配多类型生成任务的判断规则
2026-08-05 19:41:42 +08:00

642 lines
19 KiB
Vue

<template>
<view class="aigc-detail-page">
<AigcTopBar :points="pointBalance" @back="handleBack" @history="handleOpenRecords" @recharge="handleRecharge" />
<view class="aigc-detail-content">
<template v-if="taskDetail">
<ResultPreview v-if="result.cover" class="aigc-detail-preview" :result="result" />
<view class="aigc-detail-controls">
<!-- <ResultMeta :items="metaItems" /> -->
<ContinuationCard v-if="result.mediaType === 'image'" :info="continuationInfo"
@upgrade="handleUpgradeToVideo" />
<ResultActions :saving="isSaving" :media-type="result.mediaType" :share-ready="Boolean(shareKey)"
:share-preparing="isPreparingShare" @save="handleSave" @prepare-share="handlePrepareShare" />
<view class="aigc-detail-text-actions">
<view class="aigc-detail-text-action" @tap="handleRegenerate">再生成一版</view>
<view class="aigc-detail-text-action" @tap="handleChangeTemplate">换个模板</view>
<view class="aigc-detail-text-action" @tap="handleOpenRecords">历史记录</view>
</view>
</view>
</template>
</view>
<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>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShareAppMessage, onShow } from "@dcloudio/uni-app";
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 { getWechatLoginCode } from "@/pages-aigc/services/wechatLogin.js";
import {
createAigcGeneratorTask,
createAigcGeneratorTaskShare,
getAigcGeneratorTaskDetail,
} from "@/pages-aigc/request/AigcApi.js";
import PointInsufficientDialog from "@/pages-aigc/useTemplate/components/PointInsufficientDialog/index.vue";
import ContinuationCard from "./components/ContinuationCard/index.vue";
import RegenerateConfirmPopup from "./components/RegenerateConfirmPopup/index.vue";
import ResultActions from "./components/ResultActions/index.vue";
import ResultPreview from "./components/ResultPreview/index.vue";
const { pointBalance, fetchCurrentCredit } = useCurrentCredit();
const taskId = ref("");
const taskDetail = ref(null);
const isSaving = ref(false);
const isPreparingShare = ref(false);
const shareKey = ref("");
const privacyVisible = ref(false);
const privacyContractName = ref("隐私保护指引");
const pendingSaveAfterPrivacy = ref(false);
const regeneratePopupRef = ref(null);
const isRegenerating = ref(false);
const isUpgradingToVideo = ref(false);
const pointDialogVisible = ref(false);
const GENERATOR_TYPE_TEXT = {
0: "图片版",
1: "视频版",
};
const TASK_STATUS_TEXT = {
0: "排队中",
1: "生成中",
2: "已完成",
3: "生成失败",
4: "视频生成中",
};
const getMappedText = (mapping, value, prefix) => {
if (Object.prototype.hasOwnProperty.call(mapping, value)) {
return mapping[value];
}
return value === undefined || value === null || value === "" ? "-" : `${prefix}${value}`;
};
const formatDateOnly = (value) => {
if (!value) return "-";
return String(value).match(/^\d{4}-\d{2}-\d{2}/)?.[0] || "-";
};
const result = computed(() => {
const record = taskDetail.value || {};
const imageUrl = record.imageResultUrl || "";
const videoUrl = record.videoResultUrl || "";
const isVideo = [1, 2].includes(Number(record.generatorType));
const typeText = getMappedText(GENERATOR_TYPE_TEXT, record.generatorType, "类型");
const statusText = getMappedText(TASK_STATUS_TEXT, record.taskStatus, "状态");
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,
imageResultUrl: imageUrl,
videoResultUrl: videoUrl,
mediaType: isVideo ? "video" : "image",
accent: "#0a4d46",
variantLabel: typeText,
resultLabel: statusText,
consumedText,
desc: record.itemSubTitle || statusText,
};
});
const metaItems = computed(() => {
const record = taskDetail.value;
if (!record) return [];
const completeTime = formatDateOnly(
record.videoGeneratorCompleteTime || record.imageGeneratorCompleteTime || record.createTime
);
return [
{
label: "状态",
value: result.value.resultLabel,
},
{
label: "已消耗",
value: result.value.consumedText,
},
{
label: "完成时间",
value: completeTime,
},
];
});
const sharePreviewImage = computed(() => taskDetail.value?.imageResultUrl || "");
const regenerateCost = computed(() => Math.max(0, Number(taskDetail.value?.generatorCost) || 0));
const continuationInfo = {
title: "创意续作",
desc: "让这张图动起来 · 5秒内动效视频",
cost: "追加消耗 650积分",
buttonText: "升级为动效视频",
};
const showPlaceholderToast = (title) => {
uni.showToast({
title,
icon: "none",
});
};
const fetchTaskDetail = async () => {
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;
prepareShareKey();
return;
}
taskDetail.value = null;
showPlaceholderToast("获取任务详情失败");
} catch (error) {
console.error("获取AIGC生成任务详情失败", error);
taskDetail.value = null;
showPlaceholderToast("获取任务详情失败");
} finally {
uni.hideLoading();
}
};
onLoad((query = {}) => {
taskId.value = String(query.taskId || "").trim();
if (!taskId.value) {
showPlaceholderToast("缺少任务ID");
return;
}
fetchTaskDetail();
});
onShow(() => {
isUpgradingToVideo.value = false;
fetchCurrentCredit();
});
onShareAppMessage(() => {
const shareInfo = {
title: taskDetail.value?.itemTitle || "分享旅行作品",
path: shareKey.value
? `/pages-aigc/sharedWork/sharedWork?shareKey=${encodeURIComponent(shareKey.value)}`
: "/pages/index/index",
};
const imageUrl = sharePreviewImage.value;
if (imageUrl) {
shareInfo.imageUrl = imageUrl;
}
return shareInfo;
});
const prepareShareKey = async (showFeedback = false) => {
if (isPreparingShare.value || !taskId.value) return;
if (shareKey.value) return;
isPreparingShare.value = true;
try {
const res = await createAigcGeneratorTaskShare({ taskId: taskId.value });
const nextShareKey = String(res?.data?.shareKey || "").trim();
if (res?.code === 0 && nextShareKey) {
shareKey.value = nextShareKey;
if (showFeedback) {
showPlaceholderToast("分享已准备,请再次点击");
}
return;
}
if (showFeedback) {
showPlaceholderToast(res?.msg || "创建分享失败");
}
} catch (error) {
console.error("创建AIGC任务分享失败", error);
if (showFeedback) {
showPlaceholderToast("创建分享失败");
}
} finally {
isPreparingShare.value = false;
}
};
const handlePrepareShare = () => {
prepareShareKey(true);
};
const handleBack = () => {
const pages = getCurrentPages();
if (pages.length > 1) {
uni.navigateBack();
}
};
const handleRecharge = () => {
uni.navigateTo({
url: "/pages-aigc/recharge/recharge",
fail: () => showPlaceholderToast("打开充值页面失败"),
});
};
const handleUpgradeToVideo = () => {
if (isUpgradingToVideo.value || result.value.mediaType !== "image") return;
const sourceTaskId = String(taskDetail.value?.taskId || taskId.value || "").trim();
if (!sourceTaskId) {
showPlaceholderToast("缺少任务ID");
return;
}
const templateId = String(taskDetail.value?.templateId || "").trim();
const templateItemId = String(taskDetail.value?.templateItemId || "").trim();
const query = [
templateId && `templateId=${encodeURIComponent(templateId)}`,
`upgradeTaskId=${encodeURIComponent(sourceTaskId)}`,
templateItemId && `upgradeTemplateItemId=${encodeURIComponent(templateItemId)}`,
]
.filter(Boolean)
.join("&");
isUpgradingToVideo.value = true;
uni.navigateTo({
url: `/pages-aigc/useTemplate/useTemplate?${query}`,
fail: () => {
isUpgradingToVideo.value = false;
showPlaceholderToast("打开图片生成页面失败");
},
});
};
const getMediaFileExtension = (url, mediaType) => {
const pathname = String(url || "").split(/[?#]/)[0];
const matchedExtension = pathname.match(/\.([a-z0-9]{2,5})$/i)?.[1]?.toLowerCase();
const supportedExtensions =
mediaType === "video" ? ["mp4", "mov", "m4v", "3gp"] : ["jpg", "jpeg", "png", "webp", "gif"];
if (matchedExtension && supportedExtensions.includes(matchedExtension)) {
return `.${matchedExtension}`;
}
return mediaType === "video" ? ".mp4" : ".jpg";
};
const createMediaError = (stage, error = {}) => ({
stage,
errMsg: String(error?.errMsg || error?.message || ""),
statusCode: error?.statusCode,
});
const downloadMedia = (url, mediaType) =>
new Promise((resolve, reject) => {
if (!/^https?:\/\//i.test(url)) {
resolve({ filePath: url, shouldCleanup: false });
return;
}
const downloadOptions = {
url,
success: (res) => {
const filePath = res.filePath || res.tempFilePath;
if (res.statusCode === 200 && filePath) {
resolve({ filePath, shouldCleanup: Boolean(downloadOptions.filePath) });
return;
}
reject(createMediaError("download", res));
},
fail: (error) => reject(createMediaError("download", error)),
};
// 微信端显式保留扩展名,避免部分系统无法识别下载后的媒体格式。
// #ifdef MP-WEIXIN
if (typeof wx !== "undefined" && wx.env?.USER_DATA_PATH) {
const extension = getMediaFileExtension(url, mediaType);
const uniqueName = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
downloadOptions.filePath = `${wx.env.USER_DATA_PATH}/aigc-${uniqueName}${extension}`;
}
// #endif
uni.downloadFile(downloadOptions);
});
const saveImageToAlbum = (filePath) =>
new Promise((resolve, reject) => {
uni.saveImageToPhotosAlbum({
filePath,
success: resolve,
fail: reject,
});
});
const saveVideoToAlbum = (filePath) =>
new Promise((resolve, reject) => {
uni.saveVideoToPhotosAlbum({
filePath,
success: resolve,
fail: reject,
});
});
const ensureAlbumPermission = () =>
new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.getSetting({
success: ({ authSetting = {} }) => {
const permission = authSetting["scope.writePhotosAlbum"];
if (permission === true) {
resolve();
return;
}
if (permission === false) {
reject({ errMsg: "scope.writePhotosAlbum permission denied" });
return;
}
uni.authorize({
scope: "scope.writePhotosAlbum",
success: resolve,
fail: reject,
});
},
fail: reject,
});
// #endif
// #ifndef MP-WEIXIN
resolve();
// #endif
});
const isAlbumPermissionDenied = (error) => {
const errorMessage = String(error?.errMsg || "").toLowerCase();
return (
errorMessage.includes("auth deny") ||
errorMessage.includes("authorize:no response") ||
errorMessage.includes("permission denied")
);
};
const isDownloadDomainError = (error) => {
const errorMessage = String(error?.errMsg || "").toLowerCase();
return error?.stage === "download" && (
errorMessage.includes("domain list") ||
errorMessage.includes("url domain") ||
errorMessage.includes("合法域名")
);
};
const removeDownloadedMedia = (filePath, shouldCleanup) => {
if (!filePath || !shouldCleanup) return;
// #ifdef MP-WEIXIN
wx.getFileSystemManager().unlink({
filePath,
fail: () => { },
});
// #endif
};
const promptAlbumPermission = (mediaType) => {
const mediaName = mediaType === "video" ? "视频" : "图片";
uni.showModal({
title: "需要相册权限",
content: `请在设置中允许保存${mediaName}到相册。`,
confirmText: "去设置",
success: ({ confirm }) => {
if (confirm) {
uni.openSetting();
}
},
});
};
const saveResultMedia = async () => {
if (isSaving.value) return;
const mediaType = result.value.mediaType;
const isVideo = mediaType === "video";
const mediaName = isVideo ? "视频" : "图片";
const mediaUrl = isVideo ? result.value.videoResultUrl : result.value.imageResultUrl;
if (!mediaUrl) {
showPlaceholderToast(`暂无可保存的${mediaName}`);
return;
}
isSaving.value = true;
let saveError = null;
let loadingVisible = false;
let downloadedMedia = null;
try {
try {
await ensureAlbumPermission();
} catch (error) {
throw createMediaError("permission", error);
}
uni.showLoading({
title: "保存中",
mask: true,
});
loadingVisible = true;
downloadedMedia = await downloadMedia(mediaUrl, mediaType);
try {
if (isVideo) {
await saveVideoToAlbum(downloadedMedia.filePath);
} else {
await saveImageToAlbum(downloadedMedia.filePath);
}
} catch (error) {
throw createMediaError("save", error);
}
} catch (error) {
saveError = error;
const mediaHost = String(mediaUrl).match(/^https?:\/\/([^/?#]+)/i)?.[1] || "local";
console.error("保存AIGC媒体失败", {
stage: error?.stage,
errMsg: error?.errMsg,
statusCode: error?.statusCode,
mediaType,
mediaHost,
});
} finally {
removeDownloadedMedia(downloadedMedia?.filePath, downloadedMedia?.shouldCleanup);
if (loadingVisible) {
uni.hideLoading();
}
isSaving.value = false;
}
if (!saveError) {
uni.showToast({
title: `${mediaName}已保存`,
icon: "success",
});
return;
}
if (isAlbumPermissionDenied(saveError)) {
promptAlbumPermission(mediaType);
} else if (isDownloadDomainError(saveError)) {
showPlaceholderToast("资源下载域名未配置");
} else if (saveError?.stage === "download") {
showPlaceholderToast("资源下载失败,请稍后重试");
} else {
showPlaceholderToast(`保存${mediaName}失败`);
}
};
const handleSave = () => {
if (isSaving.value || pendingSaveAfterPrivacy.value) return;
// #ifdef MP-WEIXIN
pendingSaveAfterPrivacy.value = true;
wx.getPrivacySetting({
success: (res) => {
if (res?.needAuthorization) {
privacyContractName.value = res.privacyContractName || "隐私保护指引";
privacyVisible.value = true;
return;
}
pendingSaveAfterPrivacy.value = false;
saveResultMedia();
},
fail: (error) => {
pendingSaveAfterPrivacy.value = false;
console.warn("检查微信隐私授权失败", error);
showPlaceholderToast("隐私授权检查失败");
},
});
// #endif
// #ifndef MP-WEIXIN
saveResultMedia();
// #endif
};
const handlePrivacyAgree = () => {
const shouldSave = pendingSaveAfterPrivacy.value;
privacyVisible.value = false;
pendingSaveAfterPrivacy.value = false;
if (shouldSave) {
saveResultMedia();
}
};
const handlePrivacyDisagree = () => {
privacyVisible.value = false;
pendingSaveAfterPrivacy.value = false;
};
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 wxLoginCode = await getWechatLoginCode();
const res = await createAigcGeneratorTask({
templateItemId,
imageUrlList: [imageResultUrl],
wxLoginCode,
});
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 = () => {
uni.navigateTo({
url: "/pages-aigc/home/home",
fail: () => showPlaceholderToast("换个模板"),
});
};
const handleOpenRecords = () => {
uni.navigateTo({
url: "/pages-aigc/record/record",
fail: () => showPlaceholderToast("历史记录"),
});
};
</script>
<style scoped lang="scss">
@import "./styles/index.scss";
</style>