5 Commits

Author SHA1 Message Date
duanshuwen
2407a9d941 refactor(aigc): update carousel and video components
- rewrite template carousel from custom scroll to uni swiper
- add video loading, error, and replay states to result preview
- update template card styles and video/poster handling logic
- clean up unused CSS vars and remove unnecessary min-height on detail page
- fix video file regex and add video error recovery
2026-07-18 15:30:06 +08:00
duanshuwen
05edc5666c Merge branch 'home3.0' of https://git.nianxx.cn/zoujing/YGChatCS into aigc 2026-07-18 09:29:22 +08:00
duanshuwen
5fe3dd54b3 feat(aigc): add auto-redirect when generation completes
Add complete event to GeneratingProgressPanel that emits when task status is completed and progress reaches 100%. Add handler in the parent progress page to automatically navigate to task detail page when the complete event is triggered, and stop ongoing task detail polling. Also enhance media download handling in the detail page with proper WeChat mini-program file path handling, error wrapping, temporary file cleanup, and improved error reporting for download failures.
2026-07-17 23:33:12 +08:00
duanshuwen
9b19da2257 fix(GiftPointsToast): update default points to 200 2026-07-17 21:44:46 +08:00
duanshuwen
1a4b320dc5 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.
2026-07-17 21:36:08 +08:00
14 changed files with 810 additions and 161 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

@@ -2,30 +2,167 @@
<view class="result-preview" :style="{ '--result-accent': result.accent }">
<video
v-if="result.mediaType === 'video'"
:id="videoId"
:key="videoRenderKey"
class="result-preview-image"
:src="result.videoResultUrl"
:poster="result.imageResultUrl"
:title="result.title"
controls
show-progress
show-play-btn
show-fullscreen-btn
show-center-play-btn
enable-progress-gesture
enable-play-gesture
auto-pause-if-navigate
auto-pause-if-open-native
play-btn-position="center"
object-fit="cover"
@play="handleVideoPlay"
@waiting="handleVideoWaiting"
@timeupdate="handleVideoTimeUpdate"
@progress="handleVideoProgress"
@loadedmetadata="handleVideoLoaded"
@ended="handleVideoEnded"
@error="handleVideoError"
/>
<image v-else class="result-preview-image" :src="result.imageResultUrl" mode="aspectFill" />
<view class="result-preview-shade" />
<view v-if="showCaptionOverlay" class="result-preview-shade" />
<view class="result-preview-caption">
<view v-if="showCaptionOverlay" class="result-preview-caption">
<text class="result-preview-badge">{{ result.variantLabel }}</text>
<text class="result-preview-title">{{ result.title }}</text>
<text class="result-preview-desc">{{ result.desc }}</text>
</view>
<view v-if="isVideo && isVideoLoading && !isVideoError" class="result-video-state is-loading">
<view class="result-video-loading-icon">
<uni-icons type="spinner-cycle" size="22" color="#ffffff" />
</view>
<text class="result-video-state-text">{{ loadingText }}</text>
</view>
<view v-if="isVideo && isVideoEnded" class="result-video-state is-ended">
<view class="result-video-state-action" hover-class="is-pressed" @tap.stop="handleReplay">
<uni-icons type="reload" size="22" color="#17324d" />
<text>重新播放</text>
</view>
</view>
<view v-if="isVideo && isVideoError" class="result-video-state is-error">
<text class="result-video-error-title">视频加载失败</text>
<view class="result-video-state-action" hover-class="is-pressed" @tap.stop="handleVideoRetry">
<uni-icons type="refresh" size="21" color="#17324d" />
<text>重新加载</text>
</view>
</view>
</view>
</template>
<script setup>
defineProps({
import { computed, getCurrentInstance, nextTick, ref, watch } from "vue";
const props = defineProps({
result: {
type: Object,
default: () => ({}),
},
});
const instance = getCurrentInstance();
const videoId = `aigc-result-video-${Math.random().toString(36).slice(2, 10)}`;
const videoLoadVersion = ref(0);
const isVideoLoading = ref(true);
const isVideoEnded = ref(false);
const isVideoError = ref(false);
const hasVideoStarted = ref(false);
const bufferedProgress = ref(0);
const isVideo = computed(() => props.result.mediaType === "video");
const videoRenderKey = computed(() => `${props.result.videoResultUrl || "video"}-${videoLoadVersion.value}`);
const showCaptionOverlay = computed(
() => !isVideo.value || (!hasVideoStarted.value && !isVideoEnded.value && !isVideoError.value)
);
const loadingText = computed(() =>
bufferedProgress.value > 0 ? `视频加载中 ${bufferedProgress.value}%` : "视频加载中"
);
const getVideoContext = () => uni.createVideoContext(videoId, instance?.proxy || instance);
const resetVideoState = () => {
isVideoLoading.value = isVideo.value;
isVideoEnded.value = false;
isVideoError.value = false;
hasVideoStarted.value = false;
bufferedProgress.value = 0;
};
const handleVideoLoaded = () => {
isVideoLoading.value = false;
isVideoError.value = false;
};
const handleVideoPlay = () => {
hasVideoStarted.value = true;
isVideoLoading.value = false;
isVideoEnded.value = false;
isVideoError.value = false;
};
const handleVideoWaiting = () => {
if (hasVideoStarted.value) {
isVideoLoading.value = true;
}
};
const handleVideoTimeUpdate = () => {
if (isVideoLoading.value) {
isVideoLoading.value = false;
}
};
const handleVideoProgress = (event) => {
const buffered = Number(event?.detail?.buffered);
if (Number.isFinite(buffered)) {
bufferedProgress.value = Math.max(0, Math.min(100, Math.round(buffered)));
}
};
const handleVideoEnded = () => {
isVideoLoading.value = false;
isVideoEnded.value = true;
};
const handleVideoError = (event) => {
isVideoLoading.value = false;
isVideoEnded.value = false;
isVideoError.value = true;
console.error("AIGC视频加载失败", event?.detail?.errMsg || event?.detail || "unknown error");
};
const handleReplay = () => {
isVideoEnded.value = false;
isVideoLoading.value = true;
const videoContext = getVideoContext();
videoContext?.seek?.(0);
videoContext?.play?.();
};
const handleVideoRetry = () => {
videoLoadVersion.value += 1;
resetVideoState();
nextTick(() => {
getVideoContext()?.play?.();
});
};
watch(
() => props.result.videoResultUrl,
() => {
resetVideoState();
}
);
</script>
<style scoped lang="scss">

View File

@@ -32,6 +32,7 @@
rgba(0, 0, 0, 0.74) 100%
);
pointer-events: none;
z-index: 1;
}
.result-preview-caption {
@@ -40,6 +41,86 @@
bottom: 18px;
left: 18px;
pointer-events: none;
z-index: 2;
}
.result-video-state {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
}
.result-video-state.is-loading {
pointer-events: none;
background: rgba(9, 25, 39, 0.2);
}
.result-video-state.is-ended,
.result-video-state.is-error {
background: rgba(9, 25, 39, 0.52);
}
.result-video-loading-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: rgba(9, 25, 39, 0.48);
animation: result-video-spin 1s linear infinite;
}
.result-video-state-text,
.result-video-error-title {
color: #ffffff;
font-size: 13px;
line-height: 20px;
font-weight: 700;
}
.result-video-error-title {
font-size: 15px;
}
.result-video-state-action {
min-width: 132px;
height: 48px;
padding: 0 20px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.94);
color: #17324d;
font-size: 14px;
line-height: 20px;
font-weight: 800;
box-shadow: 0 8px 22px rgba(9, 25, 39, 0.22);
}
.result-video-state-action.is-pressed {
transform: scale(0.97);
opacity: 0.9;
}
@keyframes result-video-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.result-preview-badge {

View File

@@ -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({
@@ -235,24 +246,54 @@ const handleRecharge = () => {
});
};
const downloadMedia = (url) =>
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(url);
resolve({ filePath: url, shouldCleanup: false });
return;
}
uni.downloadFile({
const downloadOptions = {
url,
success: (res) => {
if (res.statusCode === 200 && res.tempFilePath) {
resolve(res.tempFilePath);
const filePath = res.filePath || res.tempFilePath;
if (res.statusCode === 200 && filePath) {
resolve({ filePath, shouldCleanup: Boolean(downloadOptions.filePath) });
return;
}
reject(res);
reject(createMediaError("download", res));
},
fail: reject,
});
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) =>
@@ -313,6 +354,26 @@ const isAlbumPermissionDenied = (error) => {
);
};
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({
@@ -343,23 +404,42 @@ const saveResultMedia = async () => {
let saveError = null;
let loadingVisible = false;
let downloadedMedia = null;
try {
await ensureAlbumPermission();
try {
await ensureAlbumPermission();
} catch (error) {
throw createMediaError("permission", error);
}
uni.showLoading({
title: "保存中",
mask: true,
});
loadingVisible = true;
const filePath = await downloadMedia(mediaUrl);
if (isVideo) {
await saveVideoToAlbum(filePath);
} else {
await saveImageToAlbum(filePath);
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();
}
@@ -376,6 +456,10 @@ const saveResultMedia = async () => {
if (isAlbumPermissionDenied(saveError)) {
promptAlbumPermission(mediaType);
} else if (isDownloadDomainError(saveError)) {
showPlaceholderToast("资源下载域名未配置");
} else if (saveError?.stage === "download") {
showPlaceholderToast("资源下载失败,请稍后重试");
} else {
showPlaceholderToast(`保存${mediaName}失败`);
}
@@ -425,8 +509,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 = () => {

View File

@@ -4,7 +4,6 @@
flex-direction: column;
width: 100vw;
height: 100vh;
min-height: 812px;
overflow: hidden;
color: #172033;
background:

View File

@@ -16,7 +16,7 @@ defineProps({
},
title: {
type: String,
default: "300积分已到账",
default: "200积分已到账",
},
message: {
type: String,

View File

@@ -4,19 +4,22 @@
:class="{ 'is-active': active }"
>
<view class="template-media">
<image v-if="templatePosterUrl" class="template-image" :src="templatePosterUrl" mode="aspectFill" />
<video
v-if="templateMediaUrl && isVideoTemplate"
v-else-if="templateVideoUrl && playing && !videoLoadFailed"
class="template-video"
:src="templateMediaUrl"
:src="templateVideoUrl"
autoplay
muted
loop
:controls="false"
:show-play-btn="false"
:show-center-play-btn="false"
:show-fullscreen-btn="false"
:enable-progress-gesture="false"
object-fit="cover"
@error="handleVideoError"
/>
<image v-else-if="templateMediaUrl" class="template-image" :src="templateMediaUrl" mode="aspectFill" />
<view class="template-media-shade" />
</view>
@@ -33,7 +36,7 @@
</template>
<script setup>
import { computed } from "vue";
import { computed, ref, watch } from "vue";
const props = defineProps({
template: {
@@ -44,11 +47,16 @@ const props = defineProps({
type: Boolean,
default: false,
},
playing: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["select"]);
const VIDEO_EXT_REGEXP = /\.(mp4|mov|webm|m4v)(\?.*)?$/i;
const VIDEO_EXT_REGEXP = /\.(mp4|mov|webm|m4v)(?:[?#].*)?$/i;
const videoLoadFailed = ref(false);
const templateMediaUrl = computed(() => {
return props.template.templateContentUrl || props.template.coverPhotoUrl || "";
@@ -57,6 +65,23 @@ const templateMediaUrl = computed(() => {
const isVideoTemplate = computed(() => {
return VIDEO_EXT_REGEXP.test(templateMediaUrl.value);
});
const templateVideoUrl = computed(() => (isVideoTemplate.value ? templateMediaUrl.value : ""));
const templatePosterUrl = computed(() => {
const coverUrl = props.template.coverPhotoUrl || "";
if (isVideoTemplate.value) {
return coverUrl && !VIDEO_EXT_REGEXP.test(coverUrl) ? coverUrl : "";
}
return templateMediaUrl.value;
});
const handleVideoError = () => {
videoLoadFailed.value = true;
};
watch(templateVideoUrl, () => {
videoLoadFailed.value = false;
});
</script>
<style scoped lang="scss">

View File

@@ -6,24 +6,30 @@
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
border-radius: var(--template-card-radius, 28px);
background: linear-gradient(180deg, #0f7069, #082226);
color: #ffffff;
text-align: left;
box-shadow: 0 3px 8px rgba(24, 37, 50, 0.12);
transform: scale(0.96);
transform-origin: center;
transition: box-shadow 0.18s ease, transform 0.18s ease;
opacity: 0.82;
transition:
box-shadow 0.2s ease,
opacity 0.2s ease;
}
.template-card.is-active {
border-color: rgba(255, 255, 255, 0.34);
box-shadow: 0 5px 12px rgba(24, 37, 50, 0.16);
transform: scale(1);
opacity: 1;
}
.template-card.is-postcard .template-copy {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 34%),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0) 34%
),
linear-gradient(180deg, #0a4d46, #082226);
}
@@ -46,6 +52,10 @@
display: block;
}
.template-video {
pointer-events: none;
}
.template-media-shade {
position: absolute;
top: 0;
@@ -53,8 +63,18 @@
bottom: 0;
left: 0;
background:
radial-gradient(92% 45% at 50% 28%, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0) 62%),
linear-gradient(180deg, rgba(4, 22, 24, 0), rgba(4, 22, 24, 0.02) 58%, rgba(4, 22, 24, 0.24));
radial-gradient(
92% 45% at 50% 28%,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0) 62%
),
linear-gradient(
180deg,
rgba(4, 22, 24, 0),
rgba(4, 22, 24, 0.02) 58%,
rgba(4, 22, 24, 0.24)
);
pointer-events: none;
}
.template-copy {
@@ -66,7 +86,11 @@
align-items: flex-start;
padding: 15px 18px 14px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 34%),
linear-gradient(
180deg,
rgba(255, 255, 255, 0.06),
rgba(255, 255, 255, 0) 34%
),
linear-gradient(180deg, #0f7069, #092128);
box-sizing: border-box;
}

View File

@@ -7,18 +7,24 @@
</view>
</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">
<view class="template-track">
<TemplateCard v-for="(template, index) in templates" :key="template.templateId || index" :template="template"
:active="index === modelValue" @select="handleSelect(template, index)" />
</view>
</scroll-view>
<swiper class="template-rail" :current="currentIndex" :duration="280" :previous-margin="swiperMargin"
:next-margin="swiperMargin" :skip-hidden-item-layout="true" easing-function="easeOutCubic"
@change="handleSwiperChange" @transition="handleSwiperTransition"
@animationfinish="handleSwiperAnimationFinish">
<swiper-item v-for="(template, index) in templates" :key="template.templateId || index">
<view class="template-slide">
<view class="template-slide-card" :class="{ 'is-active': index === currentIndex }">
<TemplateCard :template="template" :active="index === currentIndex"
:playing="!isSwiperMoving && index === settledIndex"
@select="handleSelect(template, index)" />
</view>
</view>
</swiper-item>
</swiper>
<view class="template-dots">
<view v-for="(template, index) in templates" :key="`${template.templateId || index}-dot`" class="template-dot"
:class="{ 'is-active': index === modelValue }" />
:class="{ 'is-active': index === currentIndex }" />
</view>
</view>
</template>
@@ -46,9 +52,7 @@ const BASE_MEDIA_HEIGHT = 358;
const BASE_RAIL_EXTRA_HEIGHT = 18;
const BASE_RAIL_HEIGHT = BASE_CARD_HEIGHT + BASE_RAIL_EXTRA_HEIGHT;
const BASE_TRACK_GAP = 12;
const BASE_TRACK_END_PADDING = 41;
const BASE_SIDE_PADDING = 16;
const BASE_SWITCH_THRESHOLD = 88;
const HEADING_TOTAL_HEIGHT = 54;
const DOTS_TOTAL_HEIGHT = 19;
const FLOW_STEPS_TOTAL_HEIGHT = 67;
@@ -59,14 +63,11 @@ const MAX_CARD_SCALE = 1;
const screenWidth = ref(375);
const screenHeight = ref(812);
const cardScale = ref(1);
const railScrollLeft = ref(0);
const currentScrollLeft = ref(0);
const settledIndex = ref(0);
const isSwiperMoving = ref(false);
const instance = getCurrentInstance();
let touchStartLeft = 0;
let touchStartIndex = 0;
let isTouching = false;
let settleTimer = null;
let isSwiperTransitioning = false;
const getMaxIndex = () => Math.max(props.templates.length - 1, 0);
@@ -78,9 +79,12 @@ const mediaHeight = computed(() => Math.round(BASE_MEDIA_HEIGHT * cardScale.valu
const copyHeight = computed(() => Math.max(cardHeight.value - mediaHeight.value, 96));
const railHeight = computed(() => Math.round(BASE_RAIL_HEIGHT * cardScale.value));
const trackGap = computed(() => Math.round(BASE_TRACK_GAP * cardScale.value));
const trackEndPadding = computed(() => Math.round(BASE_TRACK_END_PADDING * cardScale.value));
const cardStep = computed(() => cardWidth.value + trackGap.value);
const switchThreshold = computed(() => Math.round(BASE_SWITCH_THRESHOLD * cardScale.value));
const currentIndex = computed(() => clampIndex(props.modelValue));
const swiperMarginValue = computed(() => {
const margin = (screenWidth.value - cardWidth.value - trackGap.value) / 2;
return Math.max(0, Math.round(margin));
});
const swiperMargin = computed(() => `${swiperMarginValue.value}px`);
const carouselStyle = computed(() => ({
"--template-card-width": `${cardWidth.value}px`,
@@ -90,8 +94,6 @@ const carouselStyle = computed(() => ({
"--template-card-radius": `${Math.round(28 * cardScale.value)}px`,
"--template-rail-height": `${railHeight.value}px`,
"--template-track-gap": `${trackGap.value}px`,
"--template-track-end-padding": `${trackEndPadding.value}px`,
"--template-side-padding": `${BASE_SIDE_PADDING}px`,
}));
const getScreenSize = () => {
@@ -143,17 +145,6 @@ const getNextScale = (carouselTop = 0) => {
return Math.max(MIN_CARD_SCALE, nextScale);
};
const getCenterOffset = () => (screenWidth.value - cardWidth.value) / 2 - BASE_SIDE_PADDING;
const getTargetScrollLeft = (index) => {
const targetIndex = clampIndex(index);
if (targetIndex === 0) return 0;
return Math.round(targetIndex * cardStep.value - getCenterOffset());
};
const getNearestIndex = (left) =>
clampIndex(Math.round((left + getCenterOffset()) / cardStep.value));
const updateLayoutMetrics = async (size = {}) => {
const screenSize = getScreenSize();
screenWidth.value = Number(size.windowWidth) || screenSize.width;
@@ -163,88 +154,61 @@ const updateLayoutMetrics = async (size = {}) => {
const carouselTop = await getCarouselTop();
cardScale.value = getNextScale(carouselTop);
scrollToIndex(props.modelValue);
};
const handleWindowResize = (event) => {
updateLayoutMetrics(event?.size);
};
const setRailScrollLeft = async (targetLeft, force = false) => {
await nextTick();
if (force && railScrollLeft.value === targetLeft) {
railScrollLeft.value = targetLeft > 0 ? targetLeft - 1 : 1;
setTimeout(() => {
railScrollLeft.value = targetLeft;
currentScrollLeft.value = targetLeft;
}, 0);
return;
}
railScrollLeft.value = targetLeft;
currentScrollLeft.value = targetLeft;
};
const scrollToIndex = (index, force = false) => {
setRailScrollLeft(getTargetScrollLeft(index), force);
};
const settleToIndex = (index, force = true) => {
const targetIndex = clampIndex(index);
if (targetIndex !== props.modelValue) {
emit("update:modelValue", targetIndex);
}
scrollToIndex(targetIndex, force);
};
const handleScroll = (event) => {
currentScrollLeft.value = event.detail?.scrollLeft || 0;
if (!isTouching) {
clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
settleToIndex(getNearestIndex(currentScrollLeft.value));
}, 140);
const handleSwiperChange = (event) => {
const nextIndex = clampIndex(Number(event.detail?.current) || 0);
isSwiperTransitioning = true;
if (nextIndex !== props.modelValue) {
emit("update:modelValue", nextIndex);
}
};
const handleTouchStart = () => {
clearTimeout(settleTimer);
isTouching = true;
touchStartLeft = currentScrollLeft.value;
touchStartIndex = clampIndex(props.modelValue);
const handleSwiperTransition = (event) => {
const offset = Number(event.detail?.dx) || 0;
if (Math.abs(offset) > 1) {
if (!isSwiperMoving.value) {
isSwiperMoving.value = true;
}
}
};
const handleTouchEnd = () => {
if (!isTouching) return;
isTouching = false;
clearTimeout(settleTimer);
const offset = currentScrollLeft.value - touchStartLeft;
const direction = offset > 0 ? 1 : -1;
const targetIndex =
Math.abs(offset) >= switchThreshold.value
? touchStartIndex + direction
: touchStartIndex;
settleToIndex(targetIndex);
const handleSwiperAnimationFinish = (event) => {
isSwiperTransitioning = false;
settledIndex.value = clampIndex(Number(event.detail?.current) || 0);
isSwiperMoving.value = false;
};
const handleSelect = (template, index) => {
settleToIndex(index);
const nextIndex = clampIndex(index);
if (nextIndex !== props.modelValue) {
emit("update:modelValue", nextIndex);
}
emit("select", template, index);
};
watch(
() => [props.modelValue, props.templates.length],
() => {
scrollToIndex(props.modelValue);
() => props.modelValue,
(value) => {
if (!isSwiperTransitioning) {
settledIndex.value = clampIndex(value);
isSwiperMoving.value = false;
}
},
{ immediate: true }
);
watch(
() => props.templates.length,
() => {
settledIndex.value = currentIndex.value;
}
);
onMounted(() => {
updateLayoutMetrics();
if (typeof uni.onWindowResize === "function") {
@@ -253,7 +217,6 @@ onMounted(() => {
});
onUnmounted(() => {
clearTimeout(settleTimer);
if (typeof uni.offWindowResize === "function") {
uni.offWindowResize(handleWindowResize);
}

View File

@@ -32,27 +32,33 @@
}
.template-rail {
max-width: 100vw;
width: 100%;
height: var(--template-rail-height, 560px);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.template-rail::-webkit-scrollbar {
display: none;
}
.template-track {
display: inline-flex;
.template-slide {
width: 100%;
height: var(--template-rail-height, 560px);
display: flex;
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);
justify-content: center;
padding: 0 calc(var(--template-track-gap, 12px) / 2);
box-sizing: border-box;
}
.template-slide-card {
width: var(--template-card-width, 318px);
height: var(--template-card-height, 494px);
flex: 0 0 var(--template-card-width, 318px);
transform-origin: center;
transform: scale(0.94);
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.template-slide-card.is-active {
transform: scale(1);
}
.template-dots {
height: 14px;
display: flex;

View File

@@ -70,7 +70,7 @@ const props = defineProps({
},
});
const emit = defineEmits(["continue", "view"]);
const emit = defineEmits(["continue", "view", "complete"]);
const TASK_STATUS_PROGRESS = {
0: 23,
@@ -99,10 +99,19 @@ const clearProgressTimer = () => {
}
};
const emitComplete = () => {
if (Number(props.taskStatus) === 2 && normalizedProgress.value === 100) {
emit("complete");
}
};
const startProgressAnimation = () => {
clearProgressTimer();
const target = targetProgress.value;
if (normalizedProgress.value === target) return;
if (normalizedProgress.value === target) {
emitComplete();
return;
}
progressTimer = setInterval(() => {
const current = normalizedProgress.value;
@@ -114,6 +123,7 @@ const startProgressAnimation = () => {
displayProgress.value = nextProgress;
if (nextProgress === target) {
clearProgressTimer();
emitComplete();
}
}, 60);
};

View File

@@ -7,7 +7,8 @@
<view class="aigc-progress-content">
<GeneratingProgressPanel v-if="taskDetail" :cost="taskDetail.generatorCost"
:generator-type="taskDetail.generatorType" :item-title="taskDetail.itemTitle" :task-id="taskId"
:task-status="taskDetail.taskStatus" @continue="handleContinueTemplates" @view="handleViewTasks" />
:task-status="taskDetail.taskStatus" @continue="handleContinueTemplates" @view="handleViewTasks"
@complete="handleProgressComplete" />
</view>
</scroll-view>
</view>
@@ -15,7 +16,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 +25,11 @@ 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;
let isNavigatingToDetail = false;
const showToast = (title) => {
uni.showToast({
@@ -37,42 +43,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 = () => {
@@ -117,6 +180,20 @@ const handleRecharge = () => {
fail: () => showToast("打开积分充值失败"),
});
};
const handleProgressComplete = () => {
if (isNavigatingToDetail || !taskId.value || !isTaskCompleted()) return;
isNavigatingToDetail = true;
stopTaskDetailPolling();
uni.redirectTo({
url: `/pages-aigc/detail/detail?taskId=${encodeURIComponent(taskId.value)}`,
fail: () => {
isNavigatingToDetail = false;
showToast("打开生成详情失败");
},
});
};
</script>
<style scoped lang="scss">

View File

@@ -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 () => {