From 8e8cde9ef5f515a08c23d3e1346d84052e4316fc Mon Sep 17 00:00:00 2001 From: Linzm <1015157648@qq.com> Date: Tue, 27 Oct 2026 14:34:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E6=8A=96=E9=9F=B3?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B8=80=E4=B8=AA=E9=93=BE=E6=8E=A5=E4=B8=8B?= =?UTF-8?q?=E5=A4=9A=E5=8D=95=EF=BC=9B=E5=9B=BE=E7=89=87=E6=8C=89=E8=A3=81?= =?UTF-8?q?=E5=89=AA3:4=E6=AF=94=E4=BE=8B=EF=BC=9B=E6=A0=B9=E6=8D=AE?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=9A=84=E6=95=B0=E6=8D=AE=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E9=97=A8=E5=BA=97=E5=90=8D=E7=A7=B0=E5=92=8C=E9=97=A8=E5=BA=97?= =?UTF-8?q?logo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components.d.ts | 1 + package.json | 1 + src/api/douyin.ts | 118 ++- src/config/douyin.ts | 27 +- src/router/index.ts | 32 +- .../douyin/components/ImagePointCloud.vue | 77 +- .../douyin/components/OrderSizeQtyPanel.vue | 258 +++++++ .../components/image-point-cloud/constants.js | 12 +- .../image-point-cloud/logo-cloud-cache.js | 50 +- .../image-point-cloud/point-cloud-sampler.js | 10 +- .../image-point-cloud/webgl/renderer.js | 20 +- .../image-point-cloud/webgl/shaders.js | 56 +- .../webgl/texture-manager.js | 10 +- src/views/douyin/generating.vue | 255 +++++-- src/views/douyin/index.vue | 548 +++++++++++--- src/views/douyin/preview.vue | 174 +++-- src/views/douyin/records.vue | 683 +++++++++++++++--- src/views/douyin/shopBrandCache.ts | 59 ++ src/views/douyin/success.vue | 171 ++++- 19 files changed, 2158 insertions(+), 404 deletions(-) create mode 100644 src/views/douyin/components/OrderSizeQtyPanel.vue create mode 100644 src/views/douyin/shopBrandCache.ts diff --git a/components.d.ts b/components.d.ts index 284f32c..180d013 100644 --- a/components.d.ts +++ b/components.d.ts @@ -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'] diff --git a/package.json b/package.json index 90bb99c..e8a9f35 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/douyin.ts b/src/api/douyin.ts index 94be850..3c37b6e 100644 --- a/src/api/douyin.ts +++ b/src/api/douyin.ts @@ -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 = {}) => { + 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, } } diff --git a/src/config/douyin.ts b/src/config/douyin.ts index 4cf0af8..87d0057 100644 --- a/src/config/douyin.ts +++ b/src/config/douyin.ts @@ -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: '请选择下单数量', diff --git a/src/router/index.ts b/src/router/index.ts index 0d759bc..edaeff3 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -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('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) { diff --git a/src/views/douyin/components/ImagePointCloud.vue b/src/views/douyin/components/ImagePointCloud.vue index 4c7f638..d74989d 100644 --- a/src/views/douyin/components/ImagePointCloud.vue +++ b/src/views/douyin/components/ImagePointCloud.vue @@ -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(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%; diff --git a/src/views/douyin/components/OrderSizeQtyPanel.vue b/src/views/douyin/components/OrderSizeQtyPanel.vue new file mode 100644 index 0000000..ff61a7c --- /dev/null +++ b/src/views/douyin/components/OrderSizeQtyPanel.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/src/views/douyin/components/image-point-cloud/constants.js b/src/views/douyin/components/image-point-cloud/constants.js index 9744f65..d0b98e4 100644 --- a/src/views/douyin/components/image-point-cloud/constants.js +++ b/src/views/douyin/components/image-point-cloud/constants.js @@ -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 = { diff --git a/src/views/douyin/components/image-point-cloud/logo-cloud-cache.js b/src/views/douyin/components/image-point-cloud/logo-cloud-cache.js index 4ade606..402ae7b 100644 --- a/src/views/douyin/components/image-point-cloud/logo-cloud-cache.js +++ b/src/views/douyin/components/image-point-cloud/logo-cloud-cache.js @@ -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 = '' } diff --git a/src/views/douyin/components/image-point-cloud/point-cloud-sampler.js b/src/views/douyin/components/image-point-cloud/point-cloud-sampler.js index 0a7a87f..cfa9ec4 100644 --- a/src/views/douyin/components/image-point-cloud/point-cloud-sampler.js +++ b/src/views/douyin/components/image-point-cloud/point-cloud-sampler.js @@ -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, diff --git a/src/views/douyin/components/image-point-cloud/webgl/renderer.js b/src/views/douyin/components/image-point-cloud/webgl/renderer.js index fae861b..3f46dfb 100644 --- a/src/views/douyin/components/image-point-cloud/webgl/renderer.js +++ b/src/views/douyin/components/image-point-cloud/webgl/renderer.js @@ -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) diff --git a/src/views/douyin/components/image-point-cloud/webgl/shaders.js b/src/views/douyin/components/image-point-cloud/webgl/shaders.js index 335ed1a..f1bcf70 100644 --- a/src/views/douyin/components/image-point-cloud/webgl/shaders.js +++ b/src/views/douyin/components/image-point-cloud/webgl/shaders.js @@ -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); } diff --git a/src/views/douyin/components/image-point-cloud/webgl/texture-manager.js b/src/views/douyin/components/image-point-cloud/webgl/texture-manager.js index 66a6da4..b7a7738 100644 --- a/src/views/douyin/components/image-point-cloud/webgl/texture-manager.js +++ b/src/views/douyin/components/image-point-cloud/webgl/texture-manager.js @@ -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, diff --git a/src/views/douyin/generating.vue b/src/views/douyin/generating.vue index 5be101f..ad82de7 100644 --- a/src/views/douyin/generating.vue +++ b/src/views/douyin/generating.vue @@ -18,8 +18,10 @@

效果已保存到生图记录 · 可随时回来查看

+
{{ errorTip }}
- + + + - -
-
-
确认效果满意
-
-

请仔细确认效果是否满意后再提交

-

提交制作后将进入制作流程,请确认效果满意后再继续

-
-
- - +
+
+
确认效果满意
+
+

请仔细确认效果是否满意后再提交

+

+ 将提交 {{ orderQty }} 件 + + ,进入制作流程 +

+
+
+ + +
-
+
@@ -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 | 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 | null = null @@ -175,8 +214,34 @@ let maskAborted = false let uploading = false let fadeTimer: ReturnType | 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 { + + + diff --git a/src/views/douyin/index.vue b/src/views/douyin/index.vue index 8945474..4277ae5 100644 --- a/src/views/douyin/index.vue +++ b/src/views/douyin/index.vue @@ -9,8 +9,8 @@ > @@ -19,7 +19,7 @@
- +
{{ config.messages.homeTitle }}
{{ config.messages.homeHint }}
@@ -38,6 +38,24 @@ ›
+
+
{{ config.messages.orderSizeRemain }}
+
+
+ + {{ config.messages.orderSizeLabel }} {{ item.size }} + + + {{ config.messages.orderRemainLabel }} {{ item.remaining }} + +
+
+
+
{{ photoPreview ? '确认照片后开始生成' : '添加 1 张清晰正面人像' }}
@@ -45,7 +63,6 @@
+
{{ config.messages.addPhoto }}
-
相册或拍照
- -
+ + + + + +
+
+ +
+
+ +
+ + +
+ +
+
+
@@ -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(() => { } } + + diff --git a/src/views/douyin/preview.vue b/src/views/douyin/preview.vue index 3822153..152a541 100644 --- a/src/views/douyin/preview.vue +++ b/src/views/douyin/preview.vue @@ -38,35 +38,44 @@

效果已保存到生图记录 · 可随时回来查看

+ +
- + + + -
-
-
确认效果满意
-
-

请仔细确认效果是否满意后再提交

-

提交制作后将进入制作流程,请确认效果满意后再继续

-
-
- - +
+
+
确认效果满意
+
+

请仔细确认效果是否满意后再提交

+

+ 将提交 {{ orderQty }} 件 + + ,进入制作流程 +

+
+
+ + +
-
+
@@ -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 | null>(null) +const orderQty = ref(1) +const orderSize = ref('') let pollTimer: ReturnType | 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 { + + + diff --git a/src/views/douyin/records.vue b/src/views/douyin/records.vue index 2888329..b3f113b 100644 --- a/src/views/douyin/records.vue +++ b/src/views/douyin/records.vue @@ -1,91 +1,159 @@ @@ -93,14 +161,22 @@