修改:新增抖音下单页面
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing

This commit is contained in:
Linzm committed 2026-10-20 15:17:22 +08:00
1 parent 024c8a5155
commit 73971e4a08
38 files changed
+14447 -35505

No files matched your search

+855
View File
@@ -0,0 +1,855 @@
<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>
<img
v-if="resultUrl"
class="stage-layer result-layer"
:src="resultUrl"
alt=""
>
</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'
const SUBMITTED_KEY = 'douyin_order_submitted'
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)
/** 红框正方形边长:首屏即算好,不依赖 CSS min() */
function calcStageSide() {
if (typeof window === 'undefined') return 280
const w = window.innerWidth || 375
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() {
softTimer = setInterval(() => {
if (showResult.value) return
// 效果图已到:快速冲到 96%,留给揭示动画收尾到 100%
if (resultUrl.value) {
if (progress.value < 96) {
progress.value = Math.min(96, progress.value + 3.2)
}
return
}
const cap = config.poll.softCap
if (progress.value >= cap) return
// 整体加快:前段更快,后段仍有余量
let step = 2.4
if (progress.value >= 35) step = 1.4
if (progress.value >= 60) step = 0.85
if (progress.value >= 75) step = 0.45
progress.value = Math.min(cap, progress.value + step)
}, 280)
}
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)) {
sessionStorage.setItem(SUBMITTED_KEY, '1')
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)
}
sessionStorage.setItem(SUBMITTED_KEY, '1')
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(100vw - 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;
}
.result-layer {
object-fit: contain;
opacity: 0.35;
z-index: 2;
pointer-events: none;
background: transparent;
}
.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>