forked from natuka/web.puabadge.com
Compare commits
23
Commits
main
..
8e8cde9ef5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e8cde9ef5 | ||
|
|
6bf542bf1d | ||
|
|
290b065310 | ||
|
|
e6ff4756df | ||
|
|
704f6a30e0 | ||
|
|
5892c47a6b | ||
|
|
605bd894c5 | ||
|
|
17908ab309 | ||
|
|
a76b181650 | ||
|
|
e2afe86ea3 | ||
|
|
73971e4a08 | ||
|
|
024c8a5155 | ||
|
|
9cc1275cd7 | ||
|
|
085885b6a6 | ||
|
|
898c159ab8 | ||
|
|
7ba46aed34 | ||
|
|
6b2f588ea5 | ||
|
|
d109b1dfd9 | ||
|
|
018a3db13d | ||
|
|
7427056dfd | ||
|
|
c7aeb2f6ce | ||
|
|
5ada4015e8 | ||
|
|
0cea63cbbd |
No files matched your search
-55
@@ -174,61 +174,6 @@ steps:
|
||||
- rollback
|
||||
target:
|
||||
- prod
|
||||
#================================= oem ==========================
|
||||
# - name: upload to oem oss
|
||||
# commands:
|
||||
# - python3 /home/devops/python_script/aliyun/upload.py
|
||||
# pull: never
|
||||
# image: oss2:latest
|
||||
# volumes:
|
||||
# - name: files
|
||||
# path: /files
|
||||
# - name: scripts
|
||||
# path: /scripts
|
||||
# commands:
|
||||
# - python3 /scripts/oss.py oem-${DRONE_REPO_NAME} backup
|
||||
# - python3 /scripts/oss.py oem-${DRONE_REPO_NAME} upload
|
||||
# when:
|
||||
# event:
|
||||
# - promote
|
||||
# target:
|
||||
# - oem
|
||||
# - name: restore to oem oss
|
||||
# pull: never
|
||||
# image: oss2:latest
|
||||
# volumes:
|
||||
# - name: files
|
||||
# path: /files
|
||||
# - name: scripts
|
||||
# path: /scripts
|
||||
# commands:
|
||||
# - python3 /scripts/oss.py oem-${DRONE_REPO_NAME} restore
|
||||
# when:
|
||||
# event:
|
||||
# - rollback
|
||||
# target:
|
||||
# - oem
|
||||
# - name: flush oem cdn
|
||||
# pull: never
|
||||
# image: oss2:latest
|
||||
# volumes:
|
||||
# - name: files
|
||||
# path: /files
|
||||
# - name: scripts
|
||||
# path: /scripts
|
||||
# commands:
|
||||
# - sleep 3
|
||||
# - echo https://web.minimi.studio/ > /scripts/oem
|
||||
# - echo https://www.sailner.web/ >> /scripts/oem
|
||||
# - cd /scripts && python3 /scripts/refresh.py -r oem -t clear
|
||||
# when:
|
||||
# event:
|
||||
# - promote
|
||||
# target:
|
||||
# - oem
|
||||
|
||||
|
||||
#================================= sharewow3d ==========================
|
||||
volumes:
|
||||
- name: files
|
||||
host:
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
VITE_APP_PREVIEW=true
|
||||
# 开发环境走 Vite 同源代理 /api,避免直连 shop.api 跨域(后端 CORS 头重复冲突)
|
||||
# VITE_APP_API_BASE_URL=http://172.16.20.21:20001/api/web/
|
||||
# VITE_APP_API_BASE_URL=http://172.16.0.40:20001/api/web/
|
||||
VITE_APP_API_BASE_URL=https://shop.api.suwa3d.com/api/web/
|
||||
# VITE_APP_API_BASE_URL=https://shop.api.suwa3d.com/api/web/
|
||||
VITE_APP_API_BASE_URL=/api/
|
||||
# VITE_APP_API_BASE_URL=https://wechat-test.api.puabadge.com/web/
|
||||
# VITE_APP_API_BASE_URL=https://wechat.api.puabadge.com/web/
|
||||
# VITE_APP_API_WX_URL=https://wechat.api.puabadge.com/web/
|
||||
|
||||
+6
-6
@@ -10,7 +10,7 @@
|
||||
|
||||
### 包管理器
|
||||
|
||||
推荐使用 [pnpm](https://pnpm.io/) 来进行包管理,可以极大地减少install时间, 而且非常节省磁盘空间。
|
||||
推荐使用 [yarn](https://yarnpkg.com/) 来进行包管理。
|
||||
|
||||
### 目录结构
|
||||
|
||||
@@ -63,17 +63,17 @@ git clone https://github.com/CharleeWa/vue3-vant-mobile.git
|
||||
```bash
|
||||
cd vue3-vant-mobile
|
||||
|
||||
# with pnpm
|
||||
pnpm install
|
||||
# with yarn
|
||||
yarn install
|
||||
|
||||
```
|
||||
|
||||
> 如果网络状况不佳,可以设置 pnpm 的 npm 加载源,如 pnpm config set registry https://registry.npmmirror.com
|
||||
> 如果网络状况不佳,可以设置 yarn 的 npm 加载源,如 `yarn config set registry https://registry.npmmirror.com`
|
||||
|
||||
完成前面的步骤以后,就可以启动项目,开始进行业务开发了。
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
### 新增页面
|
||||
@@ -87,7 +87,7 @@ pnpm dev
|
||||
这里已经为大家预置了两种:`view` 和 `component`,选择一个之后按回车键就可以在项目目录的左侧看到了。
|
||||
|
||||
```bash
|
||||
pnpm plop
|
||||
yarn plop
|
||||
```
|
||||
|
||||
- 手动创建
|
||||
|
||||
+5
-5
@@ -31,7 +31,7 @@
|
||||
- 🚀 Vue3 生态开箱即用
|
||||
- 🤖 自动导入组件
|
||||
- 📄 使用plop轻松创建文件
|
||||
- 📦 使用pnpm包管理器
|
||||
- 📦 使用 yarn 包管理器
|
||||
- 💾 支持Mock服务器
|
||||
- 🌈 支持Git hooks(lint和commit)
|
||||
- 💖 移动端UI组件(Vant4)
|
||||
@@ -50,21 +50,21 @@ git clone https://github.com/CharleeWa/vue3-vant-mobile.git
|
||||
```bash
|
||||
cd vue3-vant-mobile
|
||||
|
||||
# with pnpm
|
||||
pnpm install
|
||||
# with yarn
|
||||
yarn install
|
||||
|
||||
```
|
||||
|
||||
- 运行
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
- 打包构建
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
yarn build
|
||||
```
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
@@ -31,7 +31,7 @@ English / [简体中文](./README-zh_CN.md) , [使用指南](./Guide-zh_CN.md) /
|
||||
- 🚀 Support Vue 3 out-of-the-box
|
||||
- 🤖 Auto Importing components
|
||||
- 📄 Easy to create files (plop)
|
||||
- 📦 Using pnpm package manager
|
||||
- 📦 Using yarn package manager
|
||||
- 💾 Support Mock server
|
||||
- 🌈 Support Git hooks (lint and commit)
|
||||
- 💖 Mobile UI Components (Vant4)
|
||||
@@ -51,21 +51,21 @@ git clone https://git.suwa3d.com/natuka/mobile.suwa3d.com.git
|
||||
```bash
|
||||
cd mobile.suwa3d.com
|
||||
|
||||
# with pnpm
|
||||
pnpm install
|
||||
# with yarn
|
||||
yarn install
|
||||
|
||||
```
|
||||
|
||||
- run
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
yarn dev
|
||||
```
|
||||
|
||||
- build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
yarn build
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
Vendored
+13
-2
@@ -13,10 +13,11 @@ declare module '@vue/runtime-core' {
|
||||
Backup: typeof import('./src/components/arFrame/backup.vue')['default']
|
||||
Backup2: typeof import('./src/components/arFrame/backup2.vue')['default']
|
||||
Brand: typeof import('./src/components/brand/index.vue')['default']
|
||||
Constants: typeof import('./src/views/douyin/components/image-point-cloud/constants.js')['default']
|
||||
DyNavBar: typeof import('./src/views/douyin/components/DyNavBar.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
@@ -25,18 +26,28 @@ declare module '@vue/runtime-core' {
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
Gsplat: typeof import('./src/components/arFrame/gsplat.vue')['default']
|
||||
Hint: typeof import('./src/components/hint/hint.vue')['default']
|
||||
ImagePointCloud: typeof import('./src/views/douyin/components/ImagePointCloud.vue')['default']
|
||||
Loading: typeof import('./src/components/loading/index.vue')['default']
|
||||
LogoCloudCache: typeof import('./src/views/douyin/components/image-point-cloud/logo-cloud-cache.js')['default']
|
||||
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']
|
||||
Shaders: typeof import('./src/views/douyin/components/image-point-cloud/webgl/shaders.js')['default']
|
||||
State: typeof import('./src/views/douyin/components/image-point-cloud/state.js')['default']
|
||||
SubjectOutline: typeof import('./src/views/douyin/components/image-point-cloud/subject-outline.js')['default']
|
||||
Tabbar: typeof import('./src/components/tabbar/index.vue')['default']
|
||||
Tensorflow: typeof import('./src/components/arFrame/tensorflow.vue')['default']
|
||||
TextureManager: typeof import('./src/views/douyin/components/image-point-cloud/webgl/texture-manager.js')['default']
|
||||
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
||||
VanAddressEdit: typeof import('vant/es')['AddressEdit']
|
||||
VanDivider: typeof import('vant/es')['Divider']
|
||||
VanIcon: typeof import('vant/es')['Icon']
|
||||
VanList: typeof import('vant/es')['List']
|
||||
VanLoading: typeof import('vant/es')['Loading']
|
||||
VanOverlay: typeof import('vant/es')['Overlay']
|
||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||
VanStepper: typeof import('vant/es')['Stepper']
|
||||
VanUploader: typeof import('vant/es')['Uploader']
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="description" content="The template for Vue3 Vant Mobile" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, user-scalable=no"/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<title>葡哇立体肖像</title>
|
||||
<link rel="icon" type="image/png" href="/douyin-logo.png" />
|
||||
<title>首页</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="max-width: 500px;margin: 0 auto;"></div>
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<meta name="description" content="The template for Vue3 Vant Mobile" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover, user-scalable=no"/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" href="/douyin-logo.png" />
|
||||
<title>Oauth2</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
-25169
File diff suppressed because it is too large.
Load diff
+2
-8
@@ -18,7 +18,7 @@
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"plop": "plop",
|
||||
"prepare": "npx husky install"
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/model-viewer": "^3.4.0",
|
||||
@@ -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",
|
||||
@@ -111,13 +112,6 @@
|
||||
"vitest": "^0.25.8",
|
||||
"vue-tsc": "^1.8.27"
|
||||
},
|
||||
"pnpm": {
|
||||
"peerDependencyRules": {
|
||||
"ignoreMissing": [
|
||||
"postcss"
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "./node_modules/cz-emoji-chinese"
|
||||
|
||||
Generated
-10304
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1,39 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const root = path.resolve('src/views/douyin/components/image-point-cloud')
|
||||
|
||||
function walk(d) {
|
||||
for (const f of fs.readdirSync(d)) {
|
||||
const p = path.join(d, f)
|
||||
if (fs.statSync(p).isDirectory()) walk(p)
|
||||
else if (f.endsWith('.js')) convert(p)
|
||||
}
|
||||
}
|
||||
|
||||
function convert(file) {
|
||||
let s = fs.readFileSync(file, 'utf8')
|
||||
|
||||
s = s.replace(
|
||||
/const\s+\{([^}]+)\}\s*=\s*require\((['"])(.*?)\2\)/g,
|
||||
(_m, names, _q, mod) => `import {${names}} from '${mod}'`
|
||||
)
|
||||
s = s.replace(
|
||||
/const\s+(\w+)\s*=\s*require\((['"])(.*?)\2\)/g,
|
||||
(_m, name, _q, mod) => `import * as ${name} from '${mod}'`
|
||||
)
|
||||
|
||||
// remove config perf require block
|
||||
s = s.replace(
|
||||
/try\s*\{[\s\S]*?POINT_CLOUD_PERF_LOG[\s\S]*?\}\s*catch[\s\S]*?\{[\s\S]*?\}/,
|
||||
'let _perfLog = false'
|
||||
)
|
||||
|
||||
s = s.replace(/module\.exports\s*=\s*\{([\s\S]*?)\}\s*$/m, (_m, body) => `export {${body}}`)
|
||||
s = s.replace(/module\.exports\s*=\s*(\w+)\s*$/m, 'export default $1')
|
||||
|
||||
fs.writeFileSync(file, s)
|
||||
console.log('converted', file)
|
||||
}
|
||||
|
||||
walk(root)
|
||||
+28
-2
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useTranslation } from "i18next-vue";
|
||||
import * as i18n from '@/lang/utils'
|
||||
import { Locale } from 'vant';
|
||||
@@ -7,11 +9,14 @@ import enUS from 'vant/es/locale/lang/en-US';
|
||||
import zhCN from 'vant/es/locale/lang/zh-CN';
|
||||
import zhTW from 'vant/es/locale/lang/zh-TW';
|
||||
import ko from 'vant/es/locale/lang/ko-KR';
|
||||
// import { zhCN } from "element-plus/es/locale";
|
||||
|
||||
const { i18next, t } = useTranslation();
|
||||
i18n.set(t);
|
||||
|
||||
const route = useRoute()
|
||||
/** 仅抖音页使用独立布局壳,避免影响 cartoon / badge 等页面 */
|
||||
const isDouyinRoute = computed(() => String(route.path || '').startsWith('/douyin'))
|
||||
|
||||
// 根据 i18next 语言获取对应的 Vant Locale
|
||||
function getVantLocale() {
|
||||
const lang = i18next.language || "zh-CN";
|
||||
@@ -62,9 +67,30 @@ onMounted(() => {
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<Suspense>
|
||||
<div>
|
||||
<div :class="isDouyinRoute ? 'app-shell app-shell--douyin' : 'app-shell'">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</Suspense>
|
||||
</router-view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 公共根节点:cartoon 等页白底 */
|
||||
.app-shell {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
box-sizing: border-box;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/* 仅抖音链路:桌面居中 + fixed 相对本容器(不影响 cartoon) */
|
||||
.app-shell--douyin {
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
transform: translateZ(0);
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,503 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
const PREFIX = 'douyin'
|
||||
|
||||
function normalizeOssUrl(url: string) {
|
||||
if (!url) return ''
|
||||
let s = String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=').trim()
|
||||
// 真机 https 页加载 http 图会被拦截,统一升 https
|
||||
if (s.indexOf('//') === 0) s = `https:${s}`
|
||||
if (/^http:\/\//i.test(s)) s = `https://${s.slice(7)}`
|
||||
return s
|
||||
}
|
||||
|
||||
function guessContentType(file: File | Blob, filename?: string) {
|
||||
if (file.type) return file.type
|
||||
const name = String(filename || '').toLowerCase()
|
||||
if (name.endsWith('.png')) return 'image/png'
|
||||
if (name.endsWith('.webp')) return 'image/webp'
|
||||
if (name.endsWith('.gif')) return 'image/gif'
|
||||
return 'image/jpeg'
|
||||
}
|
||||
|
||||
function guessFilename(file?: File) {
|
||||
if (file && (file as File).name) return (file as File).name
|
||||
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`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
content_type: data.content_type || 'image/jpeg',
|
||||
...(data.filename ? { filename: data.filename } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /douyin/upload/complete */
|
||||
export const completeUpload = (data: { upload_id: string }) => {
|
||||
return request(`${PREFIX}/upload/complete`, {
|
||||
method: 'POST',
|
||||
data: { upload_id: data.upload_id },
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /douyin/imagegen/submit */
|
||||
export const submitImageGen = (data: {
|
||||
upload_id: string
|
||||
prompt?: string
|
||||
style?: string
|
||||
n?: number
|
||||
}) => {
|
||||
const body: any = { upload_id: data.upload_id }
|
||||
if (data.prompt) body.prompt = String(data.prompt).slice(0, 4000)
|
||||
if (data.style) body.style = String(data.style).slice(0, 200)
|
||||
if (data.n != null) body.n = Math.min(4, Math.max(1, Number(data.n) || 1))
|
||||
return request(`${PREFIX}/imagegen/submit`, {
|
||||
method: 'POST',
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /douyin/imagegen/status */
|
||||
export const getImageGenStatus = (taskId: string) => {
|
||||
return request(`${PREFIX}/imagegen/status`, {
|
||||
method: 'GET',
|
||||
params: { task_id: taskId },
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /douyin/imagegen/records */
|
||||
export const getImageGenRecords = (params: { page?: number; page_size?: number } = {}) => {
|
||||
return request(`${PREFIX}/imagegen/records`, {
|
||||
method: 'GET',
|
||||
params: {
|
||||
page: Math.max(1, Number(params.page) || 1),
|
||||
page_size: Math.max(1, Number(params.page_size) || 20),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /douyin/make/submit
|
||||
* 下单/提交制作:task_id + image_id + count(下单数量)
|
||||
*/
|
||||
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`, {
|
||||
method: 'POST',
|
||||
data: {
|
||||
task_id: taskId,
|
||||
image_id: imageId,
|
||||
count,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 make/submit 所需 image_id;本地缺失时拉生图状态补齐
|
||||
*/
|
||||
export async function resolveMakeImageId(
|
||||
taskId: string,
|
||||
imageId?: string | number | null,
|
||||
): Promise<string> {
|
||||
const local = String(imageId || '').trim()
|
||||
if (local) return local
|
||||
const tid = String(taskId || '').trim()
|
||||
if (!tid) return ''
|
||||
const res: any = await getImageGenStatus(tid)
|
||||
const parsed = parseImageGenStatus(res, tid)
|
||||
return String(parsed.imageId || '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 抖音下单入口:自动补齐 image_id 后调用 make/submit
|
||||
*/
|
||||
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 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,
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /douyin/mask/create */
|
||||
export const createMask = (data: { pid: number | string }) => {
|
||||
return request(`${PREFIX}/mask/create`, {
|
||||
method: 'POST',
|
||||
data: { pid: data.pid },
|
||||
})
|
||||
}
|
||||
|
||||
/** GET /douyin/mask/url */
|
||||
export const getMaskUrl = (params: { pid: number | string }) => {
|
||||
return request(`${PREFIX}/mask/url`, {
|
||||
method: 'GET',
|
||||
params: { pid: params.pid },
|
||||
}).then((data: any) => {
|
||||
const d = data || {}
|
||||
return {
|
||||
pid: d.pid != null ? d.pid : params.pid,
|
||||
path: d.path || '',
|
||||
url: normalizeOssUrl(d.url || ''),
|
||||
ready: !!(d.ready === true || d.ready === 1 || d.ready === 'true'),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** create 后轮询 mask/url,直到 ready 且有 url */
|
||||
export async function waitMaskUrl(options: {
|
||||
pid: number | string
|
||||
intervalMs?: number
|
||||
timeoutMs?: number
|
||||
}) {
|
||||
const pid = options.pid
|
||||
if (pid == null || pid === '') throw new Error('缺少 pid')
|
||||
const intervalMs = options.intervalMs != null ? options.intervalMs : 2000
|
||||
const timeoutMs = options.timeoutMs != null ? options.timeoutMs : 60000
|
||||
const started = Date.now()
|
||||
|
||||
try {
|
||||
await createMask({ pid })
|
||||
} catch (e) {
|
||||
// create 失败仍尝试拉 url(任务可能已存在)
|
||||
}
|
||||
|
||||
const poll = async (): Promise<{ pid: any; path: string; url: string; ready: boolean }> => {
|
||||
if (Date.now() - started > timeoutMs) throw new Error('Mask 生成超时')
|
||||
const res = await getMaskUrl({ pid })
|
||||
if (res && res.ready && res.url) return res
|
||||
await new Promise((r) => setTimeout(r, intervalMs))
|
||||
return poll()
|
||||
}
|
||||
return poll()
|
||||
}
|
||||
|
||||
export function parseUploadUrlResult(res: any) {
|
||||
const data = res || {}
|
||||
return {
|
||||
uploadId: data.upload_id || data.uploadId || '',
|
||||
uploadUrl: normalizeOssUrl(data.upload_url || data.uploadUrl || data.put_url || data.putUrl || ''),
|
||||
fileUrl: normalizeOssUrl(data.file_url || data.fileUrl || ''),
|
||||
contentType: data.content_type || data.contentType || 'image/jpeg',
|
||||
pid: data.pid != null ? data.pid : '',
|
||||
expireAt: Number(data.expire_at || data.expireAt || 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function parseImageGenStatus(res: any, taskId = '') {
|
||||
const data = res || {}
|
||||
const statusRaw = String(data.status || data.state || data.task_status || '').toLowerCase()
|
||||
const images = data.images || data.results || data.result_images || data.outputs || []
|
||||
const firstImage = Array.isArray(images) && images.length ? images[0] : null
|
||||
const resultUrl =
|
||||
data.result_url ||
|
||||
data.resultUrl ||
|
||||
data.image_url ||
|
||||
data.imageUrl ||
|
||||
data.file_url ||
|
||||
data.fileUrl ||
|
||||
(firstImage &&
|
||||
(typeof firstImage === 'string'
|
||||
? firstImage
|
||||
: firstImage.url ||
|
||||
firstImage.image_url ||
|
||||
firstImage.imageUrl ||
|
||||
firstImage.result_url ||
|
||||
firstImage.file_url)) ||
|
||||
''
|
||||
const imageId =
|
||||
data.image_id ||
|
||||
data.imageId ||
|
||||
data.selected_image_id ||
|
||||
data.result_image_id ||
|
||||
(firstImage &&
|
||||
typeof firstImage === 'object' &&
|
||||
(firstImage.image_id || firstImage.imageId || firstImage.id || firstImage.img_id)) ||
|
||||
''
|
||||
|
||||
let status = 'processing'
|
||||
if (['success', 'succeeded', 'done', 'completed', 'finished'].includes(statusRaw)) {
|
||||
status = 'success'
|
||||
} else if (['failed', 'fail', 'error', 'canceled', 'cancelled'].includes(statusRaw)) {
|
||||
status = 'failed'
|
||||
} else if (['pending', 'queued', 'created', 'waiting'].includes(statusRaw)) {
|
||||
status = 'pending'
|
||||
} else if (['processing', 'running', 'generating'].includes(statusRaw)) {
|
||||
status = 'processing'
|
||||
}
|
||||
if (resultUrl && status !== 'failed') status = 'success'
|
||||
|
||||
return {
|
||||
taskId: data.task_id || data.taskId || taskId,
|
||||
status,
|
||||
statusRaw,
|
||||
resultUrl: normalizeOssUrl(resultUrl),
|
||||
imageId: String(imageId || ''),
|
||||
message: data.message || data.msg || '',
|
||||
raw: data,
|
||||
}
|
||||
}
|
||||
|
||||
/** 原图上传:upload/url → PUT → upload/complete */
|
||||
export async function uploadOriginal(file: File | Blob, options: {
|
||||
filename?: string
|
||||
content_type?: string
|
||||
} = {}) {
|
||||
const filename = options.filename || guessFilename(file as File)
|
||||
const contentType = options.content_type || guessContentType(file, filename)
|
||||
const res: any = await getUploadUrl({ filename, content_type: contentType })
|
||||
const parsed = parseUploadUrlResult(res)
|
||||
if (!parsed.uploadId) throw new Error('签发上传地址失败:缺少 upload_id')
|
||||
if (!parsed.uploadUrl) throw new Error('签发上传地址失败:缺少 upload_url')
|
||||
if (parsed.expireAt > 0) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (now >= parsed.expireAt - 5) throw new Error('上传地址已过期,请重试')
|
||||
}
|
||||
|
||||
const putRes = await fetch(parsed.uploadUrl, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': parsed.contentType || contentType },
|
||||
body: file,
|
||||
})
|
||||
if (!putRes.ok) throw new Error('上传失败')
|
||||
|
||||
await completeUpload({ upload_id: parsed.uploadId })
|
||||
return {
|
||||
upload_id: parsed.uploadId,
|
||||
file_url: parsed.fileUrl,
|
||||
pid: parsed.pid,
|
||||
content_type: parsed.contentType || contentType,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上传 + 提交生图 */
|
||||
export async function startGeneratePipeline(file: File | Blob, options: {
|
||||
filename?: string
|
||||
content_type?: string
|
||||
prompt?: string
|
||||
style?: string
|
||||
n?: number
|
||||
} = {}) {
|
||||
const uploadRes = await uploadOriginal(file, options)
|
||||
const data: any = await submitImageGen({
|
||||
upload_id: uploadRes.upload_id,
|
||||
prompt: options.prompt,
|
||||
style: options.style || '坐姿手办风',
|
||||
n: options.n || 1,
|
||||
})
|
||||
const taskId = data.task_id || data.taskId
|
||||
if (!taskId) throw new Error('提交生图任务失败')
|
||||
return {
|
||||
taskId: String(taskId),
|
||||
upload_id: uploadRes.upload_id,
|
||||
file_url: uploadRes.file_url || '',
|
||||
pid: uploadRes.pid,
|
||||
status: data.status || 'pending',
|
||||
raw: data,
|
||||
}
|
||||
}
|
||||
|
||||
/** 规范化记录列表 */
|
||||
export function normalizeRecords(res: any) {
|
||||
const data = res || {}
|
||||
let list: any[] = []
|
||||
let total = 0
|
||||
let page = 1
|
||||
let pageSize = 20
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
list = data
|
||||
total = data.length
|
||||
} else {
|
||||
list = data.list || data.records || data.items || data.rows || []
|
||||
if (!Array.isArray(list)) list = []
|
||||
total = data.total != null ? Number(data.total) : data.count != null ? Number(data.count) : list.length
|
||||
page = Number(data.page) || 1
|
||||
pageSize = Number(data.page_size || data.pageSize) || 20
|
||||
}
|
||||
|
||||
const mapped = list.map((item: any) => {
|
||||
const statusRaw = String(item.status || item.state || '').toLowerCase()
|
||||
let status = 1
|
||||
if (['failed', 'fail', 'error'].includes(statusRaw) || item.status === 2) status = 2
|
||||
else if (['pending', 'processing', 'running', 'queued', 'generating'].includes(statusRaw) || item.status === 0) status = 0
|
||||
else if (item.status === 1 || ['success', 'succeeded', 'done', 'completed', 'submitted', 'generated'].includes(statusRaw)) status = 1
|
||||
|
||||
// 接口图片在 images[] 里:{ image_id, url },与 parseImageGenStatus 保持一致
|
||||
const images = item.images || item.results || item.result_images || item.outputs || []
|
||||
const firstImage = Array.isArray(images) && images.length ? images[0] : null
|
||||
const nestedUrl =
|
||||
firstImage &&
|
||||
(typeof firstImage === 'string'
|
||||
? firstImage
|
||||
: firstImage.url ||
|
||||
firstImage.image_url ||
|
||||
firstImage.imageUrl ||
|
||||
firstImage.result_url ||
|
||||
firstImage.file_url ||
|
||||
firstImage.path ||
|
||||
'')
|
||||
const nestedImageId =
|
||||
firstImage &&
|
||||
typeof firstImage === 'object' &&
|
||||
(firstImage.image_id || firstImage.imageId || firstImage.id || firstImage.img_id)
|
||||
|
||||
const resultUrl = normalizeOssUrl(
|
||||
item.result_url ||
|
||||
item.resultUrl ||
|
||||
item.image_url ||
|
||||
item.imageUrl ||
|
||||
item.path ||
|
||||
item.thumb_url ||
|
||||
nestedUrl ||
|
||||
''
|
||||
)
|
||||
const imageId = item.image_id || item.imageId || nestedImageId || ''
|
||||
|
||||
// 有图且非明确失败时,按成功展示(避免 completed 但字段不全时误判)
|
||||
if (resultUrl && status !== 2) status = 1
|
||||
|
||||
return {
|
||||
id: item.id || item.task_id || item.taskId,
|
||||
taskId: item.task_id || item.taskId || '',
|
||||
imageId: String(imageId || ''),
|
||||
path: resultUrl,
|
||||
status,
|
||||
style: item.style || item.style_name || item.styleName || '',
|
||||
reason: item.reason || item.message || item.msg || '',
|
||||
sw_pid: item.pid || item.sw_pid || item.task_id || item.taskId || '',
|
||||
num: imageId || item.key || item.num || '',
|
||||
prod_id: item.prod_id || '',
|
||||
group_count: item.group_count || 1,
|
||||
createdAt: item.created_at || item.createdAt || item.completed_at || '',
|
||||
raw: item,
|
||||
}
|
||||
})
|
||||
|
||||
return { list: mapped, total, page, page_size: pageSize }
|
||||
}
|
||||
+3
-2
@@ -12,7 +12,7 @@
|
||||
color-scheme: light !important;
|
||||
}
|
||||
[data-theme='light'] body {
|
||||
background-color: #eff2f5;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
:root {
|
||||
--van-checkbox-checked-icon-color: #07c160;
|
||||
@@ -22,7 +22,8 @@
|
||||
top: 50%!important;
|
||||
max-width: 100vw!important;
|
||||
}
|
||||
canvas {
|
||||
/* 仅 AR 全屏画布;勿用全局 canvas(会把点云 WebGL 拉成 100vw 导致偏左) */
|
||||
.fullscreen-canvas {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
+17
-4
@@ -1,6 +1,18 @@
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
/* cartoon 等业务页默认白底;勿用全局灰底覆盖 */
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
#app {
|
||||
// height: 100%;
|
||||
// overflow-x: hidden;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -23,7 +35,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #eff2f5;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +49,8 @@
|
||||
// height: 100vh;
|
||||
}
|
||||
|
||||
canvas {
|
||||
/* 仅 AR 全屏画布;勿用全局 canvas(会把点云 WebGL 拉成 100vw 导致偏左) */
|
||||
.fullscreen-canvas {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 484 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 779 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -345,7 +345,7 @@ export const cartoonConfig = {
|
||||
styles: {
|
||||
gap: 8, // 间距
|
||||
itemWidth: 'calc(50% - 4px)', // 订单项宽度
|
||||
itemHeight: '43vw', // 订单项高度
|
||||
itemHeight: 'calc(50vw - 4px)', // 订单项高度
|
||||
borderRadius: 12, // 圆角
|
||||
padding: {
|
||||
horizontal: 16,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export const config: any = {
|
||||
/** 暂时隐藏浏览器标题中的品牌名;恢复时改回 true / 填回 brand */
|
||||
showBrandInTitle: false,
|
||||
brand: '葡哇立体肖像',
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import mengqu from '@/assets/douyin/mengqu.png'
|
||||
import zuozi from '@/assets/douyin/zuozi.png'
|
||||
|
||||
export const douyinConfig = {
|
||||
routes: {
|
||||
home: '/douyin',
|
||||
generating: '/douyin/generating',
|
||||
preview: '/douyin/preview',
|
||||
records: '/douyin/records',
|
||||
success: '/douyin/success',
|
||||
},
|
||||
|
||||
brandName: '萌趣小宝.ai定制',
|
||||
primaryColor: '#5fbf45',
|
||||
background: '#ffffff',
|
||||
|
||||
images: {
|
||||
logo: mengqu,
|
||||
/** 点云 Logo 段原图 */
|
||||
logoCloud: mengqu,
|
||||
effectRef: zuozi,
|
||||
},
|
||||
|
||||
upload: {
|
||||
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: {
|
||||
interval: 5000,
|
||||
/** 软进度上限;效果图返回后会继续冲到 96%,预览前动画到 100% */
|
||||
softCap: 88,
|
||||
},
|
||||
|
||||
phases: {
|
||||
LOGO: { key: 'LOGO', copy: '正在准备生成环境' },
|
||||
SOURCE_GRAY: { key: 'SOURCE_GRAY', copy: '解析照片主体' },
|
||||
SOURCE_COLOR: { key: 'SOURCE_COLOR', copy: '提取画面特征' },
|
||||
WAITING: { key: 'WAITING', copy: 'AI 效果生成中' },
|
||||
RESULT_REVEAL: { key: 'RESULT_REVEAL', copy: '效果图生成完成' },
|
||||
DONE: { key: 'DONE', copy: '准备预览' },
|
||||
},
|
||||
|
||||
records: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
initialPage: 1,
|
||||
},
|
||||
status: {
|
||||
success: 1,
|
||||
designing: 0,
|
||||
failed: 2,
|
||||
},
|
||||
messages: {
|
||||
title: '生成记录',
|
||||
note: '注:可点击效果图继续下单',
|
||||
batchOrder: '批量下单',
|
||||
cancelBatch: '取消批量',
|
||||
submitOrder: '确认下单',
|
||||
submittingOrder: '下单中...',
|
||||
noOrderRemain: '暂无可下单数量',
|
||||
selectAtLeastOne: '请先勾选效果图',
|
||||
exceedOrderRemain: '已达可下单数量上限',
|
||||
batchOrderSuccess: '下单成功',
|
||||
batchOrderFail: '下单失败',
|
||||
noMore: '没有更多了',
|
||||
noData: '暂无生成记录',
|
||||
designing: '生成中...',
|
||||
designFailed: '生成失败',
|
||||
imageLoadFailed: '图片加载失败',
|
||||
loading: '加载中...',
|
||||
getDataFailed: '获取记录失败',
|
||||
loadRetry: '请求失败,点击重试',
|
||||
},
|
||||
},
|
||||
|
||||
messages: {
|
||||
homeTitle: 'AI 定制专属效果',
|
||||
homeHint: '上传照片,轻松生成专属效果图',
|
||||
uploadBtn: '上传并生成',
|
||||
startGenerate: '开始生成',
|
||||
/** 按钮内展示:上传并生成(生图次数:24) */
|
||||
genCountInBtn: '生图次数',
|
||||
reselect: '重新选图',
|
||||
addPhoto: '添加照片',
|
||||
effectLabel: '效果示意',
|
||||
effectTitle: '坐姿手办风',
|
||||
orderSizeRemain: '可下单尺寸和数量',
|
||||
orderSizeLabel: '尺寸:',
|
||||
orderRemainLabel: '数量:',
|
||||
orderQtyLabel: '下单数量',
|
||||
noOrderRemain: '暂无可下单数量',
|
||||
remainCount: '可生图剩余次数',
|
||||
uploading: '上传中...',
|
||||
uploadSuccess: '照片上传成功',
|
||||
uploadFail: '上传失败',
|
||||
pleaseUpload: '请先添加照片',
|
||||
noRemain: '可生图剩余次数不足',
|
||||
confirmOrder: '确认提交该效果图并下单?',
|
||||
orderSuccess: '下单成功',
|
||||
selectQuantity: '请选择下单数量',
|
||||
orderSubmitted: '本订单号已提交制作,不能继续上传图片或生图',
|
||||
orderSubmittedBtn: '订单已提交制作',
|
||||
orderStatFail: '订单信息获取失败,暂不可上传生图',
|
||||
orderStatFailBtn: '暂不可上传生图',
|
||||
},
|
||||
}
|
||||
|
||||
export type DouyinPhaseKey = keyof typeof douyinConfig.phases
|
||||
+23
-2
@@ -1,13 +1,34 @@
|
||||
/** 去掉上游重复/冲突的 CORS 头,避免经代理后浏览器仍报跨域 */
|
||||
const stripCorsHeaders = (proxyRes: { headers: Record<string, any> }) => {
|
||||
const keys = [
|
||||
'access-control-allow-origin',
|
||||
'access-control-allow-credentials',
|
||||
'access-control-allow-headers',
|
||||
'access-control-allow-methods',
|
||||
'access-control-expose-headers',
|
||||
'access-control-max-age',
|
||||
]
|
||||
keys.forEach((key) => {
|
||||
delete proxyRes.headers[key]
|
||||
})
|
||||
}
|
||||
|
||||
export const proxy = {
|
||||
"/api": {
|
||||
target: "https://web.api.suwa3d.com/",
|
||||
target: "https://shop.api.suwa3d.com/api/web/",
|
||||
// target: 'http://127.0.0.1:28499',
|
||||
// target: 'http://172.16.0.36:28499',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path: string) => {
|
||||
console.log("path:", path);
|
||||
return path.replace(/^\/api/, "");
|
||||
}
|
||||
},
|
||||
configure: (proxyServer: any) => {
|
||||
proxyServer.on('proxyRes', (proxyRes: any) => {
|
||||
stripCorsHeaders(proxyRes)
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
"/prod": {
|
||||
|
||||
@@ -203,5 +203,8 @@ const en = {
|
||||
"选择": "Select",
|
||||
"镜像处理": "Mirror Processing",
|
||||
"确认选择后视图": "Confirm Rear View Selection",
|
||||
"相框标题": "Frame Title",
|
||||
"请输入相框标题": "Please enter frame title",
|
||||
"相框标题将用于生成图片,请输入简洁明了的相框标题": "The frame title will be used to generate images. Please enter a concise and clear frame title",
|
||||
}
|
||||
export default en
|
||||
@@ -203,5 +203,8 @@ const ko = {
|
||||
"选择": "선택",
|
||||
"镜像处理": "미러 처리",
|
||||
"确认选择后视图": "후면 뷰 선택 확인",
|
||||
"相框标题": "프레임 제목",
|
||||
"请输入相框标题": "프레임 제목을 입력하세요",
|
||||
"相框标题将用于生成图片,请输入简洁明了的相框标题": "프레임 제목은 이미지 생성에 사용됩니다. 간단하고 명확한 프레임 제목을 입력하세요",
|
||||
}
|
||||
export default ko
|
||||
@@ -206,5 +206,8 @@ const zhCN = {
|
||||
"选择": "选择",
|
||||
"镜像处理": "镜像处理",
|
||||
"确认选择后视图": "确认选择后视图",
|
||||
"相框标题": "相框标题",
|
||||
"请输入相框标题": "请输入相框标题",
|
||||
"相框标题将用于生成图片,请输入简洁明了的相框标题": "相框标题将用于生成图片,请输入简洁明了的相框标题",
|
||||
}
|
||||
export default zhCN
|
||||
@@ -203,5 +203,8 @@ const zhCT = {
|
||||
"选择": "選擇",
|
||||
"镜像处理": "鏡像處理",
|
||||
"确认选择后视图": "確認選擇後視圖",
|
||||
"相框标题": "相框標題",
|
||||
"请输入相框标题": "請輸入相框標題",
|
||||
"相框标题将用于生成图片,请输入简洁明了的相框标题": "相框標題將用於生成圖片,請輸入簡潔明了的相框標題",
|
||||
}
|
||||
export default zhCT
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'nprogress/nprogress.css'
|
||||
|
||||
import douyinHome from '@/views/douyin/index.vue'
|
||||
import douyinGenerating from '@/views/douyin/generating.vue'
|
||||
import douyinPreview from '@/views/douyin/preview.vue'
|
||||
import douyinRecords from '@/views/douyin/records.vue'
|
||||
import douyinSuccess from '@/views/douyin/success.vue'
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: '/douyin',
|
||||
name: 'douyin',
|
||||
component: douyinHome,
|
||||
meta: {
|
||||
needGuard: true,
|
||||
title: '首页',
|
||||
channel: 'douyin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/douyin/generating',
|
||||
name: 'douyinGenerating',
|
||||
component: douyinGenerating,
|
||||
meta: {
|
||||
needGuard: true,
|
||||
title: '生成中',
|
||||
channel: 'douyin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/douyin/preview',
|
||||
name: 'douyinPreview',
|
||||
component: douyinPreview,
|
||||
meta: {
|
||||
needGuard: true,
|
||||
title: '效果预览',
|
||||
channel: 'douyin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/douyin/records',
|
||||
name: 'douyinRecords',
|
||||
component: douyinRecords,
|
||||
meta: {
|
||||
needGuard: true,
|
||||
title: '生成记录',
|
||||
channel: 'douyin',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/douyin/success',
|
||||
name: 'douyinSuccess',
|
||||
component: douyinSuccess,
|
||||
meta: {
|
||||
needGuard: true,
|
||||
title: '提交成功',
|
||||
channel: 'douyin',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function mergeRoutes(allRoutes: any[]) {
|
||||
allRoutes.push(...routes)
|
||||
}
|
||||
+26
-4
@@ -5,11 +5,19 @@ import 'nprogress/nprogress.css'
|
||||
// 导入路由组件
|
||||
// import * as badgeRouter from './badge'
|
||||
import * as cartoonRouter from './cartoon'
|
||||
import * as douyinRouter from './douyin'
|
||||
// import badge from '@/views/badge/index.vue'
|
||||
import cartoon from '@/views/cartoon/index.vue'
|
||||
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'
|
||||
|
||||
NProgress.configure({ showSpinner: true, parent: '#app' })
|
||||
|
||||
@@ -28,6 +36,7 @@ const routes = [
|
||||
|
||||
// badgeRouter.mergeRoutes(routes)
|
||||
cartoonRouter.mergeRoutes(routes)
|
||||
douyinRouter.mergeRoutes(routes)
|
||||
// 创建路由实例并传递 `routes` 配置
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(process.env.VUE_APP_PUBLIC_PATH),
|
||||
@@ -56,7 +65,7 @@ router.beforeEach((_to, _from, next) => {
|
||||
if (code) {
|
||||
let url = store.redirectUrl()
|
||||
|
||||
if (_to.path !== '/cartoon') {
|
||||
if (_to.path !== '/cartoon' && !_to.path.includes('douyin')) {
|
||||
if (url) {
|
||||
store.setRedirect('')
|
||||
next(url)
|
||||
@@ -69,9 +78,22 @@ router.beforeEach((_to, _from, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 根据API获取用信息
|
||||
window.document.title = `${config.brand.$t} - ${(_to.meta.title as string || '相册').$t}`
|
||||
// store.updateTitle(_to.meta.title.$t); // 导航栏标题变化
|
||||
const pageTitle = (_to.meta.title as string) || '相册'
|
||||
const titleText = pageTitle.$t || pageTitle
|
||||
const brandText = config.brand?.$t || config.brand || ''
|
||||
const isDouyin = _to.meta.channel === 'douyin' || _to.path.includes('douyin')
|
||||
// 抖音页优先使用 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) {
|
||||
|
||||
@@ -14,7 +14,8 @@ export const REQUEST_TOKEN_KEY = 'Authorization'
|
||||
const request = axios.create({
|
||||
// API 请求的默认前缀
|
||||
baseURL: process.env.VUE_APP_API_BASE_URL,
|
||||
timeout: 6000, // 请求超时时间
|
||||
// 上传/生图等接口常超过 6s;过短会导致旧业务也误超时
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
export type RequestError = AxiosError<{
|
||||
|
||||
@@ -14,7 +14,8 @@ export const REQUEST_TOKEN_KEY = 'Authorization'
|
||||
const requestWx = axios.create({
|
||||
// API 请求的默认前缀
|
||||
baseURL: process.env.VUE_APP_API_WX_URL,
|
||||
timeout: 6000, // 请求超时时间
|
||||
// 与 request.ts 保持一致,避免微信相关接口过早超时
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
export type RequestError = AxiosError<{
|
||||
|
||||
@@ -128,31 +128,6 @@
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #888; text-align: center;">{{ toValueWithout("不同主体的照片检测标准不同") }}</div>
|
||||
</div>
|
||||
<div class="step-line" v-if="prop == '3D真人肖像'">
|
||||
<div class="step-line-item">
|
||||
<div class="step-line-item-title">
|
||||
<span style="display: flex; align-items: center;justify-content: center;">
|
||||
<span style="height: 1px; background: #ccc;width: 50px;"></span>
|
||||
<span style="margin: 0 8px; color: #888;">{{ toValueWithout("照片示例") }}</span>
|
||||
<span style="height: 1px; background: #ccc;width: 50px;"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="step-line-item-desc" style="display: flex; align-items: center;justify-content: space-around;padding: 16px 16px 0 16px;">
|
||||
<div class="step-line-item-desc-item">
|
||||
<img src="@/assets/badge/1.png" style="width: 20vw;height: 20vw;border-radius: 8px;" alt="">
|
||||
</div>
|
||||
<div class="step-line-item-desc-item">
|
||||
<img src="@/assets/badge/2.png" style="width: 20vw;height: 20vw;border-radius: 8px;" alt="">
|
||||
</div>
|
||||
<div class="step-line-item-desc-item">
|
||||
<img src="@/assets/badge/3.png" style="width: 20vw;height: 20vw;border-radius: 8px;" alt="">
|
||||
</div>
|
||||
<div class="step-line-item-desc-item">
|
||||
<img src="@/assets/badge/4.png" style="width: 20vw;height: 20vw;border-radius: 8px;" alt="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="photo-upload-body">
|
||||
<div v-if="!picture" class="photo-upload-box">
|
||||
<div class="photo-upload-header" @click="imgShow = true">
|
||||
|
||||
+58
-97
@@ -94,12 +94,23 @@
|
||||
</div>
|
||||
<div class="kind-box">
|
||||
<div class="kind-box-item" v-for="item in kindList" :key="item.id" :class="{ styleActive: kindId == item.id }">
|
||||
<div class="kind-item-title" @click="kindChange(item.id)">
|
||||
<div class="kind-item-title" @click="kindChange(item)">
|
||||
{{ toValueWithout(item.name) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="typeId == 11 && count_prop > 0">
|
||||
<div :style="`font-size: 16px; color: ${config.styles.colors.textPrimary}; font-weight: bold; margin-top: ${config.styles.spacing.sectionMargin};padding: 0 ${config.styles.spacing.pagePadding};`">
|
||||
{{ toValueWithout("相框标题") }}
|
||||
</div>
|
||||
<div :style="`padding: 10px ${config.styles.spacing.pagePadding};`">
|
||||
<el-input type="text" v-model="scene_prop" :placeholder="toValueWithout('请输入相框标题')" @input="scenePropInputText" />
|
||||
<div class="photo-upload-tip" style="color: red;">
|
||||
*{{ toValueWithout("相框标题将用于生成图片,请输入简洁明了的相框标题") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isViews == 1">
|
||||
<div :style="`font-size: 16px; color: ${config.styles.colors.textPrimary}; font-weight: bold; margin-top: ${config.styles.spacing.sectionMargin};padding: 0 ${config.styles.spacing.pagePadding};`">
|
||||
{{ toValueWithout("多视角参考图") }}
|
||||
@@ -144,24 +155,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="typeId == 9">
|
||||
<div v-if="typeId == 9 && count_prop > 0">
|
||||
<div :style="`font-size: 16px; color: ${config.styles.colors.textPrimary}; font-weight: bold; margin-top: ${config.styles.spacing.sectionMargin};padding: 0 ${config.styles.spacing.pagePadding};`">
|
||||
{{ toValueWithout("场景道具") }}
|
||||
</div>
|
||||
<div :style="`padding: 10px ${config.styles.spacing.pagePadding};`">
|
||||
<el-radio-group
|
||||
v-model="method"
|
||||
text-color="#000"
|
||||
fill="#43CF7C"
|
||||
@change="handleScenePropsChange"
|
||||
>
|
||||
<el-radio-button v-for="item in scenePropsList" :key="item.id" :label="item.id">
|
||||
{{ toValueWithout(item.name) }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div v-if="method == 2" :style="`margin-top: 16px;`">
|
||||
<div :style="`margin-top: 16px;`">
|
||||
<div
|
||||
v-for="(_item, index) in scenePropsInputs"
|
||||
v-for="(_, index) in 4"
|
||||
:key="index"
|
||||
:style="`display: flex; align-items: center; margin-bottom: 12px;`"
|
||||
>
|
||||
@@ -169,27 +170,9 @@
|
||||
v-model="scenePropsInputs[index]"
|
||||
:placeholder="toValueWithout('请输入场景道具')"
|
||||
:style="`flex: 1; margin-right: 8px;`"
|
||||
@input="updateSceneProp"
|
||||
@input="updateScenePropFromInputs"
|
||||
clearable
|
||||
/>
|
||||
<el-button
|
||||
v-if="index === scenePropsInputs.length - 1 && scenePropsInputs.length < 4"
|
||||
type="primary"
|
||||
circle
|
||||
size="small"
|
||||
@click="addScenePropsInput"
|
||||
>
|
||||
<span style="font-size: 16px; line-height: 1;">+</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="scenePropsInputs.length > 1"
|
||||
type="danger"
|
||||
circle
|
||||
size="small"
|
||||
@click="removeScenePropsInput(index)"
|
||||
>
|
||||
<span style="font-size: 16px; line-height: 1;">−</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -318,8 +301,7 @@ const getOrderStat = () => {
|
||||
mountsId.value = 0
|
||||
getmountsList()
|
||||
} else if (res.type_id === 9) {
|
||||
method.value = 1
|
||||
scenePropsInputs.value = ['']
|
||||
scenePropsInputs.value = ['', '', '', '']
|
||||
scene_prop.value = ''
|
||||
}
|
||||
})
|
||||
@@ -342,14 +324,41 @@ const getKindList = () => {
|
||||
}).then((res: any) => {
|
||||
kindList.value = res.list
|
||||
kindId.value = res.list && res.list[0]?.id
|
||||
count_prop.value = res.list && res.list[0]?.count_prop
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
const kindId = ref(0)
|
||||
const kindChange = (id: number) => {
|
||||
kindId.value = id
|
||||
const count_prop = ref(0)
|
||||
const kindChange = (item: any) => {
|
||||
kindId.value = item.id
|
||||
count_prop.value = item.count_prop
|
||||
}
|
||||
|
||||
// 计算字符单位:中文占2个,英文/数字/空格/符号占1个
|
||||
const getCharUnits = (str: string) => {
|
||||
let units = 0
|
||||
for (const char of str) {
|
||||
units += /[\u4e00-\u9fff\u3400-\u4dbf]/.test(char) ? 2 : 1
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
// 限制最多 12 个字符单位:中文占2个,英文/数字/空格/符号占1个
|
||||
const scenePropInputText = (val: string) => {
|
||||
if (getCharUnits(val) > 18) {
|
||||
let units = 0
|
||||
let i = 0
|
||||
for (; i < val.length; i++) {
|
||||
const add = /[\u4e00-\u9fff\u3400-\u4dbf]/.test(val[i]) ? 2 : 1
|
||||
if (units + add > 18) break
|
||||
units += add
|
||||
}
|
||||
val = val.slice(0, i)
|
||||
}
|
||||
scene_prop.value = val
|
||||
}
|
||||
|
||||
// 形状列表相关
|
||||
@@ -391,51 +400,17 @@ const rideChange = (item: any) => {
|
||||
mountsId.value = item.id
|
||||
}
|
||||
|
||||
// 场景道具相关
|
||||
const method = ref(1) // 1: 智能生成, 2: 自定义
|
||||
const scenePropsList = ref([
|
||||
{ id: 1, name: '智能生成' },
|
||||
{ id: 2, name: '自定义' }
|
||||
])
|
||||
const scenePropsInputs = ref<string[]>(['']) // 初始显示一个输入框
|
||||
const scene_prop = ref('') // 最终存储的字符串,用逗号连接
|
||||
const scene_prop = ref('') // 四个输入框的值用;拼接后提交接口
|
||||
|
||||
// 处理场景道具选择变化
|
||||
const handleScenePropsChange = (val: number) => {
|
||||
method.value = val
|
||||
if (val === 1) {
|
||||
// 选择不使用场景道具时,清空输入和结果
|
||||
scenePropsInputs.value = ['']
|
||||
scene_prop.value = ''
|
||||
} else if (val === 2) {
|
||||
// 选择自定义场景道具时,确保至少有一个输入框
|
||||
if (scenePropsInputs.value.length === 0) {
|
||||
scenePropsInputs.value = ['']
|
||||
}
|
||||
updateSceneProp()
|
||||
}
|
||||
}
|
||||
// 场景道具四个输入框
|
||||
const scenePropsInputs = ref<string[]>(['', '', '', ''])
|
||||
|
||||
// 添加输入框
|
||||
const addScenePropsInput = () => {
|
||||
if (scenePropsInputs.value.length < 4) {
|
||||
scenePropsInputs.value.push('')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除输入框
|
||||
const removeScenePropsInput = (index: number) => {
|
||||
if (scenePropsInputs.value.length > 1) {
|
||||
scenePropsInputs.value.splice(index, 1)
|
||||
updateSceneProp()
|
||||
}
|
||||
}
|
||||
|
||||
// 更新场景道具字符串
|
||||
const updateSceneProp = () => {
|
||||
// 过滤空值,然后用逗号连接
|
||||
const validInputs = scenePropsInputs.value.filter(input => input && input.trim() !== '')
|
||||
scene_prop.value = validInputs.join(',')
|
||||
// 四个输入框有值时,用;拼接更新到 scene_prop
|
||||
const updateScenePropFromInputs = () => {
|
||||
scene_prop.value = scenePropsInputs.value
|
||||
.map(v => (v || '').trim())
|
||||
.filter(v => v)
|
||||
.join(';')
|
||||
}
|
||||
|
||||
const isLoading = ref(false)
|
||||
@@ -667,21 +642,6 @@ const sendToOss = async (src: string | Blob | File, url: string) => {
|
||||
if (--pendingUploads.value === 0 && !isAnotherAPICalled.value) {
|
||||
isAnotherAPICalled.value = true
|
||||
createLog()
|
||||
// const params = {
|
||||
// pid: pid.value,
|
||||
// group: 1,
|
||||
// prod_id: prodId.value,
|
||||
// extend_value: -1,
|
||||
// type_id: typeId.value,
|
||||
// kind_id: kindId.value
|
||||
// }
|
||||
// badgeApi.putModeling(params).then(() => {
|
||||
// createLog()
|
||||
// }).catch((err) => {
|
||||
// console.log('putModeling', err)
|
||||
// }).finally(() => {
|
||||
// closeToast()
|
||||
// })
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
@@ -703,9 +663,8 @@ const createLog = () => {
|
||||
type_id: typeId.value,
|
||||
kind_id: kindId.value,
|
||||
subject_id: subjectId.value,
|
||||
scene_prop: typeId.value === 9 && method.value === 2 && scene_prop.value ? scene_prop.value : undefined,
|
||||
scene_prop: scene_prop.value ? scene_prop.value : undefined,
|
||||
mounts_id: mountsId.value || undefined,
|
||||
method: method.value
|
||||
}
|
||||
badgeApi.createLog(params).then(() => {
|
||||
closeToast()
|
||||
@@ -748,6 +707,8 @@ onMounted(() => {
|
||||
.photo-upload-page {
|
||||
height: auto;
|
||||
overflow-y: scroll!important;
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.badge-size {
|
||||
padding: 16px;
|
||||
|
||||
@@ -78,7 +78,7 @@ function getStatus(status: number) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -98,47 +98,49 @@ function getStatus(status: number) {
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: v-bind('config.myOrder.styles.gap + "px"');
|
||||
padding: v-bind('config.myOrder.styles.padding.vertical + "px"') v-bind('config.myOrder.styles.padding.horizontal + "px"');
|
||||
gap: 8px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.order-item {
|
||||
position: relative;
|
||||
width: v-bind('config.myOrder.styles.itemWidth');
|
||||
height: v-bind('config.myOrder.styles.itemHeight');
|
||||
box-shadow: v-bind('config.myOrder.styles.shadow');
|
||||
width: calc(45vw - 4px);
|
||||
height: calc(45vw - 4px);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
border-radius: 12px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.order-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: v-bind('config.myOrder.styles.borderRadius + "px"');
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.order-status {
|
||||
position: absolute;
|
||||
bottom: v-bind('config.myOrder.styles.status.bottom + "px"');
|
||||
right: v-bind('config.myOrder.styles.status.right + "px"');
|
||||
padding: v-bind('config.myOrder.styles.status.padding.vertical + "px"') v-bind('config.myOrder.styles.status.padding.horizontal + "px"');
|
||||
border-radius: v-bind('config.myOrder.styles.status.borderRadius + "px"');
|
||||
font-size: v-bind('config.myOrder.styles.status.fontSize + "px"');
|
||||
color: v-bind('config.myOrder.statusStyles.default.color');
|
||||
background: v-bind('config.myOrder.statusStyles.default.background');
|
||||
opacity: v-bind('config.myOrder.statusStyles.default.opacity');
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
padding: 2px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
background: #bdbdbd;
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
background: v-bind('config.myOrder.statusStyles.processing.background');
|
||||
background: #bdbdbd;
|
||||
}
|
||||
|
||||
.status-finished {
|
||||
background: v-bind('config.myOrder.statusStyles.finished.background');
|
||||
background: #8bc34a;
|
||||
}
|
||||
|
||||
.record-image-item-null {
|
||||
|
||||
@@ -283,11 +283,17 @@
|
||||
<div style="height: 130px;"></div>
|
||||
<div class="confirm-box">
|
||||
<div class="action-section">
|
||||
<button @click="compare" :disabled="flag < 1" class="action-btn"><img class="action-img" :src="compareImage" alt=""> {{ toValueWithout(config.preview.messages.compare) }}</button>
|
||||
<button @click="compare" :disabled="flag < 1 || isCodeDisabled" class="action-btn"><img class="action-img" :src="compareImage" alt=""> {{ toValueWithout(config.preview.messages.compare) }}</button>
|
||||
<button @click="save" :disabled="flag < 1" class="action-btn"><img class="action-img" :src="downloadImage" alt=""> {{ toValueWithout(config.preview.messages.saveImage) }}</button>
|
||||
</div>
|
||||
<div class="btn-box">
|
||||
<button @click="confirm" :disabled="flag < 1" class="confirm-btn">{{ toValueWithout(config.preview.messages.confirmSelect) }}</button>
|
||||
<button
|
||||
@click="confirm"
|
||||
:disabled="flag < 1 || isCodeDisabled"
|
||||
class="confirm-btn"
|
||||
>
|
||||
{{ toValueWithout(config.preview.messages.confirmSelect) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -571,6 +577,8 @@ const originUrl = ref('')
|
||||
const group = ref(config.preview.defaults.group);
|
||||
// 轮询获取图片
|
||||
const flag = ref(1)
|
||||
// 指定 code 时禁止下单
|
||||
const isCodeDisabled = computed(() => localStorage.get('code') === 'AB3UQAAFCR4C093MMF2A11')
|
||||
const timer = ref()
|
||||
const progress = ref(0)
|
||||
const progressTimer = ref()
|
||||
@@ -1546,6 +1554,7 @@ watch(() => isViews.value, (val) => {
|
||||
min-height: 100vh;
|
||||
font-family: 'PingFang SC', Arial, sans-serif;
|
||||
color: #222;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.backstyle_1 {
|
||||
position: fixed;
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<header class="dy-nav" :class="[`is-${theme}`, { 'has-border': border }]">
|
||||
<div class="dy-nav__side dy-nav__left">
|
||||
<button
|
||||
v-if="showBack"
|
||||
class="dy-nav__btn dy-nav__back"
|
||||
:class="{ 'is-text': backText.length > 1 }"
|
||||
type="button"
|
||||
aria-label="返回"
|
||||
@click="emit('back')"
|
||||
>
|
||||
{{ backText }}
|
||||
</button>
|
||||
<slot v-else name="left" />
|
||||
</div>
|
||||
|
||||
<div class="dy-nav__title">
|
||||
<slot>{{ title }}</slot>
|
||||
</div>
|
||||
|
||||
<div class="dy-nav__side dy-nav__right">
|
||||
<button
|
||||
v-if="rightText"
|
||||
class="dy-nav__btn dy-nav__action"
|
||||
type="button"
|
||||
@click="emit('right')"
|
||||
>
|
||||
{{ rightText }}
|
||||
</button>
|
||||
<slot v-else name="right" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{
|
||||
title?: string
|
||||
/** light:白底深字;dark:黑底浅字(生成中) */
|
||||
theme?: 'light' | 'dark'
|
||||
showBack?: boolean
|
||||
backText?: string
|
||||
rightText?: string
|
||||
border?: boolean
|
||||
}>(), {
|
||||
title: '',
|
||||
theme: 'light',
|
||||
showBack: true,
|
||||
backText: '‹',
|
||||
rightText: '',
|
||||
border: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'back'): void
|
||||
(e: 'right'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dy-nav {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
flex-shrink: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(80px, 1fr) auto minmax(80px, 1fr);
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
padding: 0 8px;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.dy-nav.is-light {
|
||||
background: #ffffff;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.dy-nav.is-dark {
|
||||
background: #000000;
|
||||
color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dy-nav.has-border.is-light {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.dy-nav.has-border.is-dark {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.dy-nav__side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.dy-nav__left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.dy-nav__right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dy-nav__title {
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
line-height: 48px;
|
||||
max-width: 46vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.dy-nav__btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0 8px;
|
||||
height: 48px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #5fbf45;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dy-nav__back {
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
line-height: 44px;
|
||||
padding: 0 10px 0 6px;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dy-nav__back.is-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.is-light .dy-nav__back {
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.is-dark .dy-nav__back {
|
||||
color: #8fd96f;
|
||||
}
|
||||
|
||||
.dy-nav__action {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #5fbf45;
|
||||
}
|
||||
|
||||
.is-dark .dy-nav__action {
|
||||
color: #8fd96f;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,491 @@
|
||||
<template>
|
||||
<div class="ipc" ref="wrapRef" :class="{ 'is-fading': fading }">
|
||||
<canvas
|
||||
ref="webglRef"
|
||||
class="ipc-webgl"
|
||||
:class="{ live: canvasLive }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@pointerleave="onPointerUp"
|
||||
></canvas>
|
||||
<canvas ref="scaleRef" class="ipc-scale" aria-hidden="true"></canvas>
|
||||
<img
|
||||
v-if="useFallback"
|
||||
class="ipc-fallback"
|
||||
:src="fallbackSrc"
|
||||
:style="{ opacity: fallbackOpacity }"
|
||||
alt=""
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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
|
||||
maskUrl?: string
|
||||
resultUrl?: string
|
||||
pointShape?: string
|
||||
/** 交叉淡出点云(同页出效果图时) */
|
||||
fading?: boolean
|
||||
/** @deprecated 正方形边长,优先使用 width/height */
|
||||
side?: number
|
||||
/** 舞台宽(px),按上传图比例由父级传入 */
|
||||
width?: number
|
||||
/** 舞台高(px),按上传图比例由父级传入 */
|
||||
height?: number
|
||||
}>(), {
|
||||
pointShape: 'circle',
|
||||
fading: false,
|
||||
side: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'ready', payload: { fromCache: boolean }): void
|
||||
(e: 'phasechange', payload: { phase: string; tip: string; subTip: string }): void
|
||||
(e: 'handoff'): void
|
||||
}>()
|
||||
|
||||
const wrapRef = ref<HTMLElement | null>(null)
|
||||
const webglRef = ref<HTMLCanvasElement | null>(null)
|
||||
const scaleRef = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const canvasLive = ref(false)
|
||||
const useFallback = ref(false)
|
||||
const fallbackSrc = ref(getLogoCloudSrc() || PATHS.LOGO)
|
||||
const fallbackOpacity = ref(1)
|
||||
|
||||
let destroyed = false
|
||||
let running = false
|
||||
let firstFrameSent = false
|
||||
let renderer: any = null
|
||||
let sm: any = null
|
||||
let raf = 0
|
||||
let lastTs = 0
|
||||
let cssW = 340
|
||||
let cssH = 340
|
||||
let logoFromCache = false
|
||||
let resultKey = ''
|
||||
|
||||
let pointerX = 0
|
||||
let pointerY = 0
|
||||
let pointerTargetX = 0
|
||||
let pointerTargetY = 0
|
||||
let pointerStrength = 0
|
||||
let pointerStrengthTarget = 0
|
||||
|
||||
function enterFallback() {
|
||||
useFallback.value = true
|
||||
canvasLive.value = true
|
||||
emit('ready', { fromCache: false })
|
||||
emit('phasechange', { phase: 'LOGO_POINT_CLOUD', tip: '准备中', sub: '正在启动' })
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
pause()
|
||||
firstFrameSent = false
|
||||
if (renderer) {
|
||||
try { renderer.destroy() } catch (e) {}
|
||||
renderer = null
|
||||
}
|
||||
sm = null
|
||||
}
|
||||
|
||||
function pause() {
|
||||
running = false
|
||||
pointerStrengthTarget = 0
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = 0
|
||||
}
|
||||
}
|
||||
|
||||
function resume() {
|
||||
if (running || destroyed) return
|
||||
if (useFallback.value || !renderer || !sm) return
|
||||
running = true
|
||||
lastTs = 0
|
||||
loop()
|
||||
}
|
||||
|
||||
function setPointerFromEvent(e: PointerEvent, active: boolean) {
|
||||
if (!cssW || !cssH || !webglRef.value) return
|
||||
const rect = webglRef.value.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
pointerTargetX = (x / cssW) * 2 - 1
|
||||
pointerTargetY = 1 - (y / cssH) * 2
|
||||
pointerStrengthTarget = active ? 1 : 0
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
|
||||
setPointerFromEvent(e, true)
|
||||
pointerX = pointerTargetX
|
||||
pointerY = pointerTargetY
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (e.buttons || pointerStrengthTarget > 0) setPointerFromEvent(e, true)
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
pointerStrengthTarget = 0
|
||||
}
|
||||
|
||||
function onPhase(phase: string) {
|
||||
const copy = (PHASE_COPY as any)[phase] || { tip: '生成中', sub: '请稍候' }
|
||||
emit('phasechange', { phase, tip: copy.tip, subTip: copy.sub })
|
||||
}
|
||||
|
||||
function prepareSubjectCloud(url: string, maskUrlOverride?: string) {
|
||||
const maskUrl = maskUrlOverride != null ? maskUrlOverride : props.maskUrl
|
||||
if (!url || !maskUrl || !renderer || !sm) return
|
||||
let settled = false
|
||||
const timer = setTimeout(() => {
|
||||
if (destroyed || !sm || settled) return
|
||||
sm.setSubjectFailed()
|
||||
}, 62000)
|
||||
|
||||
renderer.textures
|
||||
.prepareSubject(url, { maskUrl })
|
||||
.then((entry: any) => {
|
||||
if (destroyed || !sm) {
|
||||
clearTimeout(timer)
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (entry && entry.subjectCloudBuffer) sm.setSubjectReady(true)
|
||||
else sm.setSubjectFailed()
|
||||
})
|
||||
.catch(() => {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (sm) sm.setSubjectFailed()
|
||||
})
|
||||
}
|
||||
|
||||
function preloadSource(url: string) {
|
||||
if (!url || !renderer) return
|
||||
renderer.textures
|
||||
.load(url, { removeBackground: true })
|
||||
.then(() => {
|
||||
if (destroyed || !sm) return
|
||||
sm.setSourceReady(true)
|
||||
if (props.maskUrl) prepareSubjectCloud(url, props.maskUrl)
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error('[ImagePointCloud] source preload fail', err)
|
||||
})
|
||||
}
|
||||
|
||||
function preloadResult(url: string) {
|
||||
if (!url || !renderer || !sm) return
|
||||
resultKey = url
|
||||
sm.setResultPreloadStatus('loading')
|
||||
// 效果图点云去掉背景,只保留主体
|
||||
renderer.textures
|
||||
.load(url, { removeBackground: true })
|
||||
.then((entry: any) => {
|
||||
if (destroyed || !sm) return
|
||||
if (!entry || !entry.pointCloudBuffer) {
|
||||
console.error('[ImagePointCloud] result cloud buffer missing')
|
||||
sm.setResultPreloadStatus('error')
|
||||
return
|
||||
}
|
||||
renderer.setResultTexture(entry)
|
||||
sm.setResultPreloadStatus('ready')
|
||||
// 若状态机已进入 RESULT_*(异步晚到),立刻切到效果图点云
|
||||
const phase = String(sm.getPhase?.() || '')
|
||||
if (phase.startsWith('RESULT')) {
|
||||
renderer.transitionToCloud(entry)
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error('[ImagePointCloud] result preload fail', err)
|
||||
if (sm) sm.setResultPreloadStatus('error')
|
||||
})
|
||||
}
|
||||
|
||||
function loop() {
|
||||
if (!running || destroyed) return
|
||||
|
||||
const step = () => {
|
||||
if (!running || destroyed) return
|
||||
const now = Date.now()
|
||||
const dt = lastTs ? Math.min(64, now - lastTs) : VISUAL.FRAME_MS
|
||||
lastTs = now
|
||||
const uniforms = sm.tick(dt)
|
||||
|
||||
const targetStr = pointerStrengthTarget
|
||||
const curStr = pointerStrength
|
||||
const baseK = targetStr > curStr ? POINTER.STRENGTH_ATTACK : POINTER.STRENGTH_RELEASE
|
||||
const kStr = 1 - Math.pow(1 - baseK, dt / 16.67)
|
||||
pointerStrength = curStr + (targetStr - curStr) * kStr
|
||||
if (pointerStrength < 0.0015) pointerStrength = 0
|
||||
|
||||
const kPos = 1 - Math.pow(1 - POINTER.POS_LERP, dt / 16.67)
|
||||
pointerX = pointerX + (pointerTargetX - pointerX) * kPos
|
||||
pointerY = pointerY + (pointerTargetY - pointerY) * kPos
|
||||
|
||||
uniforms.pointerX = pointerX
|
||||
uniforms.pointerY = pointerY
|
||||
uniforms.pointerStrength = pointerStrength
|
||||
uniforms.pointerRadius = POINTER.RADIUS
|
||||
uniforms.pointerInflate = POINTER.INFLATE
|
||||
uniforms.pointerAspect = cssW / Math.max(cssH, 1)
|
||||
|
||||
const shapeMode = (props.pointShape || POINT_CLOUD.DEFAULT_SHAPE).toString().toLowerCase()
|
||||
if (shapeMode === 'square') uniforms.pointShape = 1
|
||||
else if (shapeMode === 'circle') uniforms.pointShape = 0
|
||||
else uniforms.pointShape = 0.5 + 0.5 * Math.sin((uniforms.time || 0) * 1.15)
|
||||
|
||||
if (renderer) renderer.draw(uniforms)
|
||||
if (!firstFrameSent) {
|
||||
firstFrameSent = true
|
||||
canvasLive.value = true
|
||||
emit('ready', { fromCache: logoFromCache })
|
||||
}
|
||||
if (sm.isFinished()) {
|
||||
pause()
|
||||
return
|
||||
}
|
||||
raf = requestAnimationFrame(step)
|
||||
}
|
||||
raf = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
/** 优先父级 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) {
|
||||
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 {
|
||||
width: Math.max(120, Math.round(pw)),
|
||||
height: Math.max(120, Math.round(ph || pw * 4 / 3)),
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function init() {
|
||||
const canvas = webglRef.value
|
||||
const scaleCanvas = scaleRef.value
|
||||
const wrap = wrapRef.value
|
||||
if (!canvas || !wrap) {
|
||||
enterFallback()
|
||||
return
|
||||
}
|
||||
|
||||
const size = resolveStageSize(wrap)
|
||||
cssW = size.width
|
||||
cssH = size.height
|
||||
applyCanvasSize(canvas, cssW, cssH)
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
|
||||
try {
|
||||
renderer = createRenderer(canvas, cssW, cssH, dpr, scaleCanvas)
|
||||
} catch (e) {
|
||||
console.error('[ImagePointCloud] webgl init fail', e)
|
||||
enterFallback()
|
||||
return
|
||||
}
|
||||
if (!renderer) {
|
||||
enterFallback()
|
||||
return
|
||||
}
|
||||
|
||||
sm = createStateMachine({
|
||||
onPhase,
|
||||
onSwitchToSource: () => {
|
||||
const entry = renderer.textures.get(props.imageUrl)
|
||||
if (entry && entry.pointCloudBuffer) renderer.transitionToCloud(entry)
|
||||
},
|
||||
onSwitchToSubject: () => {
|
||||
const src = props.imageUrl
|
||||
const entry = renderer.textures.getSubject(src) || renderer.textures.get(src)
|
||||
// 缓冲无效则不切,避免切到空点云后整页变黑
|
||||
if (entry && entry.subjectCloudBuffer && entry.subjectCloudBuffer.buffer) {
|
||||
renderer.transitionToCloud({
|
||||
pointCloudBuffer: entry.subjectCloudBuffer,
|
||||
aspect: entry.aspect,
|
||||
})
|
||||
}
|
||||
},
|
||||
onSwitchToResult: () => {
|
||||
const key = resultKey || props.resultUrl
|
||||
const entry = key ? renderer.textures.get(key) : null
|
||||
if (entry && entry.pointCloudBuffer) {
|
||||
renderer.transitionToCloud(entry)
|
||||
renderer.setResultTexture(entry)
|
||||
return
|
||||
}
|
||||
// 缓冲晚到时由 preloadResult 成功回调补切
|
||||
if (key) preloadResult(key)
|
||||
},
|
||||
onHandoff: () => emit('handoff'),
|
||||
})
|
||||
|
||||
const logoSrc = getLogoCloudSrc() || PATHS.LOGO
|
||||
fallbackSrc.value = logoSrc
|
||||
renderer.textures
|
||||
.load(logoSrc, { 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)
|
||||
})
|
||||
.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()
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => props.imageUrl, (url) => {
|
||||
if (url && renderer) preloadSource(url)
|
||||
})
|
||||
watch(() => props.maskUrl, (url) => {
|
||||
if (url && props.imageUrl && renderer) prepareSubjectCloud(props.imageUrl, url)
|
||||
})
|
||||
watch(() => props.resultUrl, (url) => {
|
||||
if (url && renderer) preloadResult(url)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
destroyed = false
|
||||
const tryInit = (attempt = 0) => {
|
||||
if (destroyed) return
|
||||
// 有父级宽高时无需等待 DOM 测量
|
||||
if ((props.width > 0 && props.height > 0) || (props.side && props.side > 0)) {
|
||||
init()
|
||||
return
|
||||
}
|
||||
const wrap = wrapRef.value
|
||||
const w = wrap?.getBoundingClientRect().width || 0
|
||||
const h = wrap?.getBoundingClientRect().height || 0
|
||||
if ((w < 8 || h < 8) && attempt < 20) {
|
||||
requestAnimationFrame(() => tryInit(attempt + 1))
|
||||
return
|
||||
}
|
||||
init()
|
||||
}
|
||||
requestAnimationFrame(() => tryInit())
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyed = true
|
||||
teardown()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ipc {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
touch-action: none;
|
||||
border-radius: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ipc-webgl {
|
||||
position: relative !important;
|
||||
left: auto !important;
|
||||
top: auto !important;
|
||||
right: auto !important;
|
||||
bottom: auto !important;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
/* 覆盖全局/历史 fullscreen canvas 规则,按上传图比例居中 */
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 1.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.ipc-webgl.live {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ipc.is-fading .ipc-webgl.live {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 仅占位;真实采样已改用离屏 canvas,勿再用 CSS 锁尺寸 */
|
||||
.ipc-scale {
|
||||
position: absolute !important;
|
||||
left: -9999px !important;
|
||||
top: 0 !important;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ipc-fallback {
|
||||
position: absolute;
|
||||
inset: 12%;
|
||||
width: 76%;
|
||||
height: 76%;
|
||||
object-fit: contain;
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="pcw">
|
||||
<div class="pcw-stage">
|
||||
<img
|
||||
v-if="displayUrl"
|
||||
:src="displayUrl"
|
||||
class="pcw-img"
|
||||
:class="imgClass"
|
||||
alt=""
|
||||
>
|
||||
<div v-else class="pcw-placeholder"></div>
|
||||
<div class="pcw-particles" aria-hidden="true">
|
||||
<span v-for="n in 18" :key="n" class="dot" :style="dotStyle(n)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pcw-progress">
|
||||
<div class="pcw-bar">
|
||||
<div class="pcw-bar-inner" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
<div class="pcw-meta">
|
||||
<span>{{ copy }}</span>
|
||||
<span>{{ Math.floor(progress) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { douyinConfig } from '@/config/douyin'
|
||||
import logo from '@/assets/douyin/mengqu.png'
|
||||
|
||||
const props = defineProps<{
|
||||
phase: string
|
||||
progress: number
|
||||
sourceUrl?: string
|
||||
resultUrl?: string
|
||||
}>()
|
||||
|
||||
const copy = computed(() => {
|
||||
const found = Object.values(douyinConfig.phases).find(p => p.key === props.phase)
|
||||
return found?.copy || '生成中...'
|
||||
})
|
||||
|
||||
const displayUrl = computed(() => {
|
||||
if (props.phase === 'LOGO') return logo
|
||||
if (props.phase === 'RESULT_REVEAL' || props.phase === 'DONE') {
|
||||
return props.resultUrl || props.sourceUrl || logo
|
||||
}
|
||||
return props.sourceUrl || logo
|
||||
})
|
||||
|
||||
const imgClass = computed(() => {
|
||||
if (props.phase === 'SOURCE_GRAY') return 'is-gray'
|
||||
if (props.phase === 'SOURCE_COLOR' || props.phase === 'WAITING') return 'is-color'
|
||||
if (props.phase === 'RESULT_REVEAL' || props.phase === 'DONE') return 'is-reveal'
|
||||
return 'is-logo'
|
||||
})
|
||||
|
||||
function dotStyle(n: number) {
|
||||
const angle = (n / 18) * Math.PI * 2
|
||||
const r = 38 + (n % 5) * 6
|
||||
const x = 50 + Math.cos(angle) * r * 0.35
|
||||
const y = 50 + Math.sin(angle) * r * 0.35
|
||||
const delay = (n % 9) * 0.12
|
||||
return {
|
||||
left: `${x}%`,
|
||||
top: `${y}%`,
|
||||
animationDelay: `${delay}s`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.pcw {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pcw-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #0c0d10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pcw-img {
|
||||
max-width: 78%;
|
||||
max-height: 78%;
|
||||
object-fit: contain;
|
||||
transition: filter 0.8s ease, opacity 0.8s ease, transform 0.8s ease;
|
||||
}
|
||||
|
||||
.pcw-img.is-logo {
|
||||
opacity: 0.95;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.pcw-img.is-gray {
|
||||
filter: grayscale(1) contrast(1.05);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.pcw-img.is-color {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pcw-img.is-reveal {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.pcw-placeholder {
|
||||
width: 40%;
|
||||
height: 40%;
|
||||
border-radius: 50%;
|
||||
background: rgba(95, 191, 69, 0.2);
|
||||
}
|
||||
|
||||
.pcw-particles {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(95, 191, 69, 0.85);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: floatDot 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes floatDot {
|
||||
0%, 100% { opacity: 0.2; transform: translate(-50%, -50%) scale(0.8); }
|
||||
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.3); }
|
||||
}
|
||||
|
||||
.pcw-progress {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.pcw-bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pcw-bar-inner {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.pcw-meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
/** Image point cloud — shared constants */
|
||||
import logo1 from '@/assets/douyin/mengqu.png'
|
||||
|
||||
const PHASE = {
|
||||
INIT: 'INIT',
|
||||
LOGO_POINT_CLOUD: 'LOGO_POINT_CLOUD',
|
||||
IMAGE_GRAY_POINT_CLOUD: 'IMAGE_GRAY_POINT_CLOUD',
|
||||
IMAGE_COLOR_TRANSITION: 'IMAGE_COLOR_TRANSITION',
|
||||
IMAGE_COLOR_POINT_CLOUD: 'IMAGE_COLOR_POINT_CLOUD',
|
||||
/** 人物检测(切 Buff + 开始抠背景) */
|
||||
SUBJECT_DETECTING: 'SUBJECT_DETECTING',
|
||||
/** 人物轮廓显现(灰色) */
|
||||
SUBJECT_OUTLINE: 'SUBJECT_OUTLINE',
|
||||
/** 灰色人物抠图点云 5s */
|
||||
SUBJECT_CLOUD_GRAY: 'SUBJECT_CLOUD_GRAY',
|
||||
/** 人物灰 → 彩 */
|
||||
SUBJECT_CLOUD_COLORIZE: 'SUBJECT_CLOUD_COLORIZE',
|
||||
/** 彩色人物抠图,等待效果图 */
|
||||
SUBJECT_CLOUD_COLOR: 'SUBJECT_CLOUD_COLOR',
|
||||
/** @deprecated 兼容旧名,等同 SUBJECT_CLOUD_COLOR */
|
||||
SUBJECT_POINT_CLOUD: 'SUBJECT_CLOUD_COLOR',
|
||||
RESULT_PRELOADING: 'RESULT_PRELOADING',
|
||||
RESULT_TRANSITION: 'RESULT_TRANSITION',
|
||||
// 效果图灰色点云 ~1s
|
||||
RESULT_CLOUD_GRAY: 'RESULT_CLOUD_GRAY',
|
||||
/** 效果图灰 → 彩过渡 */
|
||||
RESULT_CLOUD_COLORIZE: 'RESULT_CLOUD_COLORIZE',
|
||||
/** 效果图彩色点云 ~3s */
|
||||
RESULT_CLOUD_COLOR: 'RESULT_CLOUD_COLOR',
|
||||
/** 点云 → 完整效果图 */
|
||||
RESULT_IMAGE_REVEAL: 'RESULT_IMAGE_REVEAL',
|
||||
/** 完整效果图停留 ~2s */
|
||||
RESULT_PREVIEW: 'RESULT_PREVIEW',
|
||||
NAVIGATING: 'NAVIGATING',
|
||||
}
|
||||
|
||||
const TIMING = {
|
||||
LOGO_MS: 10000,
|
||||
IMAGE_GRAY_MS: 5000,
|
||||
COLOR_TRANSITION_MS: 1500,
|
||||
TEX_CROSSFADE_MS: 1000,
|
||||
/** 原图彩色点云停留后再进人物抠图 */
|
||||
IMAGE_COLOR_MS: 10000,
|
||||
/** 人物检测相位最长等待(Buffer 已就绪时很快进入轮廓) */
|
||||
SUBJECT_DETECT_MS: 900,
|
||||
/** 检测 CPU 预算;超时则放弃人物段 */
|
||||
SUBJECT_DETECT_BUDGET_MS: 1200,
|
||||
SUBJECT_OUTLINE_MS: 1600,
|
||||
/** 灰色人物抠图停留 5s 后再上色 */
|
||||
SUBJECT_GRAY_MS: 5000,
|
||||
/** 彩色人物点云最短停留后再切效果图点云 */
|
||||
SUBJECT_HOLD_MS: 1200,
|
||||
RESULT_TRANSITION_MS: 1200,
|
||||
/** 效果图灰色点云 */
|
||||
RESULT_CLOUD_GRAY_MS: 1000,
|
||||
/** 效果图彩色点云 */
|
||||
RESULT_CLOUD_COLOR_MS: 3000,
|
||||
/** 点云淡出、完整图淡入(交叉淡化) */
|
||||
RESULT_IMAGE_FADE_MS: 1600,
|
||||
/** 完整图短暂停留后进入同页预览态 */
|
||||
RESULT_PREVIEW_MS: 360,
|
||||
NAVIGATING_MS: 200,
|
||||
ASSEMBLY_MS: 900,
|
||||
}
|
||||
|
||||
/** 与阶段绑定的主/副文案(简单直接,不出现「点云」) */
|
||||
const PHASE_COPY = {
|
||||
INIT: {
|
||||
tip: '准备中',
|
||||
sub: '马上开始',
|
||||
},
|
||||
LOGO_POINT_CLOUD: {
|
||||
tip: '准备中',
|
||||
sub: '正在启动',
|
||||
},
|
||||
IMAGE_GRAY_POINT_CLOUD: {
|
||||
tip: '分析照片',
|
||||
sub: '请稍候',
|
||||
},
|
||||
IMAGE_COLOR_TRANSITION: {
|
||||
tip: '处理照片',
|
||||
sub: '请稍候',
|
||||
},
|
||||
IMAGE_COLOR_POINT_CLOUD: {
|
||||
tip: '处理照片',
|
||||
sub: '请稍候',
|
||||
},
|
||||
SUBJECT_DETECTING: {
|
||||
tip: '识别人物',
|
||||
sub: '请稍候',
|
||||
},
|
||||
SUBJECT_OUTLINE: {
|
||||
tip: '勾勒轮廓',
|
||||
sub: '请稍候',
|
||||
},
|
||||
SUBJECT_CLOUD_GRAY: {
|
||||
tip: '处理人物',
|
||||
sub: '请稍候',
|
||||
},
|
||||
SUBJECT_CLOUD_COLORIZE: {
|
||||
tip: '人物上色',
|
||||
sub: '请稍候',
|
||||
},
|
||||
SUBJECT_CLOUD_COLOR: {
|
||||
tip: '生成中',
|
||||
sub: '请稍候',
|
||||
},
|
||||
SUBJECT_POINT_CLOUD: {
|
||||
tip: '生成中',
|
||||
sub: '请稍候',
|
||||
},
|
||||
RESULT_PRELOADING: {
|
||||
tip: '生成中',
|
||||
sub: '请稍候',
|
||||
},
|
||||
RESULT_TRANSITION: {
|
||||
tip: '合成效果',
|
||||
sub: '请稍候',
|
||||
},
|
||||
RESULT_CLOUD_GRAY: {
|
||||
tip: '完善效果',
|
||||
sub: '请稍候',
|
||||
},
|
||||
RESULT_CLOUD_COLORIZE: {
|
||||
tip: '完善效果',
|
||||
sub: '请稍候',
|
||||
},
|
||||
RESULT_CLOUD_COLOR: {
|
||||
tip: '完善效果',
|
||||
sub: '请稍候',
|
||||
},
|
||||
RESULT_IMAGE_REVEAL: {
|
||||
tip: '已完成',
|
||||
sub: '正在展示效果图',
|
||||
},
|
||||
RESULT_PREVIEW: {
|
||||
tip: '已完成',
|
||||
sub: '即将进入预览',
|
||||
},
|
||||
NAVIGATING: {
|
||||
tip: '请稍候',
|
||||
sub: '正在打开预览',
|
||||
},
|
||||
}
|
||||
|
||||
const PATHS = {
|
||||
/** Logo 点云原图 */
|
||||
LOGO: logo1,
|
||||
}
|
||||
|
||||
const POINT_CLOUD = {
|
||||
HIGH: 12800,
|
||||
MEDIUM: 9200,
|
||||
LOW: 6200,
|
||||
/** 人物点云 */
|
||||
SUBJECT_HIGH: 12000,
|
||||
SUBJECT_MEDIUM: 9000,
|
||||
SUBJECT_LOW: 6000,
|
||||
SAMPLE_HIGH: 448,
|
||||
SAMPLE_MEDIUM: 384,
|
||||
SAMPLE_LOW: 320,
|
||||
MASK_HIGH: 288,
|
||||
MASK_MEDIUM: 256,
|
||||
MASK_LOW: 160,
|
||||
/** 粒子尺寸:拉开远近差(小=远,大=近),并保证圆点软边 */
|
||||
MIN_SIZE: 2.0,
|
||||
MAX_SIZE: 6.4,
|
||||
/** 采样抖动加大,粒子更散开 */
|
||||
POSITION_JITTER: 0.011,
|
||||
OPACITY: 0.9,
|
||||
BG_THRESHOLD: 0.12,
|
||||
/** x,y,r,g,b,size,alpha,seed,subjectWeight */
|
||||
STRIDE_FLOATS: 9,
|
||||
/**
|
||||
* 粒子形状:circle | square | auto
|
||||
* auto = 随时间在圆/方之间缓动切换
|
||||
*/
|
||||
DEFAULT_SHAPE: 'circle',
|
||||
/** Logo 缓存版本:舞台比例跟图后需重采样 */
|
||||
CACHE_VERSION: 17,
|
||||
}
|
||||
|
||||
const VISUAL = {
|
||||
MAX_TEX_SIZE: 512,
|
||||
LOW_TEX_SIZE: 384,
|
||||
FRAME_MS: 33,
|
||||
/** 与等待页背景 #000 一致,避免画布出现灰框 */
|
||||
STAGE_BG: [0, 0, 0, 1],
|
||||
}
|
||||
|
||||
/** 触点大圆膨胀(clip space,无空洞) */
|
||||
const POINTER = {
|
||||
RADIUS: 0.38,
|
||||
STRENGTH_ATTACK: 0.32,
|
||||
STRENGTH_RELEASE: 0.08,
|
||||
POS_LERP: 0.45,
|
||||
INFLATE: 0.14,
|
||||
}
|
||||
|
||||
export {
|
||||
PHASE,
|
||||
TIMING,
|
||||
PHASE_COPY,
|
||||
PATHS,
|
||||
POINT_CLOUD,
|
||||
VISUAL,
|
||||
POINTER,
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
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
|
||||
let sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
|
||||
try {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const cores = navigator.hardwareConcurrency || 4
|
||||
if (dpr >= 2.5 && cores >= 8) {
|
||||
maxPoints = POINT_CLOUD.HIGH
|
||||
sampleSide = POINT_CLOUD.SAMPLE_HIGH
|
||||
} else if (cores <= 4) {
|
||||
maxPoints = POINT_CLOUD.LOW
|
||||
sampleSide = POINT_CLOUD.SAMPLE_LOW
|
||||
}
|
||||
} catch (e) {}
|
||||
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) {
|
||||
cached = null
|
||||
return null
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
export function isLogoReady(src) {
|
||||
const c = getCachedLogoCloud()
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ canvas?: HTMLCanvasElement, scaleCanvas?: HTMLCanvasElement, src?: string }} nodes
|
||||
*/
|
||||
export function preloadLogoCloud(nodes = {}) {
|
||||
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')
|
||||
// 与等待页正方形舞台一致,避免预采样比例与展示框不一致导致拉伸
|
||||
const side = 512
|
||||
const vp = { w: side, h: side }
|
||||
const tier = pickTier()
|
||||
|
||||
let scaleCtx = null
|
||||
try {
|
||||
scaleCtx = scaleCanvas.getContext('2d', { willReadFrequently: true })
|
||||
} catch (e) {
|
||||
scaleCtx = scaleCanvas.getContext('2d')
|
||||
}
|
||||
|
||||
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: src,
|
||||
width: w,
|
||||
height: h,
|
||||
aspect: w / Math.max(h, 1),
|
||||
}
|
||||
const cloud = sampler.sample(entry, {
|
||||
maxPoints: tier.maxPoints,
|
||||
maxSide: tier.sampleSide,
|
||||
removeBackground: true,
|
||||
})
|
||||
putCachedLogoCloud({
|
||||
src,
|
||||
width: w,
|
||||
height: h,
|
||||
aspect: entry.aspect,
|
||||
maxPoints: cloud.maxPoints,
|
||||
cloud: {
|
||||
data: cloud.data.slice(0),
|
||||
count: cloud.count,
|
||||
maxPoints: cloud.maxPoints,
|
||||
aspect: cloud.aspect,
|
||||
},
|
||||
})
|
||||
resolve(cached)
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
} finally {
|
||||
inflight = null
|
||||
inflightSrc = ''
|
||||
}
|
||||
}
|
||||
img.onerror = () => {
|
||||
inflight = null
|
||||
inflightSrc = ''
|
||||
reject(new Error('logo preload fail'))
|
||||
}
|
||||
img.src = src
|
||||
})
|
||||
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function clearLogoCloudCache() {
|
||||
cached = null
|
||||
inflight = null
|
||||
inflightSrc = ''
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* 将 Mask 图解码为软 Mask(Uint8Array 0~255)
|
||||
* 白=主体,黑=背景;与原图必须同逻辑尺寸(等比缩放)
|
||||
*/
|
||||
|
||||
function clamp01(v) {
|
||||
return Math.max(0, Math.min(1, v))
|
||||
}
|
||||
|
||||
function luminance(r, g, b) {
|
||||
return 0.299 * r + 0.587 * g + 0.114 * b
|
||||
}
|
||||
|
||||
function smoothStep(edge0, edge1, value) {
|
||||
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ width: number, height: number }} sourceSize
|
||||
* @param {{ width: number, height: number }} maskSize
|
||||
* @param {number} maxSide
|
||||
*/
|
||||
function calculateTargetSize(width, height, maxSide) {
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height, 1))
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
scale: scale,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从已绘制到 canvas 的 Mask 像素构建 SubjectMask
|
||||
* @returns {{ width: number, height: number, data: Uint8Array } | null}
|
||||
*/
|
||||
function buildMaskFromPixels(pixels, w, h) {
|
||||
if (!pixels || !w || !h) return null
|
||||
const data = new Uint8Array(w * h)
|
||||
let fg = 0
|
||||
for (let i = 0, p = 0; i < w * h; i++, p += 4) {
|
||||
const v = luminance(pixels[p], pixels[p + 1], pixels[p + 2])
|
||||
// 0~255 软权重;全黑附近保持 0
|
||||
const weight = smoothStep(32, 220, v)
|
||||
const u8 = Math.round(weight * 255)
|
||||
data[i] = u8
|
||||
if (u8 > 40) fg += 1
|
||||
}
|
||||
if (fg < 24 || fg / (w * h) < 0.008) {
|
||||
return null
|
||||
}
|
||||
return { width: w, height: h, data: data }
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 maskImage 等比缩放到 target,读像素
|
||||
* @returns {{ width: number, height: number, data: Uint8Array } | null}
|
||||
*/
|
||||
function decodeMaskImage(maskImage, ctx, canvas, targetW, targetH) {
|
||||
if (!maskImage || !ctx || !canvas || !targetW || !targetH) return null
|
||||
canvas.width = targetW
|
||||
canvas.height = targetH
|
||||
ctx.clearRect(0, 0, targetW, targetH)
|
||||
try {
|
||||
ctx.drawImage(maskImage, 0, 0, targetW, targetH)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
let pixels
|
||||
try {
|
||||
pixels = ctx.getImageData(0, 0, targetW, targetH).data
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
return buildMaskFromPixels(pixels, targetW, targetH)
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并得到统一采样尺寸;尺寸不一致时按原图比例缩放到同一 target
|
||||
*/
|
||||
function resolveAlignedSize(sourceW, sourceH, maskW, maskH, maxSide) {
|
||||
if (!sourceW || !sourceH || !maskW || !maskH) return null
|
||||
const same = sourceW === maskW && sourceH === maskH
|
||||
const target = calculateTargetSize(sourceW, sourceH, maxSide)
|
||||
if (!same) {
|
||||
// 业务约定应同尺寸;不一致时仍按原图比例统一缩放双方
|
||||
const maskTarget = calculateTargetSize(maskW, maskH, maxSide)
|
||||
// 若宽高比偏差过大则不可靠
|
||||
const a0 = sourceW / sourceH
|
||||
const a1 = maskW / maskH
|
||||
if (Math.abs(a0 - a1) / Math.max(a0, a1) > 0.08) {
|
||||
return null
|
||||
}
|
||||
// 使用原图 target,Mask 强制画到同一尺寸
|
||||
return {
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
sameSize: false,
|
||||
maskNative: maskTarget,
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: target.width,
|
||||
height: target.height,
|
||||
sameSize: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从效果图自身 flood-fill 抠主体 Mask(手办类白底/纯色底)
|
||||
* 白=主体,黑=背景,与 API Mask 约定一致
|
||||
* @returns {{ width: number, height: number, data: Uint8Array } | null}
|
||||
*/
|
||||
function buildFloodFillSubjectMask(pixels, w, h, options) {
|
||||
options = options || {}
|
||||
if (!pixels || !w || !h) return null
|
||||
|
||||
const colorTol = options.colorTol != null ? options.colorTol : 38
|
||||
const lumTol = options.lumTol != null ? options.lumTol : 28
|
||||
const data = new Uint8ClampedArray(pixels)
|
||||
const bg = new Uint8Array(w * h) // 1 = background
|
||||
|
||||
// 四角采样背景色
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[w - 1, 0],
|
||||
[0, h - 1],
|
||||
[w - 1, h - 1],
|
||||
]
|
||||
let bgR = 0
|
||||
let bgG = 0
|
||||
let bgB = 0
|
||||
corners.forEach(function (p) {
|
||||
const i = (p[1] * w + p[0]) * 4
|
||||
bgR += data[i]
|
||||
bgG += data[i + 1]
|
||||
bgB += data[i + 2]
|
||||
})
|
||||
bgR /= 4
|
||||
bgG /= 4
|
||||
bgB /= 4
|
||||
const bgLum = luminance(bgR, bgG, bgB)
|
||||
|
||||
function isBgPixel(x, y) {
|
||||
const i = (y * w + x) * 4
|
||||
const r = data[i]
|
||||
const g = data[i + 1]
|
||||
const b = data[i + 2]
|
||||
const a = data[i + 3]
|
||||
if (a < 18) return true
|
||||
const dr = r - bgR
|
||||
const dg = g - bgG
|
||||
const db = b - bgB
|
||||
const dist = Math.sqrt(dr * dr + dg * dg + db * db)
|
||||
if (dist <= colorTol) return true
|
||||
// 接近背景亮度的浅色也当背景(手办白底常见)
|
||||
const lum = luminance(r, g, b)
|
||||
if (Math.abs(lum - bgLum) <= lumTol && dist <= colorTol * 1.85) return true
|
||||
// 近白且背景也偏亮
|
||||
if (bgLum > 200 && lum > 235 && dist <= colorTol * 2.2) return true
|
||||
return false
|
||||
}
|
||||
|
||||
// 从四边 flood-fill 连通背景
|
||||
const queue = new Int32Array(w * h)
|
||||
let qh = 0
|
||||
let qt = 0
|
||||
function tryPush(x, y) {
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) return
|
||||
const idx = y * w + x
|
||||
if (bg[idx]) return
|
||||
if (!isBgPixel(x, y)) return
|
||||
bg[idx] = 1
|
||||
queue[qt++] = idx
|
||||
}
|
||||
|
||||
for (let x = 0; x < w; x++) {
|
||||
tryPush(x, 0)
|
||||
tryPush(x, h - 1)
|
||||
}
|
||||
for (let y = 0; y < h; y++) {
|
||||
tryPush(0, y)
|
||||
tryPush(w - 1, y)
|
||||
}
|
||||
|
||||
while (qh < qt) {
|
||||
const idx = queue[qh++]
|
||||
const x = idx % w
|
||||
const y = (idx / w) | 0
|
||||
tryPush(x - 1, y)
|
||||
tryPush(x + 1, y)
|
||||
tryPush(x, y - 1)
|
||||
tryPush(x, y + 1)
|
||||
}
|
||||
|
||||
// 生成软 Mask:主体=255,背景=0,边缘略软化
|
||||
const out = new Uint8Array(w * h)
|
||||
let fg = 0
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < w; x++) {
|
||||
const idx = y * w + x
|
||||
if (bg[idx]) {
|
||||
out[idx] = 0
|
||||
continue
|
||||
}
|
||||
// 邻域背景占比 → 边缘软化
|
||||
let border = 0
|
||||
let n = 0
|
||||
for (let dy = -1; dy <= 1; dy++) {
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const nx = x + dx
|
||||
const ny = y + dy
|
||||
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue
|
||||
n++
|
||||
if (bg[ny * w + nx]) border++
|
||||
}
|
||||
}
|
||||
const edge = n > 0 ? border / n : 0
|
||||
const weight = Math.round(255 * (1 - edge * 0.55))
|
||||
out[idx] = Math.max(48, weight)
|
||||
fg++
|
||||
}
|
||||
}
|
||||
|
||||
if (fg < 24 || fg / (w * h) < 0.008) return null
|
||||
// 抠得太狠(几乎全是背景)不可用
|
||||
if (fg / (w * h) > 0.97) return null
|
||||
return { width: w, height: h, data: out }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从图片生成 flood-fill 主体 Mask
|
||||
*/
|
||||
function buildSubjectMaskFromImage(image, ctx, canvas, maxSide) {
|
||||
if (!image || !ctx || !canvas) return null
|
||||
const srcW = image.width || 0
|
||||
const srcH = image.height || 0
|
||||
if (!srcW || !srcH) return null
|
||||
const target = calculateTargetSize(srcW, srcH, maxSide || 256)
|
||||
canvas.width = target.width
|
||||
canvas.height = target.height
|
||||
ctx.clearRect(0, 0, target.width, target.height)
|
||||
try {
|
||||
ctx.drawImage(image, 0, 0, target.width, target.height)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
let pixels
|
||||
try {
|
||||
pixels = ctx.getImageData(0, 0, target.width, target.height).data
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
return buildFloodFillSubjectMask(pixels, target.width, target.height)
|
||||
}
|
||||
|
||||
export {
|
||||
clamp01,
|
||||
luminance,
|
||||
smoothStep,
|
||||
calculateTargetSize,
|
||||
buildMaskFromPixels,
|
||||
decodeMaskImage,
|
||||
resolveAlignedSize,
|
||||
buildFloodFillSubjectMask,
|
||||
buildSubjectMaskFromImage,
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { POINT_CLOUD } from './constants'
|
||||
import { sampleMaskValue } from './subject-outline'
|
||||
|
||||
/**
|
||||
* Preload-only sampler: image → Float32Array particle buffer.
|
||||
* Never called from the render loop.
|
||||
*
|
||||
* Layout (9 floats / particle):
|
||||
* x, y, r, g, b, size, alpha, seed, subjectWeight
|
||||
*/
|
||||
function createPointCloudSampler(scaleCanvas, viewportW, viewportH, sharedCtx) {
|
||||
let ctx = sharedCtx || null
|
||||
if (!ctx && scaleCanvas) {
|
||||
try {
|
||||
ctx = scaleCanvas.getContext('2d', { willReadFrequently: true })
|
||||
} catch (e) {
|
||||
try {
|
||||
ctx = scaleCanvas.getContext('2d')
|
||||
} catch (e2) {
|
||||
ctx = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hash(x, y) {
|
||||
const v = Math.sin(x * 127.1 + y * 311.7) * 43758.5453
|
||||
return v - Math.floor(v)
|
||||
}
|
||||
|
||||
function luminance(r, g, b) {
|
||||
return (r * 0.299 + g * 0.587 + b * 0.114) / 255
|
||||
}
|
||||
|
||||
function sample(entry, options) {
|
||||
options = options || {}
|
||||
if (!ctx || !entry || !entry.image) {
|
||||
throw new Error('point cloud sampler unavailable')
|
||||
}
|
||||
|
||||
const maxPoints = options.maxPoints || POINT_CLOUD.MEDIUM
|
||||
const removeBackground = options.removeBackground !== false
|
||||
const maxSide = options.maxSide || POINT_CLOUD.SAMPLE_MEDIUM
|
||||
const subjectMask = options.subjectMask || null
|
||||
const outlineMask = options.outlineMask || null
|
||||
const maskMode = options.maskMode || 'full' // full | outline | subject
|
||||
const forceSampleSize = options.forceSampleSize || null
|
||||
|
||||
const image = entry.image
|
||||
const srcW = (image && (image.naturalWidth || image.width)) || 0
|
||||
const srcH = (image && (image.naturalHeight || image.height)) || 0
|
||||
if (!image || !srcW || !srcH) {
|
||||
throw new Error('image not ready for sampling')
|
||||
}
|
||||
let w
|
||||
let h
|
||||
if (forceSampleSize && forceSampleSize.width && forceSampleSize.height) {
|
||||
w = Math.max(1, Math.round(forceSampleSize.width))
|
||||
h = Math.max(1, Math.round(forceSampleSize.height))
|
||||
} else {
|
||||
const scale = Math.min(1, maxSide / Math.max(srcW, srcH))
|
||||
w = Math.max(1, Math.round(srcW * scale))
|
||||
h = Math.max(1, Math.round(srcH * scale))
|
||||
}
|
||||
|
||||
scaleCanvas.width = w
|
||||
scaleCanvas.height = h
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
ctx.drawImage(image, 0, 0, w, h)
|
||||
|
||||
let pixels
|
||||
try {
|
||||
pixels = ctx.getImageData(0, 0, w, h).data
|
||||
} catch (err) {
|
||||
throw new Error('getImageData unavailable: ' + (err && err.message))
|
||||
}
|
||||
|
||||
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) {
|
||||
scaleY = (scaleX * viewportAspect) / imageAspect
|
||||
} else {
|
||||
scaleX = (scaleY * imageAspect) / viewportAspect
|
||||
}
|
||||
|
||||
const useSubjectMask = !!(subjectMask && subjectMask.data)
|
||||
const stride = Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
Math.sqrt(
|
||||
(w * h) / (maxPoints * (useSubjectMask ? 2.1 : 1.35))
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const corners = [
|
||||
[0, 0],
|
||||
[w - 1, 0],
|
||||
[0, h - 1],
|
||||
[w - 1, h - 1],
|
||||
]
|
||||
let bgR = 0
|
||||
let bgG = 0
|
||||
let bgB = 0
|
||||
corners.forEach(function (p) {
|
||||
const i = (p[1] * w + p[0]) * 4
|
||||
bgR += pixels[i]
|
||||
bgG += pixels[i + 1]
|
||||
bgB += pixels[i + 2]
|
||||
})
|
||||
bgR /= 4
|
||||
bgG /= 4
|
||||
bgB /= 4
|
||||
|
||||
const candidates = []
|
||||
const minSize = POINT_CLOUD.MIN_SIZE
|
||||
const maxSize = POINT_CLOUD.MAX_SIZE
|
||||
const jitterAmp = POINT_CLOUD.POSITION_JITTER
|
||||
|
||||
for (let y = 0; y < h; y += stride) {
|
||||
for (let x = 0; x < w; x += stride) {
|
||||
const i = (y * w + x) * 4
|
||||
const r = pixels[i]
|
||||
const g = pixels[i + 1]
|
||||
const b = pixels[i + 2]
|
||||
const a = pixels[i + 3] / 255
|
||||
if (a < 0.05) continue
|
||||
|
||||
const lum = luminance(r, g, b)
|
||||
let presence = a
|
||||
let subjectWeight = 1
|
||||
|
||||
if (useSubjectMask) {
|
||||
const maskVal = sampleMaskValue(
|
||||
subjectMask,
|
||||
outlineMask,
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
maskMode === 'outline' ? 'outline' : 'subject'
|
||||
)
|
||||
// API Mask:灰度 < ~32/255 丢弃;soft weight 来自 smoothStep(32,220)
|
||||
if (maskVal < 0.05) continue
|
||||
const density = 0.75 + maskVal * 0.25
|
||||
if (hash(x + 3, y + 11) > density) continue
|
||||
presence = Math.max(0.35, maskVal) * a
|
||||
subjectWeight = Math.max(0, Math.min(1, maskVal))
|
||||
} else if (removeBackground) {
|
||||
const dr = r - bgR
|
||||
const dg = g - bgG
|
||||
const db = b - bgB
|
||||
const distance = Math.sqrt(dr * dr + dg * dg + db * db) / 441.67
|
||||
const softKnockout = !!options.softKnockout
|
||||
// 默认更狠地抠掉接近四角背景色的点;softKnockout 略放宽防采空
|
||||
const distCut = softKnockout ? 0.028 : 0.05
|
||||
const distRamp = softKnockout ? 0.08 : 0.07
|
||||
if (distance < distCut) continue
|
||||
const notBackground = Math.max(0, Math.min(1, (distance - distCut) / distRamp))
|
||||
const chroma = Math.abs(r - g) + Math.abs(g - b) + Math.abs(r - b)
|
||||
const bgLum = luminance(bgR, bgG, bgB)
|
||||
// 仅亮底时丢弃近黑噪点,避免黑底效果图误杀主体暗部
|
||||
if (bgLum > 0.35) {
|
||||
const nearBlack = lum < 0.07 && chroma < 16
|
||||
if (nearBlack) continue
|
||||
}
|
||||
presence = notBackground * a
|
||||
subjectWeight = 1
|
||||
}
|
||||
|
||||
if (presence < 0.045) continue
|
||||
|
||||
const edge = 1 - presence
|
||||
let probability = 0.28 + Math.pow(presence, 0.6) * 0.55 - edge * 0.08
|
||||
if (useSubjectMask) {
|
||||
// 人物区几乎全保留,形成可读轮廓
|
||||
probability = Math.min(0.99, 0.55 + subjectWeight * 0.45)
|
||||
}
|
||||
const seed = hash(x, y)
|
||||
if (seed > Math.min(0.95, probability)) continue
|
||||
|
||||
const uvX = x / Math.max(w - 1, 1)
|
||||
const uvY = y / Math.max(h - 1, 1)
|
||||
// 人物采样几乎不抖,轮廓更贴合
|
||||
const edgeJitter = useSubjectMask
|
||||
? 0.25 + (1 - subjectWeight) * 0.5
|
||||
: 1.35 + edge * 2.2
|
||||
const jx = (hash(x + 17, y + 31) - 0.5) * jitterAmp * edgeJitter
|
||||
const jy = (hash(x + 41, y + 7) - 0.5) * jitterAmp * edgeJitter
|
||||
const px = (uvX - 0.5) * 2 * scaleX + jx
|
||||
const py = (0.5 - uvY) * 2 * scaleY + jy
|
||||
|
||||
// 三档远中近,拉开粒子大小差
|
||||
const band = seed < 0.32 ? 0.0 : seed < 0.66 ? 0.5 : 1.0
|
||||
let depth = Math.max(
|
||||
0.05,
|
||||
Math.min(
|
||||
1,
|
||||
0.06 + band * 0.52 + Math.pow(lum, 0.7) * 0.22 + seed * 0.16
|
||||
)
|
||||
)
|
||||
let size = minSize + Math.pow(depth, 0.75) * (maxSize - minSize)
|
||||
|
||||
const alpha = Math.min(
|
||||
1,
|
||||
presence * POINT_CLOUD.OPACITY * (0.82 + depth * 0.18)
|
||||
)
|
||||
|
||||
candidates.push({
|
||||
x: px,
|
||||
y: py,
|
||||
r: r / 255,
|
||||
g: g / 255,
|
||||
b: b / 255,
|
||||
size: size,
|
||||
alpha: alpha,
|
||||
seed: seed,
|
||||
subjectWeight: subjectWeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let selected = candidates
|
||||
if (selected.length > maxPoints) {
|
||||
const step = selected.length / maxPoints
|
||||
selected = []
|
||||
for (let i = 0; i < maxPoints; i++) {
|
||||
selected.push(candidates[Math.floor(i * step)])
|
||||
}
|
||||
}
|
||||
|
||||
// 无足够人物点 → 采样失败(由上层 fallback)
|
||||
if (useSubjectMask && selected.length < Math.min(80, maxPoints * 0.02)) {
|
||||
throw new Error('subject sample too sparse')
|
||||
}
|
||||
|
||||
const floats = POINT_CLOUD.STRIDE_FLOATS
|
||||
const data = new Float32Array(maxPoints * floats)
|
||||
|
||||
for (let i = 0; i < maxPoints; i++) {
|
||||
const p = selected[i]
|
||||
const o = i * floats
|
||||
if (!p) {
|
||||
data[o] = 0
|
||||
data[o + 1] = 0
|
||||
data[o + 2] = 1
|
||||
data[o + 3] = 1
|
||||
data[o + 4] = 1
|
||||
data[o + 5] = 0
|
||||
data[o + 6] = 0
|
||||
data[o + 7] = 0
|
||||
data[o + 8] = 0
|
||||
continue
|
||||
}
|
||||
data[o] = p.x
|
||||
data[o + 1] = p.y
|
||||
data[o + 2] = p.r
|
||||
data[o + 3] = p.g
|
||||
data[o + 4] = p.b
|
||||
data[o + 5] = p.size
|
||||
data[o + 6] = p.alpha
|
||||
data[o + 7] = p.seed
|
||||
data[o + 8] = p.subjectWeight != null ? p.subjectWeight : 1
|
||||
}
|
||||
|
||||
return {
|
||||
data: data,
|
||||
count: selected.length,
|
||||
maxPoints: maxPoints,
|
||||
aspect: imageAspect,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sample: sample,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
createPointCloudSampler,
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { PHASE, TIMING } from './constants'
|
||||
|
||||
function easeInOut(t) {
|
||||
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
|
||||
}
|
||||
|
||||
function smootherstep(t) {
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
return t * t * t * (t * (t * 6 - 15) + 10)
|
||||
}
|
||||
|
||||
function createStateMachine(hooks) {
|
||||
let phase = PHASE.INIT
|
||||
let phaseStartedAt = 0
|
||||
let timeSec = 0
|
||||
let colorProgress = 0
|
||||
let opacity = 1
|
||||
let reveal = 0
|
||||
let transition = 1
|
||||
let assembly = 1
|
||||
let motion = 1
|
||||
let subjectProgress = 0
|
||||
let outlineProgress = 0
|
||||
let sourceReady = false
|
||||
let subjectReady = false
|
||||
let subjectFailed = false
|
||||
let resultPreloadStatus = 'idle'
|
||||
let colorWaitReached = false
|
||||
let finished = false
|
||||
let handoffSent = false
|
||||
let morphing = false
|
||||
let morphStartedAt = 0
|
||||
let subjectEntered = false
|
||||
|
||||
function emitPhase(p) {
|
||||
if (hooks && hooks.onPhase) hooks.onPhase(p)
|
||||
}
|
||||
|
||||
function enter(p) {
|
||||
if (phase === p) return
|
||||
phase = p
|
||||
phaseStartedAt = Date.now()
|
||||
emitPhase(p)
|
||||
}
|
||||
|
||||
function start() {
|
||||
finished = false
|
||||
handoffSent = false
|
||||
timeSec = 0
|
||||
colorProgress = 0
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
transition = 1
|
||||
assembly = 0.55
|
||||
motion = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
colorWaitReached = false
|
||||
morphing = false
|
||||
subjectReady = false
|
||||
subjectFailed = false
|
||||
subjectEntered = false
|
||||
resultReadyAt = 0
|
||||
enter(PHASE.LOGO_POINT_CLOUD)
|
||||
}
|
||||
|
||||
function setSourceReady(v) {
|
||||
sourceReady = !!v
|
||||
}
|
||||
|
||||
function setSubjectReady(v) {
|
||||
subjectReady = !!v
|
||||
if (v) subjectFailed = false
|
||||
else subjectFailed = true
|
||||
}
|
||||
|
||||
function setSubjectFailed() {
|
||||
subjectFailed = true
|
||||
subjectReady = false
|
||||
}
|
||||
|
||||
let resultReadyAt = 0
|
||||
|
||||
function setResultPreloadStatus(s) {
|
||||
resultPreloadStatus = s
|
||||
if (s === 'ready' && !resultReadyAt) resultReadyAt = Date.now()
|
||||
}
|
||||
|
||||
function getUniforms() {
|
||||
return {
|
||||
time: timeSec,
|
||||
colorProgress: colorProgress,
|
||||
opacity: opacity,
|
||||
reveal: reveal,
|
||||
transition: transition,
|
||||
assembly: assembly,
|
||||
motion: motion,
|
||||
subjectProgress: subjectProgress,
|
||||
outlineProgress: outlineProgress,
|
||||
}
|
||||
}
|
||||
|
||||
function beginMorph() {
|
||||
transition = 0
|
||||
morphing = true
|
||||
morphStartedAt = Date.now()
|
||||
}
|
||||
|
||||
function tryEnterSubject() {
|
||||
if (subjectEntered || subjectFailed || !subjectReady) return false
|
||||
if (phase !== PHASE.IMAGE_COLOR_POINT_CLOUD && phase !== PHASE.RESULT_PRELOADING) {
|
||||
return false
|
||||
}
|
||||
// 原图彩色点云需先完整展示,再进 mask 人物段(不因效果图就绪而跳过)
|
||||
if (
|
||||
phase === PHASE.IMAGE_COLOR_POINT_CLOUD &&
|
||||
Date.now() - phaseStartedAt < TIMING.IMAGE_COLOR_MS
|
||||
) {
|
||||
return false
|
||||
}
|
||||
subjectEntered = true
|
||||
if (hooks && hooks.onSwitchToSubject) hooks.onSwitchToSubject()
|
||||
beginMorph()
|
||||
enter(PHASE.SUBJECT_DETECTING)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 切到效果图点云。人物段未走完时禁止跳转:
|
||||
* - subjectReady:必须播完 SUBJECT_* 到彩色人物点云后才可切
|
||||
* - subjectFailed:可从原图彩/预加载直接切效果图
|
||||
*/
|
||||
function tryStartResultTransition() {
|
||||
if (!colorWaitReached) return false
|
||||
if (resultPreloadStatus !== 'ready') return false
|
||||
|
||||
const inSubjectColor =
|
||||
phase === PHASE.SUBJECT_CLOUD_COLOR || phase === PHASE.SUBJECT_POINT_CLOUD
|
||||
const canSkipSubject =
|
||||
subjectFailed &&
|
||||
(phase === PHASE.IMAGE_COLOR_POINT_CLOUD || phase === PHASE.RESULT_PRELOADING)
|
||||
|
||||
if (inSubjectColor) {
|
||||
// 彩色人物点云至少停留 SUBJECT_HOLD_MS
|
||||
if (Date.now() - phaseStartedAt < TIMING.SUBJECT_HOLD_MS) return false
|
||||
} else if (!canSkipSubject) {
|
||||
return false
|
||||
}
|
||||
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
reveal = 0
|
||||
opacity = 1
|
||||
assembly = 0.4
|
||||
enter(PHASE.RESULT_TRANSITION)
|
||||
if (hooks && hooks.onSwitchToResult) hooks.onSwitchToResult()
|
||||
beginMorph()
|
||||
return true
|
||||
}
|
||||
|
||||
function tick(dtMs) {
|
||||
if (finished) return getUniforms()
|
||||
const dt = Math.min(64, dtMs || 33)
|
||||
timeSec += dt / 1000
|
||||
const elapsed = Date.now() - phaseStartedAt
|
||||
|
||||
if (morphing) {
|
||||
const mt = Math.min(
|
||||
1,
|
||||
(Date.now() - morphStartedAt) / TIMING.TEX_CROSSFADE_MS
|
||||
)
|
||||
transition = smootherstep(mt)
|
||||
assembly = 0.35 + 0.65 * Math.abs(mt - 0.5) * 2
|
||||
if (mt >= 1) {
|
||||
transition = 1
|
||||
assembly = 1
|
||||
morphing = false
|
||||
}
|
||||
}
|
||||
|
||||
if (phase === PHASE.LOGO_POINT_CLOUD) {
|
||||
colorProgress = 0
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (!morphing) {
|
||||
const at = Math.min(1, elapsed / TIMING.ASSEMBLY_MS)
|
||||
assembly = 0.55 + 0.45 * smootherstep(at)
|
||||
}
|
||||
if (elapsed >= TIMING.LOGO_MS && sourceReady) {
|
||||
if (hooks && hooks.onSwitchToSource) hooks.onSwitchToSource()
|
||||
beginMorph()
|
||||
enter(PHASE.IMAGE_GRAY_POINT_CLOUD)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
if (phase === PHASE.IMAGE_GRAY_POINT_CLOUD) {
|
||||
colorProgress = 0
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (!morphing && elapsed >= TIMING.IMAGE_GRAY_MS) {
|
||||
enter(PHASE.IMAGE_COLOR_TRANSITION)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
if (phase === PHASE.IMAGE_COLOR_TRANSITION) {
|
||||
const t = Math.min(1, elapsed / TIMING.COLOR_TRANSITION_MS)
|
||||
colorProgress = easeInOut(t)
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (!morphing) assembly = 1
|
||||
if (t >= 1) {
|
||||
colorProgress = 1
|
||||
colorWaitReached = true
|
||||
enter(PHASE.IMAGE_COLOR_POINT_CLOUD)
|
||||
// 先展示原图彩色点云;人物段由 IMAGE_COLOR_MS 结束后再进
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
if (
|
||||
phase === PHASE.IMAGE_COLOR_POINT_CLOUD ||
|
||||
phase === PHASE.RESULT_PRELOADING
|
||||
) {
|
||||
colorProgress = 1
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (!morphing) assembly = 1
|
||||
|
||||
const colorHoldDone =
|
||||
phase === PHASE.RESULT_PRELOADING ||
|
||||
Date.now() - phaseStartedAt >= TIMING.IMAGE_COLOR_MS
|
||||
|
||||
if (colorHoldDone) {
|
||||
// 优先进 mask 人物点云,绝不因效果图已就绪而跳过
|
||||
const entered = tryEnterSubject()
|
||||
if (!entered) {
|
||||
// mask 尚未就绪:继续等;超时或失败才直切效果图
|
||||
const waitMs =
|
||||
phase === PHASE.RESULT_PRELOADING
|
||||
? 12000
|
||||
: TIMING.IMAGE_COLOR_MS + 12000
|
||||
const subjectWaitedOut =
|
||||
!subjectReady &&
|
||||
!subjectFailed &&
|
||||
Date.now() - phaseStartedAt > waitMs
|
||||
if (subjectWaitedOut) subjectFailed = true
|
||||
if (subjectFailed) {
|
||||
tryStartResultTransition()
|
||||
} else if (
|
||||
phase === PHASE.IMAGE_COLOR_POINT_CLOUD &&
|
||||
(resultPreloadStatus === 'loading' || resultPreloadStatus === 'idle')
|
||||
) {
|
||||
enter(PHASE.RESULT_PRELOADING)
|
||||
}
|
||||
}
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
// —— 人物粗轮廓段:检测 → 轮廓 → 灰抠图 → 上色 → 彩抠图(不可中途跳到效果图)——
|
||||
if (phase === PHASE.SUBJECT_DETECTING) {
|
||||
const detectDur = subjectReady
|
||||
? Math.min(TIMING.SUBJECT_DETECT_MS, 420)
|
||||
: TIMING.SUBJECT_DETECT_MS
|
||||
const t = Math.min(1, elapsed / detectDur)
|
||||
colorProgress = 1 - smootherstep(t)
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 1
|
||||
if (!morphing) assembly = 1
|
||||
subjectProgress = smootherstep(t) * 0.75
|
||||
outlineProgress = smootherstep(Math.max(0, (t - 0.2) / 0.8)) * 0.5
|
||||
if (!morphing && t >= 1) {
|
||||
colorProgress = 0
|
||||
enter(PHASE.SUBJECT_OUTLINE)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
if (phase === PHASE.SUBJECT_OUTLINE) {
|
||||
colorProgress = 0
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 0.85
|
||||
if (!morphing) assembly = 1
|
||||
const t = Math.min(1, elapsed / TIMING.SUBJECT_OUTLINE_MS)
|
||||
subjectProgress = 0.7 + smootherstep(t) * 0.3
|
||||
outlineProgress = 0.45 + smootherstep(t) * 0.55
|
||||
if (!morphing && t >= 1) {
|
||||
enter(PHASE.SUBJECT_CLOUD_GRAY)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
if (phase === PHASE.SUBJECT_CLOUD_GRAY) {
|
||||
colorProgress = 0
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 0.72
|
||||
if (!morphing) assembly = 1
|
||||
subjectProgress = 1
|
||||
outlineProgress = Math.max(0.35, 1 - Math.min(1, elapsed / 2400) * 0.55)
|
||||
if (!morphing && elapsed >= TIMING.SUBJECT_GRAY_MS) {
|
||||
enter(PHASE.SUBJECT_CLOUD_COLORIZE)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
if (phase === PHASE.SUBJECT_CLOUD_COLORIZE) {
|
||||
const t = Math.min(1, elapsed / TIMING.COLOR_TRANSITION_MS)
|
||||
colorProgress = easeInOut(t)
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 0.7
|
||||
if (!morphing) assembly = 1
|
||||
subjectProgress = 1
|
||||
outlineProgress = 0.35
|
||||
if (t >= 1) {
|
||||
colorProgress = 1
|
||||
enter(PHASE.SUBJECT_CLOUD_COLOR)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
if (
|
||||
phase === PHASE.SUBJECT_CLOUD_COLOR ||
|
||||
phase === PHASE.SUBJECT_POINT_CLOUD
|
||||
) {
|
||||
colorProgress = 1
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 0.68
|
||||
if (!morphing) assembly = 1
|
||||
subjectProgress = 1
|
||||
outlineProgress = 0.3
|
||||
// 人物彩色点云播完(且效果图就绪)后再切效果图点云
|
||||
tryStartResultTransition()
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
// Morph into result point cloud → then gray cloud hold
|
||||
if (phase === PHASE.RESULT_TRANSITION) {
|
||||
colorProgress = 0
|
||||
motion = 1
|
||||
reveal = 0
|
||||
opacity = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (!morphing) assembly = 1
|
||||
if (!morphing && elapsed >= TIMING.RESULT_TRANSITION_MS) {
|
||||
transition = 1
|
||||
enter(PHASE.RESULT_CLOUD_GRAY)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
// 效果图灰色点云 2s
|
||||
if (phase === PHASE.RESULT_CLOUD_GRAY) {
|
||||
colorProgress = 0
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 1
|
||||
assembly = 1
|
||||
transition = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (elapsed >= TIMING.RESULT_CLOUD_GRAY_MS) {
|
||||
enter(PHASE.RESULT_CLOUD_COLORIZE)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
// 灰 → 彩
|
||||
if (phase === PHASE.RESULT_CLOUD_COLORIZE) {
|
||||
const t = Math.min(1, elapsed / TIMING.COLOR_TRANSITION_MS)
|
||||
colorProgress = easeInOut(t)
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 1
|
||||
assembly = 1
|
||||
transition = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (t >= 1) {
|
||||
colorProgress = 1
|
||||
enter(PHASE.RESULT_CLOUD_COLOR)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
// 效果图彩色点云 3s → 完整效果图
|
||||
if (phase === PHASE.RESULT_CLOUD_COLOR) {
|
||||
colorProgress = 1
|
||||
opacity = 1
|
||||
reveal = 0
|
||||
motion = 1
|
||||
assembly = 1
|
||||
transition = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (elapsed >= TIMING.RESULT_CLOUD_COLOR_MS) {
|
||||
enter(PHASE.RESULT_IMAGE_REVEAL)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
// 点云淡出 → 完整效果图淡入
|
||||
if (phase === PHASE.RESULT_IMAGE_REVEAL) {
|
||||
const t = Math.min(1, elapsed / TIMING.RESULT_IMAGE_FADE_MS)
|
||||
const e = smootherstep(t)
|
||||
colorProgress = 1
|
||||
motion = 0.28
|
||||
assembly = 1
|
||||
transition = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
reveal = e
|
||||
opacity = 1 - smootherstep(Math.max(0, (t - 0.05) / 0.95))
|
||||
if (t >= 1) {
|
||||
opacity = 0
|
||||
reveal = 1
|
||||
enter(PHASE.RESULT_PREVIEW)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
// 完整效果图停留 ~2s
|
||||
if (phase === PHASE.RESULT_PREVIEW) {
|
||||
opacity = 0
|
||||
reveal = 1
|
||||
motion = 0
|
||||
assembly = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (elapsed >= TIMING.RESULT_PREVIEW_MS) {
|
||||
enter(PHASE.NAVIGATING)
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
if (phase === PHASE.NAVIGATING) {
|
||||
opacity = 0
|
||||
reveal = 1
|
||||
subjectProgress = 0
|
||||
outlineProgress = 0
|
||||
if (elapsed >= TIMING.NAVIGATING_MS && !handoffSent) {
|
||||
handoffSent = true
|
||||
finished = true
|
||||
if (hooks && hooks.onHandoff) hooks.onHandoff()
|
||||
}
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
return getUniforms()
|
||||
}
|
||||
|
||||
return {
|
||||
start: start,
|
||||
tick: tick,
|
||||
setSourceReady: setSourceReady,
|
||||
setSubjectReady: setSubjectReady,
|
||||
setSubjectFailed: setSubjectFailed,
|
||||
setResultPreloadStatus: setResultPreloadStatus,
|
||||
getPhase: function () {
|
||||
return phase
|
||||
},
|
||||
getUniforms: getUniforms,
|
||||
isFinished: function () {
|
||||
return finished
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
createStateMachine,
|
||||
PHASE,
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* 从软 Mask 提取粗轮廓(边缘带)
|
||||
* 轮廓只增强边缘,不能替代主体内部粒子
|
||||
*/
|
||||
|
||||
function smoothStep(edge0, edge1, value) {
|
||||
const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
|
||||
return t * t * (3 - 2 * t)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ width: number, height: number, data: Uint8Array }} mask
|
||||
* @param {{ threshold?: number }} [options]
|
||||
* @returns {{ width: number, height: number, data: Uint8Array } | null}
|
||||
*/
|
||||
function buildOutlineMask(mask, options) {
|
||||
options = options || {}
|
||||
if (!mask || !mask.data || !mask.width || !mask.height) return null
|
||||
|
||||
const w = mask.width
|
||||
const h = mask.height
|
||||
const src = mask.data
|
||||
const thr = options.threshold != null ? options.threshold : 40
|
||||
const out = new Uint8Array(w * h)
|
||||
|
||||
for (let y = 1; y < h - 1; y++) {
|
||||
for (let x = 1; x < w - 1; x++) {
|
||||
const i = y * w + x
|
||||
const c = src[i]
|
||||
if (c < thr) {
|
||||
out[i] = 0
|
||||
continue
|
||||
}
|
||||
// 邻域有背景 → 轮廓
|
||||
const neighbors = [src[i - 1], src[i + 1], src[i - w], src[i + w]]
|
||||
let isEdge = false
|
||||
for (let n = 0; n < 4; n++) {
|
||||
if (neighbors[n] < thr) {
|
||||
isEdge = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (isEdge) {
|
||||
out[i] = 255
|
||||
} else {
|
||||
out[i] = Math.round(c * 0.08)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: w,
|
||||
height: h,
|
||||
data: out,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 采样合成权重:主体为主,轮廓仅边缘增强
|
||||
* Mask data 已是 0~255 soft weight(API Mask 经 smoothStep)
|
||||
* @returns {number} 0~1
|
||||
*/
|
||||
function sampleMaskValue(mask, outline, x, y, imageW, imageH, mode) {
|
||||
if (!mask || !mask.data) return 1
|
||||
const mw = mask.width
|
||||
const mh = mask.height
|
||||
// Mask 与采样图同尺寸时 1:1;否则按比例映射
|
||||
const mx = Math.min(
|
||||
mw - 1,
|
||||
Math.max(0, Math.floor((x / Math.max(imageW - 1, 1)) * (mw - 1)))
|
||||
)
|
||||
const my = Math.min(
|
||||
mh - 1,
|
||||
Math.max(0, Math.floor((y / Math.max(imageH - 1, 1)) * (mh - 1)))
|
||||
)
|
||||
const mi = my * mw + mx
|
||||
let body = mask.data[mi] / 255
|
||||
// 若仍是原始灰度风格,再过一遍 soft step 保底
|
||||
if (body > 0 && body < 1) {
|
||||
body = Math.max(body, smoothStep(32 / 255, 220 / 255, body))
|
||||
}
|
||||
let edge = 0
|
||||
if (outline && outline.data && outline.width === mw && outline.height === mh) {
|
||||
edge = outline.data[mi] / 255
|
||||
}
|
||||
if (mode === 'outline') {
|
||||
return Math.max(0, Math.min(1, body * 0.35 + edge * 0.85))
|
||||
}
|
||||
return Math.max(0, Math.min(1, body * 0.94 + edge * 0.12))
|
||||
}
|
||||
|
||||
export {
|
||||
buildOutlineMask,
|
||||
sampleMaskValue,
|
||||
smoothStep,
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
POINT_VERTEX,
|
||||
POINT_FRAGMENT,
|
||||
QUAD_VERTEX,
|
||||
QUAD_FRAGMENT,
|
||||
} from './shaders'
|
||||
import { createTextureManager } from './texture-manager'
|
||||
import { VISUAL, POINT_CLOUD } from '../constants'
|
||||
|
||||
function compileShader(gl, type, source) {
|
||||
const shader = gl.createShader(type)
|
||||
gl.shaderSource(shader, source)
|
||||
gl.compileShader(shader)
|
||||
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const log = gl.getShaderInfoLog(shader)
|
||||
gl.deleteShader(shader)
|
||||
throw new Error('shader compile: ' + log)
|
||||
}
|
||||
return shader
|
||||
}
|
||||
|
||||
function createProgram(gl, vsSrc, fsSrc) {
|
||||
const vs = compileShader(gl, gl.VERTEX_SHADER, vsSrc)
|
||||
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fsSrc)
|
||||
const program = gl.createProgram()
|
||||
gl.attachShader(program, vs)
|
||||
gl.attachShader(program, fs)
|
||||
gl.linkProgram(program)
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
const log = gl.getProgramInfoLog(program)
|
||||
gl.deleteProgram(program)
|
||||
throw new Error('program link: ' + log)
|
||||
}
|
||||
gl.deleteShader(vs)
|
||||
gl.deleteShader(fs)
|
||||
return program
|
||||
}
|
||||
|
||||
function createEmptyCloud(maxPoints) {
|
||||
const floats = POINT_CLOUD.STRIDE_FLOATS
|
||||
const data = new Float32Array(maxPoints * floats)
|
||||
return {
|
||||
buffer: null,
|
||||
count: maxPoints,
|
||||
liveCount: 0,
|
||||
aspect: 1,
|
||||
data: data,
|
||||
}
|
||||
}
|
||||
|
||||
function createRenderer(canvas, cssW, cssH, dpr, scaleCanvas) {
|
||||
const gl = canvas.getContext('webgl', {
|
||||
alpha: false,
|
||||
antialias: false,
|
||||
premultipliedAlpha: true,
|
||||
preserveDrawingBuffer: false,
|
||||
})
|
||||
if (!gl) return null
|
||||
|
||||
// 按上传图比例矩形舞台;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)
|
||||
|
||||
const textures = createTextureManager(gl, canvas, scaleCanvas, cssW, cssH)
|
||||
const maxPoints = textures.getMaxPoints()
|
||||
let emptyCloud = createEmptyCloud(maxPoints)
|
||||
emptyCloud.buffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, emptyCloud.buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, emptyCloud.data, gl.STATIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, null)
|
||||
|
||||
let cloudA = emptyCloud
|
||||
let cloudB = emptyCloud
|
||||
let resultTex = textures.createEmptyTexture()
|
||||
let hasResult = false
|
||||
let contentAspect = 1
|
||||
let pointCount = maxPoints
|
||||
|
||||
// Point program attrib/uniform locations
|
||||
gl.useProgram(pointProgram)
|
||||
const pointAttr = {
|
||||
aPosA: gl.getAttribLocation(pointProgram, 'aPosA'),
|
||||
aPosB: gl.getAttribLocation(pointProgram, 'aPosB'),
|
||||
aColorA: gl.getAttribLocation(pointProgram, 'aColorA'),
|
||||
aColorB: gl.getAttribLocation(pointProgram, 'aColorB'),
|
||||
aSizeA: gl.getAttribLocation(pointProgram, 'aSizeA'),
|
||||
aSizeB: gl.getAttribLocation(pointProgram, 'aSizeB'),
|
||||
aAlphaA: gl.getAttribLocation(pointProgram, 'aAlphaA'),
|
||||
aAlphaB: gl.getAttribLocation(pointProgram, 'aAlphaB'),
|
||||
aSeedA: gl.getAttribLocation(pointProgram, 'aSeedA'),
|
||||
aSeedB: gl.getAttribLocation(pointProgram, 'aSeedB'),
|
||||
aSubjectWeightA: gl.getAttribLocation(pointProgram, 'aSubjectWeightA'),
|
||||
aSubjectWeightB: gl.getAttribLocation(pointProgram, 'aSubjectWeightB'),
|
||||
}
|
||||
const pointUni = {
|
||||
uTransition: gl.getUniformLocation(pointProgram, 'uTransition'),
|
||||
uTime: gl.getUniformLocation(pointProgram, 'uTime'),
|
||||
uColorProgress: gl.getUniformLocation(pointProgram, 'uColorProgress'),
|
||||
uOpacity: gl.getUniformLocation(pointProgram, 'uOpacity'),
|
||||
uDpr: gl.getUniformLocation(pointProgram, 'uDpr'),
|
||||
uMotion: gl.getUniformLocation(pointProgram, 'uMotion'),
|
||||
uAssembly: gl.getUniformLocation(pointProgram, 'uAssembly'),
|
||||
uSubjectProgress: gl.getUniformLocation(pointProgram, 'uSubjectProgress'),
|
||||
uOutlineProgress: gl.getUniformLocation(pointProgram, 'uOutlineProgress'),
|
||||
uPointer: gl.getUniformLocation(pointProgram, 'uPointer'),
|
||||
uPointerStrength: gl.getUniformLocation(pointProgram, 'uPointerStrength'),
|
||||
uPointerRadius: gl.getUniformLocation(pointProgram, 'uPointerRadius'),
|
||||
uPointerAspect: gl.getUniformLocation(pointProgram, 'uPointerAspect'),
|
||||
uPointerInflate: gl.getUniformLocation(pointProgram, 'uPointerInflate'),
|
||||
uPointShape: gl.getUniformLocation(pointProgram, 'uPointShape'),
|
||||
}
|
||||
|
||||
// Quad for result reveal
|
||||
const quadBuffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0, 1, 1, 1, 0]),
|
||||
gl.STATIC_DRAW
|
||||
)
|
||||
gl.useProgram(quadProgram)
|
||||
const quadAttr = {
|
||||
aPosition: gl.getAttribLocation(quadProgram, 'aPosition'),
|
||||
aUv: gl.getAttribLocation(quadProgram, 'aUv'),
|
||||
}
|
||||
const quadUni = {
|
||||
uTexture: gl.getUniformLocation(quadProgram, 'uTexture'),
|
||||
uOpacity: gl.getUniformLocation(quadProgram, 'uOpacity'),
|
||||
uContentScale: gl.getUniformLocation(quadProgram, 'uContentScale'),
|
||||
uContentOffset: gl.getUniformLocation(quadProgram, 'uContentOffset'),
|
||||
uBgColor: gl.getUniformLocation(quadProgram, 'uBgColor'),
|
||||
}
|
||||
|
||||
function containFit(aspect, boxW, boxH, fit) {
|
||||
const f = fit == null ? 0.98 : fit
|
||||
const maxW = boxW * f
|
||||
const maxH = boxH * f
|
||||
let w = maxW
|
||||
let h = w / aspect
|
||||
if (h > maxH) {
|
||||
h = maxH
|
||||
w = h * aspect
|
||||
}
|
||||
return {
|
||||
scaleX: w / boxW,
|
||||
scaleY: h / boxH,
|
||||
offsetX: (boxW - w) * 0.5 / boxW,
|
||||
offsetY: (boxH - h) * 0.5 / boxH,
|
||||
}
|
||||
}
|
||||
|
||||
function bindCloudAttribs(
|
||||
bufferObj,
|
||||
posLoc,
|
||||
colorLoc,
|
||||
sizeLoc,
|
||||
alphaLoc,
|
||||
seedLoc,
|
||||
weightLoc
|
||||
) {
|
||||
const stride = POINT_CLOUD.STRIDE_FLOATS * 4
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, bufferObj.buffer)
|
||||
if (posLoc >= 0) {
|
||||
gl.enableVertexAttribArray(posLoc)
|
||||
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0)
|
||||
}
|
||||
if (colorLoc >= 0) {
|
||||
gl.enableVertexAttribArray(colorLoc)
|
||||
gl.vertexAttribPointer(colorLoc, 3, gl.FLOAT, false, stride, 8)
|
||||
}
|
||||
if (sizeLoc >= 0) {
|
||||
gl.enableVertexAttribArray(sizeLoc)
|
||||
gl.vertexAttribPointer(sizeLoc, 1, gl.FLOAT, false, stride, 20)
|
||||
}
|
||||
if (alphaLoc >= 0) {
|
||||
gl.enableVertexAttribArray(alphaLoc)
|
||||
gl.vertexAttribPointer(alphaLoc, 1, gl.FLOAT, false, stride, 24)
|
||||
}
|
||||
if (seedLoc >= 0) {
|
||||
gl.enableVertexAttribArray(seedLoc)
|
||||
gl.vertexAttribPointer(seedLoc, 1, gl.FLOAT, false, stride, 28)
|
||||
}
|
||||
if (weightLoc >= 0) {
|
||||
gl.enableVertexAttribArray(weightLoc)
|
||||
gl.vertexAttribPointer(weightLoc, 1, gl.FLOAT, false, stride, 32)
|
||||
}
|
||||
}
|
||||
|
||||
function setMainCloud(entry) {
|
||||
if (!entry || !entry.pointCloudBuffer) return
|
||||
cloudA = entry.pointCloudBuffer
|
||||
cloudB = entry.pointCloudBuffer
|
||||
contentAspect = entry.aspect || cloudA.aspect || 1
|
||||
// count 是整段缓冲长度。点按从上到下写入,只画 liveCount 会把下半身裁掉
|
||||
pointCount = cloudA.count || maxPoints
|
||||
}
|
||||
|
||||
/** Promote current target to A, set B = new target (particle morph) */
|
||||
function transitionToCloud(entry) {
|
||||
if (!entry || !entry.pointCloudBuffer || !entry.pointCloudBuffer.buffer) return
|
||||
cloudA = cloudB
|
||||
cloudB = entry.pointCloudBuffer
|
||||
contentAspect = entry.aspect || cloudB.aspect || contentAspect
|
||||
const countA = cloudA.count || 0
|
||||
const countB = cloudB.count || 0
|
||||
// 取两边缓冲容量的较小值,避免越界;不要用有效点数,否则下半身被截掉
|
||||
pointCount = Math.max(1, Math.min(countA || countB, countB || countA))
|
||||
}
|
||||
|
||||
function setResultTexture(entry) {
|
||||
if (!entry || !entry.texture) {
|
||||
hasResult = false
|
||||
return
|
||||
}
|
||||
resultTex = entry.texture
|
||||
hasResult = true
|
||||
if (entry.aspect) contentAspect = entry.aspect
|
||||
}
|
||||
|
||||
function draw(uniformsIn) {
|
||||
const u = uniformsIn || {}
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
gl.clearColor(
|
||||
VISUAL.STAGE_BG[0],
|
||||
VISUAL.STAGE_BG[1],
|
||||
VISUAL.STAGE_BG[2],
|
||||
1
|
||||
)
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
|
||||
const pointOpacity = u.opacity != null ? u.opacity : 1
|
||||
const reveal = u.reveal != null ? u.reveal : 0
|
||||
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
// 揭示阶段绘制效果图纹理,避免点云淡出后整页黑屏
|
||||
if (hasResult && reveal > 0.001 && resultTex) {
|
||||
const fit = containFit(contentAspect || 1, cssW, cssH, 0.98)
|
||||
gl.useProgram(quadProgram)
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, resultTex)
|
||||
gl.uniform1i(quadUni.uTexture, 0)
|
||||
gl.uniform1f(quadUni.uOpacity, Math.min(1, reveal))
|
||||
gl.uniform2f(quadUni.uContentScale, fit.scaleX, fit.scaleY)
|
||||
gl.uniform2f(quadUni.uContentOffset, fit.offsetX, fit.offsetY)
|
||||
gl.uniform4f(
|
||||
quadUni.uBgColor,
|
||||
VISUAL.STAGE_BG[0],
|
||||
VISUAL.STAGE_BG[1],
|
||||
VISUAL.STAGE_BG[2],
|
||||
1
|
||||
)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer)
|
||||
if (quadAttr.aPosition >= 0) {
|
||||
gl.enableVertexAttribArray(quadAttr.aPosition)
|
||||
gl.vertexAttribPointer(quadAttr.aPosition, 2, gl.FLOAT, false, 16, 0)
|
||||
}
|
||||
if (quadAttr.aUv >= 0) {
|
||||
gl.enableVertexAttribArray(quadAttr.aUv)
|
||||
gl.vertexAttribPointer(quadAttr.aUv, 2, gl.FLOAT, false, 16, 8)
|
||||
}
|
||||
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
}
|
||||
|
||||
if (pointOpacity > 0.001 && cloudA && cloudB && cloudA.buffer && cloudB.buffer) {
|
||||
gl.useProgram(pointProgram)
|
||||
|
||||
bindCloudAttribs(
|
||||
cloudA,
|
||||
pointAttr.aPosA,
|
||||
pointAttr.aColorA,
|
||||
pointAttr.aSizeA,
|
||||
pointAttr.aAlphaA,
|
||||
pointAttr.aSeedA,
|
||||
pointAttr.aSubjectWeightA
|
||||
)
|
||||
bindCloudAttribs(
|
||||
cloudB,
|
||||
pointAttr.aPosB,
|
||||
pointAttr.aColorB,
|
||||
pointAttr.aSizeB,
|
||||
pointAttr.aAlphaB,
|
||||
pointAttr.aSeedB,
|
||||
pointAttr.aSubjectWeightB
|
||||
)
|
||||
|
||||
gl.uniform1f(pointUni.uTransition, u.transition != null ? u.transition : 1)
|
||||
gl.uniform1f(pointUni.uTime, u.time || 0)
|
||||
gl.uniform1f(
|
||||
pointUni.uColorProgress,
|
||||
u.colorProgress != null ? u.colorProgress : 0
|
||||
)
|
||||
gl.uniform1f(pointUni.uOpacity, pointOpacity)
|
||||
gl.uniform1f(pointUni.uDpr, dpr)
|
||||
gl.uniform1f(pointUni.uMotion, u.motion != null ? u.motion : 1)
|
||||
gl.uniform1f(pointUni.uAssembly, u.assembly != null ? u.assembly : 1)
|
||||
gl.uniform1f(
|
||||
pointUni.uSubjectProgress,
|
||||
u.subjectProgress != null ? u.subjectProgress : 0
|
||||
)
|
||||
gl.uniform1f(
|
||||
pointUni.uOutlineProgress,
|
||||
u.outlineProgress != null ? u.outlineProgress : 0
|
||||
)
|
||||
gl.uniform2f(
|
||||
pointUni.uPointer,
|
||||
u.pointerX != null ? u.pointerX : 0,
|
||||
u.pointerY != null ? u.pointerY : 0
|
||||
)
|
||||
gl.uniform1f(
|
||||
pointUni.uPointerStrength,
|
||||
u.pointerStrength != null ? u.pointerStrength : 0
|
||||
)
|
||||
gl.uniform1f(
|
||||
pointUni.uPointerRadius,
|
||||
u.pointerRadius != null ? u.pointerRadius : 0.38
|
||||
)
|
||||
gl.uniform1f(
|
||||
pointUni.uPointerAspect,
|
||||
u.pointerAspect != null ? u.pointerAspect : 1
|
||||
)
|
||||
gl.uniform1f(
|
||||
pointUni.uPointerInflate,
|
||||
u.pointerInflate != null ? u.pointerInflate : 0.14
|
||||
)
|
||||
gl.uniform1f(
|
||||
pointUni.uPointShape,
|
||||
u.pointShape != null ? u.pointShape : 0
|
||||
)
|
||||
|
||||
gl.drawArrays(gl.POINTS, 0, pointCount)
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
textures.destroy()
|
||||
try {
|
||||
if (emptyCloud.buffer) gl.deleteBuffer(emptyCloud.buffer)
|
||||
gl.deleteBuffer(quadBuffer)
|
||||
gl.deleteProgram(pointProgram)
|
||||
gl.deleteProgram(quadProgram)
|
||||
gl.deleteTexture(resultTex)
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return {
|
||||
gl: gl,
|
||||
textures: textures,
|
||||
draw: draw,
|
||||
setMainCloud: setMainCloud,
|
||||
transitionToCloud: transitionToCloud,
|
||||
setResultTexture: setResultTexture,
|
||||
destroy: destroy,
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
createRenderer,
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Real GPU point cloud (GL_POINTS) — Tripo-style.
|
||||
* Circle / square particle shapes via uPointShape (0=圆, 1=方).
|
||||
* subjectWeight + uSubjectProgress / uOutlineProgress:人物阶段背景消散/轮廓(结果阶段恒 0)
|
||||
*/
|
||||
|
||||
const POINT_VERTEX = `
|
||||
precision mediump float;
|
||||
|
||||
attribute vec2 aPosA;
|
||||
attribute vec2 aPosB;
|
||||
attribute vec3 aColorA;
|
||||
attribute vec3 aColorB;
|
||||
attribute float aSizeA;
|
||||
attribute float aSizeB;
|
||||
attribute float aAlphaA;
|
||||
attribute float aAlphaB;
|
||||
attribute float aSeedA;
|
||||
attribute float aSeedB;
|
||||
attribute float aSubjectWeightA;
|
||||
attribute float aSubjectWeightB;
|
||||
|
||||
uniform float uTransition;
|
||||
uniform float uTime;
|
||||
uniform float uColorProgress;
|
||||
uniform float uOpacity;
|
||||
uniform float uDpr;
|
||||
uniform float uMotion;
|
||||
uniform float uAssembly;
|
||||
uniform float uSubjectProgress;
|
||||
uniform float uOutlineProgress;
|
||||
uniform vec2 uPointer;
|
||||
uniform float uPointerStrength;
|
||||
uniform float uPointerRadius;
|
||||
uniform float uPointerAspect;
|
||||
uniform float uPointerInflate;
|
||||
|
||||
varying vec4 vColor;
|
||||
|
||||
float hash(float n) {
|
||||
return fract(sin(n * 127.1) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float t = smoothstep(0.0, 1.0, uTransition);
|
||||
float fidelity = clamp(uColorProgress, 0.0, 1.0);
|
||||
|
||||
vec2 position = mix(aPosA, aPosB, t);
|
||||
float seed = mix(aSeedA, aSeedB, t);
|
||||
float size = mix(aSizeA, aSizeB, t);
|
||||
float alpha = mix(aAlphaA, aAlphaB, t);
|
||||
float subjectW = mix(aSubjectWeightA, aSubjectWeightB, t);
|
||||
|
||||
float s = seed * 6.2831853;
|
||||
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);
|
||||
|
||||
float spread = 1.0 + 0.012 * (0.55 + depth * 0.45);
|
||||
position *= spread;
|
||||
|
||||
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.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.006 + depth * 0.01) * uMotion * mix(1.0, 0.4, fidelity);
|
||||
vec2 wave = vec2(
|
||||
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.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);
|
||||
float op = clamp(uOutlineProgress, 0.0, 1.0);
|
||||
if (sp > 0.001 || op > 0.001) {
|
||||
float bgFade = 1.0 - smoothstep(0.18, 0.72, subjectW);
|
||||
position *= 1.0 + bgFade * sp * 0.1;
|
||||
float keep = smoothstep(0.1, 0.5, subjectW);
|
||||
alpha *= mix(1.0, keep * keep * max(subjectW, 0.08), sp);
|
||||
if (subjectW < 0.15 && sp > 0.5) {
|
||||
alpha *= 0.05;
|
||||
}
|
||||
// 轮廓:size +10~15%,alpha +10~20%
|
||||
float edgeBoost = 1.0 - abs(subjectW - 0.48) * 2.4;
|
||||
edgeBoost = clamp(edgeBoost, 0.0, 1.0);
|
||||
size *= 1.0 + edgeBoost * op * 0.14;
|
||||
alpha *= 1.0 + edgeBoost * op * 0.16;
|
||||
position *= mix(1.0, 0.965 + subjectW * 0.035, sp * 0.65);
|
||||
}
|
||||
|
||||
if (uPointerStrength > 0.001) {
|
||||
vec2 delta = position - uPointer;
|
||||
float aspect = max(uPointerAspect, 0.25);
|
||||
vec2 sd = vec2(delta.x * aspect, delta.y);
|
||||
float dist = length(sd);
|
||||
float radius = max(uPointerRadius, 0.08);
|
||||
float tn = clamp(dist / radius, 0.0, 1.0);
|
||||
|
||||
float disk = 1.0 - smoothstep(0.0, 1.0, tn);
|
||||
disk = disk * disk * (3.0 - 2.0 * disk);
|
||||
float strength = smoothstep(0.0, 1.0, uPointerStrength);
|
||||
float amount = max(uPointerInflate, 0.08) * disk * strength;
|
||||
|
||||
vec2 sdOut = sd * (1.0 + amount);
|
||||
position = uPointer + vec2(sdOut.x / aspect, sdOut.y);
|
||||
|
||||
size *= 1.0 + amount * 2.2;
|
||||
alpha = min(1.0, alpha * (1.0 + disk * strength * 0.08));
|
||||
}
|
||||
|
||||
vec3 grayA = vec3(dot(aColorA, vec3(0.299, 0.587, 0.114)));
|
||||
vec3 grayB = vec3(dot(aColorB, vec3(0.299, 0.587, 0.114)));
|
||||
grayA = vec3(0.82 + grayA.r * 0.32);
|
||||
grayB = vec3(0.82 + grayB.r * 0.32);
|
||||
|
||||
/* 彩色段贴原图色相,略提亮避免发暗 */
|
||||
vec3 photoA = clamp(aColorA, 0.0, 1.0);
|
||||
vec3 photoB = clamp(aColorB, 0.0, 1.0);
|
||||
float lumA = dot(photoA, vec3(0.299, 0.587, 0.114));
|
||||
float lumB = dot(photoB, vec3(0.299, 0.587, 0.114));
|
||||
photoA = clamp(mix(vec3(lumA), photoA, 1.08) * 1.16 + 0.02, 0.0, 1.0);
|
||||
photoB = clamp(mix(vec3(lumB), photoB, 1.08) * 1.16 + 0.02, 0.0, 1.0);
|
||||
|
||||
vec3 colorA = mix(grayA, photoA, fidelity);
|
||||
vec3 colorB = mix(grayB, photoB, fidelity);
|
||||
vec3 color = mix(colorA, colorB, t);
|
||||
|
||||
float brightStyle =
|
||||
mix(0.72, 1.28, depth) *
|
||||
(0.92 + 0.14 * hash(seed * 91.7));
|
||||
brightStyle *= 0.96 + 0.04 * sin(uTime * 1.9 + s);
|
||||
|
||||
float brightPhoto =
|
||||
mix(1.12, 1.24, depth) *
|
||||
(0.99 + 0.03 * hash(seed * 91.7));
|
||||
|
||||
color = clamp(color * mix(brightStyle, brightPhoto, fidelity), 0.0, 1.0);
|
||||
|
||||
float alphaStyle = alpha * uOpacity * mix(0.7, 1.0, depth);
|
||||
float alphaPhoto = alpha * uOpacity * mix(0.85, 1.0, depth);
|
||||
vColor = vec4(color, mix(alphaStyle, alphaPhoto, fidelity));
|
||||
|
||||
/* 远近尺寸差更明显:远点更小、近点更大 */
|
||||
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.5 + s), 1.0, fidelity * 0.5);
|
||||
gl_PointSize = max(2.8, size * sizeMul * uDpr);
|
||||
gl_Position = vec4(position, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const POINT_FRAGMENT = `
|
||||
precision mediump float;
|
||||
|
||||
varying vec4 vColor;
|
||||
uniform float uPointShape; // 0=圆形, 1=方形, 中间值= morph
|
||||
|
||||
void main() {
|
||||
vec2 p = gl_PointCoord - vec2(0.5);
|
||||
float d = length(p);
|
||||
vec2 q = abs(p);
|
||||
|
||||
/* 更宽的 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.03) {
|
||||
discard;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
`
|
||||
|
||||
/** Fullscreen textured quad for result image reveal */
|
||||
const QUAD_VERTEX = `
|
||||
attribute vec2 aPosition;
|
||||
attribute vec2 aUv;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = aUv;
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const QUAD_FRAGMENT = `
|
||||
precision mediump float;
|
||||
varying vec2 vUv;
|
||||
uniform sampler2D uTexture;
|
||||
uniform float uOpacity;
|
||||
uniform vec2 uContentScale;
|
||||
uniform vec2 uContentOffset;
|
||||
uniform vec4 uBgColor;
|
||||
|
||||
void main() {
|
||||
vec2 cuv = (vUv - uContentOffset) / max(uContentScale, vec2(0.0001));
|
||||
if (cuv.x < 0.0 || cuv.x > 1.0 || cuv.y < 0.0 || cuv.y > 1.0) {
|
||||
gl_FragColor = vec4(uBgColor.rgb, 1.0);
|
||||
return;
|
||||
}
|
||||
vec4 tex = texture2D(uTexture, cuv);
|
||||
float a = tex.a * uOpacity;
|
||||
vec3 rgb = mix(uBgColor.rgb, tex.rgb, a);
|
||||
gl_FragColor = vec4(rgb, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export {
|
||||
POINT_VERTEX,
|
||||
POINT_FRAGMENT,
|
||||
QUAD_VERTEX,
|
||||
QUAD_FRAGMENT,
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
import { VISUAL, POINT_CLOUD, PATHS } from '../constants'
|
||||
import { createPointCloudSampler } from '../point-cloud-sampler'
|
||||
import * as logoCloudCache from '../logo-cloud-cache'
|
||||
import { buildOutlineMask } from '../subject-outline'
|
||||
import * as maskProcessor from '../mask-processor'
|
||||
|
||||
let _perfLog = false
|
||||
|
||||
/**
|
||||
* Image → Texture + PointCloud Float32Array + GPU Buffer (preload only).
|
||||
*/
|
||||
function createTextureManager(gl, canvas, scaleCanvas, viewportW, viewportH) {
|
||||
const cache = {}
|
||||
let maxTexSize = VISUAL.MAX_TEX_SIZE
|
||||
let maxPoints = POINT_CLOUD.MEDIUM
|
||||
let sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
|
||||
|
||||
/**
|
||||
* 真机关键:不要用 DOM 里被 CSS 锁成 1×1 的 canvas 做 getImageData。
|
||||
* 部分 WebView 会把绘图缓冲钳到 CSS 尺寸,导致效果图采点为空 → 整页黑屏。
|
||||
*/
|
||||
const sampleCanvas = document.createElement('canvas')
|
||||
let scaleCtx = null
|
||||
try {
|
||||
scaleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true })
|
||||
} catch (e) {
|
||||
try {
|
||||
scaleCtx = sampleCanvas.getContext('2d')
|
||||
} catch (e2) {
|
||||
scaleCtx = null
|
||||
}
|
||||
}
|
||||
// 保留入参 scaleCanvas 作兼容,实际采样/缩放一律走离屏 canvas
|
||||
void scaleCanvas
|
||||
|
||||
const sampler = createPointCloudSampler(
|
||||
sampleCanvas,
|
||||
viewportW,
|
||||
viewportH,
|
||||
scaleCtx
|
||||
)
|
||||
|
||||
function pickTier() {
|
||||
try {
|
||||
const dpr = (typeof window !== 'undefined' && window.devicePixelRatio) || 1
|
||||
const cores = (typeof navigator !== 'undefined' && navigator.hardwareConcurrency) || 4
|
||||
const ua = (typeof navigator !== 'undefined' && navigator.userAgent) || ''
|
||||
const mobile = /Android|iPhone|iPad|iPod|Mobile/i.test(ua)
|
||||
// 真机内存紧,高点数上传容易丢 WebGL 上下文,之后整页黑屏
|
||||
if (mobile) {
|
||||
maxPoints = POINT_CLOUD.LOW
|
||||
sampleSide = POINT_CLOUD.SAMPLE_LOW
|
||||
maxTexSize = VISUAL.LOW_TEX_SIZE
|
||||
maskSide = POINT_CLOUD.MASK_LOW
|
||||
subjectPoints = POINT_CLOUD.SUBJECT_LOW
|
||||
quality = 'low'
|
||||
} else if (dpr >= 2.5 && cores >= 8) {
|
||||
maxPoints = POINT_CLOUD.HIGH
|
||||
sampleSide = POINT_CLOUD.SAMPLE_HIGH
|
||||
maxTexSize = VISUAL.MAX_TEX_SIZE
|
||||
maskSide = POINT_CLOUD.MASK_HIGH
|
||||
subjectPoints = POINT_CLOUD.SUBJECT_HIGH
|
||||
quality = 'high'
|
||||
} else if (cores <= 4) {
|
||||
maxPoints = POINT_CLOUD.LOW
|
||||
sampleSide = POINT_CLOUD.SAMPLE_LOW
|
||||
maxTexSize = VISUAL.LOW_TEX_SIZE
|
||||
maskSide = POINT_CLOUD.MASK_LOW
|
||||
subjectPoints = POINT_CLOUD.SUBJECT_LOW
|
||||
quality = 'low'
|
||||
} else {
|
||||
maxPoints = POINT_CLOUD.MEDIUM
|
||||
sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
|
||||
maxTexSize = VISUAL.MAX_TEX_SIZE
|
||||
maskSide = POINT_CLOUD.MASK_MEDIUM
|
||||
subjectPoints = POINT_CLOUD.SUBJECT_MEDIUM
|
||||
quality = 'medium'
|
||||
}
|
||||
} catch (e) {
|
||||
maxPoints = POINT_CLOUD.MEDIUM
|
||||
sampleSide = POINT_CLOUD.SAMPLE_MEDIUM
|
||||
maskSide = POINT_CLOUD.MASK_MEDIUM
|
||||
subjectPoints = POINT_CLOUD.SUBJECT_MEDIUM
|
||||
quality = 'medium'
|
||||
}
|
||||
}
|
||||
let maskSide = POINT_CLOUD.MASK_MEDIUM
|
||||
let subjectPoints = POINT_CLOUD.SUBJECT_MEDIUM
|
||||
let quality = 'medium'
|
||||
pickTier()
|
||||
|
||||
function setMaxSize(n) {
|
||||
maxTexSize = n || VISUAL.MAX_TEX_SIZE
|
||||
}
|
||||
|
||||
function createEmptyTexture() {
|
||||
const tex = gl.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
new Uint8Array([0, 0, 0, 0])
|
||||
)
|
||||
gl.bindTexture(gl.TEXTURE_2D, null)
|
||||
return tex
|
||||
}
|
||||
|
||||
function uploadCloudBuffer(cloud) {
|
||||
const buffer = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, cloud.data, gl.STATIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, null)
|
||||
return {
|
||||
buffer: buffer,
|
||||
count: cloud.maxPoints,
|
||||
liveCount: cloud.count,
|
||||
aspect: cloud.aspect,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageSrc(src) {
|
||||
let s = String(src || '').trim()
|
||||
if (!s) return ''
|
||||
if (s.indexOf('//') === 0) s = 'https:' + s
|
||||
if (/^http:\/\//i.test(s)) s = 'https://' + s.slice(7)
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图片并严格在 onload 之后再交给后续逻辑。
|
||||
* http(s) 优先 fetch→blob,避免手机 WebView 跨域导致 getImageData 采点失败。
|
||||
*/
|
||||
function loadImage(rawSrc) {
|
||||
const src = normalizeImageSrc(rawSrc)
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (!src) {
|
||||
reject(new Error('empty src'))
|
||||
return
|
||||
}
|
||||
|
||||
function bindImage(imgSrc, revokeUrl) {
|
||||
let settled = false
|
||||
const img = new Image()
|
||||
if (/^https?:\/\//i.test(imgSrc) && imgSrc.indexOf('blob:') !== 0) {
|
||||
img.crossOrigin = 'anonymous'
|
||||
}
|
||||
|
||||
function finishOk() {
|
||||
if (settled) return
|
||||
setTimeout(function () {
|
||||
if (settled) return
|
||||
const w = img.naturalWidth || img.width || 0
|
||||
const h = img.naturalHeight || img.height || 0
|
||||
if (!w || !h) {
|
||||
settled = true
|
||||
if (revokeUrl) {
|
||||
try { URL.revokeObjectURL(revokeUrl) } catch (e) {}
|
||||
}
|
||||
reject(new Error('image empty after onload'))
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve({
|
||||
image: img,
|
||||
path: src,
|
||||
width: w,
|
||||
height: h,
|
||||
revokeUrl: revokeUrl || '',
|
||||
})
|
||||
}, 0)
|
||||
}
|
||||
|
||||
function finishErr(err) {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (revokeUrl) {
|
||||
try { URL.revokeObjectURL(revokeUrl) } catch (e) {}
|
||||
}
|
||||
reject(err || new Error('image load fail'))
|
||||
}
|
||||
|
||||
img.onload = finishOk
|
||||
img.onerror = finishErr
|
||||
img.src = imgSrc
|
||||
}
|
||||
|
||||
// 远程图:先 blob 同源化,再采样(手机端效果图点云依赖此路径)
|
||||
if (/^https?:\/\//i.test(src) && typeof fetch === 'function') {
|
||||
fetch(src, { mode: 'cors', credentials: 'omit', cache: 'no-cache' })
|
||||
.then(function (res) {
|
||||
if (!res || !res.ok) throw new Error('fetch image http ' + (res && res.status))
|
||||
return res.blob()
|
||||
})
|
||||
.then(function (blob) {
|
||||
if (!blob || !blob.size) throw new Error('empty image blob')
|
||||
// createImageBitmap 在部分 WebView 上更稳,再回退 blob URL
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
return createImageBitmap(blob).then(function (bitmap) {
|
||||
if (bitmap && bitmap.width && bitmap.height) {
|
||||
resolve({
|
||||
image: bitmap,
|
||||
path: src,
|
||||
width: bitmap.width,
|
||||
height: bitmap.height,
|
||||
revokeUrl: '',
|
||||
})
|
||||
return null
|
||||
}
|
||||
throw new Error('empty bitmap')
|
||||
}).catch(function () {
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
bindImage(objUrl, objUrl)
|
||||
return null
|
||||
})
|
||||
}
|
||||
const objUrl = URL.createObjectURL(blob)
|
||||
bindImage(objUrl, objUrl)
|
||||
return null
|
||||
})
|
||||
.catch(function () {
|
||||
// CORS 拉流失败时退回 <img crossOrigin>
|
||||
bindImage(src, null)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
bindImage(src, null)
|
||||
})
|
||||
}
|
||||
|
||||
function uploadTexture(image, path) {
|
||||
if (!image || !(image.width || image.naturalWidth) || !(image.height || image.naturalHeight)) {
|
||||
throw new Error('uploadTexture: image not ready')
|
||||
}
|
||||
const srcW = image.naturalWidth || image.width || 1
|
||||
const srcH = image.naturalHeight || image.height || 1
|
||||
let drawW = srcW
|
||||
let drawH = srcH
|
||||
const longest = Math.max(srcW, srcH)
|
||||
if (longest > maxTexSize) {
|
||||
const s = maxTexSize / longest
|
||||
drawW = Math.max(1, Math.round(srcW * s))
|
||||
drawH = Math.max(1, Math.round(srcH * s))
|
||||
}
|
||||
|
||||
const tex = createEmptyTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
try {
|
||||
if ((drawW !== srcW || drawH !== srcH) && sampleCanvas && scaleCtx) {
|
||||
sampleCanvas.width = drawW
|
||||
sampleCanvas.height = drawH
|
||||
scaleCtx.clearRect(0, 0, drawW, drawH)
|
||||
scaleCtx.drawImage(image, 0, 0, drawW, drawH)
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
gl.RGBA,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
sampleCanvas
|
||||
)
|
||||
} else {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image)
|
||||
} catch (e2) {
|
||||
gl.bindTexture(gl.TEXTURE_2D, null)
|
||||
throw e2
|
||||
}
|
||||
}
|
||||
gl.bindTexture(gl.TEXTURE_2D, null)
|
||||
|
||||
return {
|
||||
texture: tex,
|
||||
width: srcW,
|
||||
height: srcH,
|
||||
aspect: srcW / Math.max(srcH, 1),
|
||||
path: path,
|
||||
}
|
||||
}
|
||||
|
||||
function get(src) {
|
||||
return cache[src] || null
|
||||
}
|
||||
|
||||
function load(src, options) {
|
||||
options = options || {}
|
||||
if (!src) return Promise.reject(new Error('empty src'))
|
||||
if (cache[src] && cache[src].pointCloudBuffer) {
|
||||
return Promise.resolve(cache[src])
|
||||
}
|
||||
if (cache[src] && cache[src]._promise) {
|
||||
return cache[src]._promise
|
||||
}
|
||||
|
||||
const removeBackground = options.removeBackground !== false
|
||||
const logoCached = logoCloudCache.getCachedLogoCloud()
|
||||
const isLogoSrc =
|
||||
!!logoCached && logoCached.src === src
|
||||
|
||||
// Logo 已在首页预采样:立刻上传 GPU buffer,纹理后台补齐
|
||||
if (isLogoSrc && logoCached.cloud && logoCached.cloud.data) {
|
||||
const entry = {
|
||||
_promise: null,
|
||||
texture: null,
|
||||
pointCloud: null,
|
||||
pointCloudBuffer: null,
|
||||
preloadStatus: 'loading',
|
||||
}
|
||||
try {
|
||||
entry.pointCloud = {
|
||||
data: logoCached.cloud.data,
|
||||
count: logoCached.cloud.count,
|
||||
maxPoints: logoCached.cloud.maxPoints || maxPoints,
|
||||
aspect: logoCached.cloud.aspect || logoCached.aspect || 1,
|
||||
}
|
||||
entry.pointCloudBuffer = uploadCloudBuffer(entry.pointCloud)
|
||||
entry.width = logoCached.width || 1
|
||||
entry.height = logoCached.height || 1
|
||||
entry.aspect =
|
||||
logoCached.aspect || entry.width / Math.max(entry.height, 1)
|
||||
entry.preloadStatus = 'ready'
|
||||
cache[src] = entry
|
||||
|
||||
loadImage(src)
|
||||
.then(function (res) {
|
||||
if (!res.image || !res.width || !res.height) return
|
||||
try {
|
||||
const uploaded = uploadTexture(res.image, res.path)
|
||||
entry.image = res.image
|
||||
entry.texture = uploaded.texture
|
||||
entry.width = uploaded.width
|
||||
entry.height = uploaded.height
|
||||
entry.aspect = uploaded.aspect
|
||||
} catch (e) {}
|
||||
})
|
||||
.catch(function () {})
|
||||
|
||||
return Promise.resolve(entry)
|
||||
} catch (e) {
|
||||
delete cache[src]
|
||||
}
|
||||
}
|
||||
|
||||
const entry = {
|
||||
_promise: null,
|
||||
texture: null,
|
||||
pointCloud: null,
|
||||
pointCloudBuffer: null,
|
||||
preloadStatus: 'loading',
|
||||
}
|
||||
cache[src] = entry
|
||||
|
||||
entry._promise = loadImage(src)
|
||||
.then(function (res) {
|
||||
entry.image = res.image
|
||||
entry.path = res.path
|
||||
if (!res.image || !res.width || !res.height) {
|
||||
throw new Error('image not ready')
|
||||
}
|
||||
const uploaded = uploadTexture(res.image, res.path)
|
||||
entry.texture = uploaded.texture
|
||||
entry.width = uploaded.width
|
||||
entry.height = uploaded.height
|
||||
entry.aspect = uploaded.aspect
|
||||
|
||||
let cloud = sampler.sample(entry, {
|
||||
maxPoints: maxPoints,
|
||||
maxSide: sampleSide,
|
||||
removeBackground: removeBackground,
|
||||
})
|
||||
// 采点过稀:降分辨率再采,仍保持去背景(效果图不要背景点云)
|
||||
if (!cloud || cloud.count < Math.max(40, maxPoints * 0.01)) {
|
||||
cloud = sampler.sample(entry, {
|
||||
maxPoints: maxPoints,
|
||||
maxSide: Math.min(sampleSide, POINT_CLOUD.SAMPLE_LOW),
|
||||
removeBackground: removeBackground,
|
||||
softKnockout: true,
|
||||
})
|
||||
}
|
||||
if (!cloud || cloud.count < Math.max(80, Math.floor(maxPoints * 0.02))) {
|
||||
throw new Error('point cloud sample too sparse: ' + (cloud && cloud.count))
|
||||
}
|
||||
entry.pointCloud = cloud
|
||||
entry.pointCloudBuffer = uploadCloudBuffer(cloud)
|
||||
entry.preloadStatus = 'ready'
|
||||
entry._promise = null
|
||||
|
||||
if (src === logoCloudCache.getLogoCloudSrc() || src === PATHS.LOGO) {
|
||||
logoCloudCache.putCachedLogoCloud({
|
||||
src,
|
||||
width: entry.width,
|
||||
height: entry.height,
|
||||
aspect: entry.aspect,
|
||||
maxPoints: cloud.maxPoints,
|
||||
cloud: cloud,
|
||||
viewportW: viewportW,
|
||||
viewportH: viewportH,
|
||||
})
|
||||
}
|
||||
|
||||
return entry
|
||||
})
|
||||
.catch(function (err) {
|
||||
entry.preloadStatus = 'error'
|
||||
delete cache[src]
|
||||
throw err
|
||||
})
|
||||
|
||||
return entry._promise
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台:API Mask 图 → 人物点云 Buffer(失败返回 null,不抛)
|
||||
* 有 maskUrl 时不再做启发式抠图
|
||||
* @param {string} src 源图 key
|
||||
* @param {{ maskUrl?: string }} [options]
|
||||
*/
|
||||
function prepareSubject(src, options) {
|
||||
options = options || {}
|
||||
const maskUrl = options.maskUrl || ''
|
||||
if (!src || !maskUrl) return Promise.resolve(null)
|
||||
|
||||
const cachedEntry = cache[src]
|
||||
if (
|
||||
cachedEntry &&
|
||||
cachedEntry.subjectCloudBuffer &&
|
||||
cachedEntry._maskUrl === maskUrl
|
||||
) {
|
||||
return Promise.resolve(cachedEntry)
|
||||
}
|
||||
if (
|
||||
cachedEntry &&
|
||||
cachedEntry._subjectPromise &&
|
||||
cachedEntry._maskUrl === maskUrl
|
||||
) {
|
||||
return cachedEntry._subjectPromise
|
||||
}
|
||||
if (cachedEntry && cachedEntry._maskUrl !== maskUrl) {
|
||||
cachedEntry.subjectCloudBuffer = null
|
||||
cachedEntry.subjectMask = null
|
||||
cachedEntry.outlineMask = null
|
||||
cachedEntry._subjectPromise = null
|
||||
}
|
||||
|
||||
const run = function (entry) {
|
||||
if (!entry || !entry.image) {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
const t0 = Date.now()
|
||||
entry._maskUrl = maskUrl
|
||||
entry._subjectPromise = loadImage(maskUrl)
|
||||
.then(function (maskRes) {
|
||||
if (!maskRes || !maskRes.image) {
|
||||
entry.subjectFailed = true
|
||||
entry._subjectPromise = null
|
||||
return null
|
||||
}
|
||||
const sw = entry.width || entry.image.width || 0
|
||||
const sh = entry.height || entry.image.height || 0
|
||||
const mw = maskRes.width || maskRes.image.width || 0
|
||||
const mh = maskRes.height || maskRes.image.height || 0
|
||||
const aligned = maskProcessor.resolveAlignedSize(
|
||||
sw,
|
||||
sh,
|
||||
mw,
|
||||
mh,
|
||||
maskSide
|
||||
)
|
||||
if (!aligned) {
|
||||
entry.subjectFailed = true
|
||||
entry._subjectPromise = null
|
||||
return null
|
||||
}
|
||||
const mask = maskProcessor.decodeMaskImage(
|
||||
maskRes.image,
|
||||
scaleCtx,
|
||||
sampleCanvas,
|
||||
aligned.width,
|
||||
aligned.height
|
||||
)
|
||||
const detectMs = Date.now() - t0
|
||||
if (!mask) {
|
||||
entry.subjectFailed = true
|
||||
entry._subjectPromise = null
|
||||
return null
|
||||
}
|
||||
const t1 = Date.now()
|
||||
const outline = buildOutlineMask(mask)
|
||||
const maskMs = Date.now() - t1
|
||||
try {
|
||||
const t2 = Date.now()
|
||||
// 颜色采样与 Mask 使用同一逻辑边长,保证坐标对齐
|
||||
const cloud = sampler.sample(entry, {
|
||||
maxPoints: subjectPoints,
|
||||
maxSide: Math.max(aligned.width, aligned.height),
|
||||
removeBackground: false,
|
||||
subjectMask: mask,
|
||||
outlineMask: outline,
|
||||
maskMode: 'subject',
|
||||
forceSampleSize: {
|
||||
width: aligned.width,
|
||||
height: aligned.height,
|
||||
},
|
||||
})
|
||||
const sampleMs = Date.now() - t2
|
||||
const t3 = Date.now()
|
||||
entry.subjectMask = mask
|
||||
entry.outlineMask = outline
|
||||
entry.subjectCloudBuffer = uploadCloudBuffer(cloud)
|
||||
const uploadMs = Date.now() - t3
|
||||
entry.subjectCloud = {
|
||||
count: cloud.count,
|
||||
maxPoints: cloud.maxPoints,
|
||||
aspect: cloud.aspect,
|
||||
}
|
||||
entry.subjectFailed = false
|
||||
entry._subjectPromise = null
|
||||
if (_perfLog) {
|
||||
console.log(
|
||||
'[PointCloud] apiMask:' +
|
||||
detectMs +
|
||||
'ms outline:' +
|
||||
maskMs +
|
||||
'ms sample:' +
|
||||
sampleMs +
|
||||
'ms upload:' +
|
||||
uploadMs +
|
||||
'ms particles:' +
|
||||
cloud.count +
|
||||
' src:' +
|
||||
sw +
|
||||
'x' +
|
||||
sh +
|
||||
' mask:' +
|
||||
mw +
|
||||
'x' +
|
||||
mh +
|
||||
' target:' +
|
||||
aligned.width +
|
||||
'x' +
|
||||
aligned.height +
|
||||
' quality:' +
|
||||
quality
|
||||
)
|
||||
}
|
||||
return entry
|
||||
} catch (err) {
|
||||
entry.subjectFailed = true
|
||||
entry._subjectPromise = null
|
||||
return null
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
entry.subjectFailed = true
|
||||
entry._subjectPromise = null
|
||||
return null
|
||||
})
|
||||
return entry._subjectPromise
|
||||
}
|
||||
|
||||
if (cachedEntry && cachedEntry.image) {
|
||||
return run(cachedEntry)
|
||||
}
|
||||
|
||||
return load(src, { removeBackground: true }).then(function (entry) {
|
||||
return run(entry)
|
||||
})
|
||||
}
|
||||
|
||||
function getSubject(src) {
|
||||
const e = cache[src]
|
||||
if (!e || !e.subjectCloudBuffer) return null
|
||||
return e
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
Object.keys(cache).forEach(function (key) {
|
||||
const e = cache[key]
|
||||
if (e && e.texture) {
|
||||
try {
|
||||
gl.deleteTexture(e.texture)
|
||||
} catch (err) {}
|
||||
}
|
||||
if (e && e.pointCloudBuffer && e.pointCloudBuffer.buffer) {
|
||||
try {
|
||||
gl.deleteBuffer(e.pointCloudBuffer.buffer)
|
||||
} catch (err) {}
|
||||
}
|
||||
if (e && e.subjectCloudBuffer && e.subjectCloudBuffer.buffer) {
|
||||
try {
|
||||
gl.deleteBuffer(e.subjectCloudBuffer.buffer)
|
||||
} catch (err) {}
|
||||
}
|
||||
if (e) {
|
||||
e.image = null
|
||||
e.subjectMask = null
|
||||
e.outlineMask = null
|
||||
e.subjectCloud = null
|
||||
e.pointCloud = null
|
||||
e._promise = null
|
||||
e._subjectPromise = null
|
||||
}
|
||||
delete cache[key]
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
load: load,
|
||||
get: get,
|
||||
prepareSubject: prepareSubject,
|
||||
getSubject: getSubject,
|
||||
setMaxSize: setMaxSize,
|
||||
destroy: destroy,
|
||||
createEmptyTexture: createEmptyTexture,
|
||||
getMaxPoints: function () {
|
||||
return maxPoints
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
createTextureManager,
|
||||
}
|
||||
@@ -0,0 +1,945 @@
|
||||
<template>
|
||||
<div class="gen-page" :class="{ 'is-result': showResult }">
|
||||
<!-- ===== 生成中 ===== -->
|
||||
<template v-if="!showResult">
|
||||
<DyNavBar
|
||||
theme="dark"
|
||||
title="生成中"
|
||||
back-text="‹ 返回"
|
||||
right-text="记录"
|
||||
border
|
||||
@back="goHome"
|
||||
@right="goRecords"
|
||||
/>
|
||||
|
||||
<div class="stage-area">
|
||||
<div class="stage-center">
|
||||
<div class="stage-shell" :style="shellStyle">
|
||||
<div class="stage-box" :style="stageStyle">
|
||||
<div class="stage-layer">
|
||||
<ImagePointCloud
|
||||
:key="`${stageWidth}x${stageHeight}`"
|
||||
point-shape="circle"
|
||||
:width="stageWidth"
|
||||
:height="stageHeight"
|
||||
:fading="cloudFading"
|
||||
:image-url="sourceUrl"
|
||||
:mask-url="maskUrl"
|
||||
:result-url="resultUrl"
|
||||
@phasechange="onPhaseChange"
|
||||
@handoff="onHandoff"
|
||||
/>
|
||||
</div>
|
||||
<!-- 效果图点云阶段不叠底图,等揭示后再进预览页展示 -->
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="errorTip" class="error-tip">{{ errorTip }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-dock">
|
||||
<div class="bar"><div class="inner" :style="{ width: `${progress}%` }"></div></div>
|
||||
<div class="meta">
|
||||
<div>
|
||||
<div class="tip">{{ tip }}</div>
|
||||
<div class="sub">{{ subTip }}</div>
|
||||
</div>
|
||||
<span>{{ Math.floor(progress) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ===== 效果预览(对齐设计图) ===== -->
|
||||
<template v-else>
|
||||
<DyNavBar
|
||||
theme="light"
|
||||
title="效果预览"
|
||||
right-text="生图记录"
|
||||
border
|
||||
@back="goBack"
|
||||
@right="goRecords"
|
||||
/>
|
||||
|
||||
<div class="result-body">
|
||||
<div class="result-head">
|
||||
<div class="status-row">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">生成完成</span>
|
||||
</div>
|
||||
<h1 class="headline">专属效果已就绪</h1>
|
||||
<p class="subline">确认满意后即可提交制作</p>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" @click="openZoom">
|
||||
<img v-if="resultUrl" class="preview-img" :src="resultUrl" alt="效果图">
|
||||
<div v-if="sourceUrl" class="source-polaroid" aria-hidden="true">
|
||||
<img :src="sourceUrl" alt="">
|
||||
</div>
|
||||
<button class="zoom-btn" type="button" @click.stop="openZoom">点击放大</button>
|
||||
</div>
|
||||
|
||||
<p class="save-hint">效果已保存到生图记录 · 可随时回来查看</p>
|
||||
<OrderSizeQtyPanel ref="orderPanelRef" @change="onOrderChange" />
|
||||
<div v-if="errorTip" class="error-tip light">{{ errorTip }}</div>
|
||||
</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="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>
|
||||
</Teleport>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { showImagePreview, showToast } from 'vant'
|
||||
import * as douyinApi from '@/api/douyin'
|
||||
import { douyinConfig as config } from '@/config/douyin'
|
||||
import ImagePointCloud from './components/ImagePointCloud.vue'
|
||||
import DyNavBar from './components/DyNavBar.vue'
|
||||
import OrderSizeQtyPanel from './components/OrderSizeQtyPanel.vue'
|
||||
import { takePendingUpload } from './pendingUpload'
|
||||
|
||||
function isSubmittedBizError(msg: string) {
|
||||
return /已提交制作|不能继续上传|不能继续生图/.test(String(msg || ''))
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const taskId = ref(String(route.query.task_id || sessionStorage.getItem('douyin_task_id') || ''))
|
||||
const pid = ref(String(route.query.pid || sessionStorage.getItem('douyin_pid') || ''))
|
||||
const sourceUrl = ref(sessionStorage.getItem('douyin_source_preview') || '')
|
||||
const maskUrl = ref('')
|
||||
const resultUrl = ref('')
|
||||
const imageId = ref('')
|
||||
const tip = ref('准备中')
|
||||
const subTip = ref('正在启动')
|
||||
const progress = ref(2)
|
||||
const showResult = ref(false)
|
||||
const cloudFading = ref(false)
|
||||
const ordering = ref(false)
|
||||
const errorTip = ref('')
|
||||
const showConfirmModal = ref(false)
|
||||
const orderPanelRef = ref<InstanceType<typeof OrderSizeQtyPanel> | null>(null)
|
||||
const orderQty = ref(1)
|
||||
const orderSize = ref('')
|
||||
|
||||
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 || '')
|
||||
}
|
||||
|
||||
/** 上传图宽高比(裁剪后默认 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: `${stageWidth.value}px`,
|
||||
height: `${stageHeight.value}px`,
|
||||
}))
|
||||
const shellStyle = computed(() => ({
|
||||
width: showResult.value ? `${stageWidth.value + 32}px` : `${stageWidth.value}px`,
|
||||
}))
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let softTimer: ReturnType<typeof setInterval> | null = null
|
||||
let handoffDone = false
|
||||
let maskAborted = false
|
||||
let uploading = false
|
||||
let fadeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function 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() {
|
||||
refreshStageSize()
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.replace(config.routes.home)
|
||||
}
|
||||
|
||||
/** 预览顶栏返回:有历史则回上一页,否则回首页 */
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.replace(config.routes.home)
|
||||
}
|
||||
|
||||
function goRecords() {
|
||||
router.push(config.routes.records)
|
||||
}
|
||||
|
||||
let progressAnimTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function clearTimers() {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (softTimer) clearInterval(softTimer)
|
||||
if (fadeTimer) clearTimeout(fadeTimer)
|
||||
if (progressAnimTimer) clearInterval(progressAnimTimer)
|
||||
pollTimer = null
|
||||
softTimer = null
|
||||
fadeTimer = null
|
||||
progressAnimTimer = null
|
||||
}
|
||||
|
||||
function onPhaseChange(payload: { phase: string; tip: string; subTip: string }) {
|
||||
tip.value = payload.tip || tip.value
|
||||
subTip.value = payload.subTip || subTip.value
|
||||
const phase = payload.phase
|
||||
if (phase?.startsWith('SUBJECT')) progress.value = Math.max(progress.value, 58)
|
||||
if (phase?.startsWith('RESULT_CLOUD')) progress.value = Math.max(progress.value, 90)
|
||||
if (phase === 'RESULT_IMAGE_REVEAL' && resultUrl.value) {
|
||||
startSmoothReveal()
|
||||
}
|
||||
if (phase === 'RESULT_IMAGE_REVEAL' || phase === 'RESULT_PREVIEW' || phase === 'NAVIGATING') {
|
||||
// 预览前由 startSmoothReveal 动画到 100,这里兜底
|
||||
if (showResult.value) progress.value = 100
|
||||
}
|
||||
}
|
||||
|
||||
/** 平滑加速到目标进度 */
|
||||
function animateProgressTo(target: number, durationMs = 480): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (progressAnimTimer) {
|
||||
clearInterval(progressAnimTimer)
|
||||
progressAnimTimer = null
|
||||
}
|
||||
const from = progress.value
|
||||
const to = Math.max(from, Math.min(100, target))
|
||||
if (to <= from + 0.2) {
|
||||
progress.value = to
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const start = Date.now()
|
||||
progressAnimTimer = setInterval(() => {
|
||||
const t = Math.min(1, (Date.now() - start) / durationMs)
|
||||
// ease-out
|
||||
const eased = 1 - (1 - t) * (1 - t)
|
||||
progress.value = from + (to - from) * eased
|
||||
if (t >= 1) {
|
||||
if (progressAnimTimer) clearInterval(progressAnimTimer)
|
||||
progressAnimTimer = null
|
||||
progress.value = to
|
||||
resolve()
|
||||
}
|
||||
}, 32)
|
||||
})
|
||||
}
|
||||
|
||||
function startSmoothReveal() {
|
||||
if (showResult.value || !resultUrl.value) return
|
||||
// 先把进度条跑到 100%,再切效果预览,保证用户能看到满格
|
||||
animateProgressTo(100, 520).then(() => {
|
||||
if (showResult.value) return
|
||||
cloudFading.value = true
|
||||
requestAnimationFrame(() => {
|
||||
showResult.value = true
|
||||
})
|
||||
sessionStorage.setItem('douyin_task_id', taskId.value)
|
||||
sessionStorage.setItem('douyin_image_id', imageId.value)
|
||||
sessionStorage.setItem('douyin_result_url', resultUrl.value)
|
||||
})
|
||||
}
|
||||
|
||||
function enterResultView() {
|
||||
if (handoffDone) return
|
||||
handoffDone = true
|
||||
// 停轮询/软进度,但保留进度冲满动画
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (softTimer) {
|
||||
clearInterval(softTimer)
|
||||
softTimer = null
|
||||
}
|
||||
if (fadeTimer) {
|
||||
clearTimeout(fadeTimer)
|
||||
fadeTimer = null
|
||||
}
|
||||
startSmoothReveal()
|
||||
}
|
||||
|
||||
function onHandoff() {
|
||||
enterResultView()
|
||||
}
|
||||
|
||||
function startSoftProgress() {
|
||||
// 固定节奏:每 300ms +1%,效果图就绪后冲到 96%,预览前再动画到 100%
|
||||
softTimer = setInterval(() => {
|
||||
if (showResult.value) return
|
||||
const cap = resultUrl.value ? 96 : config.poll.softCap
|
||||
if (progress.value >= cap) return
|
||||
progress.value = Math.min(cap, progress.value + 1)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function startMaskFetch() {
|
||||
if (!pid.value) return
|
||||
maskAborted = false
|
||||
douyinApi
|
||||
.waitMaskUrl({ pid: pid.value, intervalMs: 2000, timeoutMs: 60000 })
|
||||
.then((res) => {
|
||||
if (maskAborted || showResult.value) return
|
||||
if (res && res.url) {
|
||||
maskUrl.value = res.url
|
||||
sessionStorage.setItem('douyin_mask_url', res.url)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
function pollStatus() {
|
||||
if (!taskId.value) return
|
||||
douyinApi.getImageGenStatus(taskId.value).then((res: any) => {
|
||||
const parsed = douyinApi.parseImageGenStatus(res, taskId.value)
|
||||
if (parsed.status === 'failed') {
|
||||
clearTimers()
|
||||
errorTip.value = parsed.message || '生成失败,请返回重试'
|
||||
return
|
||||
}
|
||||
if (parsed.status === 'success' && parsed.resultUrl) {
|
||||
resultUrl.value = parsed.resultUrl
|
||||
// make/submit 必须用效果图 image_id,禁止回退成 task_id
|
||||
if (parsed.imageId) {
|
||||
imageId.value = parsed.imageId
|
||||
sessionStorage.setItem('douyin_image_id', parsed.imageId)
|
||||
}
|
||||
// 不再 10s 强切预览:会掐掉 RESULT_CLOUD_* 效果图点云段
|
||||
// 正常走点云状态机 handoff;仅作兜底(给足灰/彩点云播放时间)
|
||||
if (fadeTimer) clearTimeout(fadeTimer)
|
||||
fadeTimer = setTimeout(() => {
|
||||
if (!handoffDone) enterResultView()
|
||||
}, 60000)
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function beginPendingUpload() {
|
||||
if (uploading) return
|
||||
const { file, preview } = takePendingUpload()
|
||||
if (!file) {
|
||||
errorTip.value = '未找到待上传图片,请返回重新选择'
|
||||
return
|
||||
}
|
||||
uploading = true
|
||||
if (preview) {
|
||||
sourceUrl.value = preview
|
||||
syncImageAspect(preview)
|
||||
}
|
||||
tip.value = '上传中'
|
||||
subTip.value = '请稍候'
|
||||
progress.value = Math.max(progress.value, 8)
|
||||
|
||||
try {
|
||||
const res = await douyinApi.startGeneratePipeline(file, {
|
||||
filename: file.name,
|
||||
content_type: file.type || 'image/jpeg',
|
||||
style: config.messages.effectTitle,
|
||||
n: 1,
|
||||
})
|
||||
taskId.value = res.taskId
|
||||
if (res.pid != null) {
|
||||
pid.value = String(res.pid)
|
||||
sessionStorage.setItem('douyin_pid', String(res.pid))
|
||||
}
|
||||
sessionStorage.setItem('douyin_task_id', res.taskId)
|
||||
if (res.upload_id) sessionStorage.setItem('douyin_upload_id', res.upload_id)
|
||||
if (res.file_url) {
|
||||
sessionStorage.setItem('douyin_file_url', res.file_url)
|
||||
sourceUrl.value = res.file_url
|
||||
}
|
||||
tip.value = '生成中'
|
||||
subTip.value = '请稍候'
|
||||
progress.value = Math.max(progress.value, 18)
|
||||
startMaskFetch()
|
||||
pollStatus()
|
||||
pollTimer = setInterval(pollStatus, config.poll.interval)
|
||||
} catch (err: any) {
|
||||
const msg = err?.message || '上传失败,请返回重试'
|
||||
errorTip.value = msg
|
||||
// 已提交制作:不停留等待页,标记锁定并回首页
|
||||
if (isSubmittedBizError(msg)) {
|
||||
showToast(msg)
|
||||
setTimeout(() => {
|
||||
router.replace(config.routes.home)
|
||||
}, 600)
|
||||
}
|
||||
} finally {
|
||||
uploading = false
|
||||
}
|
||||
}
|
||||
|
||||
function openZoom() {
|
||||
if (!resultUrl.value) return
|
||||
showImagePreview({
|
||||
images: [resultUrl.value],
|
||||
closeable: true,
|
||||
closeIconPosition: 'top-right',
|
||||
})
|
||||
}
|
||||
|
||||
function openConfirmModal() {
|
||||
if (ordering.value || !taskId.value) return
|
||||
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(() => {
|
||||
syncImageAspect(sourceUrl.value)
|
||||
window.addEventListener('resize', onResize)
|
||||
startSoftProgress()
|
||||
|
||||
const pending = route.query.pending === '1'
|
||||
if (pending) {
|
||||
beginPendingUpload()
|
||||
return
|
||||
}
|
||||
|
||||
if (!taskId.value) {
|
||||
errorTip.value = '缺少任务参数,请返回重新生成'
|
||||
return
|
||||
}
|
||||
const fileUrl = sessionStorage.getItem('douyin_file_url')
|
||||
if (fileUrl) {
|
||||
sourceUrl.value = fileUrl
|
||||
syncImageAspect(fileUrl)
|
||||
}
|
||||
startMaskFetch()
|
||||
pollStatus()
|
||||
pollTimer = setInterval(pollStatus, config.poll.interval)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
maskAborted = true
|
||||
clearTimers()
|
||||
window.removeEventListener('resize', onResize)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.gen-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: #000000;
|
||||
color: #f2f2f2;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
transition: background-color 0.85s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.gen-page.is-result {
|
||||
background: #ffffff;
|
||||
color: #1a1a1a;
|
||||
padding-bottom: calc(108px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.stage-area {
|
||||
position: relative;
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.stage-center {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 46%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.stage-shell {
|
||||
margin: 0 auto;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stage-box {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
.stage-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stage-layer > :deep(.ipc) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.error-tip {
|
||||
margin-top: 12px;
|
||||
max-width: 280px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #ff8a8a;
|
||||
}
|
||||
|
||||
.error-tip.light {
|
||||
color: #e05a5a;
|
||||
margin: 8px auto 0;
|
||||
}
|
||||
|
||||
.progress-dock {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
padding: 16px 24px calc(20px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, transparent 0%, rgba(0, 0, 0, 0.88) 40%);
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.inner {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #8fd96f 0%, #5fbf45 100%);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
|
||||
.tip {
|
||||
font-size: 14px;
|
||||
color: #f2f2f2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* —— 效果预览(设计图) —— */
|
||||
.result-body {
|
||||
padding: 4px 18px 24px;
|
||||
animation: resultIn 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes resultIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.result-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #5fbf45;
|
||||
box-shadow: 0 0 0 3px rgba(95, 191, 69, 0.22);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5fbf45;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.subline {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #8a9586;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 3.55;
|
||||
max-height: 58vh;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.source-polaroid {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 18%;
|
||||
width: 72px;
|
||||
padding: 5px 5px 14px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
transform: rotate(-8deg);
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.source-polaroid img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 3;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
background: rgba(40, 44, 48, 0.55);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.save-hint {
|
||||
margin: 14px 0 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #9aa394;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* Teleport 到 body:相对视口固定在全屏底部(与 preview 共用类名) */
|
||||
.dy-result-footer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
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%);
|
||||
}
|
||||
|
||||
.dy-result-footer__remake {
|
||||
flex: 0 0 108px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: #e2eedd;
|
||||
color: #4cae3a;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dy-result-footer__submit {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(90deg, #7ed45a 0%, #5fbf45 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.35);
|
||||
}
|
||||
|
||||
.dy-result-footer__submit:disabled {
|
||||
opacity: 0.55;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dy-result-confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dy-result-confirm-sheet {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 6px 8px 0 rgba(0, 0, 0, 0.55);
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.dy-result-confirm-title {
|
||||
background: #5fbf45;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.dy-result-confirm-body {
|
||||
padding: 20px 18px 8px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dy-result-confirm-sub {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.dy-result-confirm-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 18px;
|
||||
}
|
||||
|
||||
.dy-result-confirm-cancel,
|
||||
.dy-result-confirm-ok {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
border-radius: 21px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dy-result-confirm-cancel {
|
||||
border: 1.5px solid #8fd96f;
|
||||
background: #f3faf0;
|
||||
color: #5fbf45;
|
||||
}
|
||||
|
||||
.dy-result-confirm-ok {
|
||||
border: none;
|
||||
background: #5fbf45;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dy-result-confirm-ok:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,805 @@
|
||||
<template>
|
||||
<div class="dy-page">
|
||||
<DyNavBar
|
||||
theme="light"
|
||||
:show-back="false"
|
||||
right-text="生成记录"
|
||||
border
|
||||
@right="goRecords"
|
||||
>
|
||||
<template #left>
|
||||
<div class="brand">
|
||||
<img class="brand-logo" :src="brandLogo" alt="">
|
||||
<span class="brand-name">{{ brandName }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 首页中间不放标题,品牌在左侧 -->
|
||||
<span />
|
||||
</DyNavBar>
|
||||
|
||||
<div class="content">
|
||||
<div class="hero">
|
||||
<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">
|
||||
<span>选图</span><span class="dot">·</span>
|
||||
<span>生成</span><span class="dot">·</span>
|
||||
<span>提交</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ref-hint" @click="showEffect = true">
|
||||
<img class="ref-thumb" :src="config.images.effectRef" alt="">
|
||||
<div class="ref-body">
|
||||
<div class="ref-label">{{ config.messages.effectLabel }}</div>
|
||||
<div class="ref-title">{{ config.messages.effectTitle }}</div>
|
||||
</div>
|
||||
<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">
|
||||
<img v-if="photoPreview" :src="photoPreview" class="photo-preview" alt="">
|
||||
<div v-else class="empty">
|
||||
<div class="plus">+</div>
|
||||
<div>{{ config.messages.addPhoto }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden-input"
|
||||
@change="onFileChange"
|
||||
>
|
||||
</div>
|
||||
</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 { 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'
|
||||
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)
|
||||
|
||||
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 = ''
|
||||
|
||||
const remainTotal = orderRemainTotal()
|
||||
if (remainTotal <= 0) {
|
||||
orderLocked.value = true
|
||||
lockReason.value = config.messages.noOrderRemain
|
||||
return
|
||||
}
|
||||
|
||||
// 可生图次数仍看 orders/stat.remain_count
|
||||
if (orderStat.value?.remain_count != null && Number(orderStat.value.remain_count) <= 0) {
|
||||
orderLocked.value = true
|
||||
lockReason.value = config.messages.noRemain
|
||||
}
|
||||
}
|
||||
|
||||
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 || 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) => {
|
||||
orderStat.value = res || {}
|
||||
}).catch(() => {
|
||||
// stat 仅用于可生图次数展示;失败不单独锁下单
|
||||
orderStat.value = {}
|
||||
})
|
||||
}
|
||||
|
||||
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(() => {
|
||||
// 失败时保留默认品牌
|
||||
})
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
if (file.size > config.upload.maxSize) {
|
||||
showToast(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`)
|
||||
return
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
const img = new Image()
|
||||
img.src = objectUrl
|
||||
img.onload = () => {
|
||||
const w = img.naturalWidth
|
||||
const h = img.naturalHeight
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
if (w < config.upload.minWidth || h < config.upload.minHeight) {
|
||||
showToast(`请上传尺寸大于${config.upload.minWidth}×${config.upload.minHeight}像素的照片`)
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = (ev) => {
|
||||
const dataUrl = String(ev.target?.result || '')
|
||||
if (!dataUrl) {
|
||||
showToast('图片读取失败')
|
||||
return
|
||||
}
|
||||
nextTick(() => openCropping(dataUrl))
|
||||
}
|
||||
reader.onerror = () => showToast('图片读取失败')
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
showToast('图片加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value) return
|
||||
if (!photoFile.value || !photoPreview.value) {
|
||||
showToast(config.messages.pleaseUpload)
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
// 提交前重新拉额度,不依赖本地缓存 / 页面内存旧值
|
||||
await refreshQuota()
|
||||
if (orderLocked.value) {
|
||||
submitting.value = false
|
||||
showToast(lockReason.value || config.messages.noOrderRemain)
|
||||
return
|
||||
}
|
||||
|
||||
// 先跳等待页,后台再上传/生图(不弹成功失败 toast)
|
||||
setPendingUpload(photoFile.value, photoPreview.value)
|
||||
sessionStorage.setItem('douyin_source_preview', photoPreview.value)
|
||||
router.push({
|
||||
path: config.routes.generating,
|
||||
query: { pending: '1' },
|
||||
})
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 清理历史本地标记(锁定状态改为看 remaining)
|
||||
try {
|
||||
sessionStorage.removeItem('douyin_order_submitted')
|
||||
} catch (_) {}
|
||||
applyDocumentBrand(brandName.value, brandLogo.value, '首页')
|
||||
// 先用缓存门店 logo(或默认)预采样;shopBrand 返回后再按新 logo 刷新
|
||||
preloadLogoCloud({ src: brandLogo.value }).catch(() => {})
|
||||
fetchShopBrand()
|
||||
refreshQuota()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
setBodyScrollLocked(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dy-page {
|
||||
min-height: 100vh;
|
||||
background: #ffffff;
|
||||
padding-bottom: 120px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-left: 4px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 4px 16px 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 2px 0 20px;
|
||||
}
|
||||
|
||||
.hero-img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #1d2b1a;
|
||||
}
|
||||
|
||||
.hero-hint {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #6b7a66;
|
||||
}
|
||||
|
||||
.steps {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #8aa083;
|
||||
.dot { margin: 0 6px; }
|
||||
}
|
||||
|
||||
.ref-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.ref-thumb {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
background: #eef5ea;
|
||||
}
|
||||
|
||||
.ref-label {
|
||||
font-size: 12px;
|
||||
color: #8aa083;
|
||||
}
|
||||
|
||||
.ref-title {
|
||||
margin-top: 2px;
|
||||
font-size: 15px;
|
||||
color: #222;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ref-arrow {
|
||||
margin-left: auto;
|
||||
color: #999;
|
||||
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;
|
||||
padding: 16px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.guide {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.photo-slot {
|
||||
width: 100%;
|
||||
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: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #7d8f78;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.plus {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 auto 10px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #5fbf45;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 30px;
|
||||
color: #5fbf45;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #9aab94;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.effect-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 24px;
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</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>
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 首页选图后先跳等待页,用内存暂存待上传文件(避免大图塞 sessionStorage) */
|
||||
|
||||
let pendingFile: File | null = null
|
||||
let pendingPreview = ''
|
||||
|
||||
export function setPendingUpload(file: File, previewDataUrl: string) {
|
||||
pendingFile = file
|
||||
pendingPreview = previewDataUrl || ''
|
||||
}
|
||||
|
||||
export function takePendingUpload() {
|
||||
const file = pendingFile
|
||||
const preview = pendingPreview
|
||||
pendingFile = null
|
||||
pendingPreview = ''
|
||||
return { file, preview }
|
||||
}
|
||||
|
||||
export function peekPendingUpload() {
|
||||
return { file: pendingFile, preview: pendingPreview }
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
<template>
|
||||
<div class="preview-page">
|
||||
<DyNavBar
|
||||
theme="light"
|
||||
title="效果预览"
|
||||
right-text="生图记录"
|
||||
border
|
||||
@back="goBack"
|
||||
@right="goRecords"
|
||||
/>
|
||||
|
||||
<div class="result-body">
|
||||
<div class="result-head">
|
||||
<div class="head-main">
|
||||
<div class="status-row">
|
||||
<span class="status-dot" />
|
||||
<span class="status-text">生成完成</span>
|
||||
</div>
|
||||
<h1 class="headline">专属效果已就绪</h1>
|
||||
<p class="subline">确认满意后即可提交制作</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-card" @click="openZoom">
|
||||
<div v-if="!imageUrl" class="loading-box">
|
||||
<van-loading type="spinner" color="#5fbf45" />
|
||||
<div>加载效果图中...</div>
|
||||
</div>
|
||||
<img v-else class="preview-img" :src="imageUrl" alt="效果图">
|
||||
<button
|
||||
v-if="imageUrl"
|
||||
class="zoom-btn"
|
||||
type="button"
|
||||
@click.stop="openZoom"
|
||||
>
|
||||
点击放大
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="save-hint">效果已保存到生图记录 · 可随时回来查看</p>
|
||||
|
||||
<OrderSizeQtyPanel ref="orderPanelRef" @change="onOrderChange" />
|
||||
</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="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>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
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()
|
||||
|
||||
const taskId = ref(String(route.query.task_id || sessionStorage.getItem('douyin_task_id') || ''))
|
||||
const imageId = ref(String(route.query.image_id || sessionStorage.getItem('douyin_image_id') || ''))
|
||||
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)
|
||||
}
|
||||
|
||||
/** 从哪进来回哪去;无历史时回首页 */
|
||||
function goBack() {
|
||||
const from = String(route.query.from || sessionStorage.getItem('douyin_preview_from') || '')
|
||||
if (from === 'records') {
|
||||
router.replace(config.routes.records)
|
||||
return
|
||||
}
|
||||
if (from === 'home' || from === 'generating') {
|
||||
router.replace(config.routes.home)
|
||||
return
|
||||
}
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
return
|
||||
}
|
||||
router.replace(config.routes.home)
|
||||
}
|
||||
|
||||
function goRecords() {
|
||||
router.push(config.routes.records)
|
||||
}
|
||||
|
||||
function openZoom() {
|
||||
if (!imageUrl.value) return
|
||||
showImagePreview({
|
||||
images: [imageUrl.value],
|
||||
closeable: true,
|
||||
closeIconPosition: 'top-right',
|
||||
})
|
||||
}
|
||||
|
||||
function openConfirmModal() {
|
||||
if (ordering.value || !taskId.value) {
|
||||
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
|
||||
}
|
||||
|
||||
function applyStatus(res: any) {
|
||||
const parsed = douyinApi.parseImageGenStatus(res, taskId.value)
|
||||
if (parsed.resultUrl) imageUrl.value = parsed.resultUrl
|
||||
if (parsed.imageId) imageId.value = parsed.imageId
|
||||
if (parsed.taskId) taskId.value = parsed.taskId
|
||||
if (parsed.status === 'success' && pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function fetchStatus() {
|
||||
if (!taskId.value) return
|
||||
douyinApi.getImageGenStatus(taskId.value).then(applyStatus).catch((err: any) => {
|
||||
showToast(err.message || '获取效果图失败')
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
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,
|
||||
duration: 1800,
|
||||
})
|
||||
setTimeout(() => {
|
||||
router.replace(config.routes.success)
|
||||
}, 800)
|
||||
} catch (err: any) {
|
||||
if (err?.message) showToast(err.message)
|
||||
orderPanelRef.value?.refresh?.()
|
||||
} finally {
|
||||
ordering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!taskId.value && !imageUrl.value) {
|
||||
showToast('缺少预览参数')
|
||||
goHome()
|
||||
return
|
||||
}
|
||||
if (!imageUrl.value || !imageId.value) {
|
||||
fetchStatus()
|
||||
pollTimer = setInterval(fetchStatus, config.poll.interval)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.preview-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: #ffffff;
|
||||
color: #1a1a1a;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(108px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.result-body {
|
||||
padding: 4px 18px 24px;
|
||||
}
|
||||
|
||||
.result-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #5fbf45;
|
||||
box-shadow: 0 0 0 3px rgba(95, 191, 69, 0.22);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #5fbf45;
|
||||
}
|
||||
|
||||
.headline {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.subline {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: #8a9586;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 3.55;
|
||||
max-height: 58vh;
|
||||
border-radius: 28px;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading-box {
|
||||
text-align: center;
|
||||
color: #668;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.zoom-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
z-index: 3;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 7px 12px;
|
||||
background: rgba(40, 44, 48, 0.55);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.save-hint {
|
||||
margin: 14px 0 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #9aa394;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
/* Teleport 到 body:相对视口固定在全屏底部 */
|
||||
.dy-result-footer {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
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%);
|
||||
}
|
||||
|
||||
.dy-result-footer__remake {
|
||||
flex: 0 0 108px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: #e2eedd;
|
||||
color: #4cae3a;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dy-result-footer__submit {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(90deg, #7ed45a 0%, #5fbf45 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.35);
|
||||
}
|
||||
|
||||
.dy-result-footer__submit:disabled {
|
||||
opacity: 0.55;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.dy-result-confirm-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dy-result-confirm-sheet {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 6px 8px 0 rgba(0, 0, 0, 0.55);
|
||||
border: 2px solid #1a1a1a;
|
||||
}
|
||||
|
||||
.dy-result-confirm-title {
|
||||
background: #5fbf45;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.dy-result-confirm-body {
|
||||
padding: 20px 18px 8px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.dy-result-confirm-sub {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.dy-result-confirm-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 18px;
|
||||
}
|
||||
|
||||
.dy-result-confirm-cancel,
|
||||
.dy-result-confirm-ok {
|
||||
flex: 1;
|
||||
height: 42px;
|
||||
border-radius: 21px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dy-result-confirm-cancel {
|
||||
border: 1.5px solid #8fd96f;
|
||||
background: #f3faf0;
|
||||
color: #5fbf45;
|
||||
}
|
||||
|
||||
.dy-result-confirm-ok {
|
||||
border: none;
|
||||
background: #5fbf45;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.dy-result-confirm-ok:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,953 @@
|
||||
<template>
|
||||
<div class="records-page" :class="{ 'is-batch': batchMode }">
|
||||
<DyNavBar
|
||||
theme="light"
|
||||
:title="config.records.messages.title"
|
||||
border
|
||||
@back="goHome"
|
||||
/>
|
||||
|
||||
<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-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>
|
||||
|
||||
<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),
|
||||
'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>
|
||||
|
||||
<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)
|
||||
const loading = ref(false)
|
||||
const finished = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const loadError = ref(false)
|
||||
const total = ref(0)
|
||||
const initialLoaded = ref(false)
|
||||
/** 防止刷新与触底同时打两次接口 */
|
||||
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 || '')
|
||||
}
|
||||
|
||||
function isReady(item: any) {
|
||||
return item.status == config.records.status.success && !!item.path
|
||||
}
|
||||
|
||||
function isPending(item: any) {
|
||||
return item.status == config.records.status.designing
|
||||
}
|
||||
|
||||
function isFailed(item: any) {
|
||||
return !isPending(item) && !isReady(item)
|
||||
}
|
||||
|
||||
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() {
|
||||
router.push(config.routes.home)
|
||||
}
|
||||
|
||||
async function fetchPage() {
|
||||
const res: any = await douyinApi.getImageGenRecords({
|
||||
page: page.value,
|
||||
page_size: config.records.pagination.pageSize,
|
||||
})
|
||||
const normalized = douyinApi.normalizeRecords(res)
|
||||
total.value = normalized.total
|
||||
|
||||
if (page.value === config.records.pagination.initialPage) {
|
||||
images.value = normalized.list
|
||||
} else {
|
||||
images.value.push(...normalized.list)
|
||||
}
|
||||
|
||||
const reachedEnd =
|
||||
!normalized.list.length ||
|
||||
images.value.length >= normalized.total ||
|
||||
normalized.list.length < config.records.pagination.pageSize
|
||||
|
||||
if (reachedEnd) {
|
||||
finished.value = true
|
||||
} else {
|
||||
page.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
/** van-list 触底 / 首次检查时触发 */
|
||||
async function onLoad() {
|
||||
if (finished.value || fetching.value) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
fetching.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
await fetchPage()
|
||||
} catch (err: any) {
|
||||
console.error(msgs.getDataFailed, err)
|
||||
loadError.value = true
|
||||
} finally {
|
||||
fetching.value = false
|
||||
loading.value = false
|
||||
initialLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
refreshing.value = true
|
||||
page.value = config.records.pagination.initialPage
|
||||
finished.value = false
|
||||
loadError.value = false
|
||||
images.value = []
|
||||
Object.keys(brokenMap).forEach((k) => delete brokenMap[k])
|
||||
clearSelection()
|
||||
initialLoaded.value = false
|
||||
await fetchOrderRemaining()
|
||||
loading.value = true
|
||||
await onLoad()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
function goPreview(item: any) {
|
||||
sessionStorage.setItem('douyin_task_id', item.taskId || '')
|
||||
sessionStorage.setItem('douyin_image_id', item.imageId || item.num || '')
|
||||
sessionStorage.setItem('douyin_result_url', item.path || '')
|
||||
router.push({
|
||||
path: config.routes.preview,
|
||||
query: {
|
||||
task_id: item.taskId || '',
|
||||
image_id: item.imageId || item.num || '',
|
||||
result_url: item.path || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function onCardClick(item: any) {
|
||||
if (batchMode.value) {
|
||||
toggleSelect(item)
|
||||
return
|
||||
}
|
||||
if (isReady(item) && !isBroken(item)) {
|
||||
goPreview(item)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearBatchToasts()
|
||||
loading.value = false
|
||||
finished.value = false
|
||||
page.value = config.records.pagination.initialPage
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.records-page {
|
||||
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;
|
||||
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);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 14px;
|
||||
background: #fff;
|
||||
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, border-color 0.18s ease;
|
||||
|
||||
&.is-ready:not(.is-batch) {
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.record-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
vertical-align: top;
|
||||
|
||||
&.is-fallback {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
aspect-ratio: 3 / 4;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
background: #f3f5f2;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.45));
|
||||
line-height: 1.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.record-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(180deg, #eef2ec 0%, #e6ebe4 100%);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.placeholder-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.record-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
min-height: 40px;
|
||||
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;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-action {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #5fbf45;
|
||||
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;
|
||||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.skeleton-media {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
background: linear-gradient(90deg, #ecefec 25%, #f5f7f4 37%, #ecefec 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
}
|
||||
|
||||
.skeleton-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #ecefec 25%, #f5f7f4 37%, #ecefec 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-shimmer 1.4s ease infinite;
|
||||
flex: 1;
|
||||
|
||||
&.short {
|
||||
flex: 0 0 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.record-image-item-null {
|
||||
height: 50vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.record-image-item-null img {
|
||||
width: 80vw;
|
||||
height: 200px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
<template>
|
||||
<div class="success-page">
|
||||
<DyNavBar
|
||||
theme="light"
|
||||
title="提交成功"
|
||||
right-text="生图记录"
|
||||
border
|
||||
@back="goHome"
|
||||
@right="goRecords"
|
||||
/>
|
||||
|
||||
<div class="body">
|
||||
<div class="hero">
|
||||
<div class="hero-rings" aria-hidden="true">
|
||||
<span class="ring r1" />
|
||||
<span class="ring r2" />
|
||||
<span class="ring r3" />
|
||||
</div>
|
||||
<div class="hero-icon">
|
||||
<span class="check">✓</span>
|
||||
</div>
|
||||
<h1 class="hero-title">提交制作成功</h1>
|
||||
<p class="hero-desc">{{ heroDesc }}</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="steps-title">接下来会怎样</div>
|
||||
<ul class="steps">
|
||||
<li class="step is-done">
|
||||
<span class="dot" />
|
||||
<div>
|
||||
<div class="step-name">确认效果并提交</div>
|
||||
<div class="step-desc">已完成{{ totalCountText }}</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="step is-active">
|
||||
<span class="dot" />
|
||||
<div>
|
||||
<div class="step-name">进入制作流程</div>
|
||||
<div class="step-desc">制作团队将按效果图开始生产</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="step">
|
||||
<span class="dot" />
|
||||
<div>
|
||||
<div class="step-name">完成后通知发货</div>
|
||||
<div class="step-desc">请留意订单物流与消息通知</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tips-card">
|
||||
<div class="tips-title">温馨提示</div>
|
||||
<p>本订单提交后不可再次上传生图,如需查看效果请前往生图记录。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<button class="btn-ghost" type="button" @click="goHome">返回首页</button>
|
||||
<button class="btn-primary" type="button" @click="goRecords">查看生成记录</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 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)
|
||||
}
|
||||
|
||||
function goRecords() {
|
||||
router.replace(config.routes.records)
|
||||
}
|
||||
|
||||
function openZoom(startIndex = 0) {
|
||||
const images = resultList.value.map((item) => item.url).filter(Boolean)
|
||||
if (!images.length) return
|
||||
showImagePreview({
|
||||
images,
|
||||
startPosition: Math.max(0, Math.min(startIndex, images.length - 1)),
|
||||
closeable: true,
|
||||
closeIconPosition: 'top-right',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadResults()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.success-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: #f7faf6;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 8px 16px 24px;
|
||||
animation: pageIn 0.55s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes pageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
padding: 28px 12px 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-rings {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 42px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(95, 191, 69, 0.28);
|
||||
animation: pulse 2.4s ease-out infinite;
|
||||
}
|
||||
|
||||
.r2 {
|
||||
inset: -14px;
|
||||
animation-delay: 0.35s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.r3 {
|
||||
inset: -28px;
|
||||
animation-delay: 0.7s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(0.86);
|
||||
opacity: 0.55;
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.08);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
margin: 0 auto 16px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(145deg, #8fd96f 0%, #5fbf45 100%);
|
||||
box-shadow: 0 10px 24px rgba(95, 191, 69, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: pop 0.5s cubic-bezier(0.34, 1.4, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes pop {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.check {
|
||||
color: #fff;
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #152016;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
margin: 8px auto 0;
|
||||
max-width: 300px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #7a8a76;
|
||||
}
|
||||
|
||||
.thumb-card {
|
||||
position: relative;
|
||||
margin: 8px auto 14px;
|
||||
width: min(100%, 220px);
|
||||
aspect-ratio: 1;
|
||||
border-radius: 18px;
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.thumb-badge {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(95, 191, 69, 0.92);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
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;
|
||||
border-radius: 16px;
|
||||
padding: 16px 16px 14px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.steps-title,
|
||||
.tips-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.steps {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
|
||||
.step:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.step:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 18px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #e6efe3;
|
||||
}
|
||||
|
||||
.step .dot {
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
border-radius: 50%;
|
||||
background: #d7e4d3;
|
||||
box-sizing: border-box;
|
||||
border: 3px solid #eef5eb;
|
||||
}
|
||||
|
||||
.step.is-done .dot,
|
||||
.step.is-active .dot {
|
||||
background: #5fbf45;
|
||||
border-color: rgba(95, 191, 69, 0.22);
|
||||
box-shadow: 0 0 0 3px rgba(95, 191, 69, 0.16);
|
||||
}
|
||||
|
||||
.step-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: #8a9586;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.tips-card p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #6b7a66;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(247, 250, 246, 0) 0%, #f7faf6 26%);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
flex: 0 0 112px;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: #e4efe0;
|
||||
color: #3f9e36;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(90deg, #7ed45a 0%, #5fbf45 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 6px 16px rgba(95, 191, 69, 0.32);
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user