forked from natuka/web.puabadge.com
新增:抖音支持一个链接下多单;图片按裁剪3:4比例;根据返回的数据显示门店名称和门店logo
This commit is contained in:
1 parent
6bf542bf1d
commit
8e8cde9ef5
19 files changed
+2158
-404
No files matched your search
Vendored
+1
@@ -32,6 +32,7 @@ declare module '@vue/runtime-core' {
|
||||
MaskProcessor: typeof import('./src/views/douyin/components/image-point-cloud/mask-processor.js')['default']
|
||||
Model: typeof import('./src/components/model/index.vue')['default']
|
||||
ModelGsplat: typeof import('./src/components/modelGsplat/index.vue')['default']
|
||||
OrderSizeQtyPanel: typeof import('./src/views/douyin/components/OrderSizeQtyPanel.vue')['default']
|
||||
PointCloudSampler: typeof import('./src/views/douyin/components/image-point-cloud/point-cloud-sampler.js')['default']
|
||||
PointCloudWait: typeof import('./src/views/douyin/components/PointCloudWait.vue')['default']
|
||||
Renderer: typeof import('./src/views/douyin/components/image-point-cloud/webgl/renderer.js')['default']
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"vite": "^5.3.3",
|
||||
"vue": "^3.3.4",
|
||||
"vue-clipboard3": "^2.0.0",
|
||||
"vue-cropper": "^1.1.4",
|
||||
"vue-cropper-h5": "^2.0.1",
|
||||
"vue-i18n": "^9.9.0",
|
||||
"vue-router": "^4.2.2",
|
||||
|
||||
+115
-3
@@ -25,6 +25,109 @@ function guessFilename(file?: File) {
|
||||
return 'photo.jpg'
|
||||
}
|
||||
|
||||
export type OrderRemainItem = {
|
||||
ece_order_id?: number | string
|
||||
quantity: number
|
||||
remaining: number
|
||||
size: string
|
||||
sid?: string
|
||||
total_amount?: number
|
||||
pay_amount?: number
|
||||
shape_item?: any
|
||||
use_type?: number
|
||||
raw?: any
|
||||
}
|
||||
|
||||
export type ShopBrand = {
|
||||
shopId: number | string
|
||||
douyinShopId: string
|
||||
name: string
|
||||
logo: string
|
||||
description: string
|
||||
raw?: any
|
||||
}
|
||||
|
||||
/** GET /douyin/shopBrand —— 门店名称与 logo */
|
||||
export const getShopBrand = (params: { code: string }) => {
|
||||
return request(`${PREFIX}/shopBrand`, {
|
||||
method: 'GET',
|
||||
params: { code: params.code },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 shopBrand;拦截器已解包 data,结构示例:
|
||||
* { shop_id, douyin_shop_id, douyin_shop_name, douyin_shop_logo, shop_name, description }
|
||||
*/
|
||||
export function parseShopBrand(res: any): ShopBrand {
|
||||
const data = res?.data && typeof res.data === 'object' && !Array.isArray(res.data)
|
||||
? res.data
|
||||
: (res || {})
|
||||
const name = String(
|
||||
data.douyin_shop_name ||
|
||||
data.shop_name ||
|
||||
data.douyinShopName ||
|
||||
data.shopName ||
|
||||
'',
|
||||
).trim()
|
||||
const logo = normalizeOssUrl(
|
||||
data.douyin_shop_logo ||
|
||||
data.douyinShopLogo ||
|
||||
data.shop_logo ||
|
||||
data.logo ||
|
||||
'',
|
||||
)
|
||||
return {
|
||||
shopId: data.shop_id != null ? data.shop_id : (data.shopId ?? ''),
|
||||
douyinShopId: String(data.douyin_shop_id || data.douyinShopId || ''),
|
||||
name,
|
||||
logo,
|
||||
description: String(data.description || '').trim(),
|
||||
raw: data,
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /order/remaining —— 可下单尺寸与剩余数量 */
|
||||
export const getOrderRemaining = (params: Record<string, any> = {}) => {
|
||||
return request(`${PREFIX}/order/remaining`, {
|
||||
method: 'GET',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
/** 解析 order/remaining;拦截器已解包 data */
|
||||
export function parseOrderRemaining(res: any): {
|
||||
unpaidDetailCount: number
|
||||
remainList: OrderRemainItem[]
|
||||
} {
|
||||
const data = res?.remain_list != null ? res : (res?.data || {})
|
||||
const list = Array.isArray(data?.remain_list) ? data.remain_list : []
|
||||
const remainList: OrderRemainItem[] = []
|
||||
list.forEach((item: any) => {
|
||||
if (!item || typeof item !== 'object') return
|
||||
const remaining = Number(item.remaining)
|
||||
const size = String(item.size || '').trim()
|
||||
if (!size && !Number.isFinite(remaining)) return
|
||||
remainList.push({
|
||||
ece_order_id: item.ece_order_id,
|
||||
quantity: Number(item.quantity) || 0,
|
||||
remaining: Number.isFinite(remaining) ? remaining : 0,
|
||||
size: size || '-',
|
||||
sid: item.sid != null ? String(item.sid) : '',
|
||||
total_amount: item.total_amount,
|
||||
pay_amount: item.pay_amount,
|
||||
shape_item: item.shape_item,
|
||||
use_type: item.use_type,
|
||||
raw: item,
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
unpaidDetailCount: Number(data?.unpaid_detail_count) || 0,
|
||||
remainList,
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /douyin/upload/url */
|
||||
export const getUploadUrl = (data: { filename?: string; content_type?: string } = {}) => {
|
||||
return request(`${PREFIX}/upload/url`, {
|
||||
@@ -82,11 +185,16 @@ export const getImageGenRecords = (params: { page?: number; page_size?: number }
|
||||
|
||||
/**
|
||||
* POST /douyin/make/submit
|
||||
* 下单/提交制作:选定效果图,参数仅 task_id + image_id
|
||||
* 下单/提交制作:task_id + image_id + count(下单数量)
|
||||
*/
|
||||
export const submitMake = (data: { task_id: string; image_id: string }) => {
|
||||
export const submitMake = (data: {
|
||||
task_id: string
|
||||
image_id: string
|
||||
count?: number
|
||||
}) => {
|
||||
const taskId = String(data.task_id || '').trim()
|
||||
const imageId = String(data.image_id || '').trim()
|
||||
const count = Math.max(1, Math.floor(Number(data.count) || 1))
|
||||
if (!taskId) return Promise.reject(new Error('缺少 task_id'))
|
||||
if (!imageId) return Promise.reject(new Error('缺少 image_id'))
|
||||
return request(`${PREFIX}/make/submit`, {
|
||||
@@ -94,6 +202,7 @@ export const submitMake = (data: { task_id: string; image_id: string }) => {
|
||||
data: {
|
||||
task_id: taskId,
|
||||
image_id: imageId,
|
||||
count,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -120,15 +229,18 @@ export async function resolveMakeImageId(
|
||||
export async function submitProduction(data: {
|
||||
task_id: string
|
||||
image_id?: string | number | null
|
||||
count?: number
|
||||
}) {
|
||||
const taskId = String(data.task_id || '').trim()
|
||||
if (!taskId) throw new Error('缺少 task_id')
|
||||
const imageId = await resolveMakeImageId(taskId, data.image_id)
|
||||
if (!imageId) throw new Error('缺少效果图 ID,请返回重试生成')
|
||||
const res = await submitMake({ task_id: taskId, image_id: imageId })
|
||||
const count = Math.max(1, Math.floor(Number(data.count) || 1))
|
||||
const res = await submitMake({ task_id: taskId, image_id: imageId, count })
|
||||
return {
|
||||
taskId,
|
||||
imageId,
|
||||
count,
|
||||
raw: res,
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -25,6 +25,13 @@ export const douyinConfig = {
|
||||
minWidth: 300,
|
||||
minHeight: 300,
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
/** 选图后强制 3:4;允许框超出图片,超出区域白底填充 */
|
||||
crop: {
|
||||
ratio: [3, 4] as [number, number],
|
||||
fillColor: '#ffffff',
|
||||
outputType: 'jpeg' as const,
|
||||
enlarge: 2,
|
||||
},
|
||||
},
|
||||
|
||||
poll: {
|
||||
@@ -55,6 +62,15 @@ export const douyinConfig = {
|
||||
messages: {
|
||||
title: '生成记录',
|
||||
note: '注:可点击效果图继续下单',
|
||||
batchOrder: '批量下单',
|
||||
cancelBatch: '取消批量',
|
||||
submitOrder: '确认下单',
|
||||
submittingOrder: '下单中...',
|
||||
noOrderRemain: '暂无可下单数量',
|
||||
selectAtLeastOne: '请先勾选效果图',
|
||||
exceedOrderRemain: '已达可下单数量上限',
|
||||
batchOrderSuccess: '下单成功',
|
||||
batchOrderFail: '下单失败',
|
||||
noMore: '没有更多了',
|
||||
noData: '暂无生成记录',
|
||||
designing: '生成中...',
|
||||
@@ -71,16 +87,23 @@ export const douyinConfig = {
|
||||
homeHint: '上传照片,轻松生成专属效果图',
|
||||
uploadBtn: '上传并生成',
|
||||
startGenerate: '开始生成',
|
||||
/** 按钮内展示:上传并生成(生图次数:24) */
|
||||
genCountInBtn: '生图次数',
|
||||
reselect: '重新选图',
|
||||
addPhoto: '添加照片',
|
||||
effectLabel: '效果示意',
|
||||
effectTitle: '坐姿手办风',
|
||||
remainCount: '剩余次数',
|
||||
orderSizeRemain: '可下单尺寸和数量',
|
||||
orderSizeLabel: '尺寸:',
|
||||
orderRemainLabel: '数量:',
|
||||
orderQtyLabel: '下单数量',
|
||||
noOrderRemain: '暂无可下单数量',
|
||||
remainCount: '可生图剩余次数',
|
||||
uploading: '上传中...',
|
||||
uploadSuccess: '照片上传成功',
|
||||
uploadFail: '上传失败',
|
||||
pleaseUpload: '请先添加照片',
|
||||
noRemain: '剩余次数不足',
|
||||
noRemain: '可生图剩余次数不足',
|
||||
confirmOrder: '确认提交该效果图并下单?',
|
||||
orderSuccess: '下单成功',
|
||||
selectQuantity: '请选择下单数量',
|
||||
|
||||
+16
-16
@@ -12,20 +12,13 @@ import {useStore} from '@/stores'
|
||||
import { config } from '@/config/config'
|
||||
import { douyinConfig } from '@/config/douyin'
|
||||
import { localStorage } from '@/utils/local-storage'
|
||||
import {
|
||||
getCachedShopBrand,
|
||||
setDocumentFavicon,
|
||||
} from '@/views/douyin/shopBrandCache'
|
||||
|
||||
const DEFAULT_FAVICON = '/douyin-logo.png'
|
||||
|
||||
function setFavicon(href: string) {
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.type = 'image/png'
|
||||
link.href = href
|
||||
}
|
||||
|
||||
NProgress.configure({ showSpinner: true, parent: '#app' })
|
||||
|
||||
// 定义路由,每个路由都需要映射到一个组件
|
||||
@@ -89,11 +82,18 @@ router.beforeEach((_to, _from, next) => {
|
||||
const titleText = pageTitle.$t || pageTitle
|
||||
const brandText = config.brand?.$t || config.brand || ''
|
||||
const isDouyin = _to.meta.channel === 'douyin' || _to.path.includes('douyin')
|
||||
// 暂时去掉「葡哇立体肖像」前缀;抖音页使用「萌趣小宝.ai定制」
|
||||
window.document.title = isDouyin
|
||||
? `${douyinConfig.brandName} - ${titleText}`
|
||||
: (config.showBrandInTitle && brandText ? `${brandText} - ${titleText}` : String(titleText))
|
||||
setFavicon(isDouyin ? douyinConfig.images.logo : DEFAULT_FAVICON)
|
||||
// 抖音页优先使用 shopBrand 缓存的门店名/logo,否则回退默认品牌
|
||||
if (isDouyin) {
|
||||
const shopBrand = getCachedShopBrand()
|
||||
const brandName = shopBrand.name || douyinConfig.brandName
|
||||
window.document.title = `${brandName} - ${titleText}`
|
||||
setDocumentFavicon(shopBrand.logo || douyinConfig.images.logo)
|
||||
} else {
|
||||
window.document.title = config.showBrandInTitle && brandText
|
||||
? `${brandText} - ${titleText}`
|
||||
: String(titleText)
|
||||
setDocumentFavicon(DEFAULT_FAVICON)
|
||||
}
|
||||
NProgress.start() // start progress bar
|
||||
const needGuard = _to.meta.needGuard
|
||||
if (needGuard) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { createRenderer } from './image-point-cloud/webgl/renderer'
|
||||
import { createStateMachine } from './image-point-cloud/state'
|
||||
import { PATHS, PHASE_COPY, POINTER, POINT_CLOUD, VISUAL } from './image-point-cloud/constants'
|
||||
import { getLogoCloudSrc } from './image-point-cloud/logo-cloud-cache'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
imageUrl?: string
|
||||
@@ -34,12 +35,18 @@ const props = withDefaults(defineProps<{
|
||||
pointShape?: string
|
||||
/** 交叉淡出点云(同页出效果图时) */
|
||||
fading?: boolean
|
||||
/** 父级锁定的正方形边长(px),优先于 DOM 测量 */
|
||||
/** @deprecated 正方形边长,优先使用 width/height */
|
||||
side?: number
|
||||
/** 舞台宽(px),按上传图比例由父级传入 */
|
||||
width?: number
|
||||
/** 舞台高(px),按上传图比例由父级传入 */
|
||||
height?: number
|
||||
}>(), {
|
||||
pointShape: 'circle',
|
||||
fading: false,
|
||||
side: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -54,7 +61,7 @@ const scaleRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const canvasLive = ref(false)
|
||||
const useFallback = ref(false)
|
||||
const fallbackSrc = ref(PATHS.LOGO)
|
||||
const fallbackSrc = ref(getLogoCloudSrc() || PATHS.LOGO)
|
||||
const fallbackOpacity = ref(1)
|
||||
|
||||
let destroyed = false
|
||||
@@ -258,22 +265,32 @@ function loop() {
|
||||
raf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
/** 优先用父级传入的 side,否则测容器,强制正方形 */
|
||||
function resolveSquareSide(wrap: HTMLElement) {
|
||||
/** 优先父级 width/height(按上传图比例);兼容旧 side 正方形 */
|
||||
function resolveStageSize(wrap: HTMLElement) {
|
||||
if (props.width > 0 && props.height > 0) {
|
||||
return {
|
||||
width: Math.max(120, Math.round(props.width)),
|
||||
height: Math.max(120, Math.round(props.height)),
|
||||
}
|
||||
}
|
||||
if (props.side && props.side > 0) {
|
||||
return Math.max(120, Math.round(props.side))
|
||||
const side = Math.max(120, Math.round(props.side))
|
||||
return { width: side, height: side }
|
||||
}
|
||||
const rect = wrap.getBoundingClientRect()
|
||||
const pw = rect.width || wrap.clientWidth || wrap.parentElement?.clientWidth || 280
|
||||
const ph = rect.height || wrap.clientHeight || wrap.parentElement?.clientHeight || 280
|
||||
return Math.max(120, Math.round(Math.min(pw, ph)))
|
||||
return {
|
||||
width: Math.max(120, Math.round(pw)),
|
||||
height: Math.max(120, Math.round(ph || pw * 4 / 3)),
|
||||
}
|
||||
}
|
||||
|
||||
function applyCanvasSquare(canvas: HTMLCanvasElement, side: number) {
|
||||
canvas.style.width = `${side}px`
|
||||
canvas.style.height = `${side}px`
|
||||
canvas.style.maxWidth = `${side}px`
|
||||
canvas.style.maxHeight = `${side}px`
|
||||
function applyCanvasSize(canvas: HTMLCanvasElement, width: number, height: number) {
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
canvas.style.maxWidth = `${width}px`
|
||||
canvas.style.maxHeight = `${height}px`
|
||||
canvas.style.display = 'block'
|
||||
}
|
||||
|
||||
@@ -286,10 +303,10 @@ function init() {
|
||||
return
|
||||
}
|
||||
|
||||
const side = resolveSquareSide(wrap)
|
||||
cssW = side
|
||||
cssH = side
|
||||
applyCanvasSquare(canvas, side)
|
||||
const size = resolveStageSize(wrap)
|
||||
cssW = size.width
|
||||
cssH = size.height
|
||||
applyCanvasSize(canvas, cssW, cssH)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
|
||||
try {
|
||||
@@ -335,8 +352,10 @@ function init() {
|
||||
onHandoff: () => emit('handoff'),
|
||||
})
|
||||
|
||||
const logoSrc = getLogoCloudSrc() || PATHS.LOGO
|
||||
fallbackSrc.value = logoSrc
|
||||
renderer.textures
|
||||
.load(PATHS.LOGO, { removeBackground: true })
|
||||
.load(logoSrc, { removeBackground: true })
|
||||
.then((entry: any) => {
|
||||
if (destroyed) return
|
||||
renderer.setMainCloud(entry)
|
||||
@@ -349,6 +368,26 @@ function init() {
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error('[ImagePointCloud] logo load fail', err)
|
||||
// 门店 logo 失败时回退默认 Logo 点云
|
||||
if (logoSrc !== PATHS.LOGO) {
|
||||
fallbackSrc.value = PATHS.LOGO
|
||||
return renderer.textures
|
||||
.load(PATHS.LOGO, { removeBackground: true })
|
||||
.then((entry: any) => {
|
||||
if (destroyed) return
|
||||
renderer.setMainCloud(entry)
|
||||
logoFromCache = !!(entry && entry.pointCloudBuffer)
|
||||
sm.start()
|
||||
resume()
|
||||
if (props.imageUrl) preloadSource(props.imageUrl)
|
||||
if (props.resultUrl) preloadResult(props.resultUrl)
|
||||
if (props.maskUrl && props.imageUrl) prepareSubjectCloud(props.imageUrl, props.maskUrl)
|
||||
})
|
||||
}
|
||||
throw err
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error('[ImagePointCloud] logo fallback fail', err)
|
||||
enterFallback()
|
||||
})
|
||||
}
|
||||
@@ -367,8 +406,8 @@ onMounted(() => {
|
||||
destroyed = false
|
||||
const tryInit = (attempt = 0) => {
|
||||
if (destroyed) return
|
||||
// 有父级 side 时无需等待 DOM 测量
|
||||
if (props.side && props.side > 0) {
|
||||
// 有父级宽高时无需等待 DOM 测量
|
||||
if ((props.width > 0 && props.height > 0) || (props.side && props.side > 0)) {
|
||||
init()
|
||||
return
|
||||
}
|
||||
@@ -412,7 +451,7 @@ onUnmounted(() => {
|
||||
bottom: auto !important;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
/* 覆盖全局/历史 fullscreen canvas 规则,保证正方形居中 */
|
||||
/* 覆盖全局/历史 fullscreen canvas 规则,按上传图比例居中 */
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div v-if="remainList.length" class="order-panel">
|
||||
<div class="order-panel-title">
|
||||
{{ config.messages.orderSizeRemain }}(最大下单数量:{{ maxQty }})
|
||||
</div>
|
||||
<div class="order-panel-list">
|
||||
<div
|
||||
v-for="(item, idx) in remainList"
|
||||
:key="itemKey(item, idx)"
|
||||
class="order-panel-item"
|
||||
:class="{ 'is-active': selectedKey === itemKey(item, idx) }"
|
||||
@click="selectItem(item, idx)"
|
||||
>
|
||||
<div class="order-panel-info">
|
||||
<span class="order-panel-size">
|
||||
{{ config.messages.orderSizeLabel }} {{ item.size }}
|
||||
</span>
|
||||
</div>
|
||||
<van-stepper
|
||||
v-if="selectedKey === itemKey(item, idx)"
|
||||
:model-value="quantity"
|
||||
:min="1"
|
||||
:max="maxQty"
|
||||
integer
|
||||
theme="round"
|
||||
button-size="22px"
|
||||
@click.stop
|
||||
@update:model-value="onQtyChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="loaded && !loading" class="order-panel is-empty">
|
||||
{{ config.messages.noOrderRemain }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { showToast } from 'vant'
|
||||
import * as douyinApi from '@/api/douyin'
|
||||
import { douyinConfig as config } from '@/config/douyin'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** 进入预览时自动拉取 */
|
||||
autoFetch?: boolean
|
||||
}>(), {
|
||||
autoFetch: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', payload: {
|
||||
quantity: number
|
||||
remainTotal: number
|
||||
selected: douyinApi.OrderRemainItem | null
|
||||
}): void
|
||||
}>()
|
||||
|
||||
const remainList = ref<douyinApi.OrderRemainItem[]>([])
|
||||
const selectedKey = ref('')
|
||||
const quantity = ref(1)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const remainTotal = computed(() =>
|
||||
remainList.value.reduce((sum, item) => sum + (Number(item.remaining) || 0), 0),
|
||||
)
|
||||
|
||||
const selectedItem = computed(() => {
|
||||
const idx = remainList.value.findIndex((item, i) => itemKey(item, i) === selectedKey.value)
|
||||
return idx >= 0 ? remainList.value[idx] : null
|
||||
})
|
||||
|
||||
const maxQty = computed(() => {
|
||||
const remain = Number(selectedItem.value?.remaining)
|
||||
if (Number.isFinite(remain) && remain > 0) return remain
|
||||
return Math.max(1, remainTotal.value || 1)
|
||||
})
|
||||
|
||||
function itemKey(item: douyinApi.OrderRemainItem, idx: number) {
|
||||
return String(item.sid || item.ece_order_id || `${item.size}-${idx}`)
|
||||
}
|
||||
|
||||
function emitChange() {
|
||||
emit('change', {
|
||||
quantity: quantity.value,
|
||||
remainTotal: remainTotal.value,
|
||||
selected: selectedItem.value,
|
||||
})
|
||||
}
|
||||
|
||||
function selectItem(item: douyinApi.OrderRemainItem, idx: number) {
|
||||
selectedKey.value = itemKey(item, idx)
|
||||
const max = Math.max(1, Number(item.remaining) || 1)
|
||||
if (quantity.value > max) quantity.value = max
|
||||
if (quantity.value < 1) quantity.value = 1
|
||||
emitChange()
|
||||
}
|
||||
|
||||
function onQtyChange(value: number | string) {
|
||||
let next = Math.floor(Number(value) || 1)
|
||||
if (next < 1) next = 1
|
||||
if (next > maxQty.value) {
|
||||
next = maxQty.value
|
||||
showToast(config.records.messages.exceedOrderRemain)
|
||||
}
|
||||
quantity.value = next
|
||||
emitChange()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res: any = await douyinApi.getOrderRemaining({})
|
||||
const parsed = douyinApi.parseOrderRemaining(res)
|
||||
remainList.value = parsed.remainList.filter((item) => Number(item.remaining) > 0)
|
||||
if (!remainList.value.length) {
|
||||
selectedKey.value = ''
|
||||
quantity.value = 1
|
||||
} else {
|
||||
const stillExists = remainList.value.some((item, idx) => itemKey(item, idx) === selectedKey.value)
|
||||
if (!stillExists) {
|
||||
selectItem(remainList.value[0], 0)
|
||||
} else {
|
||||
onQtyChange(quantity.value)
|
||||
}
|
||||
}
|
||||
emitChange()
|
||||
} catch (_) {
|
||||
remainList.value = []
|
||||
selectedKey.value = ''
|
||||
quantity.value = 1
|
||||
emitChange()
|
||||
} finally {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderPayload() {
|
||||
return {
|
||||
quantity: Math.max(1, Number(quantity.value) || 1),
|
||||
remainTotal: remainTotal.value,
|
||||
selected: selectedItem.value,
|
||||
canOrder: remainTotal.value > 0 && !!selectedItem.value,
|
||||
}
|
||||
}
|
||||
|
||||
watch(maxQty, (max) => {
|
||||
if (quantity.value > max) {
|
||||
quantity.value = max
|
||||
emitChange()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.autoFetch) refresh()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
refresh,
|
||||
getOrderPayload,
|
||||
remainList,
|
||||
quantity,
|
||||
remainTotal,
|
||||
selectedItem,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.order-panel {
|
||||
margin-top: 14px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.order-panel.is-empty {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #9aa394;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.order-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.order-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-panel-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f5f8f3;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.order-panel-item.is-active {
|
||||
border-color: rgba(95, 191, 69, 0.55);
|
||||
background: rgba(95, 191, 69, 0.1);
|
||||
}
|
||||
|
||||
.order-panel-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.order-panel-size {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-panel-item :deep(.van-stepper) {
|
||||
flex-shrink: 0;
|
||||
--van-stepper-button-round-theme-color: #5fbf45;
|
||||
--van-stepper-button-icon-color: #5fbf45;
|
||||
--van-stepper-background: rgba(95, 191, 69, 0.14);
|
||||
--van-stepper-button-disabled-color: rgba(95, 191, 69, 0.12);
|
||||
--van-stepper-button-disabled-icon-color: rgba(95, 191, 69, 0.4);
|
||||
}
|
||||
|
||||
.order-panel-item :deep(.van-stepper--round .van-stepper__minus) {
|
||||
color: #5fbf45;
|
||||
background-color: #fff;
|
||||
border: 1px solid #5fbf45;
|
||||
}
|
||||
|
||||
.order-panel-item :deep(.van-stepper--round .van-stepper__plus) {
|
||||
color: #fff;
|
||||
background: #5fbf45;
|
||||
border: 1px solid #5fbf45;
|
||||
}
|
||||
|
||||
.order-panel-item :deep(.van-stepper--round .van-stepper__minus--disabled),
|
||||
.order-panel-item :deep(.van-stepper--round .van-stepper__plus--disabled) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
</style>
|
||||
@@ -162,12 +162,12 @@ const POINT_CLOUD = {
|
||||
MASK_HIGH: 288,
|
||||
MASK_MEDIUM: 256,
|
||||
MASK_LOW: 160,
|
||||
/** 略小粒子,减少重叠杂乱,仍保留远近差 */
|
||||
MIN_SIZE: 1.45,
|
||||
MAX_SIZE: 3.25,
|
||||
/** 粒子尺寸:拉开远近差(小=远,大=近),并保证圆点软边 */
|
||||
MIN_SIZE: 2.0,
|
||||
MAX_SIZE: 6.4,
|
||||
/** 采样抖动加大,粒子更散开 */
|
||||
POSITION_JITTER: 0.011,
|
||||
OPACITY: 0.93,
|
||||
OPACITY: 0.9,
|
||||
BG_THRESHOLD: 0.12,
|
||||
/** x,y,r,g,b,size,alpha,seed,subjectWeight */
|
||||
STRIDE_FLOATS: 9,
|
||||
@@ -176,8 +176,8 @@ const POINT_CLOUD = {
|
||||
* auto = 随时间在圆/方之间缓动切换
|
||||
*/
|
||||
DEFAULT_SHAPE: 'circle',
|
||||
/** Logo 缓存版本:stride 变更时失效旧缓存 */
|
||||
CACHE_VERSION: 14,
|
||||
/** Logo 缓存版本:舞台比例跟图后需重采样 */
|
||||
CACHE_VERSION: 17,
|
||||
}
|
||||
|
||||
const VISUAL = {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { PATHS, POINT_CLOUD } from './constants'
|
||||
import { createPointCloudSampler } from './point-cloud-sampler'
|
||||
import { getCachedShopBrand } from '../../shopBrandCache'
|
||||
|
||||
/**
|
||||
* 预采样 Logo 点云(CPU Float32Array),生成页可立刻上传 GPU。
|
||||
* 优先使用 shopBrand 接口返回的门店 logo。
|
||||
*/
|
||||
let cached = null
|
||||
let inflight = null
|
||||
let inflightSrc = ''
|
||||
|
||||
function pickTier() {
|
||||
let maxPoints = POINT_CLOUD.MEDIUM
|
||||
@@ -24,6 +27,15 @@ function pickTier() {
|
||||
return { maxPoints, sampleSide }
|
||||
}
|
||||
|
||||
/** 当前用于点云的 Logo 地址(门店 logo > 默认) */
|
||||
export function getLogoCloudSrc() {
|
||||
try {
|
||||
const brand = getCachedShopBrand()
|
||||
if (brand && brand.logo) return String(brand.logo)
|
||||
} catch (e) {}
|
||||
return PATHS.LOGO
|
||||
}
|
||||
|
||||
export function getCachedLogoCloud() {
|
||||
if (!cached) return null
|
||||
if (cached.cacheVersion !== POINT_CLOUD.CACHE_VERSION) {
|
||||
@@ -33,26 +45,39 @@ export function getCachedLogoCloud() {
|
||||
return cached
|
||||
}
|
||||
|
||||
export function isLogoReady() {
|
||||
export function isLogoReady(src) {
|
||||
const c = getCachedLogoCloud()
|
||||
return !!(c && c.cloud && c.cloud.data)
|
||||
if (!c || !c.cloud || !c.cloud.data) return false
|
||||
if (src != null && src !== '' && c.src !== src) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function putCachedLogoCloud(entry) {
|
||||
if (!entry || !entry.cloud) return
|
||||
const src = entry.src || getLogoCloudSrc() || PATHS.LOGO
|
||||
cached = {
|
||||
...entry,
|
||||
src,
|
||||
cacheVersion: POINT_CLOUD.CACHE_VERSION,
|
||||
src: PATHS.LOGO,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ canvas?: HTMLCanvasElement, scaleCanvas?: HTMLCanvasElement }} nodes
|
||||
* @param {{ canvas?: HTMLCanvasElement, scaleCanvas?: HTMLCanvasElement, src?: string }} nodes
|
||||
*/
|
||||
export function preloadLogoCloud(nodes = {}) {
|
||||
if (isLogoReady()) return Promise.resolve(cached)
|
||||
if (inflight) return inflight
|
||||
const src = nodes.src || getLogoCloudSrc() || PATHS.LOGO
|
||||
if (isLogoReady(src)) return Promise.resolve(cached)
|
||||
if (inflight && inflightSrc === src) return inflight
|
||||
|
||||
// logo 变更时丢弃旧缓存 / 旧请求
|
||||
if (cached && cached.src !== src) {
|
||||
cached = null
|
||||
}
|
||||
if (inflight && inflightSrc !== src) {
|
||||
inflight = null
|
||||
inflightSrc = ''
|
||||
}
|
||||
|
||||
const scaleCanvas = nodes.scaleCanvas || nodes.canvas || document.createElement('canvas')
|
||||
// 与等待页正方形舞台一致,避免预采样比例与展示框不一致导致拉伸
|
||||
@@ -69,15 +94,19 @@ export function preloadLogoCloud(nodes = {}) {
|
||||
|
||||
const sampler = createPointCloudSampler(scaleCanvas, vp.w, vp.h, scaleCtx)
|
||||
|
||||
inflightSrc = src
|
||||
inflight = new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
if (/^https?:\/\//i.test(src)) {
|
||||
img.crossOrigin = 'anonymous'
|
||||
}
|
||||
img.onload = () => {
|
||||
try {
|
||||
const w = img.width || 0
|
||||
const h = img.height || 0
|
||||
const entry = {
|
||||
image: img,
|
||||
path: PATHS.LOGO,
|
||||
path: src,
|
||||
width: w,
|
||||
height: h,
|
||||
aspect: w / Math.max(h, 1),
|
||||
@@ -88,7 +117,7 @@ export function preloadLogoCloud(nodes = {}) {
|
||||
removeBackground: true,
|
||||
})
|
||||
putCachedLogoCloud({
|
||||
src: PATHS.LOGO,
|
||||
src,
|
||||
width: w,
|
||||
height: h,
|
||||
aspect: entry.aspect,
|
||||
@@ -105,13 +134,15 @@ export function preloadLogoCloud(nodes = {}) {
|
||||
reject(err)
|
||||
} finally {
|
||||
inflight = null
|
||||
inflightSrc = ''
|
||||
}
|
||||
}
|
||||
img.onerror = () => {
|
||||
inflight = null
|
||||
inflightSrc = ''
|
||||
reject(new Error('logo preload fail'))
|
||||
}
|
||||
img.src = PATHS.LOGO
|
||||
img.src = src
|
||||
})
|
||||
|
||||
return inflight
|
||||
@@ -120,4 +151,5 @@ export function preloadLogoCloud(nodes = {}) {
|
||||
export function clearLogoCloudCache() {
|
||||
cached = null
|
||||
inflight = null
|
||||
inflightSrc = ''
|
||||
}
|
||||
@@ -76,6 +76,7 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
|
||||
|
||||
const viewportAspect = viewportW / Math.max(viewportH, 1)
|
||||
const imageAspect = w / Math.max(h, 1)
|
||||
// 舞台已按上传图比例:contain 即铺满,不再裁切高度
|
||||
let scaleX = 0.98
|
||||
let scaleY = 0.98
|
||||
if (imageAspect > viewportAspect) {
|
||||
@@ -191,15 +192,16 @@ function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
|
||||
const px = (uvX - 0.5) * 2 * scaleX + jx
|
||||
const py = (0.5 - uvY) * 2 * scaleY + jy
|
||||
|
||||
const band = seed < 0.28 ? 0.0 : seed < 0.62 ? 0.45 : 1.0
|
||||
// 三档远中近,拉开粒子大小差
|
||||
const band = seed < 0.32 ? 0.0 : seed < 0.66 ? 0.5 : 1.0
|
||||
let depth = Math.max(
|
||||
0.08,
|
||||
0.05,
|
||||
Math.min(
|
||||
1,
|
||||
0.1 + band * 0.42 + Math.pow(lum, 0.65) * 0.28 + seed * 0.18
|
||||
0.06 + band * 0.52 + Math.pow(lum, 0.7) * 0.22 + seed * 0.16
|
||||
)
|
||||
)
|
||||
let size = minSize + Math.pow(depth, 0.85) * (maxSize - minSize)
|
||||
let size = minSize + Math.pow(depth, 0.75) * (maxSize - minSize)
|
||||
|
||||
const alpha = Math.min(
|
||||
1,
|
||||
|
||||
@@ -57,16 +57,16 @@ function createRenderer(canvas, cssW, cssH, dpr, scaleCanvas) {
|
||||
})
|
||||
if (!gl) return null
|
||||
|
||||
// 正方形缓冲 + 等像素 CSS,保证圆点不被拉扁
|
||||
const side = Math.max(1, Math.round(Math.min(cssW, cssH)))
|
||||
const buf = Math.round(side * dpr)
|
||||
canvas.width = buf
|
||||
canvas.height = buf
|
||||
canvas.style.width = `${side}px`
|
||||
canvas.style.height = `${side}px`
|
||||
gl.viewport(0, 0, buf, buf)
|
||||
cssW = side
|
||||
cssH = side
|
||||
// 按上传图比例矩形舞台;gl_PointSize 以像素计,圆点不会被拉扁
|
||||
cssW = Math.max(1, Math.round(cssW))
|
||||
cssH = Math.max(1, Math.round(cssH))
|
||||
const bufW = Math.round(cssW * dpr)
|
||||
const bufH = Math.round(cssH * dpr)
|
||||
canvas.width = bufW
|
||||
canvas.height = bufH
|
||||
canvas.style.width = `${cssW}px`
|
||||
canvas.style.height = `${cssH}px`
|
||||
gl.viewport(0, 0, bufW, bufH)
|
||||
|
||||
const pointProgram = createProgram(gl, POINT_VERTEX, POINT_FRAGMENT)
|
||||
const quadProgram = createProgram(gl, QUAD_VERTEX, QUAD_FRAGMENT)
|
||||
|
||||
@@ -52,7 +52,7 @@ void main() {
|
||||
float subjectW = mix(aSubjectWeightA, aSubjectWeightB, t);
|
||||
|
||||
float s = seed * 6.2831853;
|
||||
float depth = clamp((size - 1.45) / 1.8, 0.0, 1.0);
|
||||
float depth = clamp((size - 2.0) / 4.4, 0.0, 1.0);
|
||||
|
||||
vec2 randomOffset = vec2(hash(seed), hash(seed + 17.3)) - 0.5;
|
||||
position += randomOffset * 0.14 * (1.0 - uAssembly);
|
||||
@@ -60,31 +60,37 @@ void main() {
|
||||
float spread = 1.0 + 0.012 * (0.55 + depth * 0.45);
|
||||
position *= spread;
|
||||
|
||||
float persNear = mix(1.1, 1.035, fidelity);
|
||||
float persFar = mix(0.88, 0.97, fidelity);
|
||||
float persNear = mix(1.12, 1.04, fidelity);
|
||||
float persFar = mix(0.86, 0.96, fidelity);
|
||||
position *= mix(persFar, persNear, depth);
|
||||
|
||||
vec2 cam = vec2(
|
||||
sin(uTime * 0.55) * 0.028,
|
||||
cos(uTime * 0.4) * 0.02
|
||||
sin(uTime * 0.45) * 0.022,
|
||||
cos(uTime * 0.35) * 0.016
|
||||
);
|
||||
position += cam * (depth * 1.55 - 0.45) * mix(1.0, 0.28, fidelity);
|
||||
|
||||
/* 上下缓慢漂浮:相位按粒子区分,近处略大振幅 */
|
||||
float bob =
|
||||
sin(uTime * 0.62 + s) * 0.028 +
|
||||
sin(uTime * 0.37 + s * 1.7) * 0.014;
|
||||
position.y += bob * (0.55 + depth * 0.7) * uMotion;
|
||||
|
||||
float amp =
|
||||
(0.007 + depth * 0.012) * uMotion * mix(1.0, 0.45, fidelity);
|
||||
(0.006 + depth * 0.01) * uMotion * mix(1.0, 0.4, fidelity);
|
||||
vec2 wave = vec2(
|
||||
sin(uTime * 1.35 + s + position.y * 6.5) * 0.55 +
|
||||
sin(uTime * 0.8 + s * 2.0) * 0.45,
|
||||
cos(uTime * 1.15 + s * 1.5 + position.x * 6.0) * 0.55 +
|
||||
cos(uTime * 0.65 + s) * 0.45
|
||||
sin(uTime * 1.1 + s + position.y * 5.5) * 0.5 +
|
||||
sin(uTime * 0.7 + s * 2.0) * 0.4,
|
||||
cos(uTime * 0.95 + s * 1.5 + position.x * 5.0) * 0.45 +
|
||||
cos(uTime * 0.55 + s) * 0.35
|
||||
);
|
||||
position += wave * amp;
|
||||
|
||||
float breath = sin(uTime * 1.35 + s * 0.35) * 0.5 + 0.5;
|
||||
float breathOut = sin(uTime * 1.35 + seed * 4.0);
|
||||
position *= 1.0 + breathOut * 0.018 * uMotion;
|
||||
size *= 1.0 + (breath - 0.5) * 0.16 * uMotion;
|
||||
alpha *= 1.0 + (breath - 0.5) * 0.08 * uMotion;
|
||||
float breath = sin(uTime * 1.1 + s * 0.35) * 0.5 + 0.5;
|
||||
float breathOut = sin(uTime * 1.1 + seed * 4.0);
|
||||
position *= 1.0 + breathOut * 0.014 * uMotion;
|
||||
size *= 1.0 + (breath - 0.5) * 0.12 * uMotion;
|
||||
alpha *= 1.0 + (breath - 0.5) * 0.06 * uMotion;
|
||||
|
||||
// 人物阶段:背景消散 + 轮廓/主体聚拢(uSubjectProgress=0 时无影响,RESULT_* 观感不变)
|
||||
float sp = clamp(uSubjectProgress, 0.0, 1.0);
|
||||
@@ -157,11 +163,12 @@ void main() {
|
||||
float alphaPhoto = alpha * uOpacity * mix(0.85, 1.0, depth);
|
||||
vColor = vec4(color, mix(alphaStyle, alphaPhoto, fidelity));
|
||||
|
||||
float sizeStyle = mix(0.78, 1.18, depth);
|
||||
float sizePhoto = mix(0.85, 1.12, depth);
|
||||
/* 远近尺寸差更明显:远点更小、近点更大 */
|
||||
float sizeStyle = mix(0.62, 1.38, depth);
|
||||
float sizePhoto = mix(0.68, 1.32, depth);
|
||||
float sizeMul = mix(sizeStyle, sizePhoto, fidelity);
|
||||
sizeMul *= mix(0.97 + 0.03 * sin(uTime * 1.7 + s), 1.0, fidelity * 0.5);
|
||||
gl_PointSize = max(1.5, size * sizeMul * uDpr);
|
||||
sizeMul *= mix(0.97 + 0.03 * sin(uTime * 1.5 + s), 1.0, fidelity * 0.5);
|
||||
gl_PointSize = max(2.8, size * sizeMul * uDpr);
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
@@ -177,17 +184,18 @@ void main() {
|
||||
float d = length(p);
|
||||
vec2 q = abs(p);
|
||||
|
||||
float circleA = 1.0 - smoothstep(0.38, 0.5, d);
|
||||
float squareA = 1.0 - smoothstep(0.36, 0.48, max(q.x, q.y));
|
||||
/* 更宽的 soft edge → 放大后的粒子轮廓更圆润 */
|
||||
float circleA = 1.0 - smoothstep(0.28, 0.5, d);
|
||||
float squareA = 1.0 - smoothstep(0.34, 0.48, max(q.x, q.y));
|
||||
float shape = clamp(uPointShape, 0.0, 1.0);
|
||||
float alpha = mix(circleA, squareA, shape);
|
||||
|
||||
if (alpha < 0.04) {
|
||||
if (alpha < 0.03) {
|
||||
discard;
|
||||
}
|
||||
|
||||
float core = 1.0 - smoothstep(0.0, 0.26, d);
|
||||
vec3 color = vColor.rgb * (1.0 + core * 0.12);
|
||||
float core = 1.0 - smoothstep(0.0, 0.3, d);
|
||||
vec3 color = vColor.rgb * (1.0 + core * 0.1);
|
||||
|
||||
gl_FragColor = vec4(color, vColor.a * alpha);
|
||||
}
|
||||
|
||||
@@ -306,11 +306,12 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
||||
}
|
||||
|
||||
const removeBackground = options.removeBackground !== false
|
||||
const logoCached =
|
||||
src === PATHS.LOGO ? logoCloudCache.getCachedLogoCloud() : null
|
||||
const logoCached = logoCloudCache.getCachedLogoCloud()
|
||||
const isLogoSrc =
|
||||
!!logoCached && logoCached.src === src
|
||||
|
||||
// Logo 已在首页预采样:立刻上传 GPU buffer,纹理后台补齐
|
||||
if (logoCached && logoCached.cloud && logoCached.cloud.data) {
|
||||
if (isLogoSrc && logoCached.cloud && logoCached.cloud.data) {
|
||||
const entry = {
|
||||
_promise: null,
|
||||
texture: null,
|
||||
@@ -397,8 +398,9 @@ function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
||||
entry.preloadStatus = 'ready'
|
||||
entry._promise = null
|
||||
|
||||
if (src === PATHS.LOGO) {
|
||||
if (src === logoCloudCache.getLogoCloudSrc() || src === PATHS.LOGO) {
|
||||
logoCloudCache.putCachedLogoCloud({
|
||||
src,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
aspect: entry.aspect,
|
||||
|
||||
+185
-70
@@ -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>
|
||||
+458
-90
@@ -9,8 +9,8 @@
|
||||
>
|
||||
<template #left>
|
||||
<div class="brand">
|
||||
<img class="brand-logo" :src="config.images.logo" alt="">
|
||||
<span class="brand-name">{{ config.brandName }}</span>
|
||||
<img class="brand-logo" :src="brandLogo" alt="">
|
||||
<span class="brand-name">{{ brandName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 首页中间不放标题,品牌在左侧 -->
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<div class="content">
|
||||
<div class="hero">
|
||||
<img class="hero-img" :src="config.images.logo" alt="">
|
||||
<img class="hero-img" :src="brandLogo" alt="">
|
||||
<div class="hero-title">{{ config.messages.homeTitle }}</div>
|
||||
<div class="hero-hint">{{ config.messages.homeHint }}</div>
|
||||
<div class="steps">
|
||||
@@ -38,6 +38,24 @@
|
||||
<span class="ref-arrow">›</span>
|
||||
</div>
|
||||
|
||||
<div v-if="remainList.length" class="order-remain-card">
|
||||
<div class="order-remain-title">{{ config.messages.orderSizeRemain }}</div>
|
||||
<div class="order-remain-list">
|
||||
<div
|
||||
v-for="(item, idx) in remainList"
|
||||
:key="item.sid || item.ece_order_id || idx"
|
||||
class="order-remain-item"
|
||||
>
|
||||
<span class="order-remain-size">
|
||||
{{ config.messages.orderSizeLabel }} {{ item.size }}
|
||||
</span>
|
||||
<span class="order-remain-num">
|
||||
{{ config.messages.orderRemainLabel }} {{ item.remaining }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="select-panel">
|
||||
<div class="guide">{{ photoPreview ? '确认照片后开始生成' : '添加 1 张清晰正面人像' }}</div>
|
||||
<div class="photo-slot" @click="triggerPick">
|
||||
@@ -45,7 +63,6 @@
|
||||
<div v-else class="empty">
|
||||
<div class="plus">+</div>
|
||||
<div>{{ config.messages.addPhoto }}</div>
|
||||
<div class="sub">相册或拍照</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -58,71 +75,131 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="quota" v-if="orderLocked">
|
||||
{{ lockReason || config.messages.orderSubmitted }}
|
||||
</div>
|
||||
<div class="quota" v-else-if="orderStat.remain_count != null">
|
||||
{{ config.messages.remainCount }}:{{ orderStat.remain_count || 0 }}
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
:disabled="submitting || orderLocked"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{
|
||||
orderLocked
|
||||
? (Number(orderStat.order_count) > 0
|
||||
? config.messages.orderSubmittedBtn
|
||||
: (lockReason === config.messages.noRemain
|
||||
? config.messages.noRemain
|
||||
: config.messages.orderStatFailBtn))
|
||||
: (photoPreview ? config.messages.startGenerate : config.messages.uploadBtn)
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="showEffect" class="effect-mask" @click="showEffect = false">
|
||||
<img :src="config.images.effectRef" alt="">
|
||||
</div>
|
||||
|
||||
<!-- Teleport 到 body:相对视口固定,避开 app-shell transform -->
|
||||
<Teleport to="body">
|
||||
<div v-show="!cropping" class="dy-footer">
|
||||
<button
|
||||
class="dy-footer__btn"
|
||||
type="button"
|
||||
:disabled="submitting || orderLocked"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ submitBtnText }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="cropping" class="dy-cropper">
|
||||
<div class="dy-cropper__stage">
|
||||
<VueCropper
|
||||
ref="cropperRef"
|
||||
:img="cropImg"
|
||||
:auto-crop="true"
|
||||
:auto-crop-width="9999"
|
||||
:auto-crop-height="9999"
|
||||
:fixed="true"
|
||||
:fixed-number="config.upload.crop.ratio"
|
||||
:fixed-box="false"
|
||||
:center-box="false"
|
||||
:can-move="true"
|
||||
:can-move-box="true"
|
||||
:can-scale="true"
|
||||
:info="true"
|
||||
:info-true="true"
|
||||
:full="false"
|
||||
:high="true"
|
||||
:enlarge="config.upload.crop.enlarge"
|
||||
:output-type="config.upload.crop.outputType"
|
||||
:output-size="1"
|
||||
:fill-color="config.upload.crop.fillColor"
|
||||
mode="contain"
|
||||
/>
|
||||
</div>
|
||||
<div class="dy-cropper__bar">
|
||||
<button class="dy-cropper__btn" type="button" @click="onCropCancel">取消</button>
|
||||
<div class="dy-cropper__zoom">
|
||||
<button class="dy-cropper__zoom-btn" type="button" aria-label="缩小" @click="onCropZoom(-1)">-</button>
|
||||
<button class="dy-cropper__zoom-btn" type="button" aria-label="放大" @click="onCropZoom(1)">+</button>
|
||||
</div>
|
||||
<button class="dy-cropper__btn dy-cropper__btn--ok" type="button" :disabled="cropConfirming" @click="onCropConfirm">
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { VueCropper } from 'vue-cropper'
|
||||
import 'vue-cropper/dist/index.css'
|
||||
import * as badgeApi from '@/api/badge'
|
||||
import * as douyinApi from '@/api/douyin'
|
||||
import { douyinConfig as config } from '@/config/douyin'
|
||||
import { localStorage } from '@/utils/local-storage'
|
||||
import { preloadLogoCloud } from './components/image-point-cloud/logo-cloud-cache'
|
||||
import DyNavBar from './components/DyNavBar.vue'
|
||||
import { setPendingUpload } from './pendingUpload'
|
||||
|
||||
const SUBMITTED_MSG = '本订单号已提交制作,不能继续上传图片或生图'
|
||||
import {
|
||||
applyDocumentBrand,
|
||||
getCachedShopBrand,
|
||||
setCachedShopBrand,
|
||||
} from './shopBrandCache'
|
||||
|
||||
const router = useRouter()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const cropperRef = ref<any>(null)
|
||||
const photoPreview = ref('')
|
||||
const photoFile = ref<File | null>(null)
|
||||
const submitting = ref(false)
|
||||
const showEffect = ref(false)
|
||||
const orderStat = ref<any>({})
|
||||
const remainList = ref<douyinApi.OrderRemainItem[]>([])
|
||||
const orderLocked = ref(false)
|
||||
const lockReason = ref('')
|
||||
const cropping = ref(false)
|
||||
const cropImg = ref('')
|
||||
const cropConfirming = ref(false)
|
||||
|
||||
/** 仅根据接口 orders/stat 判断,不用本地缓存 */
|
||||
function applyOrderLock(res: any) {
|
||||
orderStat.value = res || {}
|
||||
const submitBtnText = computed(() => {
|
||||
if (orderLocked.value) {
|
||||
if (lockReason.value === config.messages.noRemain) return config.messages.noRemain
|
||||
if (lockReason.value === config.messages.noOrderRemain) return config.messages.noOrderRemain
|
||||
return lockReason.value || config.messages.orderStatFailBtn
|
||||
}
|
||||
const action = photoPreview.value ? config.messages.startGenerate : config.messages.uploadBtn
|
||||
if (orderStat.value?.remain_count == null) return action
|
||||
return `${action}(${config.messages.genCountInBtn}:${orderStat.value.remain_count || 0})`
|
||||
})
|
||||
|
||||
const cachedBrand = getCachedShopBrand()
|
||||
const brandName = ref(cachedBrand.name)
|
||||
const brandLogo = ref(cachedBrand.logo)
|
||||
|
||||
function orderRemainTotal() {
|
||||
return remainList.value.reduce((sum, item) => sum + (Number(item.remaining) || 0), 0)
|
||||
}
|
||||
|
||||
/** 能否下单改由 remaining 总量判断,不再看 stat.order_count */
|
||||
function applyOrderLock() {
|
||||
orderLocked.value = false
|
||||
lockReason.value = ''
|
||||
// order_count > 0 表示已提交制作
|
||||
if (Number(res?.order_count) > 0) {
|
||||
|
||||
const remainTotal = orderRemainTotal()
|
||||
if (remainTotal <= 0) {
|
||||
orderLocked.value = true
|
||||
lockReason.value = config.messages.orderSubmitted || SUBMITTED_MSG
|
||||
lockReason.value = config.messages.noOrderRemain
|
||||
return
|
||||
}
|
||||
if (res?.remain_count != null && Number(res.remain_count) <= 0) {
|
||||
|
||||
// 可生图次数仍看 orders/stat.remain_count
|
||||
if (orderStat.value?.remain_count != null && Number(orderStat.value.remain_count) <= 0) {
|
||||
orderLocked.value = true
|
||||
lockReason.value = config.messages.noRemain
|
||||
}
|
||||
@@ -132,22 +209,171 @@ function goRecords() {
|
||||
router.push(config.routes.records)
|
||||
}
|
||||
|
||||
let lockedScrollY = 0
|
||||
|
||||
function preventTouchScroll(e: TouchEvent) {
|
||||
const host = document.querySelector('.dy-cropper')
|
||||
if (host && e.target instanceof Node && host.contains(e.target)) return
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function setBodyScrollLocked(locked: boolean) {
|
||||
const html = document.documentElement
|
||||
const body = document.body
|
||||
if (locked) {
|
||||
lockedScrollY = window.scrollY || window.pageYOffset || 0
|
||||
html.style.overflow = 'hidden'
|
||||
body.style.overflow = 'hidden'
|
||||
body.style.position = 'fixed'
|
||||
body.style.top = `-${lockedScrollY}px`
|
||||
body.style.left = '0'
|
||||
body.style.right = '0'
|
||||
body.style.width = '100%'
|
||||
document.addEventListener('touchmove', preventTouchScroll, { passive: false })
|
||||
} else {
|
||||
html.style.overflow = ''
|
||||
body.style.overflow = ''
|
||||
body.style.position = ''
|
||||
body.style.top = ''
|
||||
body.style.left = ''
|
||||
body.style.right = ''
|
||||
body.style.width = ''
|
||||
document.removeEventListener('touchmove', preventTouchScroll)
|
||||
window.scrollTo(0, lockedScrollY)
|
||||
}
|
||||
}
|
||||
|
||||
function openCropping(dataUrl: string) {
|
||||
cropImg.value = dataUrl
|
||||
cropping.value = true
|
||||
setBodyScrollLocked(true)
|
||||
}
|
||||
|
||||
function closeCropping() {
|
||||
cropping.value = false
|
||||
cropImg.value = ''
|
||||
cropConfirming.value = false
|
||||
setBodyScrollLocked(false)
|
||||
}
|
||||
|
||||
function triggerPick() {
|
||||
if (orderLocked.value) {
|
||||
showToast(lockReason.value || SUBMITTED_MSG)
|
||||
showToast(lockReason.value || config.messages.noOrderRemain)
|
||||
return
|
||||
}
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
function base64ToBlob(base64: string) {
|
||||
const arr = base64.split(',')
|
||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/jpeg'
|
||||
const bstr = atob(arr[1])
|
||||
const n = bstr.length
|
||||
const u8arr = new Uint8Array(n)
|
||||
for (let i = 0; i < n; i++) u8arr[i] = bstr.charCodeAt(i)
|
||||
return new Blob([u8arr], { type: mime })
|
||||
}
|
||||
|
||||
function clearPhoto() {
|
||||
photoPreview.value = ''
|
||||
photoFile.value = null
|
||||
}
|
||||
|
||||
function onCropCancel() {
|
||||
closeCropping()
|
||||
}
|
||||
|
||||
/** 裁剪页缩放:正数放大、负数缩小;也支持双指捏合 */
|
||||
function onCropZoom(step: number) {
|
||||
const cropper = cropperRef.value as any
|
||||
cropper?.changeScale?.(step)
|
||||
}
|
||||
|
||||
function onCropConfirm() {
|
||||
if (cropConfirming.value) return
|
||||
const cropper = cropperRef.value as any
|
||||
if (!cropper?.getCropData) {
|
||||
showToast('裁剪组件未就绪')
|
||||
return
|
||||
}
|
||||
cropConfirming.value = true
|
||||
cropper.getCropData((data: string) => {
|
||||
closeCropping()
|
||||
if (!data) {
|
||||
showToast('裁剪失败')
|
||||
return
|
||||
}
|
||||
const img = new Image()
|
||||
img.src = data
|
||||
img.onload = () => {
|
||||
const w = img.naturalWidth
|
||||
const h = img.naturalHeight
|
||||
if (w < config.upload.minWidth || h < config.upload.minHeight) {
|
||||
showToast(`请上传尺寸大于${config.upload.minWidth}×${config.upload.minHeight}像素的照片`)
|
||||
clearPhoto()
|
||||
return
|
||||
}
|
||||
const blob = base64ToBlob(data)
|
||||
if (blob.size > config.upload.maxSize) {
|
||||
showToast(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`)
|
||||
clearPhoto()
|
||||
return
|
||||
}
|
||||
photoPreview.value = data
|
||||
photoFile.value = new File([blob], 'photo.jpg', { type: blob.type || 'image/jpeg' })
|
||||
}
|
||||
img.onerror = () => {
|
||||
showToast('裁剪图片加载失败')
|
||||
clearPhoto()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getOrderStat() {
|
||||
return badgeApi.getOrderStat({}).then((res: any) => {
|
||||
applyOrderLock(res)
|
||||
}).catch((err: any) => {
|
||||
// stat 失败(如 code:-1 没有卡券数据)禁止上传生图
|
||||
orderStat.value = res || {}
|
||||
}).catch(() => {
|
||||
// stat 仅用于可生图次数展示;失败不单独锁下单
|
||||
orderStat.value = {}
|
||||
orderLocked.value = true
|
||||
lockReason.value = err?.message || config.messages.orderStatFail || '订单信息获取失败,暂不可上传生图'
|
||||
})
|
||||
}
|
||||
|
||||
function getOrderRemaining() {
|
||||
return douyinApi.getOrderRemaining({}).then((res: any) => {
|
||||
const parsed = douyinApi.parseOrderRemaining(res)
|
||||
remainList.value = parsed.remainList
|
||||
}).catch(() => {
|
||||
remainList.value = []
|
||||
})
|
||||
}
|
||||
|
||||
function refreshQuota() {
|
||||
return Promise.all([getOrderStat(), getOrderRemaining()]).then(() => {
|
||||
applyOrderLock()
|
||||
})
|
||||
}
|
||||
|
||||
function applyShopBrand(name: string, logo: string) {
|
||||
const brand = setCachedShopBrand(name, logo)
|
||||
brandName.value = brand.name
|
||||
brandLogo.value = brand.logo
|
||||
applyDocumentBrand(brand.name, brand.logo, '首页')
|
||||
// 门店 logo 变更后重采样点云,供等待页立刻使用
|
||||
if (brand.logo) {
|
||||
preloadLogoCloud({ src: brand.logo }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function fetchShopBrand() {
|
||||
const code = localStorage.get('code')
|
||||
if (!code) return Promise.resolve()
|
||||
return douyinApi.getShopBrand({ code: String(code) }).then((res: any) => {
|
||||
const parsed = douyinApi.parseShopBrand(res)
|
||||
if (parsed.name || parsed.logo) {
|
||||
applyShopBrand(parsed.name, parsed.logo)
|
||||
}
|
||||
}).catch(() => {
|
||||
// 失败时保留默认品牌
|
||||
})
|
||||
}
|
||||
|
||||
@@ -171,11 +397,16 @@ function onFileChange(e: Event) {
|
||||
showToast(`请上传尺寸大于${config.upload.minWidth}×${config.upload.minHeight}像素的照片`)
|
||||
return
|
||||
}
|
||||
photoFile.value = file
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
photoPreview.value = String(ev.target?.result || '')
|
||||
const dataUrl = String(ev.target?.result || '')
|
||||
if (!dataUrl) {
|
||||
showToast('图片读取失败')
|
||||
return
|
||||
}
|
||||
nextTick(() => openCropping(dataUrl))
|
||||
}
|
||||
reader.onerror = () => showToast('图片读取失败')
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
img.onerror = () => {
|
||||
@@ -192,11 +423,11 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
// 提交前重新拉 orders/stat,不依赖本地缓存 / 页面内存旧值
|
||||
await getOrderStat()
|
||||
// 提交前重新拉额度,不依赖本地缓存 / 页面内存旧值
|
||||
await refreshQuota()
|
||||
if (orderLocked.value) {
|
||||
submitting.value = false
|
||||
showToast(lockReason.value || SUBMITTED_MSG)
|
||||
showToast(lockReason.value || config.messages.noOrderRemain)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,13 +442,19 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 清理历史本地标记(锁定状态改为仅看 orders/stat)
|
||||
// 清理历史本地标记(锁定状态改为看 remaining)
|
||||
try {
|
||||
sessionStorage.removeItem('douyin_order_submitted')
|
||||
} catch (_) {}
|
||||
getOrderStat()
|
||||
// 预采样 Logo 点云,进入等待页可立刻上屏
|
||||
preloadLogoCloud().catch(() => {})
|
||||
applyDocumentBrand(brandName.value, brandLogo.value, '首页')
|
||||
// 先用缓存门店 logo(或默认)预采样;shopBrand 返回后再按新 logo 刷新
|
||||
preloadLogoCloud({ src: brandLogo.value }).catch(() => {})
|
||||
fetchShopBrand()
|
||||
refreshQuota()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
setBodyScrollLocked(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -325,6 +562,45 @@ onMounted(() => {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.order-remain-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.order-remain-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.order-remain-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-remain-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.order-remain-size {
|
||||
font-size: 14px;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.order-remain-num {
|
||||
font-size: 13px;
|
||||
color: #5fbf45;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.select-panel {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
@@ -340,21 +616,23 @@ onMounted(() => {
|
||||
|
||||
.photo-slot {
|
||||
width: 100%;
|
||||
min-height: 220px;
|
||||
aspect-ratio: 3 / 4;
|
||||
max-height: 70vh;
|
||||
border-radius: 14px;
|
||||
background: #f5f8f3;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -387,38 +665,6 @@ onMounted(() => {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.quota {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6b7a66;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.effect-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -435,3 +681,125 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* 非 scoped:Teleport 到 body,相对视口固定在全屏底部 */
|
||||
.dy-footer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
z-index: 1000;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.dy-footer__btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dy-footer__btn:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 全屏裁剪层 */
|
||||
.dy-cropper {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2147483000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.dy-cropper__stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dy-cropper__stage .vue-cropper {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
background: #111 !important;
|
||||
/* 允许双指捏合缩放,避免浏览器默认手势抢占 */
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dy-cropper__stage .cropper-view-box {
|
||||
outline: 2px solid #fff !important;
|
||||
}
|
||||
|
||||
.dy-cropper__stage .crop-point {
|
||||
background-color: #5fbf45 !important;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.dy-cropper__bar {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.dy-cropper__zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dy-cropper__zoom-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dy-cropper__btn {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dy-cropper__btn--ok {
|
||||
background: #5fbf45;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dy-cropper__btn:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
+122
-52
@@ -38,35 +38,44 @@
|
||||
</div>
|
||||
|
||||
<p class="save-hint">效果已保存到生图记录 · 可随时回来查看</p>
|
||||
|
||||
<OrderSizeQtyPanel ref="orderPanelRef" @change="onOrderChange" />
|
||||
</div>
|
||||
|
||||
<div class="result-footer">
|
||||
<button class="btn-remake" type="button" @click="goHome">重新制作</button>
|
||||
<button
|
||||
class="btn-submit"
|
||||
type="button"
|
||||
:disabled="ordering || !imageUrl || !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 || !imageUrl || !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="confirmOrder">
|
||||
提交制作
|
||||
</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="confirmOrder">
|
||||
提交制作
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -77,6 +86,7 @@ import { showImagePreview, showSuccessToast, showToast } from 'vant'
|
||||
import * as douyinApi from '@/api/douyin'
|
||||
import { douyinConfig as config } from '@/config/douyin'
|
||||
import DyNavBar from './components/DyNavBar.vue'
|
||||
import OrderSizeQtyPanel from './components/OrderSizeQtyPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -86,8 +96,20 @@ const imageId = ref(String(route.query.image_id || sessionStorage.getItem('douyi
|
||||
const imageUrl = ref(String(route.query.result_url || sessionStorage.getItem('douyin_result_url') || ''))
|
||||
const ordering = ref(false)
|
||||
const showConfirmModal = ref(false)
|
||||
const orderPanelRef = ref<InstanceType<typeof OrderSizeQtyPanel> | null>(null)
|
||||
const orderQty = ref(1)
|
||||
const orderSize = ref('')
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
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 || '')
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.replace(config.routes.home)
|
||||
}
|
||||
@@ -128,6 +150,15 @@ function openConfirmModal() {
|
||||
showToast('效果图尚未就绪')
|
||||
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
|
||||
}
|
||||
|
||||
@@ -149,21 +180,51 @@ function fetchStatus() {
|
||||
})
|
||||
}
|
||||
|
||||
function confirmOrder() {
|
||||
async function confirmOrder() {
|
||||
if (ordering.value) return
|
||||
if (!taskId.value) {
|
||||
showToast('效果图尚未就绪')
|
||||
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
|
||||
douyinApi.submitProduction({
|
||||
task_id: taskId.value,
|
||||
image_id: imageId.value,
|
||||
}).then((res) => {
|
||||
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 (imageUrl.value) {
|
||||
sessionStorage.setItem('douyin_result_url', imageUrl.value)
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
'douyin_success_results',
|
||||
JSON.stringify([{
|
||||
url: imageUrl.value || '',
|
||||
count: qty,
|
||||
label: '',
|
||||
taskId: taskId.value,
|
||||
imageId: imageId.value,
|
||||
}].filter((item) => item.url)),
|
||||
)
|
||||
} catch (_) {
|
||||
sessionStorage.removeItem('douyin_success_results')
|
||||
}
|
||||
showConfirmModal.value = false
|
||||
showSuccessToast({
|
||||
message: config.messages.orderSuccess,
|
||||
@@ -172,11 +233,12 @@ function confirmOrder() {
|
||||
setTimeout(() => {
|
||||
router.replace(config.routes.success)
|
||||
}, 800)
|
||||
}).catch((err: any) => {
|
||||
} catch (err: any) {
|
||||
if (err?.message) showToast(err.message)
|
||||
}).finally(() => {
|
||||
orderPanelRef.value?.refresh?.()
|
||||
} finally {
|
||||
ordering.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -203,7 +265,7 @@ onUnmounted(() => {
|
||||
background: #ffffff;
|
||||
color: #1a1a1a;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
padding-bottom: calc(108px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.result-body {
|
||||
@@ -300,19 +362,26 @@ onUnmounted(() => {
|
||||
color: #9aa394;
|
||||
}
|
||||
|
||||
.result-footer {
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* Teleport 到 body:相对视口固定在全屏底部 */
|
||||
.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));
|
||||
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;
|
||||
@@ -323,7 +392,7 @@ onUnmounted(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
.dy-result-footer__submit {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border: none;
|
||||
@@ -335,23 +404,24 @@ 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;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.confirm-sheet {
|
||||
.dy-result-confirm-sheet {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border-radius: 18px;
|
||||
@@ -361,7 +431,7 @@ onUnmounted(() => {
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
.dy-result-confirm-title {
|
||||
background: #5fbf45;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
@@ -370,7 +440,7 @@ onUnmounted(() => {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
.dy-result-confirm-body {
|
||||
padding: 20px 18px 8px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
@@ -378,20 +448,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;
|
||||
@@ -399,19 +469,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>
|
||||
+601
-82
@@ -1,91 +1,159 @@
|
||||
<template>
|
||||
<div class="records-page">
|
||||
<div class="records-page" :class="{ 'is-batch': batchMode }">
|
||||
<DyNavBar
|
||||
theme="light"
|
||||
:title="config.records.messages.title"
|
||||
border
|
||||
@back="goHome"
|
||||
/>
|
||||
<div class="record-container">
|
||||
<div class="record-note">{{ config.records.messages.note }}</div>
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<!-- 首屏骨架屏 -->
|
||||
<div class="record-images" v-if="showSkeleton">
|
||||
<div class="record-card is-skeleton" v-for="n in skeletonCount" :key="'sk-' + n">
|
||||
<div class="skeleton-media"></div>
|
||||
<div class="skeleton-meta">
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line short"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="record-scroll">
|
||||
<div class="record-container">
|
||||
<div class="record-note-row">
|
||||
<div class="record-note">{{ config.records.messages.note }}</div>
|
||||
<button
|
||||
class="batch-entry"
|
||||
type="button"
|
||||
:disabled="ordering"
|
||||
@click="toggleBatchMode"
|
||||
>
|
||||
{{ batchMode ? config.records.messages.cancelBatch : config.records.messages.batchOrder }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
v-model:error="loadError"
|
||||
:finished="finished"
|
||||
:finished-text="images.length ? config.records.messages.noMore : ''"
|
||||
:loading-text="showSkeleton ? '' : undefined"
|
||||
:error-text="config.records.messages.loadRetry"
|
||||
:immediate-check="true"
|
||||
:offset="120"
|
||||
@load="onLoad"
|
||||
>
|
||||
<div class="record-images" v-if="images.length > 0">
|
||||
<div
|
||||
class="record-card"
|
||||
v-for="item in images"
|
||||
:key="item.id || item.taskId"
|
||||
:class="{
|
||||
'is-ready': isReady(item),
|
||||
'is-pending': isPending(item),
|
||||
'is-failed': isFailed(item),
|
||||
}"
|
||||
@click="onCardClick(item)"
|
||||
>
|
||||
<div class="record-card-media">
|
||||
<!-- 成功:真实图;加载失败则切到示意图 -->
|
||||
<template v-if="isReady(item)">
|
||||
<img
|
||||
v-if="!isBroken(item)"
|
||||
:src="item.path"
|
||||
class="record-image"
|
||||
alt=""
|
||||
@error="onImgError(item)"
|
||||
>
|
||||
<template v-else>
|
||||
<img :src="fallbackImage" class="record-image is-fallback" alt="">
|
||||
<div class="fallback-tip">{{ config.records.messages.imageLoadFailed }}</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成中 -->
|
||||
<template v-else-if="isPending(item)">
|
||||
<div class="record-placeholder">
|
||||
<van-loading type="spinner" size="22px" color="#5fbf45" />
|
||||
<p class="placeholder-text">{{ item.reason || config.records.messages.designing }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 生成失败:示意图占位 -->
|
||||
<template v-else>
|
||||
<img :src="fallbackImage" class="record-image is-fallback" alt="">
|
||||
<div class="fallback-tip">{{ item.reason || config.records.messages.designFailed }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="record-card-meta">
|
||||
<span class="meta-style">{{ item.style || '效果图' }}</span>
|
||||
<span class="meta-action" v-if="isReady(item) && !isBroken(item)">查看</span>
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<!-- 首屏骨架屏 -->
|
||||
<div class="record-images" v-if="showSkeleton">
|
||||
<div class="record-card is-skeleton" v-for="n in skeletonCount" :key="'sk-' + n">
|
||||
<div class="skeleton-media"></div>
|
||||
<div class="skeleton-meta">
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line short"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!showSkeleton && !loading && finished"
|
||||
class="record-image-item-null"
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
v-model:error="loadError"
|
||||
:finished="finished"
|
||||
:finished-text="images.length ? config.records.messages.noMore : ''"
|
||||
:loading-text="showSkeleton ? '' : undefined"
|
||||
:error-text="config.records.messages.loadRetry"
|
||||
:immediate-check="true"
|
||||
:offset="120"
|
||||
@load="onLoad"
|
||||
>
|
||||
<img :src="nullDataImage" :alt="config.records.messages.noData">
|
||||
</div>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
<div class="record-images" v-if="images.length > 0">
|
||||
<div
|
||||
class="record-card"
|
||||
v-for="item in images"
|
||||
:key="item.id || item.taskId"
|
||||
:class="{
|
||||
'is-ready': isReady(item),
|
||||
'is-pending': isPending(item),
|
||||
'is-failed': isFailed(item),
|
||||
'is-selected': batchMode && isSelected(item),
|
||||
'is-batch': batchMode,
|
||||
}"
|
||||
@click="onCardClick(item)"
|
||||
>
|
||||
<div class="record-card-media">
|
||||
<!-- 成功:真实图;加载失败则切到示意图 -->
|
||||
<template v-if="isReady(item)">
|
||||
<img
|
||||
v-if="!isBroken(item)"
|
||||
:src="item.path"
|
||||
class="record-image"
|
||||
alt=""
|
||||
@error="onImgError(item)"
|
||||
>
|
||||
<template v-else>
|
||||
<img :src="fallbackImage" class="record-image is-fallback" alt="">
|
||||
<div class="fallback-tip">{{ config.records.messages.imageLoadFailed }}</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 生成中 -->
|
||||
<template v-else-if="isPending(item)">
|
||||
<div class="record-placeholder">
|
||||
<van-loading type="spinner" size="22px" color="#5fbf45" />
|
||||
<p class="placeholder-text">{{ item.reason || config.records.messages.designing }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 生成失败:示意图占位 -->
|
||||
<template v-else>
|
||||
<img :src="fallbackImage" class="record-image is-fallback" alt="">
|
||||
<div class="fallback-tip">{{ item.reason || config.records.messages.designFailed }}</div>
|
||||
</template>
|
||||
|
||||
<button
|
||||
v-if="batchMode && canSelect(item)"
|
||||
class="card-check"
|
||||
type="button"
|
||||
:class="{ 'is-on': isSelected(item) }"
|
||||
:aria-pressed="isSelected(item)"
|
||||
@click.stop="toggleSelect(item)"
|
||||
>
|
||||
<span class="card-check-mark">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="record-card-meta">
|
||||
<span class="meta-style">{{ item.style || '效果图' }}</span>
|
||||
<span class="meta-action" v-if="!batchMode && isReady(item) && !isBroken(item)">查看</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="batchMode && canSelect(item) && isSelected(item)"
|
||||
class="record-card-qty"
|
||||
@click.stop
|
||||
>
|
||||
<span class="qty-label">数量</span>
|
||||
<van-stepper
|
||||
:model-value="getQty(item)"
|
||||
:min="1"
|
||||
:max="maxQtyFor(item)"
|
||||
integer
|
||||
theme="round"
|
||||
button-size="22px"
|
||||
@update:model-value="(v) => setQty(item, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!showSkeleton && !loading && finished"
|
||||
class="record-image-item-null"
|
||||
>
|
||||
<img :src="nullDataImage" :alt="config.records.messages.noData">
|
||||
</div>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="batchMode" class="batch-footer">
|
||||
<div class="batch-footer-meta">
|
||||
已选 {{ selectedQtyTotal }} / {{ orderRemainTotal }}
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
:disabled="ordering || selectedCount === 0"
|
||||
@click="submitBatchOrders"
|
||||
>
|
||||
{{ ordering ? config.records.messages.submittingOrder : config.records.messages.submitOrder }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="batch-toasts" aria-live="polite">
|
||||
<div
|
||||
v-for="tip in batchToasts"
|
||||
:key="tip.id"
|
||||
class="batch-toast"
|
||||
:class="`is-${tip.type}`"
|
||||
>
|
||||
{{ tip.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -93,14 +161,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import * as douyinApi from '@/api/douyin'
|
||||
import { douyinConfig as config } from '@/config/douyin'
|
||||
import nullDataImage from '@/assets/badge/null-data.png'
|
||||
import DyNavBar from './components/DyNavBar.vue'
|
||||
|
||||
type BatchToast = {
|
||||
id: number
|
||||
type: 'success' | 'fail' | 'info'
|
||||
message: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const fallbackImage = config.images.effectRef
|
||||
const skeletonCount = 6
|
||||
const msgs = config.records.messages
|
||||
|
||||
const images = ref<any[]>([])
|
||||
const page = ref(config.records.pagination.initialPage)
|
||||
@@ -115,8 +191,27 @@ const fetching = ref(false)
|
||||
/** 图片加载失败的 task/id 集合 */
|
||||
const brokenMap = reactive<Record<string, boolean>>({})
|
||||
|
||||
const batchMode = ref(false)
|
||||
const ordering = ref(false)
|
||||
const orderRemainTotal = ref(0)
|
||||
const selectedMap = reactive<Record<string, boolean>>({})
|
||||
const qtyMap = reactive<Record<string, number>>({})
|
||||
const batchToasts = ref<BatchToast[]>([])
|
||||
let toastSeq = 0
|
||||
const toastTimers = new Map<number, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const showSkeleton = computed(() => !initialLoaded.value && images.value.length === 0)
|
||||
|
||||
const selectedKeys = computed(() =>
|
||||
Object.keys(selectedMap).filter((k) => selectedMap[k]),
|
||||
)
|
||||
|
||||
const selectedCount = computed(() => selectedKeys.value.length)
|
||||
|
||||
const selectedQtyTotal = computed(() =>
|
||||
selectedKeys.value.reduce((sum, key) => sum + (Number(qtyMap[key]) || 0), 0),
|
||||
)
|
||||
|
||||
function itemKey(item: any) {
|
||||
return String(item.id || item.taskId || item.sw_pid || '')
|
||||
}
|
||||
@@ -137,10 +232,128 @@ function isBroken(item: any) {
|
||||
return !!brokenMap[itemKey(item)]
|
||||
}
|
||||
|
||||
function canSelect(item: any) {
|
||||
return isReady(item) && !isBroken(item)
|
||||
}
|
||||
|
||||
function isSelected(item: any) {
|
||||
const key = itemKey(item)
|
||||
return !!(key && selectedMap[key])
|
||||
}
|
||||
|
||||
function getQty(item: any) {
|
||||
const key = itemKey(item)
|
||||
return Math.max(1, Number(qtyMap[key]) || 1)
|
||||
}
|
||||
|
||||
function otherSelectedQty(excludeKey: string) {
|
||||
return selectedKeys.value.reduce((sum, key) => {
|
||||
if (key === excludeKey) return sum
|
||||
return sum + (Number(qtyMap[key]) || 0)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function maxQtyFor(item: any) {
|
||||
const key = itemKey(item)
|
||||
const remain = Math.max(0, orderRemainTotal.value - otherSelectedQty(key))
|
||||
return Math.max(1, remain)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
Object.keys(selectedMap).forEach((k) => delete selectedMap[k])
|
||||
Object.keys(qtyMap).forEach((k) => delete qtyMap[k])
|
||||
}
|
||||
|
||||
function pushBatchToast(message: string, type: BatchToast['type'] = 'info', duration = 2600) {
|
||||
const id = ++toastSeq
|
||||
batchToasts.value = [...batchToasts.value, { id, type, message }]
|
||||
const timer = setTimeout(() => {
|
||||
batchToasts.value = batchToasts.value.filter((t) => t.id !== id)
|
||||
toastTimers.delete(id)
|
||||
}, duration)
|
||||
toastTimers.set(id, timer)
|
||||
}
|
||||
|
||||
function clearBatchToasts() {
|
||||
toastTimers.forEach((timer) => clearTimeout(timer))
|
||||
toastTimers.clear()
|
||||
batchToasts.value = []
|
||||
}
|
||||
|
||||
async function fetchOrderRemaining() {
|
||||
try {
|
||||
const res: any = await douyinApi.getOrderRemaining({})
|
||||
const parsed = douyinApi.parseOrderRemaining(res)
|
||||
orderRemainTotal.value = parsed.remainList.reduce(
|
||||
(sum, item) => sum + (Number(item.remaining) || 0),
|
||||
0,
|
||||
)
|
||||
} catch (_) {
|
||||
orderRemainTotal.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function toggleBatchMode() {
|
||||
if (ordering.value) return
|
||||
if (batchMode.value) {
|
||||
batchMode.value = false
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
if (orderRemainTotal.value <= 0) {
|
||||
showToast(msgs.noOrderRemain)
|
||||
return
|
||||
}
|
||||
batchMode.value = true
|
||||
}
|
||||
|
||||
function toggleSelect(item: any) {
|
||||
if (!batchMode.value || ordering.value || !canSelect(item)) return
|
||||
const key = itemKey(item)
|
||||
if (!key) return
|
||||
|
||||
if (selectedMap[key]) {
|
||||
delete selectedMap[key]
|
||||
delete qtyMap[key]
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedCount.value >= orderRemainTotal.value) {
|
||||
showToast(msgs.exceedOrderRemain)
|
||||
return
|
||||
}
|
||||
if (selectedQtyTotal.value >= orderRemainTotal.value) {
|
||||
showToast(msgs.exceedOrderRemain)
|
||||
return
|
||||
}
|
||||
|
||||
selectedMap[key] = true
|
||||
qtyMap[key] = 1
|
||||
}
|
||||
|
||||
function setQty(item: any, value: number | string) {
|
||||
if (!batchMode.value || ordering.value || !canSelect(item)) return
|
||||
const key = itemKey(item)
|
||||
if (!key || !selectedMap[key]) return
|
||||
|
||||
let next = Math.floor(Number(value) || 1)
|
||||
if (next < 1) next = 1
|
||||
const max = maxQtyFor(item)
|
||||
if (next > max) {
|
||||
next = max
|
||||
showToast(msgs.exceedOrderRemain)
|
||||
}
|
||||
qtyMap[key] = next
|
||||
}
|
||||
|
||||
function onImgError(item: any) {
|
||||
const key = itemKey(item)
|
||||
if (!key || brokenMap[key]) return
|
||||
brokenMap[key] = true
|
||||
if (selectedMap[key]) {
|
||||
delete selectedMap[key]
|
||||
delete qtyMap[key]
|
||||
}
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
@@ -184,7 +397,7 @@ async function onLoad() {
|
||||
try {
|
||||
await fetchPage()
|
||||
} catch (err: any) {
|
||||
console.error(config.records.messages.getDataFailed, err)
|
||||
console.error(msgs.getDataFailed, err)
|
||||
loadError.value = true
|
||||
} finally {
|
||||
fetching.value = false
|
||||
@@ -200,8 +413,9 @@ async function onRefresh() {
|
||||
loadError.value = false
|
||||
images.value = []
|
||||
Object.keys(brokenMap).forEach((k) => delete brokenMap[k])
|
||||
clearSelection()
|
||||
initialLoaded.value = false
|
||||
// 交给 van-list 重新检查并触发 @load;若未触发则主动拉一页
|
||||
await fetchOrderRemaining()
|
||||
loading.value = true
|
||||
await onLoad()
|
||||
refreshing.value = false
|
||||
@@ -222,13 +436,136 @@ function goPreview(item: any) {
|
||||
}
|
||||
|
||||
function onCardClick(item: any) {
|
||||
if (batchMode.value) {
|
||||
toggleSelect(item)
|
||||
return
|
||||
}
|
||||
if (isReady(item) && !isBroken(item)) {
|
||||
goPreview(item)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 桌面端 / 非移动调试模式下 van-list immediate-check 可能不触发,主动拉首屏
|
||||
function buildOrderQueue() {
|
||||
const queue: Array<{
|
||||
taskId: string
|
||||
imageId: string
|
||||
label: string
|
||||
count: number
|
||||
index: number
|
||||
}> = []
|
||||
let index = 0
|
||||
for (const item of images.value) {
|
||||
const key = itemKey(item)
|
||||
if (!key || !selectedMap[key] || !canSelect(item)) continue
|
||||
const count = Math.max(1, Number(qtyMap[key]) || 1)
|
||||
const taskId = String(item.taskId || '').trim()
|
||||
const imageId = String(item.imageId || item.num || '').trim()
|
||||
const label = item.style || '效果图'
|
||||
index += 1
|
||||
queue.push({ taskId, imageId, label, count, index })
|
||||
}
|
||||
return queue
|
||||
}
|
||||
|
||||
async function submitBatchOrders() {
|
||||
if (ordering.value) return
|
||||
if (!selectedCount.value) {
|
||||
showToast(msgs.selectAtLeastOne)
|
||||
return
|
||||
}
|
||||
if (selectedQtyTotal.value <= 0) {
|
||||
showToast(msgs.selectAtLeastOne)
|
||||
return
|
||||
}
|
||||
if (selectedQtyTotal.value > orderRemainTotal.value) {
|
||||
showToast(msgs.exceedOrderRemain)
|
||||
return
|
||||
}
|
||||
|
||||
const queue = buildOrderQueue()
|
||||
if (!queue.length) {
|
||||
showToast(msgs.selectAtLeastOne)
|
||||
return
|
||||
}
|
||||
|
||||
ordering.value = true
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
const successResults: Array<{
|
||||
url: string
|
||||
count: number
|
||||
label: string
|
||||
taskId: string
|
||||
imageId: string
|
||||
}> = []
|
||||
|
||||
for (const job of queue) {
|
||||
try {
|
||||
if (!job.taskId) throw new Error('缺少 task_id')
|
||||
await douyinApi.submitProduction({
|
||||
task_id: job.taskId,
|
||||
image_id: job.imageId,
|
||||
count: job.count,
|
||||
})
|
||||
successCount += 1
|
||||
const matched = images.value.find((item) => {
|
||||
const taskId = String(item.taskId || '').trim()
|
||||
const imageId = String(item.imageId || item.num || '').trim()
|
||||
return taskId === job.taskId && (!job.imageId || imageId === job.imageId || !imageId)
|
||||
})
|
||||
const url = String(matched?.path || '').trim()
|
||||
if (url) {
|
||||
successResults.push({
|
||||
url,
|
||||
count: job.count,
|
||||
label: job.label,
|
||||
taskId: job.taskId,
|
||||
imageId: job.imageId,
|
||||
})
|
||||
}
|
||||
pushBatchToast(
|
||||
`${msgs.batchOrderSuccess}(${job.label} · ${job.count}件 · 第${job.index}单)`,
|
||||
'success',
|
||||
)
|
||||
} catch (err: any) {
|
||||
failCount += 1
|
||||
pushBatchToast(
|
||||
`${msgs.batchOrderFail}(${job.label} · 第${job.index}单):${err?.message || '请重试'}`,
|
||||
'fail',
|
||||
3600,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
ordering.value = false
|
||||
|
||||
if (failCount === 0 && successCount > 0) {
|
||||
const first = successResults[0] || queue[0]
|
||||
if (first?.taskId) sessionStorage.setItem('douyin_task_id', first.taskId)
|
||||
if (first?.imageId) sessionStorage.setItem('douyin_image_id', first.imageId)
|
||||
if (successResults[0]?.url) {
|
||||
sessionStorage.setItem('douyin_result_url', successResults[0].url)
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem('douyin_success_results', JSON.stringify(successResults))
|
||||
} catch (_) {
|
||||
sessionStorage.removeItem('douyin_success_results')
|
||||
}
|
||||
setTimeout(() => {
|
||||
router.replace(config.routes.success)
|
||||
}, 600)
|
||||
return
|
||||
}
|
||||
|
||||
await fetchOrderRemaining()
|
||||
if (orderRemainTotal.value <= 0) {
|
||||
batchMode.value = false
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchOrderRemaining()
|
||||
if (!initialLoaded.value && !fetching.value) {
|
||||
loading.value = true
|
||||
onLoad()
|
||||
@@ -236,6 +573,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearBatchToasts()
|
||||
loading.value = false
|
||||
finished.value = false
|
||||
page.value = config.records.pagination.initialPage
|
||||
@@ -244,21 +582,62 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.records-page {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
max-height: 100vh;
|
||||
max-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #f7f8f6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.record-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.record-container {
|
||||
padding: 12px 16px 24px;
|
||||
}
|
||||
|
||||
.record-note-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.record-note {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.batch-entry {
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5fbf45;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.batch-entry:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.record-images {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@@ -274,9 +653,9 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
box-shadow: 0 4px 14px rgba(20, 40, 20, 0.06);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
|
||||
&.is-ready {
|
||||
&.is-ready:not(.is-batch) {
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
@@ -284,6 +663,15 @@ onUnmounted(() => {
|
||||
box-shadow: 0 2px 8px rgba(20, 40, 20, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-batch.is-ready {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: rgba(95, 191, 69, 0.55);
|
||||
box-shadow: 0 4px 14px rgba(95, 191, 69, 0.16);
|
||||
}
|
||||
}
|
||||
|
||||
.record-card-media {
|
||||
@@ -310,6 +698,36 @@ onUnmounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.card-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1.5px solid rgba(255, 255, 255, 0.95);
|
||||
background: rgba(0, 0, 0, 0.28);
|
||||
color: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
cursor: pointer;
|
||||
|
||||
&.is-on {
|
||||
background: #5fbf45;
|
||||
border-color: #5fbf45;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.card-check-mark {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fallback-tip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -356,6 +774,44 @@ onUnmounted(() => {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.record-card-qty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 12px 12px;
|
||||
|
||||
:deep(.van-stepper) {
|
||||
--van-stepper-button-round-theme-color: #5fbf45;
|
||||
--van-stepper-button-icon-color: #5fbf45;
|
||||
--van-stepper-background: rgba(95, 191, 69, 0.14);
|
||||
--van-stepper-button-disabled-color: rgba(95, 191, 69, 0.12);
|
||||
--van-stepper-button-disabled-icon-color: rgba(95, 191, 69, 0.4);
|
||||
}
|
||||
|
||||
:deep(.van-stepper--round .van-stepper__minus) {
|
||||
color: #5fbf45;
|
||||
background-color: #fff;
|
||||
border: 1px solid #5fbf45;
|
||||
}
|
||||
|
||||
:deep(.van-stepper--round .van-stepper__plus) {
|
||||
color: #fff;
|
||||
background: #5fbf45;
|
||||
border: 1px solid #5fbf45;
|
||||
}
|
||||
|
||||
:deep(.van-stepper--round .van-stepper__minus--disabled),
|
||||
:deep(.van-stepper--round .van-stepper__plus--disabled) {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
.qty-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.meta-style {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -373,6 +829,69 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.batch-footer {
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
backdrop-filter: blur(8px);
|
||||
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.batch-footer-meta {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #6b7a66;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.batch-toasts {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
bottom: calc(96px + env(safe-area-inset-bottom));
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.batch-toast {
|
||||
align-self: center;
|
||||
max-width: 92%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
color: #fff;
|
||||
background: rgba(34, 34, 34, 0.88);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.16);
|
||||
|
||||
&.is-success {
|
||||
background: rgba(45, 140, 70, 0.92);
|
||||
}
|
||||
|
||||
&.is-fail {
|
||||
background: rgba(200, 60, 60, 0.92);
|
||||
}
|
||||
}
|
||||
|
||||
/* 骨架屏 */
|
||||
.is-skeleton {
|
||||
pointer-events: none;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { douyinConfig } from '@/config/douyin'
|
||||
|
||||
const CACHE_KEY = 'douyin_shop_brand'
|
||||
|
||||
export type CachedShopBrand = {
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
|
||||
export function getCachedShopBrand(): CachedShopBrand {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(CACHE_KEY)
|
||||
if (raw) {
|
||||
const o = JSON.parse(raw)
|
||||
if (o && typeof o === 'object' && (o.name || o.logo)) {
|
||||
return {
|
||||
name: String(o.name || douyinConfig.brandName),
|
||||
logo: String(o.logo || douyinConfig.images.logo),
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return {
|
||||
name: douyinConfig.brandName,
|
||||
logo: douyinConfig.images.logo,
|
||||
}
|
||||
}
|
||||
|
||||
export function setCachedShopBrand(name?: string, logo?: string): CachedShopBrand {
|
||||
const prev = getCachedShopBrand()
|
||||
const brand: CachedShopBrand = {
|
||||
name: (name && String(name).trim()) || prev.name || douyinConfig.brandName,
|
||||
logo: (logo && String(logo).trim()) || prev.logo || douyinConfig.images.logo,
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(CACHE_KEY, JSON.stringify(brand))
|
||||
} catch (_) {}
|
||||
return brand
|
||||
}
|
||||
|
||||
export function setDocumentFavicon(href: string) {
|
||||
if (!href) return
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.type = 'image/png'
|
||||
link.href = href
|
||||
}
|
||||
|
||||
/** 同步浏览器标签页标题与 favicon */
|
||||
export function applyDocumentBrand(name: string, logo: string, pageTitle = '首页') {
|
||||
const brand = (name && String(name).trim()) || douyinConfig.brandName
|
||||
const favicon = (logo && String(logo).trim()) || douyinConfig.images.logo
|
||||
window.document.title = `${brand} - ${pageTitle}`
|
||||
setDocumentFavicon(favicon)
|
||||
}
|
||||
+158
-13
@@ -20,12 +20,27 @@
|
||||
<span class="check">✓</span>
|
||||
</div>
|
||||
<h1 class="hero-title">提交制作成功</h1>
|
||||
<p class="hero-desc">效果图已确认并进入制作流程,可随时在生图记录中查看</p>
|
||||
<p class="hero-desc">{{ heroDesc }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="resultUrl" class="thumb-card" @click="openZoom">
|
||||
<img :src="resultUrl" class="thumb-img" alt="已提交效果图">
|
||||
<div v-if="resultList.length === 1" class="thumb-card" @click="openZoom(0)">
|
||||
<img :src="resultList[0].url" class="thumb-img" alt="已提交效果图">
|
||||
<div class="thumb-badge">已提交</div>
|
||||
<div v-if="resultList[0].count > 1" class="thumb-count">×{{ resultList[0].count }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="resultList.length > 1" class="thumb-grid">
|
||||
<div
|
||||
v-for="(item, idx) in resultList"
|
||||
:key="`${item.taskId || item.url}-${idx}`"
|
||||
class="thumb-grid-item"
|
||||
@click="openZoom(idx)"
|
||||
>
|
||||
<img :src="item.url" class="thumb-img" alt="已提交效果图">
|
||||
<div class="thumb-badge">已提交</div>
|
||||
<div v-if="item.count > 1" class="thumb-count">×{{ item.count }}</div>
|
||||
<div v-if="item.label" class="thumb-label">{{ item.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="steps-card">
|
||||
@@ -35,7 +50,7 @@
|
||||
<span class="dot" />
|
||||
<div>
|
||||
<div class="step-name">确认效果并提交</div>
|
||||
<div class="step-desc">已完成</div>
|
||||
<div class="step-desc">已完成{{ totalCountText }}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="step is-active">
|
||||
@@ -69,14 +84,92 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showImagePreview } from 'vant'
|
||||
import { douyinConfig as config } from '@/config/douyin'
|
||||
import DyNavBar from './components/DyNavBar.vue'
|
||||
|
||||
type SuccessResult = {
|
||||
url: string
|
||||
count: number
|
||||
label: string
|
||||
taskId?: string
|
||||
imageId?: string
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const resultUrl = ref(sessionStorage.getItem('douyin_result_url') || '')
|
||||
const resultList = ref<SuccessResult[]>([])
|
||||
|
||||
function normalizeResult(item: any): SuccessResult | null {
|
||||
const url = String(item?.url || item?.path || item?.result_url || '').trim()
|
||||
if (!url) return null
|
||||
return {
|
||||
url,
|
||||
count: Math.max(1, Number(item?.count) || 1),
|
||||
label: String(item?.label || item?.style || '').trim(),
|
||||
taskId: item?.taskId ? String(item.taskId) : '',
|
||||
imageId: item?.imageId ? String(item.imageId) : '',
|
||||
}
|
||||
}
|
||||
|
||||
function loadResults() {
|
||||
const list: SuccessResult[] = []
|
||||
try {
|
||||
const raw = sessionStorage.getItem('douyin_success_results')
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach((item) => {
|
||||
const normalized = normalizeResult(item)
|
||||
if (normalized) list.push(normalized)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (!list.length) {
|
||||
const single = sessionStorage.getItem('douyin_result_url') || ''
|
||||
if (single) {
|
||||
list.push({
|
||||
url: single,
|
||||
count: 1,
|
||||
label: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 去重,保留首次出现顺序
|
||||
const seen = new Set<string>()
|
||||
resultList.value = list.filter((item) => {
|
||||
const key = `${item.taskId || ''}|${item.imageId || ''}|${item.url}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
const totalPieces = computed(() =>
|
||||
resultList.value.reduce((sum, item) => sum + (Number(item.count) || 1), 0),
|
||||
)
|
||||
|
||||
const heroDesc = computed(() => {
|
||||
const n = resultList.value.length
|
||||
if (n > 1) {
|
||||
return `已提交 ${n} 张效果图(共 ${totalPieces.value} 件),可随时在生图记录中查看`
|
||||
}
|
||||
if (totalPieces.value > 1) {
|
||||
return `已提交 ${totalPieces.value} 件制作,可随时在生图记录中查看`
|
||||
}
|
||||
return '效果图已确认并进入制作流程,可随时在生图记录中查看'
|
||||
})
|
||||
|
||||
const totalCountText = computed(() => {
|
||||
const n = resultList.value.length
|
||||
if (n > 1) return ` · ${n} 张 / ${totalPieces.value} 件`
|
||||
if (totalPieces.value > 1) return ` · ${totalPieces.value} 件`
|
||||
return ''
|
||||
})
|
||||
|
||||
function goHome() {
|
||||
router.replace(config.routes.home)
|
||||
@@ -86,14 +179,20 @@ function goRecords() {
|
||||
router.replace(config.routes.records)
|
||||
}
|
||||
|
||||
function openZoom() {
|
||||
if (!resultUrl.value) return
|
||||
function openZoom(startIndex = 0) {
|
||||
const images = resultList.value.map((item) => item.url).filter(Boolean)
|
||||
if (!images.length) return
|
||||
showImagePreview({
|
||||
images: [resultUrl.value],
|
||||
images,
|
||||
startPosition: Math.max(0, Math.min(startIndex, images.length - 1)),
|
||||
closeable: true,
|
||||
closeIconPosition: 'top-right',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadResults()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -215,7 +314,7 @@ function openZoom() {
|
||||
|
||||
.hero-desc {
|
||||
margin: 8px auto 0;
|
||||
max-width: 280px;
|
||||
max-width: 300px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #7a8a76;
|
||||
@@ -230,6 +329,24 @@ function openZoom() {
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 22px rgba(40, 70, 50, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thumb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
.thumb-grid-item {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 6px 18px rgba(40, 70, 50, 0.1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thumb-img {
|
||||
@@ -242,9 +359,9 @@ function openZoom() {
|
||||
|
||||
.thumb-badge {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
padding: 4px 10px;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(95, 191, 69, 0.92);
|
||||
color: #fff;
|
||||
@@ -252,6 +369,34 @@ function openZoom() {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.thumb-count {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
min-width: 28px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.thumb-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 18px 8px 8px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.55));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.steps-card,
|
||||
.tips-card {
|
||||
background: #fff;
|
||||
|
||||
Reference in new issue
Block a user