新增:抖音支持一个链接下多单;图片按裁剪3:4比例;根据返回的数据显示门店名称和门店logo
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing

This commit is contained in:
Linzm committed 2026-10-27 14:34:49 +08:00
1 parent 6bf542bf1d
commit 8e8cde9ef5
19 files changed
+2158 -404

No files matched your search

+185 -70
View File
@@ -18,8 +18,10 @@
<div class="stage-box" :style="stageStyle">
<div class="stage-layer">
<ImagePointCloud
:key="`${stageWidth}x${stageHeight}`"
point-shape="circle"
:side="stageSide"
:width="stageWidth"
:height="stageHeight"
:fading="cloudFading"
:image-url="sourceUrl"
:mask-url="maskUrl"
@@ -77,42 +79,49 @@
</div>
<p class="save-hint">效果已保存到生图记录 · 可随时回来查看</p>
<OrderSizeQtyPanel ref="orderPanelRef" @change="onOrderChange" />
<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>
<!-- Teleport 到 body:相对视口固定,避开 app-shell transform -->
<Teleport to="body">
<div class="dy-result-footer">
<button class="dy-result-footer__remake" type="button" @click="goHome">重新制作</button>
<button
class="dy-result-footer__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 v-if="showConfirmModal" class="dy-result-confirm-mask" @click.self="showConfirmModal = false">
<div class="dy-result-confirm-sheet">
<div class="dy-result-confirm-title">确认效果满意</div>
<div class="dy-result-confirm-body">
<p>请仔细确认效果是否满意后再提交</p>
<p class="dy-result-confirm-sub">
将提交 {{ orderQty }} 件
<template v-if="orderSize">(尺寸 {{ orderSize }})</template>
,进入制作流程
</p>
</div>
<div class="dy-result-confirm-actions">
<button class="dy-result-confirm-cancel" type="button" @click="showConfirmModal = false">返回查看</button>
<button
class="dy-result-confirm-ok"
type="button"
:disabled="ordering"
@click="confirmSubmit"
>
提交制作
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
</div>
</template>
@@ -125,6 +134,7 @@ 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 OrderSizeQtyPanel from './components/OrderSizeQtyPanel.vue'
import { takePendingUpload } from './pendingUpload'
function isSubmittedBizError(msg: string) {
@@ -148,24 +158,53 @@ const cloudFading = ref(false)
const ordering = ref(false)
const errorTip = ref('')
const showConfirmModal = ref(false)
const orderPanelRef = ref<InstanceType<typeof OrderSizeQtyPanel> | null>(null)
const orderQty = ref(1)
const orderSize = ref('')
/** 红框正方形边长:按 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)))
function onOrderChange(payload: {
quantity: number
remainTotal: number
selected: douyinApi.OrderRemainItem | null
}) {
orderQty.value = Math.max(1, Number(payload.quantity) || 1)
orderSize.value = String(payload.selected?.size || '')
}
const stageSide = ref(calcStageSide())
/** 上传图宽高比(裁剪后默认 3:4),舞台高度按此比例,不再强制正方形 */
const imageAspect = ref(3 / 4)
function calcStageSize() {
if (typeof window === 'undefined') {
return { width: 320, height: Math.round(320 * 4 / 3) }
}
const shell = document.querySelector('.app-shell') as HTMLElement | null
const shellW = shell?.clientWidth || Math.min(window.innerWidth || 375, 540)
const shellH = window.innerHeight || 667
const maxW = Math.max(200, shellW * 0.9)
const maxH = Math.max(220, shellH * 0.58)
const aspect = Math.max(0.35, Math.min(2.5, imageAspect.value || 3 / 4))
let width = maxW
let height = width / aspect
if (height > maxH) {
height = maxH
width = height * aspect
}
return {
width: Math.round(width),
height: Math.round(height),
}
}
const stageSize = ref(calcStageSize())
const stageWidth = computed(() => stageSize.value.width)
const stageHeight = computed(() => stageSize.value.height)
const stageStyle = computed(() => ({
width: `${stageSide.value}px`,
height: `${stageSide.value}px`,
width: `${stageWidth.value}px`,
height: `${stageHeight.value}px`,
}))
const shellStyle = computed(() => ({
width: showResult.value ? `${stageSide.value + 32}px` : `${stageSide.value}px`,
width: showResult.value ? `${stageWidth.value + 32}px` : `${stageWidth.value}px`,
}))
let pollTimer: ReturnType<typeof setInterval> | null = null
@@ -175,8 +214,34 @@ let maskAborted = false
let uploading = false
let fadeTimer: ReturnType<typeof setTimeout> | null = null
function refreshStageSize() {
stageSize.value = calcStageSize()
}
function syncImageAspect(url: string) {
if (!url) {
imageAspect.value = 3 / 4
refreshStageSize()
return
}
const img = new Image()
img.onload = () => {
const w = img.naturalWidth || 0
const h = img.naturalHeight || 0
if (w > 0 && h > 0) {
imageAspect.value = w / h
refreshStageSize()
}
}
img.onerror = () => {
imageAspect.value = 3 / 4
refreshStageSize()
}
img.src = url
}
function onResize() {
stageSide.value = calcStageSide()
refreshStageSize()
}
function goHome() {
@@ -355,7 +420,10 @@ async function beginPendingUpload() {
return
}
uploading = true
if (preview) sourceUrl.value = preview
if (preview) {
sourceUrl.value = preview
syncImageAspect(preview)
}
tip.value = '上传中'
subTip.value = '请稍候'
progress.value = Math.max(progress.value, 8)
@@ -410,34 +478,73 @@ function openZoom() {
function openConfirmModal() {
if (ordering.value || !taskId.value) return
const payload = orderPanelRef.value?.getOrderPayload?.()
if (!payload?.canOrder) {
showToast(config.messages.noOrderRemain)
return
}
if (payload.quantity > payload.remainTotal) {
showToast(config.records.messages.exceedOrderRemain)
return
}
showConfirmModal.value = true
}
async function confirmSubmit() {
if (ordering.value || !taskId.value) return
const payload = orderPanelRef.value?.getOrderPayload?.()
if (!payload?.canOrder) {
showToast(config.messages.noOrderRemain)
return
}
const qty = Math.max(1, Number(payload.quantity) || 1)
if (qty > payload.remainTotal) {
showToast(config.records.messages.exceedOrderRemain)
return
}
ordering.value = true
errorTip.value = ''
try {
const res = await douyinApi.submitProduction({
task_id: taskId.value,
image_id: imageId.value,
count: qty,
})
if (res.imageId) {
imageId.value = res.imageId
sessionStorage.setItem('douyin_image_id', res.imageId)
}
if (resultUrl.value) {
sessionStorage.setItem('douyin_result_url', resultUrl.value)
}
try {
sessionStorage.setItem(
'douyin_success_results',
JSON.stringify([{
url: resultUrl.value || '',
count: qty,
label: '',
taskId: taskId.value,
imageId: imageId.value,
}].filter((item) => item.url)),
)
} catch (_) {
sessionStorage.removeItem('douyin_success_results')
}
showConfirmModal.value = false
router.replace(config.routes.success)
} catch (err: any) {
errorTip.value = err?.message || '提交失败,请重试'
showConfirmModal.value = false
orderPanelRef.value?.refresh?.()
} finally {
ordering.value = false
}
}
onMounted(() => {
stageSide.value = calcStageSide()
syncImageAspect(sourceUrl.value)
window.addEventListener('resize', onResize)
startSoftProgress()
@@ -452,7 +559,10 @@ onMounted(() => {
return
}
const fileUrl = sessionStorage.getItem('douyin_file_url')
if (fileUrl) sourceUrl.value = fileUrl
if (fileUrl) {
sourceUrl.value = fileUrl
syncImageAspect(fileUrl)
}
startMaskFetch()
pollStatus()
pollTimer = setInterval(pollStatus, config.poll.interval)
@@ -483,7 +593,7 @@ onUnmounted(() => {
.gen-page.is-result {
background: #ffffff;
color: #1a1a1a;
padding-bottom: calc(88px + env(safe-area-inset-bottom));
padding-bottom: calc(108px + env(safe-area-inset-bottom));
}
.stage-area {
@@ -503,7 +613,7 @@ onUnmounted(() => {
flex-direction: column;
align-items: center;
width: max-content;
max-width: calc(100% - 32px);
max-width: 90vw;
}
.stage-shell {
@@ -710,20 +820,26 @@ onUnmounted(() => {
line-height: 1.5;
}
.result-footer {
</style>
<style lang="scss">
/* Teleport 到 body:相对视口固定在全屏底部(与 preview 共用类名) */
.dy-result-footer {
position: fixed;
left: 0;
right: 0;
left: 50%;
bottom: 0;
z-index: 5;
transform: translateX(-50%);
width: 100%;
max-width: 540px;
z-index: 1000;
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;
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, #ffffff 28%);
}
.btn-remake {
.dy-result-footer__remake {
flex: 0 0 108px;
height: 48px;
border: none;
@@ -734,7 +850,7 @@ onUnmounted(() => {
font-weight: 600;
}
.btn-submit {
.dy-result-footer__submit {
flex: 1;
height: 48px;
border: none;
@@ -746,16 +862,15 @@ onUnmounted(() => {
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.35);
}
.btn-submit:disabled {
.dy-result-footer__submit:disabled {
opacity: 0.55;
box-shadow: none;
}
/* 确认弹窗 */
.confirm-mask {
.dy-result-confirm-mask {
position: fixed;
inset: 0;
z-index: 20;
z-index: 1100;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
@@ -764,7 +879,7 @@ onUnmounted(() => {
box-sizing: border-box;
}
.confirm-sheet {
.dy-result-confirm-sheet {
width: 100%;
max-width: 320px;
border-radius: 18px;
@@ -774,7 +889,7 @@ onUnmounted(() => {
border: 2px solid #1a1a1a;
}
.confirm-title {
.dy-result-confirm-title {
background: #5fbf45;
color: #fff;
text-align: center;
@@ -783,7 +898,7 @@ onUnmounted(() => {
padding: 14px 16px;
}
.confirm-body {
.dy-result-confirm-body {
padding: 20px 18px 8px;
text-align: center;
color: #333;
@@ -791,20 +906,20 @@ onUnmounted(() => {
line-height: 1.55;
}
.confirm-sub {
.dy-result-confirm-sub {
margin-top: 8px;
font-size: 12px;
color: #888;
}
.confirm-actions {
.dy-result-confirm-actions {
display: flex;
gap: 10px;
padding: 12px 16px 18px;
}
.btn-cancel,
.btn-ok {
.dy-result-confirm-cancel,
.dy-result-confirm-ok {
flex: 1;
height: 42px;
border-radius: 21px;
@@ -812,19 +927,19 @@ onUnmounted(() => {
font-weight: 600;
}
.btn-cancel {
.dy-result-confirm-cancel {
border: 1.5px solid #8fd96f;
background: #f3faf0;
color: #5fbf45;
}
.btn-ok {
.dy-result-confirm-ok {
border: none;
background: #5fbf45;
color: #fff;
}
.btn-ok:disabled {
.dy-result-confirm-ok:disabled {
opacity: 0.55;
}
</style>