Files
ece.suwa3d.com/src/views/douyin/generating.vue
T
Linzm 17908ab309
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
修改:点云效果优化
2026-10-20 16:46:01 +08:00

829 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="gen-page" :class="{ 'is-result': showResult }">
<!-- ===== 生成中 ===== -->
<template v-if="!showResult">
<DyNavBar
theme="dark"
title="生成中"
back-text="‹ 返回"
right-text="记录"
border
@back="goHome"
@right="goRecords"
/>
<div class="stage-area">
<div class="stage-center">
<div class="stage-shell" :style="shellStyle">
<div class="stage-box" :style="stageStyle">
<div class="stage-layer">
<ImagePointCloud
point-shape="circle"
:side="stageSide"
:fading="cloudFading"
:image-url="sourceUrl"
:mask-url="maskUrl"
:result-url="resultUrl"
@phasechange="onPhaseChange"
@handoff="onHandoff"
/>
</div>
<!-- 效果图点云阶段不叠底图,等揭示后再进预览页展示 -->
</div>
</div>
<div v-if="errorTip" class="error-tip">{{ errorTip }}</div>
</div>
</div>
<div class="progress-dock">
<div class="bar"><div class="inner" :style="{ width: `${progress}%` }"></div></div>
<div class="meta">
<div>
<div class="tip">{{ tip }}</div>
<div class="sub">{{ subTip }}</div>
</div>
<span>{{ Math.floor(progress) }}%</span>
</div>
</div>
</template>
<!-- ===== 效果预览(对齐设计图) ===== -->
<template v-else>
<DyNavBar
theme="light"
title="效果预览"
right-text="生图记录"
border
@back="goBack"
@right="goRecords"
/>
<div class="result-body">
<div class="result-head">
<div class="status-row">
<span class="status-dot" />
<span class="status-text">生成完成</span>
</div>
<h1 class="headline">专属效果已就绪</h1>
<p class="subline">确认满意后即可提交制作</p>
</div>
<div class="preview-card" @click="openZoom">
<img v-if="resultUrl" class="preview-img" :src="resultUrl" alt="效果图">
<div v-if="sourceUrl" class="source-polaroid" aria-hidden="true">
<img :src="sourceUrl" alt="">
</div>
<button class="zoom-btn" type="button" @click.stop="openZoom">点击放大</button>
</div>
<p class="save-hint">效果已保存到生图记录 · 可随时回来查看</p>
<div v-if="errorTip" class="error-tip light">{{ errorTip }}</div>
</div>
<div class="result-footer">
<button class="btn-remake" type="button" @click="goHome">重新制作</button>
<button
class="btn-submit"
type="button"
:disabled="ordering || !taskId"
@click="openConfirmModal"
>
满意,提交制作
</button>
</div>
<!-- 确认弹窗 -->
<div v-if="showConfirmModal" class="confirm-mask" @click.self="showConfirmModal = false">
<div class="confirm-sheet">
<div class="confirm-title">确认效果满意</div>
<div class="confirm-body">
<p>请仔细确认效果是否满意后再提交</p>
<p class="confirm-sub">提交制作后将进入制作流程,请确认效果满意后再继续</p>
</div>
<div class="confirm-actions">
<button class="btn-cancel" type="button" @click="showConfirmModal = false">返回查看</button>
<button
class="btn-ok"
type="button"
:disabled="ordering"
@click="confirmSubmit"
>
提交制作
</button>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { showImagePreview, showToast } from 'vant'
import * as douyinApi from '@/api/douyin'
import { douyinConfig as config } from '@/config/douyin'
import ImagePointCloud from './components/ImagePointCloud.vue'
import DyNavBar from './components/DyNavBar.vue'
import { takePendingUpload } from './pendingUpload'
function isSubmittedBizError(msg: string) {
return /已提交制作|不能继续上传|不能继续生图/.test(String(msg || ''))
}
const route = useRoute()
const router = useRouter()
const taskId = ref(String(route.query.task_id || sessionStorage.getItem('douyin_task_id') || ''))
const pid = ref(String(route.query.pid || sessionStorage.getItem('douyin_pid') || ''))
const sourceUrl = ref(sessionStorage.getItem('douyin_source_preview') || '')
const maskUrl = ref('')
const resultUrl = ref('')
const imageId = ref('')
const tip = ref('准备中')
const subTip = ref('正在启动')
const progress = ref(2)
const showResult = ref(false)
const cloudFading = ref(false)
const ordering = ref(false)
const errorTip = ref('')
const showConfirmModal = ref(false)
/** 红框正方形边长:按 app-shell 实际宽度算,避免桌面端用整窗宽导致偏大/错位 */
function calcStageSide() {
if (typeof window === 'undefined') return 280
const shell = document.querySelector('.app-shell') as HTMLElement | null
const w = shell?.clientWidth || Math.min(window.innerWidth || 375, 540)
const h = window.innerHeight || 667
// 约容器宽 72%、屏高 40%,上限 320,贴近预览卡片区域
return Math.max(180, Math.round(Math.min(w * 0.72, h * 0.4, 320)))
}
const stageSide = ref(calcStageSide())
const stageStyle = computed(() => ({
width: `${stageSide.value}px`,
height: `${stageSide.value}px`,
}))
const shellStyle = computed(() => ({
width: showResult.value ? `${stageSide.value + 32}px` : `${stageSide.value}px`,
}))
let pollTimer: ReturnType<typeof setInterval> | null = null
let softTimer: ReturnType<typeof setInterval> | null = null
let handoffDone = false
let maskAborted = false
let uploading = false
let fadeTimer: ReturnType<typeof setTimeout> | null = null
function onResize() {
stageSide.value = calcStageSide()
}
function goHome() {
router.replace(config.routes.home)
}
/** 预览顶栏返回:有历史则回上一页,否则回首页 */
function goBack() {
if (window.history.length > 1) {
router.back()
return
}
router.replace(config.routes.home)
}
function goRecords() {
router.push(config.routes.records)
}
let progressAnimTimer: ReturnType<typeof setInterval> | null = null
function clearTimers() {
if (pollTimer) clearInterval(pollTimer)
if (softTimer) clearInterval(softTimer)
if (fadeTimer) clearTimeout(fadeTimer)
if (progressAnimTimer) clearInterval(progressAnimTimer)
pollTimer = null
softTimer = null
fadeTimer = null
progressAnimTimer = null
}
function onPhaseChange(payload: { phase: string; tip: string; subTip: string }) {
tip.value = payload.tip || tip.value
subTip.value = payload.subTip || subTip.value
const phase = payload.phase
if (phase?.startsWith('SUBJECT')) progress.value = Math.max(progress.value, 58)
if (phase?.startsWith('RESULT_CLOUD')) progress.value = Math.max(progress.value, 90)
if (phase === 'RESULT_IMAGE_REVEAL' && resultUrl.value) {
startSmoothReveal()
}
if (phase === 'RESULT_IMAGE_REVEAL' || phase === 'RESULT_PREVIEW' || phase === 'NAVIGATING') {
// 预览前由 startSmoothReveal 动画到 100,这里兜底
if (showResult.value) progress.value = 100
}
}
/** 平滑加速到目标进度 */
function animateProgressTo(target: number, durationMs = 480): Promise<void> {
return new Promise((resolve) => {
if (progressAnimTimer) {
clearInterval(progressAnimTimer)
progressAnimTimer = null
}
const from = progress.value
const to = Math.max(from, Math.min(100, target))
if (to <= from + 0.2) {
progress.value = to
resolve()
return
}
const start = Date.now()
progressAnimTimer = setInterval(() => {
const t = Math.min(1, (Date.now() - start) / durationMs)
// ease-out
const eased = 1 - (1 - t) * (1 - t)
progress.value = from + (to - from) * eased
if (t >= 1) {
if (progressAnimTimer) clearInterval(progressAnimTimer)
progressAnimTimer = null
progress.value = to
resolve()
}
}, 32)
})
}
function startSmoothReveal() {
if (showResult.value || !resultUrl.value) return
// 先把进度条跑到 100%,再切效果预览,保证用户能看到满格
animateProgressTo(100, 520).then(() => {
if (showResult.value) return
cloudFading.value = true
requestAnimationFrame(() => {
showResult.value = true
})
sessionStorage.setItem('douyin_task_id', taskId.value)
sessionStorage.setItem('douyin_image_id', imageId.value)
sessionStorage.setItem('douyin_result_url', resultUrl.value)
})
}
function enterResultView() {
if (handoffDone) return
handoffDone = true
// 停轮询/软进度,但保留进度冲满动画
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
if (softTimer) {
clearInterval(softTimer)
softTimer = null
}
if (fadeTimer) {
clearTimeout(fadeTimer)
fadeTimer = null
}
startSmoothReveal()
}
function onHandoff() {
enterResultView()
}
function startSoftProgress() {
// 固定节奏:每 300ms +1%,效果图就绪后冲到 96%,预览前再动画到 100%
softTimer = setInterval(() => {
if (showResult.value) return
const cap = resultUrl.value ? 96 : config.poll.softCap
if (progress.value >= cap) return
progress.value = Math.min(cap, progress.value + 1)
}, 300)
}
function startMaskFetch() {
if (!pid.value) return
maskAborted = false
douyinApi
.waitMaskUrl({ pid: pid.value, intervalMs: 2000, timeoutMs: 60000 })
.then((res) => {
if (maskAborted || showResult.value) return
if (res && res.url) {
maskUrl.value = res.url
sessionStorage.setItem('douyin_mask_url', res.url)
}
})
.catch(() => {})
}
function pollStatus() {
if (!taskId.value) return
douyinApi.getImageGenStatus(taskId.value).then((res: any) => {
const parsed = douyinApi.parseImageGenStatus(res, taskId.value)
if (parsed.status === 'failed') {
clearTimers()
errorTip.value = parsed.message || '生成失败,请返回重试'
return
}
if (parsed.status === 'success' && parsed.resultUrl) {
resultUrl.value = parsed.resultUrl
// make/submit 必须用效果图 image_id,禁止回退成 task_id
if (parsed.imageId) {
imageId.value = parsed.imageId
sessionStorage.setItem('douyin_image_id', parsed.imageId)
}
if (fadeTimer) clearTimeout(fadeTimer)
fadeTimer = setTimeout(() => {
if (!handoffDone) enterResultView()
}, 10000)
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
}).catch(() => {})
}
async function beginPendingUpload() {
if (uploading) return
const { file, preview } = takePendingUpload()
if (!file) {
errorTip.value = '未找到待上传图片,请返回重新选择'
return
}
uploading = true
if (preview) sourceUrl.value = preview
tip.value = '上传中'
subTip.value = '请稍候'
progress.value = Math.max(progress.value, 8)
try {
const res = await douyinApi.startGeneratePipeline(file, {
filename: file.name,
content_type: file.type || 'image/jpeg',
style: config.messages.effectTitle,
n: 1,
})
taskId.value = res.taskId
if (res.pid != null) {
pid.value = String(res.pid)
sessionStorage.setItem('douyin_pid', String(res.pid))
}
sessionStorage.setItem('douyin_task_id', res.taskId)
if (res.upload_id) sessionStorage.setItem('douyin_upload_id', res.upload_id)
if (res.file_url) {
sessionStorage.setItem('douyin_file_url', res.file_url)
sourceUrl.value = res.file_url
}
tip.value = '生成中'
subTip.value = '请稍候'
progress.value = Math.max(progress.value, 18)
startMaskFetch()
pollStatus()
pollTimer = setInterval(pollStatus, config.poll.interval)
} catch (err: any) {
const msg = err?.message || '上传失败,请返回重试'
errorTip.value = msg
// 已提交制作:不停留等待页,标记锁定并回首页
if (isSubmittedBizError(msg)) {
showToast(msg)
setTimeout(() => {
router.replace(config.routes.home)
}, 600)
}
} finally {
uploading = false
}
}
function openZoom() {
if (!resultUrl.value) return
showImagePreview({
images: [resultUrl.value],
closeable: true,
closeIconPosition: 'top-right',
})
}
function openConfirmModal() {
if (ordering.value || !taskId.value) return
showConfirmModal.value = true
}
async function confirmSubmit() {
if (ordering.value || !taskId.value) return
ordering.value = true
errorTip.value = ''
try {
const res = await douyinApi.submitProduction({
task_id: taskId.value,
image_id: imageId.value,
})
if (res.imageId) {
imageId.value = res.imageId
sessionStorage.setItem('douyin_image_id', res.imageId)
}
showConfirmModal.value = false
router.replace(config.routes.success)
} catch (err: any) {
errorTip.value = err?.message || '提交失败,请重试'
showConfirmModal.value = false
} finally {
ordering.value = false
}
}
onMounted(() => {
stageSide.value = calcStageSide()
window.addEventListener('resize', onResize)
startSoftProgress()
const pending = route.query.pending === '1'
if (pending) {
beginPendingUpload()
return
}
if (!taskId.value) {
errorTip.value = '缺少任务参数,请返回重新生成'
return
}
const fileUrl = sessionStorage.getItem('douyin_file_url')
if (fileUrl) sourceUrl.value = fileUrl
startMaskFetch()
pollStatus()
pollTimer = setInterval(pollStatus, config.poll.interval)
})
onUnmounted(() => {
maskAborted = true
clearTimers()
window.removeEventListener('resize', onResize)
})
</script>
<style scoped lang="scss">
.gen-page {
position: relative;
min-height: 100vh;
min-height: 100dvh;
background: #000000;
color: #f2f2f2;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
transition: background-color 0.85s cubic-bezier(0.4, 0, 0.2, 1);
}
.gen-page.is-result {
background: #ffffff;
color: #1a1a1a;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
}
.stage-area {
position: relative;
flex: 1 1 auto;
width: 100%;
min-height: 240px;
}
.stage-center {
position: fixed;
left: 50%;
top: 46%;
transform: translate(-50%, -50%);
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
width: max-content;
max-width: calc(100% - 32px);
}
.stage-shell {
margin: 0 auto;
overflow: hidden;
background: transparent;
}
.stage-box {
position: relative;
overflow: hidden;
background: #000000;
}
.stage-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.stage-layer > :deep(.ipc) {
width: 100% !important;
height: 100% !important;
}
.error-tip {
margin-top: 12px;
max-width: 280px;
text-align: center;
font-size: 13px;
color: #ff8a8a;
}
.error-tip.light {
color: #e05a5a;
margin: 8px auto 0;
}
.progress-dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
padding: 16px 24px calc(20px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.88) 40%);
}
.bar {
height: 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.inner {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
transition: width 0.35s ease;
}
.meta {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: flex-start;
font-size: 13px;
color: rgba(255, 255, 255, 0.55);
}
.tip {
font-size: 14px;
color: #f2f2f2;
font-weight: 600;
}
.sub {
margin-top: 2px;
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
/* —— 效果预览(设计图) —— */
.result-body {
padding: 4px 18px 24px;
animation: resultIn 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes resultIn {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.result-head {
margin-bottom: 16px;
}
.status-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #5fbf45;
box-shadow: 0 0 0 3px rgba(95, 191, 69, 0.22);
}
.status-text {
font-size: 13px;
font-weight: 600;
color: #5fbf45;
}
.headline {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #111;
letter-spacing: 0.02em;
line-height: 1.25;
}
.subline {
margin: 6px 0 0;
font-size: 13px;
color: #8a9586;
line-height: 1.4;
}
.preview-card {
position: relative;
width: 100%;
aspect-ratio: 3 / 3.55;
max-height: 58vh;
border-radius: 28px;
overflow: hidden;
background: #ffffff;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
}
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.source-polaroid {
position: absolute;
left: 14px;
top: 18%;
width: 72px;
padding: 5px 5px 14px;
background: #fff;
border-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
transform: rotate(-8deg);
z-index: 2;
pointer-events: none;
}
.source-polaroid img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
border-radius: 2px;
}
.zoom-btn {
position: absolute;
right: 12px;
bottom: 12px;
z-index: 3;
border: none;
border-radius: 999px;
padding: 7px 12px;
background: rgba(40, 44, 48, 0.55);
color: #fff;
font-size: 12px;
backdrop-filter: blur(6px);
}
.save-hint {
margin: 14px 0 0;
text-align: center;
font-size: 12px;
color: #9aa394;
line-height: 1.5;
}
.result-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 5;
display: flex;
gap: 10px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 28%);
box-sizing: border-box;
}
.btn-remake {
flex: 0 0 108px;
height: 48px;
border: none;
border-radius: 24px;
background: #e2eedd;
color: #4cae3a;
font-size: 15px;
font-weight: 600;
}
.btn-submit {
flex: 1;
height: 48px;
border: none;
border-radius: 24px;
background: linear-gradient(90deg, #7ed45a 0%, #5fbf45 100%);
color: #fff;
font-size: 16px;
font-weight: 700;
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.35);
}
.btn-submit:disabled {
opacity: 0.55;
box-shadow: none;
}
/* 确认弹窗 */
.confirm-mask {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}
.confirm-sheet {
width: 100%;
max-width: 320px;
border-radius: 18px;
overflow: hidden;
background: #fff;
box-shadow: 6px 8px 0 rgba(0, 0, 0, 0.55);
border: 2px solid #1a1a1a;
}
.confirm-title {
background: #5fbf45;
color: #fff;
text-align: center;
font-size: 17px;
font-weight: 700;
padding: 14px 16px;
}
.confirm-body {
padding: 20px 18px 8px;
text-align: center;
color: #333;
font-size: 14px;
line-height: 1.55;
}
.confirm-sub {
margin-top: 8px;
font-size: 12px;
color: #888;
}
.confirm-actions {
display: flex;
gap: 10px;
padding: 12px 16px 18px;
}
.btn-cancel,
.btn-ok {
flex: 1;
height: 42px;
border-radius: 21px;
font-size: 15px;
font-weight: 600;
}
.btn-cancel {
border: 1.5px solid #8fd96f;
background: #f3faf0;
color: #5fbf45;
}
.btn-ok {
border: none;
background: #5fbf45;
color: #fff;
}
.btn-ok:disabled {
opacity: 0.55;
}
</style>