Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
500bdbbf66 | ||
|
|
2f9d443a6a | ||
|
|
789d4f106c | ||
|
|
3b10685876 | ||
|
|
3c3c71ebee | ||
|
|
31754ef398 | ||
|
|
02d926e91e | ||
|
|
0199b924ac | ||
|
|
e1da41c2f8 | ||
|
|
4979739521 | ||
|
|
b25e3ca473 | ||
|
|
ff30dbb6b4 | ||
|
|
1887e8b2b3 | ||
|
|
6c55986337 | ||
|
|
482f6a57ce | ||
|
|
505c585772 | ||
|
|
6dd4b4a724 | ||
|
|
add2409f1f | ||
|
|
9124c07ad2 | ||
|
|
007c675603 |
No files matched your search
@@ -13,20 +13,22 @@ declare module '@vue/runtime-core' {
|
|||||||
Backup: typeof import('./src/components/arFrame/backup.vue')['default']
|
Backup: typeof import('./src/components/arFrame/backup.vue')['default']
|
||||||
Backup2: typeof import('./src/components/arFrame/backup2.vue')['default']
|
Backup2: typeof import('./src/components/arFrame/backup2.vue')['default']
|
||||||
Brand: typeof import('./src/components/brand/index.vue')['default']
|
Brand: typeof import('./src/components/brand/index.vue')['default']
|
||||||
|
ChatComposer: typeof import('./src/views/jingdong/components/ChatComposer.vue')['default']
|
||||||
Constants: typeof import('./src/views/douyin/components/image-point-cloud/constants.js')['default']
|
Constants: typeof import('./src/views/douyin/components/image-point-cloud/constants.js')['default']
|
||||||
DyNavBar: typeof import('./src/views/douyin/components/DyNavBar.vue')['default']
|
DyNavBar: typeof import('./src/views/douyin/components/DyNavBar.vue')['default']
|
||||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||||
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElImage: typeof import('element-plus/es')['ElImage']
|
ElImage: typeof import('element-plus/es')['ElImage']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
|
||||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
|
||||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||||
|
GenerationStage: typeof import('./src/views/jingdong/components/GenerationStage.vue')['default']
|
||||||
Gsplat: typeof import('./src/components/arFrame/gsplat.vue')['default']
|
Gsplat: typeof import('./src/components/arFrame/gsplat.vue')['default']
|
||||||
Hint: typeof import('./src/components/hint/hint.vue')['default']
|
Hint: typeof import('./src/components/hint/hint.vue')['default']
|
||||||
ImagePointCloud: typeof import('./src/views/douyin/components/ImagePointCloud.vue')['default']
|
ImagePointCloud: typeof import('./src/views/douyin/components/ImagePointCloud.vue')['default']
|
||||||
|
JdNavBar: typeof import('./src/views/jingdong/components/JdNavBar.vue')['default']
|
||||||
Loading: typeof import('./src/components/loading/index.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']
|
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']
|
MaskProcessor: typeof import('./src/views/douyin/components/image-point-cloud/mask-processor.js')['default']
|
||||||
@@ -36,6 +38,7 @@ declare module '@vue/runtime-core' {
|
|||||||
PointCloudSampler: typeof import('./src/views/douyin/components/image-point-cloud/point-cloud-sampler.js')['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']
|
PointCloudWait: typeof import('./src/views/douyin/components/PointCloudWait.vue')['default']
|
||||||
Renderer: typeof import('./src/views/douyin/components/image-point-cloud/webgl/renderer.js')['default']
|
Renderer: typeof import('./src/views/douyin/components/image-point-cloud/webgl/renderer.js')['default']
|
||||||
|
ResultCard: typeof import('./src/views/jingdong/components/ResultCard.vue')['default']
|
||||||
Shaders: typeof import('./src/views/douyin/components/image-point-cloud/webgl/shaders.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']
|
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']
|
SubjectOutline: typeof import('./src/views/douyin/components/image-point-cloud/subject-outline.js')['default']
|
||||||
@@ -44,10 +47,12 @@ declare module '@vue/runtime-core' {
|
|||||||
TextureManager: typeof import('./src/views/douyin/components/image-point-cloud/webgl/texture-manager.js')['default']
|
TextureManager: typeof import('./src/views/douyin/components/image-point-cloud/webgl/texture-manager.js')['default']
|
||||||
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
||||||
VanAddressEdit: typeof import('vant/es')['AddressEdit']
|
VanAddressEdit: typeof import('vant/es')['AddressEdit']
|
||||||
|
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
|
||||||
VanDivider: typeof import('vant/es')['Divider']
|
VanDivider: typeof import('vant/es')['Divider']
|
||||||
VanIcon: typeof import('vant/es')['Icon']
|
VanIcon: typeof import('vant/es')['Icon']
|
||||||
VanList: typeof import('vant/es')['List']
|
VanList: typeof import('vant/es')['List']
|
||||||
VanLoading: typeof import('vant/es')['Loading']
|
VanLoading: typeof import('vant/es')['Loading']
|
||||||
|
VanOverlay: typeof import('vant/es')['Overlay']
|
||||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||||
VanStepper: typeof import('vant/es')['Stepper']
|
VanStepper: typeof import('vant/es')['Stepper']
|
||||||
VanUploader: typeof import('vant/es')['Uploader']
|
VanUploader: typeof import('vant/es')['Uploader']
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ const { i18next, t } = useTranslation();
|
|||||||
i18n.set(t);
|
i18n.set(t);
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
/** 仅抖音页使用独立布局壳,避免影响 cartoon / badge 等页面 */
|
/** 各渠道独立布局壳,互不影响 */
|
||||||
const isDouyinRoute = computed(() => String(route.path || '').startsWith('/douyin'))
|
const shellClass = computed(() => {
|
||||||
|
const path = String(route.path || '')
|
||||||
|
if (path.startsWith('/douyin')) return 'app-shell app-shell--douyin'
|
||||||
|
if (path.startsWith('/jingdong')) return 'app-shell app-shell--jingdong'
|
||||||
|
return 'app-shell'
|
||||||
|
})
|
||||||
|
|
||||||
// 根据 i18next 语言获取对应的 Vant Locale
|
// 根据 i18next 语言获取对应的 Vant Locale
|
||||||
function getVantLocale() {
|
function getVantLocale() {
|
||||||
@@ -67,7 +72,7 @@ onMounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<div :class="isDouyinRoute ? 'app-shell app-shell--douyin' : 'app-shell'">
|
<div :class="shellClass">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -84,7 +89,7 @@ onMounted(() => {
|
|||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 仅抖音链路:桌面居中 + fixed 相对本容器(不影响 cartoon) */
|
/* 抖音链路:桌面居中(不影响 cartoon / jingdong) */
|
||||||
.app-shell--douyin {
|
.app-shell--douyin {
|
||||||
max-width: 540px;
|
max-width: 540px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -93,4 +98,14 @@ onMounted(() => {
|
|||||||
transform: translateZ(0);
|
transform: translateZ(0);
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 京东链路:桌面居中(不影响 cartoon / douyin) */
|
||||||
|
.app-shell--jingdong {
|
||||||
|
max-width: 540px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
overflow-x: hidden;
|
||||||
|
transform: translateZ(0);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,894 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import request from '@/utils/request'
|
||||||
|
import { localStorage } from '@/utils/local-storage'
|
||||||
|
|
||||||
|
/** 京东 BFF:与 agent 对话页同构接口,路径前缀为 /jd */
|
||||||
|
const PREFIX = 'jd'
|
||||||
|
|
||||||
|
function buildApiUrl(path: string) {
|
||||||
|
const base = String(process.env.VUE_APP_API_BASE_URL || '/api/').replace(/\/+$/, '')
|
||||||
|
return `${base}/${String(path).replace(/^\/+/, '')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthHeaders() {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
const code = localStorage.get('code')
|
||||||
|
const userId = localStorage.get('userId')
|
||||||
|
if (code) headers.Authorization = String(code)
|
||||||
|
if (userId) headers.Userid = String(userId)
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown) {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOf(value: unknown) {
|
||||||
|
return value == null ? '' : String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function listOf(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function asCount(value: unknown) {
|
||||||
|
const number = Number(value)
|
||||||
|
return Number.isFinite(number) ? number : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeOssUrl(url: string) {
|
||||||
|
if (!url) return ''
|
||||||
|
let s = String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=').trim()
|
||||||
|
if (s.indexOf('//') === 0) s = `https:${s}`
|
||||||
|
if (/^http:\/\//i.test(s)) s = `https://${s.slice(7)}`
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageUrlOf(item: Record<string, unknown>) {
|
||||||
|
return normalizeOssUrl(
|
||||||
|
textOf(item.url || item.file_url || item.image_url || item.cover_url || item.result_url),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(value: unknown) {
|
||||||
|
const n = asCount(value)
|
||||||
|
if (!n) {
|
||||||
|
const s = textOf(value)
|
||||||
|
if (/^\d{1,2}:\d{2}$/.test(s)) return s
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const ms = n > 1e12 ? n : n * 1000
|
||||||
|
const d = new Date(ms)
|
||||||
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
|
const h = String(d.getHours()).padStart(2, '0')
|
||||||
|
const m = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${h}:${m}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 类型(对齐 agent-api) ----------
|
||||||
|
|
||||||
|
export type UploadPurpose = 'origin' | 'external_effect'
|
||||||
|
export type ImageGenStatusCode = 'pending' | 'processing' | 'success' | 'failed'
|
||||||
|
|
||||||
|
export interface UploadTicket {
|
||||||
|
upload_id: string
|
||||||
|
upload_url: string
|
||||||
|
file_url: string
|
||||||
|
content_type: string
|
||||||
|
expire_at: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenSubmitInput {
|
||||||
|
session_id: string
|
||||||
|
/** 导入生图传 upload_id;编辑效果图不传 */
|
||||||
|
upload_id?: string
|
||||||
|
/** 编辑效果图传 image_id;导入生图不传 */
|
||||||
|
image_id?: string
|
||||||
|
prompt?: string
|
||||||
|
scene_prop?: string
|
||||||
|
custom_text?: string
|
||||||
|
/** 效果图编辑 edit;原图编辑 / 普通立即生成 generate */
|
||||||
|
mode?: 'generate' | 'edit'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenImage {
|
||||||
|
image_id: string
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenTask {
|
||||||
|
task_id: string
|
||||||
|
status: ImageGenStatusCode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenStatus {
|
||||||
|
task_id: string
|
||||||
|
status: ImageGenStatusCode
|
||||||
|
error: string
|
||||||
|
style: string
|
||||||
|
prompt: string
|
||||||
|
images: ImageGenImage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectSubmitInput {
|
||||||
|
session_id: string
|
||||||
|
upload_id: string
|
||||||
|
product_id: number
|
||||||
|
product_name?: string
|
||||||
|
user_message?: string
|
||||||
|
mounts_id?: number
|
||||||
|
scene_prop?: string
|
||||||
|
custom_text?: string
|
||||||
|
base_type?: number
|
||||||
|
frame_type?: number
|
||||||
|
mount_id?: number
|
||||||
|
pid?: number
|
||||||
|
proportion_id?: number
|
||||||
|
pose_id?: number
|
||||||
|
style_id?: number
|
||||||
|
prompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EffectSubmitResult {
|
||||||
|
task_id: string
|
||||||
|
status: ImageGenStatusCode
|
||||||
|
error: string
|
||||||
|
image_id: string
|
||||||
|
url: string
|
||||||
|
images: ImageGenImage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderConfirmInput {
|
||||||
|
task_id: string
|
||||||
|
image_id?: string
|
||||||
|
cate_id: number
|
||||||
|
product_id?: number
|
||||||
|
top_prod_id?: number
|
||||||
|
shop_id: number
|
||||||
|
print_size?: number
|
||||||
|
quantity?: number
|
||||||
|
contact_name?: string
|
||||||
|
contact_mobile?: string
|
||||||
|
address?: string
|
||||||
|
country_id?: number
|
||||||
|
province_id?: string
|
||||||
|
city_id?: string
|
||||||
|
county_id?: string
|
||||||
|
figure_type: number
|
||||||
|
check_id?: number
|
||||||
|
out_trade_no?: string
|
||||||
|
remark?: string
|
||||||
|
shape_id?: number
|
||||||
|
font_id?: number
|
||||||
|
custom_text?: string
|
||||||
|
back_text?: string
|
||||||
|
up_text?: string
|
||||||
|
down_text?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrderConfirmResult {
|
||||||
|
order_id: number
|
||||||
|
pid: number
|
||||||
|
message: string
|
||||||
|
out_trade_no?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDetailUpload {
|
||||||
|
upload_id: string
|
||||||
|
status: string
|
||||||
|
purpose: UploadPurpose | ''
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDetailImage {
|
||||||
|
image_id: string
|
||||||
|
path: string
|
||||||
|
url: string
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDetailTask {
|
||||||
|
task_id: string
|
||||||
|
upload_id: string
|
||||||
|
status: string
|
||||||
|
error: string
|
||||||
|
source: string
|
||||||
|
provider: string
|
||||||
|
product_name: string
|
||||||
|
style_code: string
|
||||||
|
style_name: string
|
||||||
|
prompt: string
|
||||||
|
n: number
|
||||||
|
pid: number
|
||||||
|
/** generate | edit,编辑效果图时右侧气泡只展示提示词 */
|
||||||
|
mode: 'generate' | 'edit' | ''
|
||||||
|
created_at: number
|
||||||
|
completed_at: number
|
||||||
|
duration: number
|
||||||
|
images: SessionDetailImage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SESSION_RECORD_TYPES = [
|
||||||
|
'session_created',
|
||||||
|
'upload',
|
||||||
|
'upload_confirmed',
|
||||||
|
'imagegen_submit',
|
||||||
|
'imagegen_success',
|
||||||
|
'imagegen_failed',
|
||||||
|
'external_effect',
|
||||||
|
'order',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type SessionRecordType = (typeof SESSION_RECORD_TYPES)[number]
|
||||||
|
|
||||||
|
export interface SessionDetailRecord {
|
||||||
|
type: SessionRecordType | ''
|
||||||
|
at: number
|
||||||
|
message: string
|
||||||
|
upload_id: string
|
||||||
|
task_id: string
|
||||||
|
status: string
|
||||||
|
product_name: string
|
||||||
|
image_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionDetail {
|
||||||
|
session_id: string
|
||||||
|
shop_id: number
|
||||||
|
customer_name: string
|
||||||
|
subtitle: string
|
||||||
|
created_at: number
|
||||||
|
updated_at: number
|
||||||
|
uploads: SessionDetailUpload[]
|
||||||
|
tasks: SessionDetailTask[]
|
||||||
|
records: SessionDetailRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionHistoryItem =
|
||||||
|
| {
|
||||||
|
role: 'user'
|
||||||
|
text: string
|
||||||
|
image?: string
|
||||||
|
time?: string
|
||||||
|
taskId?: string
|
||||||
|
uploadId?: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
role: 'agent'
|
||||||
|
type: 'result'
|
||||||
|
image: string
|
||||||
|
meta?: string
|
||||||
|
time?: string
|
||||||
|
durationSec?: number
|
||||||
|
taskId?: string
|
||||||
|
imageId?: string
|
||||||
|
uploadId?: string
|
||||||
|
prompt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExternalEffect(value: string | undefined) {
|
||||||
|
return value === 'external_effect'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 解析 ----------
|
||||||
|
|
||||||
|
function uploadPurpose(value: unknown): UploadPurpose | '' {
|
||||||
|
return value === 'origin' || value === 'external_effect' ? value : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionRecordType(value: unknown): SessionRecordType | '' {
|
||||||
|
const text = textOf(value).trim().toLowerCase()
|
||||||
|
return (SESSION_RECORD_TYPES as readonly string[]).includes(text)
|
||||||
|
? (text as SessionRecordType)
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDetailImage(raw: unknown): SessionDetailImage | null {
|
||||||
|
const item = asRecord(raw)
|
||||||
|
if (!item) return null
|
||||||
|
const url = imageUrlOf(item)
|
||||||
|
if (!url) return null
|
||||||
|
return {
|
||||||
|
image_id: textOf(item.image_id || item.id),
|
||||||
|
path: textOf(item.path),
|
||||||
|
url,
|
||||||
|
index: asCount(item.index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDetailTask(raw: unknown): SessionDetailTask | null {
|
||||||
|
const item = asRecord(raw)
|
||||||
|
if (!item || !item.task_id) return null
|
||||||
|
const images = listOf(item.images ?? item.image_list ?? item.results)
|
||||||
|
.map((image) => normalizeDetailImage(image))
|
||||||
|
.filter((image): image is SessionDetailImage => Boolean(image))
|
||||||
|
.sort((a, b) => a.index - b.index)
|
||||||
|
if (!images.length) {
|
||||||
|
const url = imageUrlOf(item)
|
||||||
|
if (url) {
|
||||||
|
images.push({
|
||||||
|
image_id: textOf(item.image_id || item.id),
|
||||||
|
path: textOf(item.path),
|
||||||
|
url,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const modeRaw = textOf(item.mode || item.image_gen_mode || item.imageGenMode).toLowerCase()
|
||||||
|
const mode: SessionDetailTask['mode'] =
|
||||||
|
modeRaw === 'edit' || modeRaw === 'generate' ? modeRaw : ''
|
||||||
|
return {
|
||||||
|
task_id: textOf(item.task_id),
|
||||||
|
upload_id: textOf(item.upload_id),
|
||||||
|
status: textOf(item.status),
|
||||||
|
error: textOf(item.error || item.message),
|
||||||
|
source: textOf(item.source),
|
||||||
|
provider: textOf(item.provider),
|
||||||
|
product_name: textOf(item.product_name),
|
||||||
|
style_code: textOf(item.style_code),
|
||||||
|
style_name: textOf(item.style_name),
|
||||||
|
prompt: textOf(item.prompt),
|
||||||
|
n: asCount(item.n),
|
||||||
|
pid: asCount(item.pid),
|
||||||
|
mode,
|
||||||
|
created_at: asCount(item.created_at),
|
||||||
|
completed_at: asCount(item.completed_at),
|
||||||
|
duration: asCount(item.duration),
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDetailRecord(raw: unknown): SessionDetailRecord | null {
|
||||||
|
const item = asRecord(raw)
|
||||||
|
if (!item) return null
|
||||||
|
return {
|
||||||
|
type: sessionRecordType(item.type),
|
||||||
|
at: asCount(item.at),
|
||||||
|
message: textOf(item.message),
|
||||||
|
upload_id: textOf(item.upload_id),
|
||||||
|
task_id: textOf(item.task_id),
|
||||||
|
status: textOf(item.status),
|
||||||
|
product_name: textOf(item.product_name),
|
||||||
|
image_count: asCount(item.image_count),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 session/detail(对齐 agent normalizeSessionDetail) */
|
||||||
|
export function normalizeSessionDetail(data: unknown): SessionDetail {
|
||||||
|
const record = asRecord(data) || {}
|
||||||
|
const uploads = listOf(record.uploads)
|
||||||
|
.map((item) => asRecord(item))
|
||||||
|
.filter((item): item is Record<string, unknown> => Boolean(item && item.upload_id))
|
||||||
|
.map((item) => ({
|
||||||
|
upload_id: textOf(item.upload_id),
|
||||||
|
status: textOf(item.status),
|
||||||
|
purpose: uploadPurpose(item.purpose),
|
||||||
|
path: textOf(item.path),
|
||||||
|
url: normalizeOssUrl(textOf(item.url)),
|
||||||
|
}))
|
||||||
|
const tasks = listOf(record.tasks)
|
||||||
|
.map((item) => normalizeDetailTask(item))
|
||||||
|
.filter((item): item is SessionDetailTask => Boolean(item))
|
||||||
|
.sort((a, b) => a.created_at - b.created_at)
|
||||||
|
const records = listOf(record.records)
|
||||||
|
.map((item) => normalizeDetailRecord(item))
|
||||||
|
.filter((item): item is SessionDetailRecord => Boolean(item))
|
||||||
|
.sort((a, b) => a.at - b.at)
|
||||||
|
return {
|
||||||
|
session_id: textOf(record.session_id),
|
||||||
|
shop_id: asCount(record.shop_id),
|
||||||
|
customer_name: textOf(record.customer_name),
|
||||||
|
subtitle: textOf(record.subtitle),
|
||||||
|
created_at: asCount(record.created_at),
|
||||||
|
updated_at: asCount(record.updated_at || record.created_at),
|
||||||
|
uploads,
|
||||||
|
tasks,
|
||||||
|
records,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadOf(detail: SessionDetail, uploadId: string) {
|
||||||
|
return uploadId ? detail.uploads.find((item) => item.upload_id === uploadId) : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroughtTask(detail: SessionDetail, task: SessionDetailTask) {
|
||||||
|
const upload = uploadOf(detail, task.upload_id)
|
||||||
|
return (
|
||||||
|
isExternalEffect(upload?.purpose) ||
|
||||||
|
isExternalEffect(task.source) ||
|
||||||
|
task.provider === 'user_effect'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 session/detail 转为对话气泡历史(对齐 agent applySessionDetail → turns)
|
||||||
|
*/
|
||||||
|
export function sessionDetailToHistory(detail: SessionDetail): SessionHistoryItem[] {
|
||||||
|
const history: SessionHistoryItem[] = []
|
||||||
|
const defaultPrompt = '按所选产品、风格生成手办效果图~'
|
||||||
|
|
||||||
|
detail.tasks.forEach((task) => {
|
||||||
|
const upload = uploadOf(detail, task.upload_id)
|
||||||
|
const photo = upload?.url || ''
|
||||||
|
const brought = isBroughtTask(detail, task)
|
||||||
|
const time = formatClock(task.completed_at || task.created_at)
|
||||||
|
const seconds =
|
||||||
|
task.duration > 0
|
||||||
|
? task.duration
|
||||||
|
: task.completed_at > task.created_at
|
||||||
|
? task.completed_at - task.created_at
|
||||||
|
: 0
|
||||||
|
const images = task.images.filter((image) => image.url)
|
||||||
|
const resultImages =
|
||||||
|
images.length > 0
|
||||||
|
? images
|
||||||
|
: brought && photo
|
||||||
|
? [{ image_id: '', path: '', url: photo, index: 0 }]
|
||||||
|
: []
|
||||||
|
|
||||||
|
if (!resultImages.length && task.status !== 'success') return
|
||||||
|
|
||||||
|
// 右侧气泡:purpose=origin 显示原图;external_effect 只展示提示词
|
||||||
|
const userImage = upload?.purpose === 'origin' && photo ? photo : undefined
|
||||||
|
const userText =
|
||||||
|
task.prompt ||
|
||||||
|
(brought || upload?.purpose === 'external_effect'
|
||||||
|
? '这是我在别的平台生成的卡通效果图,不用再调整了,直接帮我下单吧~'
|
||||||
|
: defaultPrompt)
|
||||||
|
|
||||||
|
history.push({
|
||||||
|
role: 'user',
|
||||||
|
text: userText,
|
||||||
|
image: userImage,
|
||||||
|
time: time || undefined,
|
||||||
|
taskId: task.task_id,
|
||||||
|
uploadId: task.upload_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
resultImages.forEach((image) => {
|
||||||
|
history.push({
|
||||||
|
role: 'agent',
|
||||||
|
type: 'result',
|
||||||
|
image: image.url,
|
||||||
|
time: time || undefined,
|
||||||
|
durationSec: seconds,
|
||||||
|
meta: time
|
||||||
|
? `${time}${seconds > 0 ? ` · 生图用时 ${seconds}s` : ''}`
|
||||||
|
: seconds > 0
|
||||||
|
? `生图用时 ${seconds}s`
|
||||||
|
: '',
|
||||||
|
taskId: task.task_id,
|
||||||
|
imageId: image.image_id || undefined,
|
||||||
|
uploadId: task.upload_id,
|
||||||
|
prompt: task.prompt || undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容旧调用:解析 detail 并产出 history */
|
||||||
|
export function parseSessionDetail(res: any): SessionDetail & {
|
||||||
|
sessionId: string
|
||||||
|
history: SessionHistoryItem[]
|
||||||
|
raw: any
|
||||||
|
} {
|
||||||
|
const detail = normalizeSessionDetail(res)
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
sessionId: detail.session_id,
|
||||||
|
history: sessionDetailToHistory(detail),
|
||||||
|
raw: res,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将接口 body 归一成对象(后端若返回 text/plain,axios 会得到 JSON 字符串) */
|
||||||
|
function coerceResponseBody(res: unknown): Record<string, any> {
|
||||||
|
if (res == null) return {}
|
||||||
|
if (typeof res === 'string') {
|
||||||
|
const text = res.replace(/^\uFEFF/, '').trim()
|
||||||
|
if (!text) return {}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, any>)
|
||||||
|
: {}
|
||||||
|
} catch {
|
||||||
|
const start = text.indexOf('{')
|
||||||
|
const end = text.lastIndexOf('}')
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text.slice(start, end + 1))
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, any>)
|
||||||
|
: {}
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof res === 'object' && !Array.isArray(res)) return res as Record<string, any>
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 upload/url 业务体提取票据(兼容整包 / 已解包 / JSON 字符串) */
|
||||||
|
export function parseUploadUrlResult(res: any): UploadTicket {
|
||||||
|
const root = coerceResponseBody(res)
|
||||||
|
// 优先整包 { code, data: { upload_id, upload_url } }
|
||||||
|
const fromEnvelope =
|
||||||
|
root.data && typeof root.data === 'object' && !Array.isArray(root.data)
|
||||||
|
? (root.data as Record<string, any>)
|
||||||
|
: null
|
||||||
|
const data =
|
||||||
|
fromEnvelope && (fromEnvelope.upload_id || fromEnvelope.uploadId || fromEnvelope.upload_url)
|
||||||
|
? fromEnvelope
|
||||||
|
: root
|
||||||
|
return {
|
||||||
|
upload_id: textOf(data.upload_id || data.uploadId),
|
||||||
|
upload_url: normalizeOssUrl(
|
||||||
|
textOf(data.upload_url || data.uploadUrl || data.put_url || data.putUrl),
|
||||||
|
),
|
||||||
|
file_url: normalizeOssUrl(textOf(data.file_url || data.fileUrl)),
|
||||||
|
content_type: textOf(data.content_type || data.contentType) || 'image/jpeg',
|
||||||
|
expire_at: asCount(data.expire_at || data.expireAt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeImageGenStatus(data: unknown, taskId: string): ImageGenStatus {
|
||||||
|
const record = asRecord(data) || {}
|
||||||
|
const images = listOf(record.images ?? record.image_list ?? record.results)
|
||||||
|
.map((item) => normalizeDetailImage(item))
|
||||||
|
.filter((item): item is SessionDetailImage => Boolean(item))
|
||||||
|
.map((item) => ({
|
||||||
|
image_id: item.image_id,
|
||||||
|
path: item.path,
|
||||||
|
url: item.url,
|
||||||
|
index: item.index,
|
||||||
|
}))
|
||||||
|
if (!images.length) {
|
||||||
|
const url = imageUrlOf(record)
|
||||||
|
if (url) {
|
||||||
|
images.push({
|
||||||
|
image_id: textOf(record.image_id || record.id),
|
||||||
|
path: textOf(record.path),
|
||||||
|
url,
|
||||||
|
index: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const statusText = textOf(record.status).toLowerCase()
|
||||||
|
const status: ImageGenStatusCode =
|
||||||
|
statusText === 'pending' ||
|
||||||
|
statusText === 'processing' ||
|
||||||
|
statusText === 'success' ||
|
||||||
|
statusText === 'failed'
|
||||||
|
? statusText
|
||||||
|
: images.length
|
||||||
|
? 'success'
|
||||||
|
: 'pending'
|
||||||
|
return {
|
||||||
|
task_id: textOf(record.task_id) || taskId,
|
||||||
|
status,
|
||||||
|
error: textOf(record.error),
|
||||||
|
style: textOf(record.style),
|
||||||
|
prompt: textOf(record.prompt),
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容旧 parseImageGenStatus 调用 */
|
||||||
|
export function parseImageGenStatus(res: any, taskId = '') {
|
||||||
|
const normalized = normalizeImageGenStatus(res, taskId)
|
||||||
|
const first = normalized.images[0]
|
||||||
|
return {
|
||||||
|
taskId: normalized.task_id,
|
||||||
|
status: normalized.status,
|
||||||
|
done: normalized.status === 'success' || Boolean(first?.url),
|
||||||
|
failed: normalized.status === 'failed',
|
||||||
|
resultUrl: first?.url || '',
|
||||||
|
imageId: first?.image_id || '',
|
||||||
|
images: normalized.images,
|
||||||
|
error: normalized.error,
|
||||||
|
raw: res,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEffectSubmit(data: unknown): EffectSubmitResult {
|
||||||
|
const record = asRecord(data) || {}
|
||||||
|
const images = listOf(record.images)
|
||||||
|
.map((item) => normalizeDetailImage(item))
|
||||||
|
.filter((item): item is SessionDetailImage => Boolean(item))
|
||||||
|
.map((item) => ({
|
||||||
|
image_id: item.image_id,
|
||||||
|
path: item.path,
|
||||||
|
url: item.url,
|
||||||
|
index: item.index,
|
||||||
|
}))
|
||||||
|
const statusText = textOf(record.status)
|
||||||
|
const status: ImageGenStatusCode =
|
||||||
|
statusText === 'pending' ||
|
||||||
|
statusText === 'processing' ||
|
||||||
|
statusText === 'success' ||
|
||||||
|
statusText === 'failed'
|
||||||
|
? statusText
|
||||||
|
: 'success'
|
||||||
|
return {
|
||||||
|
task_id: textOf(record.task_id),
|
||||||
|
status,
|
||||||
|
error: textOf(record.error),
|
||||||
|
image_id: textOf(record.image_id) || images[0]?.image_id || '',
|
||||||
|
url:
|
||||||
|
normalizeOssUrl(textOf(record.url || record.file_url || record.effect_url)) ||
|
||||||
|
images[0]?.url ||
|
||||||
|
'',
|
||||||
|
images,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 接口(参数对齐 agent-api,路径用 /jd) ----------
|
||||||
|
|
||||||
|
/** GET /jd/session/detail?session_id= */
|
||||||
|
export const getSessionDetail = (params: { session_id: string } | Record<string, any>) => {
|
||||||
|
const sessionId =
|
||||||
|
typeof params === 'object' && params
|
||||||
|
? textOf((params as any).session_id).trim()
|
||||||
|
: ''
|
||||||
|
return request(`${PREFIX}/session/detail`, {
|
||||||
|
method: 'GET',
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
session_id: sessionId || textOf((params as any)?.session_id),
|
||||||
|
},
|
||||||
|
}).then((res) => normalizeSessionDetail(res))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /jd/session/images */
|
||||||
|
export const getSessionImages = (query: {
|
||||||
|
shop_id?: number
|
||||||
|
session_id?: string
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
page?: number
|
||||||
|
size?: number
|
||||||
|
} = {}) => {
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
page: query.page && query.page > 0 ? query.page : 1,
|
||||||
|
size: Math.min(100, Math.max(1, query.size || 20)),
|
||||||
|
}
|
||||||
|
if (query.shop_id) params.shop_id = query.shop_id
|
||||||
|
if (query.session_id?.trim()) params.session_id = query.session_id.trim()
|
||||||
|
if (query.order === 'asc' || query.order === 'desc') params.order = query.order
|
||||||
|
return request(`${PREFIX}/session/images`, {
|
||||||
|
method: 'GET',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /jd/upload/url
|
||||||
|
* 使用独立 axios(不走全局 response 拦截器),直接读 response.data
|
||||||
|
*/
|
||||||
|
export const getUploadUrl = async (input: {
|
||||||
|
session_id: string
|
||||||
|
filename?: string
|
||||||
|
content_type?: string
|
||||||
|
purpose?: UploadPurpose
|
||||||
|
}): Promise<UploadTicket> => {
|
||||||
|
const response = await axios.post(
|
||||||
|
buildApiUrl(`${PREFIX}/upload/url`),
|
||||||
|
{
|
||||||
|
session_id: input.session_id,
|
||||||
|
filename: input.filename || '',
|
||||||
|
content_type: input.content_type || 'image/jpeg',
|
||||||
|
purpose: input.purpose || 'origin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: buildAuthHeaders(),
|
||||||
|
timeout: 30000,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// 注意:若 Content-Type 不是 application/json,axios 会把 body 留成 string
|
||||||
|
const body = coerceResponseBody(response?.data)
|
||||||
|
const code = Number(body.code)
|
||||||
|
// 业务失败(如会话不存在)需抛出原文,避免被吞成「缺少 upload_id」
|
||||||
|
if (Number.isFinite(code) && code !== 1000 && code !== 0) {
|
||||||
|
throw new Error(textOf(body.message || body.msg) || '签发上传地址失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ticket = parseUploadUrlResult(body)
|
||||||
|
|
||||||
|
if (!ticket.upload_id) {
|
||||||
|
throw new Error('签发上传地址失败:缺少 upload_id')
|
||||||
|
}
|
||||||
|
if (!ticket.upload_url) {
|
||||||
|
throw new Error('签发上传地址失败:缺少 upload_url')
|
||||||
|
}
|
||||||
|
return ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT OSS */
|
||||||
|
export async function putUpload(uploadUrl: string, file: Blob, contentType: string) {
|
||||||
|
const response = await fetch(uploadUrl, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: contentType ? { 'Content-Type': contentType } : undefined,
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error('图片上传失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /jd/upload/confirm */
|
||||||
|
export const confirmUpload = (input: { session_id: string; upload_id: string }) => {
|
||||||
|
return request(`${PREFIX}/upload/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
session_id: input.session_id,
|
||||||
|
upload_id: input.upload_id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /jd/subject/classify
|
||||||
|
* 图片上传完成后识别主体(session_id + upload_id)
|
||||||
|
*/
|
||||||
|
export const classifySubject = (input: {
|
||||||
|
session_id: string
|
||||||
|
upload_id: string
|
||||||
|
}) => {
|
||||||
|
return request('jd/subject/classify', {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
session_id: input.session_id,
|
||||||
|
upload_id: input.upload_id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /jd/imagegen/submit */
|
||||||
|
export const submitImageGen = (input: ImageGenSubmitInput) => {
|
||||||
|
const data: Record<string, string> = {
|
||||||
|
session_id: input.session_id,
|
||||||
|
prompt: input.prompt || '',
|
||||||
|
scene_prop: input.scene_prop || '',
|
||||||
|
custom_text: input.custom_text || '',
|
||||||
|
mode: input.mode || 'generate',
|
||||||
|
}
|
||||||
|
const uploadId = String(input.upload_id || '').trim()
|
||||||
|
const imageId = String(input.image_id || '').trim()
|
||||||
|
// 导入生图:upload_id;编辑效果图:image_id(互斥)
|
||||||
|
if (uploadId) data.upload_id = uploadId
|
||||||
|
if (imageId) data.image_id = imageId
|
||||||
|
return request(`${PREFIX}/imagegen/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
data,
|
||||||
|
}) as Promise<ImageGenTask>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /jd/effect/submit */
|
||||||
|
export const submitExternalEffect = (input: EffectSubmitInput) => {
|
||||||
|
return request(`${PREFIX}/effect/submit`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
session_id: input.session_id,
|
||||||
|
upload_id: input.upload_id,
|
||||||
|
product_id: input.product_id,
|
||||||
|
product_name: input.product_name || '',
|
||||||
|
user_message: input.user_message || '',
|
||||||
|
mounts_id: input.mounts_id ?? 0,
|
||||||
|
scene_prop: input.scene_prop || '',
|
||||||
|
custom_text: input.custom_text || '',
|
||||||
|
base_type: input.base_type ?? 0,
|
||||||
|
frame_type: input.frame_type ?? 0,
|
||||||
|
mount_id: input.mount_id ?? 0,
|
||||||
|
pid: input.pid ?? 0,
|
||||||
|
proportion_id: input.proportion_id ?? 0,
|
||||||
|
pose_id: input.pose_id ?? 0,
|
||||||
|
style_id: input.style_id ?? 0,
|
||||||
|
prompt: input.prompt || '',
|
||||||
|
},
|
||||||
|
}).then(normalizeEffectSubmit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /jd/imagegen/status?task_id= */
|
||||||
|
export const getImageGenStatus = (taskId: string) => {
|
||||||
|
return request(`${PREFIX}/imagegen/status`, {
|
||||||
|
method: 'GET',
|
||||||
|
params: { task_id: taskId },
|
||||||
|
}).then((data) => normalizeImageGenStatus(data, taskId))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /jd/order/confirm */
|
||||||
|
export const confirmOrder = (input: OrderConfirmInput) => {
|
||||||
|
const shapeId = input.shape_id ?? 0
|
||||||
|
return request(`${PREFIX}/order/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
...input,
|
||||||
|
top_prod_id: input.top_prod_id ?? shapeId ?? 0,
|
||||||
|
shape_id: shapeId,
|
||||||
|
font_id: input.font_id ?? 0,
|
||||||
|
custom_text: input.custom_text || '',
|
||||||
|
back_text: input.back_text || '',
|
||||||
|
up_text: input.up_text || '',
|
||||||
|
down_text: input.down_text || '',
|
||||||
|
check_id: input.check_id ?? 0,
|
||||||
|
country_id: input.country_id ?? 45,
|
||||||
|
province_id: input.province_id || '',
|
||||||
|
city_id: input.city_id || '',
|
||||||
|
county_id: input.county_id || '',
|
||||||
|
},
|
||||||
|
}) as Promise<OrderConfirmResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入阶段:签发 URL → PUT OSS(不 confirm)
|
||||||
|
* 拿到 upload_id 后可立刻调 subject/classify
|
||||||
|
*/
|
||||||
|
export async function prepareUpload(
|
||||||
|
file: File | Blob,
|
||||||
|
options: {
|
||||||
|
session_id: string
|
||||||
|
filename?: string
|
||||||
|
purpose?: UploadPurpose
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const name = options.filename || guessFilename(file as File)
|
||||||
|
const contentType = guessContentType(file, name)
|
||||||
|
const ticket = await getUploadUrl({
|
||||||
|
session_id: options.session_id,
|
||||||
|
filename: name,
|
||||||
|
content_type: contentType,
|
||||||
|
purpose: options.purpose || 'origin',
|
||||||
|
})
|
||||||
|
await putUpload(ticket.upload_url, file, ticket.content_type || contentType)
|
||||||
|
return {
|
||||||
|
uploadId: ticket.upload_id,
|
||||||
|
fileUrl: ticket.file_url,
|
||||||
|
contentType: ticket.content_type || contentType,
|
||||||
|
filename: name,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完整上传:签发 URL → PUT OSS → confirm(需 session_id)
|
||||||
|
*/
|
||||||
|
export async function uploadOriginal(
|
||||||
|
file: File | Blob,
|
||||||
|
options: {
|
||||||
|
session_id: string
|
||||||
|
filename?: string
|
||||||
|
purpose?: UploadPurpose
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const prepared = await prepareUpload(file, options)
|
||||||
|
await confirmUpload({
|
||||||
|
session_id: options.session_id,
|
||||||
|
upload_id: prepared.uploadId,
|
||||||
|
})
|
||||||
|
return prepared
|
||||||
|
}
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -73,17 +73,12 @@ export const cartoonConfig = {
|
|||||||
minHeight: 300,
|
minHeight: 300,
|
||||||
maxSize: 10 * 1024 * 1024, // 10MB
|
maxSize: 10 * 1024 * 1024, // 10MB
|
||||||
maxReferCount: 3,
|
maxReferCount: 3,
|
||||||
cropOptions: {
|
/** 选图后强制 3:4;可缩放图片;允许框超出图片,超出区域白底填充 */
|
||||||
canScale: true,
|
crop: {
|
||||||
autoCropWidth: 1024,
|
ratio: [3, 4] as [number, number],
|
||||||
autoCropHeight: 1024,
|
fillColor: '#ffffff',
|
||||||
ceilbutton: true,
|
outputType: 'jpeg' as const,
|
||||||
infoTrue: true,
|
enlarge: 2,
|
||||||
fixed: false,
|
|
||||||
fixedNumber: [3, 4],
|
|
||||||
fixedBox: false,
|
|
||||||
canMoveBox: true,
|
|
||||||
enlarge: 2
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import logoAvatar from '@/assets/jingdong/logo-mark.png'
|
||||||
|
import logoIcon from '@/assets/jingdong/logo_1.png'
|
||||||
|
import userAvatar from '@/assets/jingdong/jingdong.png'
|
||||||
|
import guideImage from '@/assets/jingdong/yindao.png'
|
||||||
|
import caseOriginal from '@/assets/jingdong/原图.png'
|
||||||
|
import caseEffect from '@/assets/jingdong/效果图.png'
|
||||||
|
import caseScene from '@/assets/jingdong/场景图.png'
|
||||||
|
import demoResult1 from '@/assets/jingdong/jd-2.png'
|
||||||
|
import demoResult2 from '@/assets/jingdong/jd-3.png'
|
||||||
|
import demoUpload from '@/assets/jingdong/jd-1.png'
|
||||||
|
import pictureCloud from '@/assets/jingdong/picture_cloud.gif'
|
||||||
|
|
||||||
|
export const jingdongConfig = {
|
||||||
|
routes: {
|
||||||
|
home: '/jingdong',
|
||||||
|
},
|
||||||
|
|
||||||
|
brandName: '速哇手办制作Agent',
|
||||||
|
primaryColor: '#3A8E4D',
|
||||||
|
primarySoft: '#E8F5E9',
|
||||||
|
background: '#FFFFFF',
|
||||||
|
|
||||||
|
images: {
|
||||||
|
/** 顶栏 / Agent 对话头像 */
|
||||||
|
avatar: logoAvatar,
|
||||||
|
logo: logoAvatar,
|
||||||
|
/** 用户侧对话头像 */
|
||||||
|
userAvatar,
|
||||||
|
/** 浏览器标签 favicon */
|
||||||
|
logoIcon,
|
||||||
|
/** 对话引导图(照片要求) */
|
||||||
|
guide: guideImage,
|
||||||
|
caseOriginal,
|
||||||
|
caseEffect,
|
||||||
|
caseScene,
|
||||||
|
demoResult1,
|
||||||
|
demoResult2,
|
||||||
|
demoUpload,
|
||||||
|
pictureCloud,
|
||||||
|
},
|
||||||
|
|
||||||
|
remainInitial: 5,
|
||||||
|
|
||||||
|
/** 生图状态轮询间隔(毫秒),对齐 agent */
|
||||||
|
pollIntervalMs: 10000,
|
||||||
|
pollTimeoutMs: 5 * 60 * 1000,
|
||||||
|
|
||||||
|
messages: {
|
||||||
|
welcome: '你好,上传一张照片,或直接说说想做成什么样的手办。',
|
||||||
|
caseIntro: '上传照片前,先看下照片要求',
|
||||||
|
caseLabels: ['原图', '效果图', '场景应用'] as const,
|
||||||
|
placeholder: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
|
||||||
|
placeholderNeedPhoto: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
|
||||||
|
placeholderEditing: '说说想怎么改,例如:换成蓝色围巾、表情更开心…',
|
||||||
|
customTextLabel: '自定义文字',
|
||||||
|
customTextPlaceholder: '输入自定义文字...',
|
||||||
|
customTextMax: 18,
|
||||||
|
defaultPrompt: '按所选产品、风格生成手办效果图~',
|
||||||
|
editingHint: '正在编辑效果图 · 输入文字描述想调整的地方',
|
||||||
|
exitEdit: '退出编辑',
|
||||||
|
view: '查看',
|
||||||
|
edit: '编辑',
|
||||||
|
buyNow: '立即购买',
|
||||||
|
buyDisabledTip: '编辑调整中,暂不可购买',
|
||||||
|
buyToast: '购买功能即将开放',
|
||||||
|
noRemain: '生图次数已用完,暂时无法继续生成',
|
||||||
|
remainTip: (n: number) => (n > 0 ? `还可以生成 ${n} 次效果图` : '生图次数已用完'),
|
||||||
|
needPhoto: '请先上传一张照片再发送',
|
||||||
|
needEditText: '请输入想调整的内容后再发送',
|
||||||
|
needPhotoOrText: '请上传照片或输入描述后再发送',
|
||||||
|
needProductTitle: (label: string) => `请填写${label}后再发送`,
|
||||||
|
uploadingTip: '正在上传照片,请稍候…',
|
||||||
|
inspectingTip: '正在识别照片,请稍候…',
|
||||||
|
sessionLoadingTip: '页面加载中,请稍候再上传…',
|
||||||
|
sessionMissing: '会话未就绪,请稍候再试或刷新页面',
|
||||||
|
/** 仅提示,不拦截发送 */
|
||||||
|
subjectMismatchTip: '上传图片的主体数量与下单主体数量不一致,生图时候会按下单的主体数量自动调整主体',
|
||||||
|
subjectMismatchToast: (got: string, need: string) =>
|
||||||
|
`上传图片主体(${got})与下单主体(${need})数量不一致`,
|
||||||
|
generating: '正在生成效果图,请稍候…',
|
||||||
|
generatingTip: '效果图生成中,完成后可继续操作',
|
||||||
|
downloadOk: '已开始下载效果图',
|
||||||
|
downloadOpened:
|
||||||
|
'当前浏览器无法直接下载,已打开图片,请长按保存;或换用 Safari / Chrome 下载',
|
||||||
|
downloadFail: '下载失败,请换用其他浏览器,或长按效果图保存',
|
||||||
|
effectPreparing: '效果图准备中,请稍后再试',
|
||||||
|
generateFail: '这次没生成成功,请换个描述或照片再试',
|
||||||
|
editFail: '这次调整没成功,请换个描述再试',
|
||||||
|
/** 内容安全策略拒绝(未成年 / 公众人物等) */
|
||||||
|
contentPolicyFail:
|
||||||
|
'当前照片不符合内容规范,可能涉及未成年或公众人物,请换一张照片再试',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -14,6 +14,19 @@ const stripCorsHeaders = (proxyRes: { headers: Record<string, any> }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const proxy = {
|
export const proxy = {
|
||||||
|
/** 图片 classify,需排在 /api 之前避免被 shop 代理吞掉 */
|
||||||
|
"/concept-classify": {
|
||||||
|
target: "https://u252424-8570-01f615a5.cqa1.seetacloud.com:8443",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: () => "/v1/classify",
|
||||||
|
},
|
||||||
|
"/concept-review": {
|
||||||
|
target: "https://u252424-8570-01f615a5.cqa1.seetacloud.com:8443",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: () => "/v1/review",
|
||||||
|
},
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "https://shop.api.suwa3d.com/api/web/",
|
target: "https://shop.api.suwa3d.com/api/web/",
|
||||||
// target: 'http://127.0.0.1:28499',
|
// target: 'http://127.0.0.1:28499',
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import 'nprogress/nprogress.css'
|
|||||||
// import * as badgeRouter from './badge'
|
// import * as badgeRouter from './badge'
|
||||||
import * as cartoonRouter from './cartoon'
|
import * as cartoonRouter from './cartoon'
|
||||||
import * as douyinRouter from './douyin'
|
import * as douyinRouter from './douyin'
|
||||||
|
import * as jingdongRouter from './jingdong'
|
||||||
// import badge from '@/views/badge/index.vue'
|
// import badge from '@/views/badge/index.vue'
|
||||||
import cartoon from '@/views/cartoon/index.vue'
|
import cartoon from '@/views/cartoon/index.vue'
|
||||||
import {useStore} from '@/stores'
|
import {useStore} from '@/stores'
|
||||||
import { config } from '@/config/config'
|
import { config } from '@/config/config'
|
||||||
import { douyinConfig } from '@/config/douyin'
|
import { douyinConfig } from '@/config/douyin'
|
||||||
|
import { jingdongConfig } from '@/config/jingdong'
|
||||||
import { localStorage } from '@/utils/local-storage'
|
import { localStorage } from '@/utils/local-storage'
|
||||||
import {
|
import {
|
||||||
getCachedShopBrand,
|
getCachedShopBrand,
|
||||||
@@ -37,6 +39,7 @@ const routes = [
|
|||||||
// badgeRouter.mergeRoutes(routes)
|
// badgeRouter.mergeRoutes(routes)
|
||||||
cartoonRouter.mergeRoutes(routes)
|
cartoonRouter.mergeRoutes(routes)
|
||||||
douyinRouter.mergeRoutes(routes)
|
douyinRouter.mergeRoutes(routes)
|
||||||
|
jingdongRouter.mergeRoutes(routes)
|
||||||
// 创建路由实例并传递 `routes` 配置
|
// 创建路由实例并传递 `routes` 配置
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(process.env.VUE_APP_PUBLIC_PATH),
|
history: createWebHashHistory(process.env.VUE_APP_PUBLIC_PATH),
|
||||||
@@ -65,7 +68,9 @@ router.beforeEach((_to, _from, next) => {
|
|||||||
if (code) {
|
if (code) {
|
||||||
let url = store.redirectUrl()
|
let url = store.redirectUrl()
|
||||||
|
|
||||||
if (_to.path !== '/cartoon' && !_to.path.includes('douyin')) {
|
const isChannelPath =
|
||||||
|
_to.path.includes('douyin') || _to.path.includes('jingdong')
|
||||||
|
if (_to.path !== '/cartoon' && !isChannelPath) {
|
||||||
if (url) {
|
if (url) {
|
||||||
store.setRedirect('')
|
store.setRedirect('')
|
||||||
next(url)
|
next(url)
|
||||||
@@ -82,12 +87,20 @@ router.beforeEach((_to, _from, next) => {
|
|||||||
const titleText = pageTitle.$t || pageTitle
|
const titleText = pageTitle.$t || pageTitle
|
||||||
const brandText = config.brand?.$t || config.brand || ''
|
const brandText = config.brand?.$t || config.brand || ''
|
||||||
const isDouyin = _to.meta.channel === 'douyin' || _to.path.includes('douyin')
|
const isDouyin = _to.meta.channel === 'douyin' || _to.path.includes('douyin')
|
||||||
|
const isJingdong =
|
||||||
|
_to.meta.channel === 'jingdong' || _to.path.includes('jingdong')
|
||||||
// 抖音页优先使用 shopBrand 缓存的门店名/logo,否则回退默认品牌
|
// 抖音页优先使用 shopBrand 缓存的门店名/logo,否则回退默认品牌
|
||||||
if (isDouyin) {
|
if (isDouyin) {
|
||||||
const shopBrand = getCachedShopBrand()
|
const shopBrand = getCachedShopBrand()
|
||||||
const brandName = shopBrand.name || douyinConfig.brandName
|
const brandName = shopBrand.name || douyinConfig.brandName
|
||||||
window.document.title = `${brandName} - ${titleText}`
|
window.document.title = `${brandName} - ${titleText}`
|
||||||
setDocumentFavicon(shopBrand.logo || douyinConfig.images.logo)
|
setDocumentFavicon(shopBrand.logo || douyinConfig.images.logo)
|
||||||
|
} else if (isJingdong) {
|
||||||
|
// 品牌名与页面 title 相同时只显示一份,避免「速哇手办制作Agent - 速哇手办制作Agent」
|
||||||
|
const brandName = jingdongConfig.brandName
|
||||||
|
window.document.title =
|
||||||
|
titleText && titleText !== brandName ? `${brandName} - ${titleText}` : brandName
|
||||||
|
setDocumentFavicon(jingdongConfig.images.logoIcon)
|
||||||
} else {
|
} else {
|
||||||
window.document.title = config.showBrandInTitle && brandText
|
window.document.title = config.showBrandInTitle && brandText
|
||||||
? `${brandText} - ${titleText}`
|
? `${brandText} - ${titleText}`
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import jingdongHome from '@/views/jingdong/index.vue'
|
||||||
|
|
||||||
|
export const routes = [
|
||||||
|
{
|
||||||
|
path: '/jingdong',
|
||||||
|
name: 'jingdong',
|
||||||
|
component: jingdongHome,
|
||||||
|
meta: {
|
||||||
|
needGuard: true,
|
||||||
|
title: '速哇手办制作Agent',
|
||||||
|
channel: 'jingdong',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function mergeRoutes(allRoutes: any[]) {
|
||||||
|
allRoutes.push(...routes)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* 图片主体识别结果(由后端 jd/subject/classify 返回后规范化)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ImageKind = 'cartoon' | 'real_photo' | 'other' | 'uncertain'
|
||||||
|
|
||||||
|
export interface ImageInspect {
|
||||||
|
imageType: ImageKind
|
||||||
|
isCartoon: boolean
|
||||||
|
personCount: number
|
||||||
|
petCount: number
|
||||||
|
/** 道具数量(若接口有返回) */
|
||||||
|
propCount: number
|
||||||
|
reviewStatus: string
|
||||||
|
conclusion: string
|
||||||
|
/** 主体识别 check_id,后续下单可用 */
|
||||||
|
checkId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InspectCancelled extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('cancelled')
|
||||||
|
this.name = 'InspectCancelled'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown) {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapInspectImageType(raw: unknown): ImageKind {
|
||||||
|
const text = String(raw ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
if (text === 'cartoon' || text === '1') return 'cartoon'
|
||||||
|
if (text === 'real_photo' || text === 'real' || text === '2') return 'real_photo'
|
||||||
|
if (text === 'other') return 'other'
|
||||||
|
return 'uncertain'
|
||||||
|
}
|
||||||
|
|
||||||
|
function countField(data: Record<string, unknown>, keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (!(key in data)) continue
|
||||||
|
const value = Number(data[key])
|
||||||
|
if (Number.isFinite(value)) return Math.max(0, Math.trunc(value))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasOwnCount(data: Record<string, unknown>, keys: string[]) {
|
||||||
|
return keys.some((key) => key in data && Number.isFinite(Number(data[key])))
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeywordBucket = 'person' | 'pet' | 'prop' | 'other'
|
||||||
|
|
||||||
|
/** 按 category / 中英文关键词区分人物、宠物、道具 */
|
||||||
|
function classifyKeywordBucket(item: Record<string, unknown>): KeywordBucket {
|
||||||
|
const category = Number(item.category ?? item.Category ?? 0)
|
||||||
|
if (category === 1) return 'person'
|
||||||
|
if (category === 2) return 'pet'
|
||||||
|
if (category === 3) return 'prop'
|
||||||
|
|
||||||
|
const text = `${item.cn || ''} ${item.en || ''} ${item.name || ''} ${item.label || ''}`.toLowerCase()
|
||||||
|
if (/人|人物|人脸|儿童|成人|person|people|human|face|child|man|woman|boy|girl/.test(text)) {
|
||||||
|
return 'person'
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
/宠|宠物|猫|狗|兔|鸟|鼠|仓鼠|龙猫|鹦鹉|pet|cat|dog|rabbit|bird|hamster|animal/.test(
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return 'pet'
|
||||||
|
}
|
||||||
|
if (/道|具|prop|道具|物件|object/.test(text)) return 'prop'
|
||||||
|
return 'other'
|
||||||
|
}
|
||||||
|
|
||||||
|
function countFromKeywords(raw: unknown): {
|
||||||
|
personCount: number
|
||||||
|
petCount: number
|
||||||
|
propCount: number
|
||||||
|
hasKeywords: boolean
|
||||||
|
} {
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
|
return { personCount: 0, petCount: 0, propCount: 0, hasKeywords: false }
|
||||||
|
}
|
||||||
|
let personCount = 0
|
||||||
|
let petCount = 0
|
||||||
|
let propCount = 0
|
||||||
|
let useful = false
|
||||||
|
raw.forEach((entry) => {
|
||||||
|
const item = asRecord(entry)
|
||||||
|
if (!item) return
|
||||||
|
const n = Number(item.count ?? item.num ?? 1)
|
||||||
|
const count = Number.isFinite(n) ? Math.max(0, Math.trunc(n)) : 1
|
||||||
|
if (count <= 0) return
|
||||||
|
const bucket = classifyKeywordBucket(item)
|
||||||
|
if (bucket === 'other') return
|
||||||
|
useful = true
|
||||||
|
if (bucket === 'person') personCount += count
|
||||||
|
else if (bucket === 'pet') petCount += count
|
||||||
|
else if (bucket === 'prop') propCount += count
|
||||||
|
})
|
||||||
|
return { personCount, petCount, propCount, hasKeywords: useful }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规范化 classify 接口 data(拦截器已解包 code=1000 的 data) */
|
||||||
|
export function normalizeInspect(body: unknown): ImageInspect {
|
||||||
|
const root = asRecord(body) || {}
|
||||||
|
const nested = asRecord(root.data)
|
||||||
|
// 兼容完整响应 { code, data } / 已解包的 data 本体
|
||||||
|
const data =
|
||||||
|
nested &&
|
||||||
|
(nested.image_type != null ||
|
||||||
|
nested.person_count != null ||
|
||||||
|
nested.is_cartoon != null ||
|
||||||
|
nested.check_id != null ||
|
||||||
|
nested.keywords != null)
|
||||||
|
? nested
|
||||||
|
: root
|
||||||
|
const imageType = mapInspectImageType(data.image_type ?? data.imageType)
|
||||||
|
const isCartoonRaw = data.is_cartoon ?? data.isCartoon
|
||||||
|
const isCartoon =
|
||||||
|
typeof isCartoonRaw === 'boolean' ? isCartoonRaw : imageType === 'cartoon'
|
||||||
|
|
||||||
|
const fieldPerson = countField(data, [
|
||||||
|
'person_count',
|
||||||
|
'personCount',
|
||||||
|
'headcount',
|
||||||
|
'head_count',
|
||||||
|
])
|
||||||
|
const fieldPet = countField(data, ['pet_count', 'petCount'])
|
||||||
|
const fieldProp = countField(data, ['prop_count', 'propCount'])
|
||||||
|
const hasFieldPerson = hasOwnCount(data, [
|
||||||
|
'person_count',
|
||||||
|
'personCount',
|
||||||
|
'headcount',
|
||||||
|
'head_count',
|
||||||
|
])
|
||||||
|
const hasFieldPet = hasOwnCount(data, ['pet_count', 'petCount'])
|
||||||
|
const hasFieldProp = hasOwnCount(data, ['prop_count', 'propCount'])
|
||||||
|
|
||||||
|
const fromKeywords = countFromKeywords(data.keywords)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 严格按类别取数:
|
||||||
|
* - keywords 能分出人物/宠物时优先用(避免把宠物误记成人物)
|
||||||
|
* - 否则回退 person_count / pet_count
|
||||||
|
*/
|
||||||
|
let personCount = fieldPerson
|
||||||
|
let petCount = fieldPet
|
||||||
|
let propCount = fieldProp
|
||||||
|
if (fromKeywords.hasKeywords) {
|
||||||
|
personCount = fromKeywords.personCount
|
||||||
|
petCount = fromKeywords.petCount
|
||||||
|
propCount = fromKeywords.propCount
|
||||||
|
} else {
|
||||||
|
if (!hasFieldPerson) personCount = 0
|
||||||
|
if (!hasFieldPet) petCount = 0
|
||||||
|
if (!hasFieldProp) propCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageType,
|
||||||
|
isCartoon,
|
||||||
|
personCount,
|
||||||
|
petCount,
|
||||||
|
propCount,
|
||||||
|
reviewStatus: String(data.review_status || ''),
|
||||||
|
conclusion: String(data.conclusion || ''),
|
||||||
|
checkId: countField(data, ['check_id', 'checkId']),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCartoonImage(result: ImageInspect) {
|
||||||
|
return result.isCartoon || result.imageType === 'cartoon'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function figureTypeOf(result: ImageInspect) {
|
||||||
|
return result.imageType === 'real_photo' ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSubjectFromInspect(result: ImageInspect) {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (result.personCount > 0) parts.push(`${result.personCount}人`)
|
||||||
|
if (result.petCount > 0) parts.push(`${result.petCount}宠物`)
|
||||||
|
if (result.propCount > 0) parts.push(`${result.propCount}道具`)
|
||||||
|
return parts.join('+')
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
export type FlyRect = {
|
||||||
|
left: number
|
||||||
|
top: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefersReducedMotion() {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 二次贝塞尔:起点 → 控制点 → 终点 */
|
||||||
|
function quad(p0: number, p1: number, p2: number, t: number) {
|
||||||
|
const u = 1 - t
|
||||||
|
return u * u * p0 + 2 * u * t * p1 + t * t * p2
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeInOut(t: number) {
|
||||||
|
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将图片从源矩形沿抛物线飞到目标矩形,动画结束后移除 */
|
||||||
|
export function flyImageToRect(url: string, from: FlyRect, to: FlyRect, duration = 860): Promise<void> {
|
||||||
|
if (typeof document === 'undefined' || !url || from.width <= 0 || from.height <= 0) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
if (prefersReducedMotion()) return Promise.resolve()
|
||||||
|
|
||||||
|
const ghost = document.createElement('img')
|
||||||
|
ghost.src = url
|
||||||
|
ghost.alt = ''
|
||||||
|
ghost.className = 'jd-img-fly'
|
||||||
|
ghost.setAttribute('aria-hidden', 'true')
|
||||||
|
Object.assign(ghost.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
left: `${from.left}px`,
|
||||||
|
top: `${from.top}px`,
|
||||||
|
width: `${from.width}px`,
|
||||||
|
height: `${from.height}px`,
|
||||||
|
margin: '0',
|
||||||
|
padding: '0',
|
||||||
|
border: '0',
|
||||||
|
borderRadius: '12px',
|
||||||
|
objectFit: 'contain',
|
||||||
|
objectPosition: 'center',
|
||||||
|
background: '#ffffff',
|
||||||
|
zIndex: '10000',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.22)',
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
willChange: 'transform, opacity, border-radius',
|
||||||
|
} as CSSStyleDeclaration)
|
||||||
|
|
||||||
|
document.body.appendChild(ghost)
|
||||||
|
|
||||||
|
const sxEnd = to.width / Math.max(1, from.width)
|
||||||
|
const syEnd = to.height / Math.max(1, from.height)
|
||||||
|
const dx = to.left - from.left
|
||||||
|
const dy = to.top - from.top
|
||||||
|
|
||||||
|
const lift = Math.min(160, Math.max(72, Math.hypot(dx, dy) * 0.28))
|
||||||
|
const cx = dx * 0.5
|
||||||
|
const cy = dy * 0.5 - lift
|
||||||
|
|
||||||
|
const steps = 18
|
||||||
|
const keyframes: Keyframe[] = []
|
||||||
|
for (let i = 0; i <= steps; i += 1) {
|
||||||
|
const raw = i / steps
|
||||||
|
const t = easeInOut(raw)
|
||||||
|
const x = quad(0, cx, dx, t)
|
||||||
|
const y = quad(0, cy, dy, t)
|
||||||
|
const scaleX = 1 + (sxEnd - 1) * t
|
||||||
|
const scaleY = 1 + (syEnd - 1) * t
|
||||||
|
const radius = 12 + (10 - 12) * t
|
||||||
|
keyframes.push({
|
||||||
|
offset: raw,
|
||||||
|
transform: `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`,
|
||||||
|
opacity: String(1 - 0.08 * t),
|
||||||
|
borderRadius: `${radius}px`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = () => {
|
||||||
|
ghost.remove()
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
const anim = ghost.animate(keyframes, {
|
||||||
|
duration,
|
||||||
|
easing: 'linear',
|
||||||
|
fill: 'forwards',
|
||||||
|
})
|
||||||
|
anim.onfinish = finish
|
||||||
|
anim.oncancel = finish
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rectFromElement(el: Element | null | undefined): FlyRect | null {
|
||||||
|
if (!el) return null
|
||||||
|
const box = el.getBoundingClientRect()
|
||||||
|
if (box.width <= 0 || box.height <= 0) return null
|
||||||
|
return { left: box.left, top: box.top, width: box.width, height: box.height }
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/** 效果图下载:尽量兼容各端浏览器,失败时回落打开大图并提示 */
|
||||||
|
|
||||||
|
export type DownloadOutcome = 'saved' | 'opened' | 'failed'
|
||||||
|
|
||||||
|
function filenameFromUrl(url: string, fallback: string) {
|
||||||
|
try {
|
||||||
|
const path = new URL(url, window.location.href).pathname
|
||||||
|
const name = decodeURIComponent(path.split('/').pop() || '')
|
||||||
|
if (/\.(jpe?g|png|webp|gif)$/i.test(name)) return name
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromMime(type: string) {
|
||||||
|
const mime = String(type || '').toLowerCase()
|
||||||
|
if (mime.includes('png')) return 'png'
|
||||||
|
if (mime.includes('webp')) return 'webp'
|
||||||
|
if (mime.includes('gif')) return 'gif'
|
||||||
|
if (mime.includes('jpeg') || mime.includes('jpg')) return 'jpg'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFilename(name: string, mime = '') {
|
||||||
|
const raw = String(name || '').trim() || `suwa-figure-${Date.now()}.png`
|
||||||
|
if (/\.(jpe?g|png|webp|gif)$/i.test(raw)) return raw
|
||||||
|
const ext = extFromMime(mime) || 'png'
|
||||||
|
return `${raw}.${ext}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageFetchTarget(url: string) {
|
||||||
|
if (url.startsWith('blob:') || url.startsWith('data:')) {
|
||||||
|
return { url, cache: 'default' as RequestCache }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url, window.location.href)
|
||||||
|
if (parsed.origin === window.location.origin) {
|
||||||
|
return { url, cache: 'default' as RequestCache }
|
||||||
|
}
|
||||||
|
// 部分 OSS 对带缓存的跨域 GET 不稳定,加时间戳并禁缓存
|
||||||
|
const busted = /\.aliyuncs\.com$/i.test(parsed.hostname)
|
||||||
|
? `${url}${url.includes('?') ? '&' : '?'}_=${Date.now()}`
|
||||||
|
: url
|
||||||
|
return { url: busted, cache: 'no-store' as RequestCache }
|
||||||
|
} catch {
|
||||||
|
return { url, cache: 'default' as RequestCache }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWeChatBrowser() {
|
||||||
|
return /MicroMessenger/i.test(navigator.userAgent || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInAppBrowser() {
|
||||||
|
const ua = navigator.userAgent || ''
|
||||||
|
return /MicroMessenger|QQ\//i.test(ua) || /; wv\)/i.test(ua)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchImageBlob(url: string) {
|
||||||
|
const target = imageFetchTarget(url)
|
||||||
|
const response = await fetch(target.url, {
|
||||||
|
method: 'GET',
|
||||||
|
mode: 'cors',
|
||||||
|
credentials: 'omit',
|
||||||
|
cache: target.cache,
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`图片读取失败(${response.status})`)
|
||||||
|
const blob = await response.blob()
|
||||||
|
if (!blob || blob.size <= 0) throw new Error('图片内容为空')
|
||||||
|
return blob
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(url: string, crossOrigin?: string) {
|
||||||
|
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
|
const image = new Image()
|
||||||
|
if (crossOrigin) image.crossOrigin = crossOrigin
|
||||||
|
image.onload = () => resolve(image)
|
||||||
|
image.onerror = () => reject(new Error('图片加载失败'))
|
||||||
|
image.src = url
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canvasToBlob(url: string) {
|
||||||
|
const image = await loadImage(url, 'anonymous')
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = Math.max(1, image.naturalWidth || image.width)
|
||||||
|
canvas.height = Math.max(1, image.naturalHeight || image.height)
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) throw new Error('当前浏览器不支持画布导出')
|
||||||
|
ctx.drawImage(image, 0, 0)
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob((value) => resolve(value), 'image/png')
|
||||||
|
})
|
||||||
|
if (!blob) throw new Error('画布导出失败')
|
||||||
|
return blob
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerAnchorDownload(href: string, filename: string, openBlank = false) {
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = href
|
||||||
|
link.download = filename
|
||||||
|
link.rel = 'noopener'
|
||||||
|
if (openBlank) link.target = '_blank'
|
||||||
|
link.style.display = 'none'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBlob(blob: Blob, filename: string) {
|
||||||
|
const name = ensureFilename(filename, blob.type)
|
||||||
|
const nav = navigator as Navigator & {
|
||||||
|
msSaveOrOpenBlob?: (blob: Blob, defaultName?: string) => boolean
|
||||||
|
}
|
||||||
|
// 旧版 Edge / IE
|
||||||
|
if (typeof nav.msSaveOrOpenBlob === 'function') {
|
||||||
|
nav.msSaveOrOpenBlob(blob, name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const objectUrl = URL.createObjectURL(blob)
|
||||||
|
try {
|
||||||
|
triggerAnchorDownload(objectUrl, name)
|
||||||
|
} finally {
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 2_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageFallback(url: string) {
|
||||||
|
try {
|
||||||
|
const opened = window.open(url, '_blank', 'noopener,noreferrer')
|
||||||
|
if (opened) return true
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
triggerAnchorDownload(url, ensureFilename(`suwa-figure-${Date.now()}.png`), true)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尽量触发本地下载;失败则打开大图,由调用方提示长按保存 / 换浏览器。
|
||||||
|
*/
|
||||||
|
export async function downloadImageCompat(
|
||||||
|
url: string,
|
||||||
|
filename = `suwa-figure-${Date.now()}.png`,
|
||||||
|
): Promise<DownloadOutcome> {
|
||||||
|
const href = String(url || '').trim()
|
||||||
|
if (!href) return 'failed'
|
||||||
|
|
||||||
|
const name = ensureFilename(filenameFromUrl(href, filename))
|
||||||
|
|
||||||
|
// 1) fetch → blob → a[download] / msSaveOrOpenBlob
|
||||||
|
try {
|
||||||
|
const blob = await fetchImageBlob(href)
|
||||||
|
saveBlob(blob, name)
|
||||||
|
return 'saved'
|
||||||
|
} catch {
|
||||||
|
/* continue */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Image + canvas 导出(部分可匿名跨域的 CDN)
|
||||||
|
try {
|
||||||
|
const blob = await canvasToBlob(href)
|
||||||
|
saveBlob(blob, name)
|
||||||
|
return 'saved'
|
||||||
|
} catch {
|
||||||
|
/* continue */
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 打开大图兜底(微信 / iOS 内置浏览器等常只能长按保存)
|
||||||
|
if (openImageFallback(href)) return 'opened'
|
||||||
|
return 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadFallbackTip() {
|
||||||
|
if (isWeChatBrowser()) {
|
||||||
|
return '微信内暂不支持直接下载,请点右上角「···」用浏览器打开后保存,或长按图片保存'
|
||||||
|
}
|
||||||
|
if (isInAppBrowser()) {
|
||||||
|
return '当前浏览器无法直接下载,请用系统浏览器打开后保存,或长按图片保存'
|
||||||
|
}
|
||||||
|
return '当前浏览器无法直接下载,请换用 Safari / Chrome,或长按图片保存'
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
/** 将后端 / 网络 / 前端技术错误转成用户可读提示 */
|
||||||
|
|
||||||
|
import { jingdongConfig } from '@/config/jingdong'
|
||||||
|
|
||||||
|
const KNOWN: Record<string, string> = {
|
||||||
|
未登录: '登录已失效,请关闭后重新进入再试',
|
||||||
|
请求失败: '服务繁忙,请稍后重试',
|
||||||
|
接口返回无法解析: '服务暂时没有响应,请稍后重试',
|
||||||
|
'Failed to fetch': '网络异常,请检查网络后重试',
|
||||||
|
'Network Error': '网络异常,请检查网络后重试',
|
||||||
|
'NetworkError when attempting to fetch resource.': '网络异常,请检查网络后重试',
|
||||||
|
'Load failed': '网络异常,请检查网络后重试',
|
||||||
|
timeout: '等待超时,请稍后重试',
|
||||||
|
Timeout: '等待超时,请稍后重试',
|
||||||
|
ECONNABORTED: '等待超时,请稍后重试',
|
||||||
|
图片上传失败: '照片上传失败,请换一张或稍后重试',
|
||||||
|
签发上传地址失败: '照片上传失败,请稍后重试',
|
||||||
|
'签发上传地址失败:缺少 upload_id': '照片上传失败,请稍后重试',
|
||||||
|
'签发上传地址失败:缺少 upload_url': '照片上传失败,请稍后重试',
|
||||||
|
'获取 upload_id 失败': '照片上传失败,请稍后重试',
|
||||||
|
'upload/url 响应无法解析': '照片上传失败,请稍后重试',
|
||||||
|
生成失败: jingdongConfig.messages.generateFail,
|
||||||
|
提交生成失败: '提交失败了,请稍后重试',
|
||||||
|
没有生成结果: '这次没有生成出效果图,请再试一次',
|
||||||
|
生成超时: '生成时间有点久,请稍后重试',
|
||||||
|
'生成超时,请稍后重试': '生成时间有点久,请稍后重试',
|
||||||
|
已取消生成: '已取消本次生成',
|
||||||
|
请先上传照片: '请先上传一张照片再发送',
|
||||||
|
缺少可编辑的效果图: '效果图还没准备好,请稍后再试',
|
||||||
|
'缺少会话信息,请刷新后重试': '页面信息已失效,请刷新后再试',
|
||||||
|
'缺少产品信息,请刷新后重试': '订单产品信息异常,请刷新后再试',
|
||||||
|
效果图加载失败: '效果图加载失败,请重试',
|
||||||
|
'效果图加载失败,请重试': '效果图加载失败,请重试',
|
||||||
|
'product_id不在Agent开放分类': '当前产品暂不支持在线生图,请联系客服',
|
||||||
|
会话不存在: jingdongConfig.messages.sessionMissing,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生图 / 编辑:内容策略单独翻译;Processing/ApiError 走场景兜底文案 */
|
||||||
|
const GENERATE_EDIT_RULES: Array<{ test: RegExp; tipKey: 'contentPolicyFail' | 'processingFail' }> = [
|
||||||
|
{
|
||||||
|
test: /content\s*policy|minor|public\s*figure|recognizable\s*public/i,
|
||||||
|
tipKey: 'contentPolicyFail',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /processing\s*failed|ApiError/i,
|
||||||
|
tipKey: 'processingFail',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 前端自抛、可原样映射的短文案(生图场景仍保留) */
|
||||||
|
const GENERATE_EDIT_KEEP = new Set([
|
||||||
|
'已取消生成',
|
||||||
|
'生成超时',
|
||||||
|
'生成超时,请稍后重试',
|
||||||
|
'提交生成失败',
|
||||||
|
'没有生成结果',
|
||||||
|
'请先上传照片',
|
||||||
|
'缺少可编辑的效果图',
|
||||||
|
'缺少会话信息,请刷新后重试',
|
||||||
|
'缺少产品信息,请刷新后重试',
|
||||||
|
'product_id不在Agent开放分类',
|
||||||
|
])
|
||||||
|
|
||||||
|
const PATTERN_RULES: Array<{ test: RegExp; tip: string }> = [
|
||||||
|
{ test: /timeout|超时|ETIMEDOUT|ECONNABORTED/i, tip: '等待超时,请稍后重试' },
|
||||||
|
{ test: /network|fetch failed|ERR_NETWORK|断网|网络/i, tip: '网络异常,请检查网络后重试' },
|
||||||
|
{ test: /upload|上传|oss|签名/i, tip: '照片上传失败,请换一张或稍后重试' },
|
||||||
|
{ test: /login|auth|401|未登录|登录|鉴权|token/i, tip: '登录已失效,请关闭后重新进入再试' },
|
||||||
|
{ test: /403|无权限|forbidden/i, tip: '暂时没有操作权限,请稍后重试' },
|
||||||
|
{ test: /404|not found/i, tip: '服务接口暂不可用,请稍后重试' },
|
||||||
|
{ test: /5\d{2}|server error|内部错误|系统繁忙/i, tip: '服务繁忙,请稍后重试' },
|
||||||
|
{ test: /product_id|开放分类|产品/i, tip: '当前产品暂不支持在线生图,请联系客服' },
|
||||||
|
{ test: /次数|remain|quota|不足/i, tip: '生图次数不足,暂时无法继续生成' },
|
||||||
|
{ test: /主体|不符|不一致/i, tip: '照片主体与订单不符,请更换照片后再试' },
|
||||||
|
{ test: /cancel|取消|aborted/i, tip: '已取消本次操作' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export type FriendlyErrorScene = 'generate' | 'edit' | 'upload' | 'inspect' | 'download' | 'common'
|
||||||
|
|
||||||
|
const SCENE_FALLBACK: Record<FriendlyErrorScene, string> = {
|
||||||
|
generate: jingdongConfig.messages.generateFail,
|
||||||
|
edit: jingdongConfig.messages.editFail,
|
||||||
|
upload: '照片上传失败,请换一张或稍后重试',
|
||||||
|
inspect: '照片识别失败,请换一张或稍后重试',
|
||||||
|
download: '下载失败,请换用其他浏览器,或长按效果图保存',
|
||||||
|
common: '操作失败了,请稍后重试',
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawMessage(error: unknown): string {
|
||||||
|
if (error == null) return ''
|
||||||
|
if (typeof error === 'string') return error.trim()
|
||||||
|
if (error instanceof Error) return String(error.message || '').trim()
|
||||||
|
if (typeof error === 'object') {
|
||||||
|
const record = error as Record<string, unknown>
|
||||||
|
const nested = record.response
|
||||||
|
if (nested && typeof nested === 'object') {
|
||||||
|
const data = (nested as Record<string, unknown>).data
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const msg = (data as Record<string, unknown>).message || (data as Record<string, unknown>).msg
|
||||||
|
if (msg != null && String(msg).trim()) return String(msg).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const msg = record.message || record.msg || record.error
|
||||||
|
if (msg != null && String(msg).trim()) return String(msg).trim()
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksTechnical(text: string) {
|
||||||
|
if (!text) return true
|
||||||
|
if (text.length > 48) return true
|
||||||
|
if (/[{}<>]|https?:|undefined|null|NaN|\[object /i.test(text)) return true
|
||||||
|
if (/[A-Za-z]{3,}[_./][A-Za-z0-9_./-]{2,}/.test(text)) return true
|
||||||
|
if (/^[A-Za-z0-9_.:\-\s/]+$/.test(text) && /[A-Za-z]{4,}/.test(text)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGenerateEditError(text: string, tip: string): string {
|
||||||
|
for (const rule of GENERATE_EDIT_RULES) {
|
||||||
|
if (!rule.test.test(text)) continue
|
||||||
|
// 内容策略用专用文案;Processing/ApiError 与其它失败一样走场景兜底
|
||||||
|
if (rule.tipKey === 'contentPolicyFail') {
|
||||||
|
return jingdongConfig.messages.contentPolicyFail
|
||||||
|
}
|
||||||
|
return tip
|
||||||
|
}
|
||||||
|
if (GENERATE_EDIT_KEEP.has(text) && KNOWN[text]) return KNOWN[text]
|
||||||
|
for (const key of GENERATE_EDIT_KEEP) {
|
||||||
|
if (key.length >= 4 && text.includes(key) && KNOWN[key]) return KNOWN[key]
|
||||||
|
}
|
||||||
|
// 生图/编辑:未识别的后端英文或其它错误,统一兜底
|
||||||
|
return tip
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任意错误 → 友好中文提示。
|
||||||
|
* - 生图/编辑:仅内容策略、Processing/ApiError 单独翻译,其余走 generateFail / editFail
|
||||||
|
* - 其它场景:已知文案 / 规则优先,技术性内容回落默认文案
|
||||||
|
*/
|
||||||
|
export function friendlyError(
|
||||||
|
error: unknown,
|
||||||
|
scene: FriendlyErrorScene = 'common',
|
||||||
|
fallback?: string,
|
||||||
|
): string {
|
||||||
|
const tip = fallback || SCENE_FALLBACK[scene] || SCENE_FALLBACK.common
|
||||||
|
const text = rawMessage(error)
|
||||||
|
if (!text) return tip
|
||||||
|
|
||||||
|
if (scene === 'generate' || scene === 'edit') {
|
||||||
|
return mapGenerateEditError(text, tip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (KNOWN[text]) return KNOWN[text]
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(KNOWN)) {
|
||||||
|
if (key.length >= 4 && text.includes(key)) return value
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of PATTERN_RULES) {
|
||||||
|
if (rule.test.test(text)) return rule.tip
|
||||||
|
}
|
||||||
|
|
||||||
|
if (looksTechnical(text)) return tip
|
||||||
|
return text
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/** 对齐 agent catalog.productTitleSpec:哪些产品需要标题输入,以及提交 scene_prop */
|
||||||
|
|
||||||
|
export type ProductTitleMode = 'chars' | 'props'
|
||||||
|
|
||||||
|
export interface ProductTitleSpec {
|
||||||
|
label: string
|
||||||
|
placeholder: string
|
||||||
|
/** chars:按字符数;props:按分号分隔的道具个数 */
|
||||||
|
mode: ProductTitleMode
|
||||||
|
max: number
|
||||||
|
/** 输入框下方辅助说明 */
|
||||||
|
hint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCENE_PROP_SPEC: ProductTitleSpec = {
|
||||||
|
label: '场景道具',
|
||||||
|
placeholder: '例如:沙发;台灯;花瓶;地毯',
|
||||||
|
mode: 'props',
|
||||||
|
max: 4,
|
||||||
|
hint: '最多填写4个场景道具,用英文或中文分号隔开',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRibbonVariant(name: string) {
|
||||||
|
return /B\s*款/.test(String(name || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productTitleSpec(label: string, variantName = ''): ProductTitleSpec | null {
|
||||||
|
const name = String(label || '').trim()
|
||||||
|
if (name.includes('场景手办')) {
|
||||||
|
return { ...SCENE_PROP_SPEC }
|
||||||
|
}
|
||||||
|
if (name.includes('相框手办挂饰') || (name.includes('相框') && name.includes('挂饰'))) {
|
||||||
|
return {
|
||||||
|
label: '相框标题',
|
||||||
|
placeholder: '输入相框名称,如「我们的小家」',
|
||||||
|
mode: 'chars',
|
||||||
|
max: 18,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (name.includes('相框手办') && isRibbonVariant(variantName)) {
|
||||||
|
return {
|
||||||
|
label: '飘带标题',
|
||||||
|
placeholder: '输入飘带名称,如「生日快乐」',
|
||||||
|
mode: 'chars',
|
||||||
|
max: 18,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按分号拆成道具列表(忽略空段) */
|
||||||
|
export function splitSceneProps(text: string): string[] {
|
||||||
|
return String(text || '')
|
||||||
|
.split(/[;;]/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 限制最多 max 个道具,统一用英文分号拼接 */
|
||||||
|
export function clampSceneProps(text: string, max = 4): string {
|
||||||
|
const parts = splitSceneProps(text).slice(0, Math.max(0, max))
|
||||||
|
const endsWithSep = /[;;]\s*$/.test(String(text || ''))
|
||||||
|
// 输入未满且以分号结尾时,保留末尾分号,方便继续填下一项
|
||||||
|
if (endsWithSep && parts.length < max) {
|
||||||
|
return parts.length ? `${parts.join(';')};` : ''
|
||||||
|
}
|
||||||
|
return parts.join(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成接口 scene_prop:仅当产品需要标题字段且已填写时传原文 */
|
||||||
|
export function resolveSceneProp(
|
||||||
|
productName: string,
|
||||||
|
title: string,
|
||||||
|
variantName = '',
|
||||||
|
): string {
|
||||||
|
const spec = productTitleSpec(productName, variantName)
|
||||||
|
if (!spec) return ''
|
||||||
|
const raw = String(title || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (spec.mode === 'props') return clampSceneProps(raw, spec.max)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOf(...values: unknown[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
const text = String(value ?? '').trim()
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectVariantName(orderStat: Record<string, any> | null | undefined) {
|
||||||
|
if (!orderStat) return ''
|
||||||
|
return [
|
||||||
|
orderStat.sub_cate_name,
|
||||||
|
orderStat.shape_name,
|
||||||
|
orderStat.kind_name,
|
||||||
|
orderStat.mounts_name,
|
||||||
|
orderStat.mount_name,
|
||||||
|
orderStat.frame_name,
|
||||||
|
orderStat.prod_kind_name,
|
||||||
|
orderStat.variant_name,
|
||||||
|
orderStat.sub_type_name,
|
||||||
|
]
|
||||||
|
.map((item) => String(item ?? '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 orders/stat 解析标题规则。
|
||||||
|
* 产品名:type_name → prod_name → product_name
|
||||||
|
* 变体名:sub_cate_name(如 B款)+ shape/kind/mounts 等,供飘带标题判断
|
||||||
|
* 兜底:type_id 9 → 场景道具;type_id 11 → 相框标题(与 cartoon 一致)
|
||||||
|
*/
|
||||||
|
export function productTitleSpecFromOrder(
|
||||||
|
orderStat: Record<string, any> | null | undefined,
|
||||||
|
): ProductTitleSpec | null {
|
||||||
|
if (!orderStat) return null
|
||||||
|
|
||||||
|
const productName = textOf(
|
||||||
|
orderStat.type_name,
|
||||||
|
orderStat.prod_name,
|
||||||
|
orderStat.product_name,
|
||||||
|
)
|
||||||
|
const variantName = collectVariantName(orderStat)
|
||||||
|
const byName = productTitleSpec(productName, variantName)
|
||||||
|
if (byName) return byName
|
||||||
|
|
||||||
|
const typeId = Number(orderStat.type_id || 0)
|
||||||
|
if (typeId === 9) {
|
||||||
|
return { ...SCENE_PROP_SPEC }
|
||||||
|
}
|
||||||
|
if (typeId === 11) {
|
||||||
|
return {
|
||||||
|
label: '相框标题',
|
||||||
|
placeholder: '输入相框名称,如「我们的小家」',
|
||||||
|
mode: 'chars',
|
||||||
|
max: 18,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveScenePropFromOrder(
|
||||||
|
orderStat: Record<string, any> | null | undefined,
|
||||||
|
title: string,
|
||||||
|
): string {
|
||||||
|
const spec = productTitleSpecFromOrder(orderStat)
|
||||||
|
if (!spec) return ''
|
||||||
|
const raw = String(title || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (spec.mode === 'props') return clampSceneProps(raw, spec.max)
|
||||||
|
return raw
|
||||||
|
}
|
||||||
@@ -69,30 +69,65 @@ const requestHandler = (config: InternalAxiosRequestConfig): InternalAxiosReques
|
|||||||
// Add a request interceptor
|
// Add a request interceptor
|
||||||
request.interceptors.request.use(requestHandler, errorHandler)
|
request.interceptors.request.use(requestHandler, errorHandler)
|
||||||
|
|
||||||
|
function coerceJsonPayload(payload: any) {
|
||||||
|
// 后端若用 text/plain 返回 JSON,axios 不会自动 parse,这里补一层
|
||||||
|
if (typeof payload !== 'string') return payload
|
||||||
|
const text = payload.replace(/^\uFEFF/, '').trim()
|
||||||
|
if (!text) return payload
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
const start = text.indexOf('{')
|
||||||
|
const end = text.lastIndexOf('}')
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text.slice(start, end + 1))
|
||||||
|
} catch {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
const responseHandler = (response: { data: any }) => {
|
const responseHandler = (response: { data: any }) => {
|
||||||
if (!response?.data)
|
if (!response?.data && response?.data !== 0 && response?.data !== false)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
const { code, data, message } = response.data
|
// 兼容偶发双层:{ data: { code, data, message } }
|
||||||
|
let payload = coerceJsonPayload(response.data)
|
||||||
|
if (
|
||||||
|
payload &&
|
||||||
|
typeof payload === 'object' &&
|
||||||
|
payload.code == null &&
|
||||||
|
payload.data &&
|
||||||
|
typeof payload.data === 'object' &&
|
||||||
|
payload.data.code != null
|
||||||
|
) {
|
||||||
|
payload = payload.data
|
||||||
|
}
|
||||||
|
|
||||||
if (!code)
|
if (!payload || typeof payload !== 'object')
|
||||||
return response.data
|
return payload
|
||||||
|
|
||||||
// 拦截时间
|
const { code, data, message } = payload
|
||||||
switch (code) {
|
|
||||||
case 1000:
|
if (code == null || code === '')
|
||||||
|
return payload
|
||||||
|
|
||||||
|
// 兼容 code 为字符串 "1000"
|
||||||
|
const numericCode = Number(code)
|
||||||
|
if (numericCode === 1000) {
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
// transfter(data, timezone.info);
|
// transfter(data, timezone.info);
|
||||||
} else if (data && data.list !== undefined && data.list?.length > 0) {
|
} else if (data && data.list !== undefined && data.list?.length > 0) {
|
||||||
// transfter(data.list, timezone.info);
|
// transfter(data.list, timezone.info);
|
||||||
}
|
}
|
||||||
return data
|
return data
|
||||||
default:
|
|
||||||
return Promise.reject(new Error(message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.data
|
return Promise.reject(new Error(message || '请求失败'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add a response interceptor
|
// Add a response interceptor
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="photo-upload-body">
|
<div class="photo-upload-body" @click="triggerPick">
|
||||||
<div v-if="!picture" class="photo-upload-box">
|
<div v-if="!picture" class="photo-upload-box">
|
||||||
<div class="photo-upload-area">
|
<div class="photo-upload-area">
|
||||||
<div class="photo-upload-plus">
|
<div class="photo-upload-plus">
|
||||||
@@ -195,10 +195,60 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<img class="photo-upload-img" v-if="picture" :src="picture" alt="" srcset="">
|
<img class="photo-upload-img" v-if="picture" :src="picture" alt="" srcset="">
|
||||||
<div class="photo-upload-box-1">
|
<input
|
||||||
<h5-cropper :option="option" @getbase64Data="getbase64Data" @imgorigoinf="imgorigoinf"></h5-cropper>
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="photo-upload-input"
|
||||||
|
@change="onFileChange"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="cropping" class="ct-cropper">
|
||||||
|
<div class="ct-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="ct-cropper__bar">
|
||||||
|
<button class="ct-cropper__btn" type="button" @click="onCropCancel">{{ toValueWithout("取消") }}</button>
|
||||||
|
<div class="ct-cropper__zoom">
|
||||||
|
<button class="ct-cropper__zoom-btn" type="button" aria-label="缩小" @click="onCropZoom(-1)">-</button>
|
||||||
|
<button class="ct-cropper__zoom-btn" type="button" aria-label="放大" @click="onCropZoom(1)">+</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="ct-cropper__btn ct-cropper__btn--ok"
|
||||||
|
type="button"
|
||||||
|
:disabled="cropConfirming"
|
||||||
|
@click="onCropConfirm"
|
||||||
|
>
|
||||||
|
{{ toValueWithout("确定") }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Teleport>
|
||||||
<div :style="`height: 120px;`"></div>
|
<div :style="`height: 120px;`"></div>
|
||||||
<div class="design-action-bar">
|
<div class="design-action-bar">
|
||||||
<div class="design-left">
|
<div class="design-left">
|
||||||
@@ -214,9 +264,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import H5Cropper from 'vue-cropper-h5'
|
import { VueCropper } from 'vue-cropper'
|
||||||
import "vue-cropper-h5/dist/style.css"
|
import 'vue-cropper/dist/index.css'
|
||||||
import { showLoadingToast, showToast, closeToast, showFailToast, showSuccessToast } from 'vant';
|
import { showLoadingToast, showToast, closeToast, showFailToast, showSuccessToast } from 'vant';
|
||||||
import type { UploaderFileListItem } from 'vant'
|
import type { UploaderFileListItem } from 'vant'
|
||||||
import * as badgeApi from '@/api/badge'
|
import * as badgeApi from '@/api/badge'
|
||||||
@@ -237,25 +288,11 @@ const config = cartoonConfig
|
|||||||
const referPicture = ref<UploaderFileListItem[]>([])
|
const referPicture = ref<UploaderFileListItem[]>([])
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 初始化裁剪器配置
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const option = ref({
|
const cropperRef = ref<any>(null)
|
||||||
...config.upload.cropOptions
|
const cropping = ref(false)
|
||||||
})
|
const cropImg = ref('')
|
||||||
|
const cropConfirming = ref(false)
|
||||||
// 判断是否是APP
|
|
||||||
const openApp = () => {
|
|
||||||
const ua = navigator.userAgent;
|
|
||||||
const isIOS = /iPhone|iPad|iPod/i.test(ua);
|
|
||||||
const isAndroid = /Android/i.test(ua);
|
|
||||||
|
|
||||||
if (isIOS) {
|
|
||||||
option.value.ceilbutton = true
|
|
||||||
} else if (isAndroid) {
|
|
||||||
option.value.ceilbutton = false
|
|
||||||
} else {
|
|
||||||
option.value.ceilbutton = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const imgurl = ref('')
|
const imgurl = ref('')
|
||||||
|
|
||||||
@@ -437,31 +474,57 @@ const picture = ref(null)
|
|||||||
const imageWidth = ref(0);
|
const imageWidth = ref(0);
|
||||||
const imageHeight = ref(0);
|
const imageHeight = ref(0);
|
||||||
|
|
||||||
function getbase64Data(data: string) {
|
let lockedScrollY = 0
|
||||||
picture.value = data;
|
|
||||||
|
|
||||||
const img = new Image();
|
function preventTouchScroll(e: TouchEvent) {
|
||||||
img.src = data;
|
const host = document.querySelector('.ct-cropper')
|
||||||
img.onload = () => {
|
if (host && e.target instanceof Node && host.contains(e.target)) return
|
||||||
imageWidth.value = img.naturalWidth;
|
e.preventDefault()
|
||||||
imageHeight.value = img.naturalHeight;
|
}
|
||||||
if (imageWidth.value < config.upload.minWidth || imageHeight.value < config.upload.minHeight) {
|
|
||||||
showToast(toValueWithout(`请上传尺寸大于${config.upload.minWidth}*${config.upload.minHeight}像素的照片`))
|
function setBodyScrollLocked(locked: boolean) {
|
||||||
imgurl.value = null
|
const html = document.documentElement
|
||||||
picture.value = null
|
const body = document.body
|
||||||
return false;
|
if (locked) {
|
||||||
}
|
lockedScrollY = window.scrollY || window.pageYOffset || 0
|
||||||
const blob = base64ToBlob(data);
|
html.style.overflow = 'hidden'
|
||||||
if (blob.size > config.upload.maxSize) {
|
body.style.overflow = 'hidden'
|
||||||
showToast(toValueWithout(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`))
|
body.style.position = 'fixed'
|
||||||
imgurl.value = null
|
body.style.top = `-${lockedScrollY}px`
|
||||||
picture.value = null
|
body.style.left = '0'
|
||||||
return false;
|
body.style.right = '0'
|
||||||
}
|
body.style.width = '100%'
|
||||||
imgurl.value = blob;
|
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() {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
function base64ToBlob(base64: string) {
|
function base64ToBlob(base64: string) {
|
||||||
const arr = base64.split(',');
|
const arr = base64.split(',');
|
||||||
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/jpeg';
|
const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/jpeg';
|
||||||
@@ -476,29 +539,98 @@ function base64ToBlob(base64: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function imgorigoinf(data: File) {
|
function applyCroppedData(data: string) {
|
||||||
const img = new Image();
|
picture.value = data
|
||||||
const objectUrl = URL.createObjectURL(data);
|
const img = new Image()
|
||||||
img.src = objectUrl;
|
img.src = data
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
imageWidth.value = img.naturalWidth;
|
imageWidth.value = img.naturalWidth
|
||||||
imageHeight.value = img.naturalHeight;
|
imageHeight.value = img.naturalHeight
|
||||||
if (imageWidth.value < config.upload.minWidth || imageHeight.value < config.upload.minHeight) {
|
if (imageWidth.value < config.upload.minWidth || imageHeight.value < config.upload.minHeight) {
|
||||||
showToast(toValueWithout(`请上传尺寸大于${config.upload.minWidth}*${config.upload.minHeight}像素的照片`))
|
showToast(toValueWithout(`请上传尺寸大于${config.upload.minWidth}*${config.upload.minHeight}像素的照片`))
|
||||||
setTimeout(() => {
|
|
||||||
const btn = document.querySelector('.btn')
|
|
||||||
if (btn) {
|
|
||||||
(btn as HTMLElement).click()
|
|
||||||
}
|
|
||||||
}, 1500);
|
|
||||||
imgurl.value = null
|
imgurl.value = null
|
||||||
picture.value = null
|
picture.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const blob = base64ToBlob(data)
|
||||||
|
if (blob.size > config.upload.maxSize) {
|
||||||
|
showToast(toValueWithout(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`))
|
||||||
|
imgurl.value = null
|
||||||
|
picture.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
imgurl.value = blob
|
||||||
|
}
|
||||||
|
img.onerror = () => {
|
||||||
|
showToast(toValueWithout('裁剪图片加载失败'))
|
||||||
|
imgurl.value = null
|
||||||
|
picture.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(toValueWithout('裁剪组件未就绪'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cropConfirming.value = true
|
||||||
|
cropper.getCropData((data: string) => {
|
||||||
|
closeCropping()
|
||||||
|
if (!data) {
|
||||||
|
showToast(toValueWithout('裁剪失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyCroppedData(data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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(toValueWithout(`照片大小不能超过${config.upload.maxSize / 1024 / 1024}M`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const objectUrl = URL.createObjectURL(file)
|
||||||
|
const img = new Image()
|
||||||
|
img.src = objectUrl
|
||||||
|
img.onload = () => {
|
||||||
|
imageWidth.value = img.naturalWidth
|
||||||
|
imageHeight.value = img.naturalHeight
|
||||||
URL.revokeObjectURL(objectUrl)
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
if (imageWidth.value < config.upload.minWidth || imageHeight.value < config.upload.minHeight) {
|
||||||
|
showToast(toValueWithout(`请上传尺寸大于${config.upload.minWidth}*${config.upload.minHeight}像素的照片`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const dataUrl = String(ev.target?.result || '')
|
||||||
|
if (!dataUrl) {
|
||||||
|
showToast(toValueWithout('图片读取失败'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextTick(() => openCropping(dataUrl))
|
||||||
|
}
|
||||||
|
reader.onerror = () => showToast(toValueWithout('图片读取失败'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
URL.revokeObjectURL(objectUrl)
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
showToast(toValueWithout('图片加载失败'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,10 +829,13 @@ const createLog = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
openApp()
|
|
||||||
getOrderStat()
|
getOrderStat()
|
||||||
getSizeList()
|
getSizeList()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (cropping.value) closeCropping()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -843,6 +978,7 @@ onMounted(() => {
|
|||||||
height: 80vw;
|
height: 80vw;
|
||||||
margin: 16px auto 0 auto;
|
margin: 16px auto 0 auto;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.photo-upload-box {
|
.photo-upload-box {
|
||||||
margin: 16px auto 0 auto;
|
margin: 16px auto 0 auto;
|
||||||
@@ -856,21 +992,94 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.photo-upload-box-1 {
|
.photo-upload-input {
|
||||||
width: 80vw;
|
|
||||||
height: 70vw;
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(0, 0, 0, 0);
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
inset: 0;
|
||||||
left: 0;
|
width: 100%;
|
||||||
right: 0;
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.photo-upload-img {
|
.photo-upload-img {
|
||||||
max-width: 80vw;
|
max-width: 80vw;
|
||||||
max-height: 80vw;
|
max-height: 80vw;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 全屏裁剪层:3:4 + 可缩放 */
|
||||||
|
.ct-cropper {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #000;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
.ct-cropper__stage {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ct-cropper__stage :deep(.vue-cropper) {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
background: #111 !important;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.ct-cropper__stage :deep(.cropper-view-box) {
|
||||||
|
outline: 2px solid #fff !important;
|
||||||
|
}
|
||||||
|
.ct-cropper__stage :deep(.crop-point) {
|
||||||
|
background-color: #50cf54 !important;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
.ct-cropper__bar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
.ct-cropper__zoom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.ct-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;
|
||||||
|
}
|
||||||
|
.ct-cropper__btn {
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.ct-cropper__btn--ok {
|
||||||
|
background: #50cf54;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ct-cropper__btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
.photo-upload-header {
|
.photo-upload-header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
<template>
|
||||||
|
<div class="composer">
|
||||||
|
<div v-if="editing" class="composer__edit-bar">
|
||||||
|
<span class="composer__edit-left">
|
||||||
|
<el-icon class="composer__edit-icon" :size="14"><EditPen /></el-icon>
|
||||||
|
<span class="composer__edit-text">{{ editingHint }}</span>
|
||||||
|
</span>
|
||||||
|
<button class="composer__exit" type="button" @click="emit('exit-edit')">
|
||||||
|
<el-icon :size="12"><Close /></el-icon>
|
||||||
|
<span>{{ exitEditLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="statusTip" class="composer__tip" :class="{ 'is-warn': tipWarn }">{{ statusTip }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="boxRef"
|
||||||
|
class="composer__box"
|
||||||
|
:class="{ 'is-editing': editing, 'is-disabled': disabled, 'has-custom': !!titleSpec }"
|
||||||
|
>
|
||||||
|
<div v-if="titleSpec" class="composer__custom-block" :class="{ 'is-invalid': titleInvalid }">
|
||||||
|
<div class="composer__custom">
|
||||||
|
<label class="composer__custom-label" :for="titleInputId">
|
||||||
|
<el-icon class="composer__custom-icon" :size="14"><EditPen /></el-icon>
|
||||||
|
<span>{{ customLabel }}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
:id="titleInputId"
|
||||||
|
class="composer__custom-input"
|
||||||
|
type="text"
|
||||||
|
:value="productTitle"
|
||||||
|
:maxlength="titleInputMaxlength"
|
||||||
|
:placeholder="customPlaceholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:aria-invalid="titleInvalid"
|
||||||
|
@input="onTitleInput"
|
||||||
|
/>
|
||||||
|
<span class="composer__custom-count">{{ titleCount }}/{{ customMax }}</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="titleSpec.hint" class="composer__custom-hint">{{ titleSpec.hint }}</p>
|
||||||
|
<p v-if="titleInvalid" class="composer__custom-error" role="alert">
|
||||||
|
请填写{{ customLabel }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="previewUrl" ref="previewRef" class="composer__preview">
|
||||||
|
<img class="composer__preview-img" :src="previewUrl" alt="已选图片" />
|
||||||
|
<span v-if="inspecting" class="composer__preview-mask">识别中</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="composer__preview-close"
|
||||||
|
type="button"
|
||||||
|
aria-label="移除图片"
|
||||||
|
@click="emit('clear-preview')"
|
||||||
|
>
|
||||||
|
<el-icon :size="10" color="#fff"><Close /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref="textareaRef"
|
||||||
|
class="composer__input"
|
||||||
|
rows="2"
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="resolvedPlaceholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
@input="onInput"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="composer__toolbar">
|
||||||
|
<button
|
||||||
|
class="composer__upload"
|
||||||
|
type="button"
|
||||||
|
:aria-label="editing ? '编辑中不可更换照片' : '上传图片'"
|
||||||
|
:disabled="disabled || editing || inspecting"
|
||||||
|
@click="emit('upload')"
|
||||||
|
>
|
||||||
|
<el-icon v-if="inspecting" class="is-loading" :size="28"><Loading /></el-icon>
|
||||||
|
<el-icon v-else :size="28"><Picture /></el-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="composer__tags">
|
||||||
|
<span v-for="tag in tagItems" :key="tag.key" class="composer__tag" :title="tag.label">
|
||||||
|
<el-icon class="composer__tag-icon" :size="12"><component :is="tag.icon" /></el-icon>
|
||||||
|
<span class="composer__tag-text">{{ tag.label }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="composer__send"
|
||||||
|
type="button"
|
||||||
|
aria-label="发送"
|
||||||
|
:disabled="disabled || !canSend"
|
||||||
|
@click="emit('send')"
|
||||||
|
>
|
||||||
|
<el-icon :size="18"><Top /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { Close, EditPen, Loading, Lock, Picture, Top } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
clampSceneProps,
|
||||||
|
splitSceneProps,
|
||||||
|
type ProductTitleSpec,
|
||||||
|
} from '@/utils/jingdong-product-title'
|
||||||
|
|
||||||
|
export type ComposerTag = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon?: 'lock'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string
|
||||||
|
placeholder?: string
|
||||||
|
placeholderNeedPhoto?: string
|
||||||
|
placeholderEditing?: string
|
||||||
|
customTextLabel?: string
|
||||||
|
customTextPlaceholder?: string
|
||||||
|
customTextMax?: number
|
||||||
|
previewUrl?: string
|
||||||
|
tags?: ComposerTag[]
|
||||||
|
editing?: boolean
|
||||||
|
editingHint?: string
|
||||||
|
exitEditLabel?: string
|
||||||
|
disabled?: boolean
|
||||||
|
canSend?: boolean
|
||||||
|
/** 图片识别 / 主体识别进行中 */
|
||||||
|
inspecting?: boolean
|
||||||
|
/** 底部状态提示 */
|
||||||
|
statusTip?: string
|
||||||
|
tipWarn?: boolean
|
||||||
|
/** 产品标题规则(有值时参与校验 / 场景道具模式) */
|
||||||
|
titleSpec?: ProductTitleSpec | null
|
||||||
|
productTitle?: string
|
||||||
|
/** 标题未填时高亮错误 */
|
||||||
|
titleInvalid?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: '',
|
||||||
|
placeholder: '',
|
||||||
|
placeholderNeedPhoto: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
|
||||||
|
placeholderEditing: '说说想怎么改…',
|
||||||
|
customTextLabel: '自定义文字',
|
||||||
|
customTextPlaceholder: '输入自定义文字...',
|
||||||
|
customTextMax: 18,
|
||||||
|
previewUrl: '',
|
||||||
|
tags: () => [],
|
||||||
|
editing: false,
|
||||||
|
editingHint: '',
|
||||||
|
exitEditLabel: '退出编辑',
|
||||||
|
disabled: false,
|
||||||
|
canSend: false,
|
||||||
|
inspecting: false,
|
||||||
|
statusTip: '',
|
||||||
|
tipWarn: false,
|
||||||
|
titleSpec: null,
|
||||||
|
productTitle: '',
|
||||||
|
titleInvalid: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string): void
|
||||||
|
(e: 'update:productTitle', value: string): void
|
||||||
|
(e: 'upload'): void
|
||||||
|
(e: 'clear-preview'): void
|
||||||
|
(e: 'send'): void
|
||||||
|
(e: 'exit-edit'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const titleInputId = 'jd-composer-product-title'
|
||||||
|
const isPropsTitle = computed(() => props.titleSpec?.mode === 'props')
|
||||||
|
|
||||||
|
/** 按产品显示:场景道具 / 飘带标题 / 相框标题;无规则时回退自定义文字 */
|
||||||
|
const customLabel = computed(
|
||||||
|
() => props.titleSpec?.label || props.customTextLabel || '自定义文字',
|
||||||
|
)
|
||||||
|
const customPlaceholder = computed(() => {
|
||||||
|
if (props.titleSpec?.placeholder) return props.titleSpec.placeholder
|
||||||
|
return props.customTextPlaceholder || '输入自定义文字...'
|
||||||
|
})
|
||||||
|
const customMax = computed(() => {
|
||||||
|
if (props.titleSpec) return props.titleSpec.max
|
||||||
|
return props.customTextMax || 18
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 道具模式不按字符截断;字符模式仍用 max */
|
||||||
|
const titleInputMaxlength = computed(() => {
|
||||||
|
if (props.titleSpec?.mode === 'props') return undefined
|
||||||
|
return customMax.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const titleCount = computed(() => {
|
||||||
|
const text = String(props.productTitle || '')
|
||||||
|
if (isPropsTitle.value) return splitSceneProps(text).length
|
||||||
|
return text.length
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
lock: Lock,
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagItems = computed(() =>
|
||||||
|
(props.tags || [])
|
||||||
|
.filter((tag) => tag && String(tag.label || '').trim())
|
||||||
|
.map((tag) => ({
|
||||||
|
key: tag.key || tag.label,
|
||||||
|
label: String(tag.label).trim(),
|
||||||
|
icon: iconMap[tag.icon || 'lock'] || Lock,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvedPlaceholder = computed(() => {
|
||||||
|
if (props.editing) return props.placeholderEditing || props.placeholder
|
||||||
|
if (!props.previewUrl) return props.placeholderNeedPhoto || props.placeholder
|
||||||
|
return props.placeholder
|
||||||
|
})
|
||||||
|
|
||||||
|
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
const boxRef = ref<HTMLElement | null>(null)
|
||||||
|
const previewRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function autosize() {
|
||||||
|
const el = textareaRef.value
|
||||||
|
if (!el) return
|
||||||
|
el.style.height = 'auto'
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput(e: Event) {
|
||||||
|
const target = e.target as HTMLTextAreaElement
|
||||||
|
emit('update:modelValue', target.value)
|
||||||
|
autosize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTitleInput(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const raw = target.value
|
||||||
|
if (props.titleSpec?.mode === 'props') {
|
||||||
|
const next = clampSceneProps(raw, props.titleSpec.max)
|
||||||
|
if (next !== raw) target.value = next
|
||||||
|
emit('update:productTitle', next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('update:productTitle', raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key !== 'Enter' || e.shiftKey) return
|
||||||
|
// 移动端/桌面:Enter 发送,Shift+Enter 换行
|
||||||
|
e.preventDefault()
|
||||||
|
if (!props.disabled && props.canSend) emit('send')
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
textareaRef.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPreviewEl() {
|
||||||
|
return previewRef.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFlyTargetEl() {
|
||||||
|
return previewRef.value || boxRef.value
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ focus, getPreviewEl, getFlyTargetEl })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.composer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px calc(10px + env(safe-area-inset-bottom));
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__tip {
|
||||||
|
margin: 0 2px 8px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #888;
|
||||||
|
|
||||||
|
&.is-warn {
|
||||||
|
color: #c45c26;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__edit-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px 8px 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f2f2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__edit-left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__edit-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: #3a8e4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__edit-text {
|
||||||
|
min-width: 0;
|
||||||
|
color: #3a8e4d;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__exit {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__box {
|
||||||
|
padding: 12px 12px 10px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #ebebeb;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||||
|
box-sizing: border-box;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
|
||||||
|
&.is-editing {
|
||||||
|
border-color: rgba(58, 142, 77, 0.45);
|
||||||
|
box-shadow: 0 4px 16px rgba(58, 142, 77, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-disabled {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__custom-block {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 0 0 10px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&.is-invalid .composer__custom-input {
|
||||||
|
border-color: rgba(196, 92, 38, 0.55);
|
||||||
|
background: #fff8f4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__custom-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #3a8e4d;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__custom-icon {
|
||||||
|
color: #3a8e4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__custom-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #e6e6e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
outline: none;
|
||||||
|
background: #fafafa;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-family: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: rgba(58, 142, 77, 0.55);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__custom-count {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__custom-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__custom-error {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #c45c26;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__preview {
|
||||||
|
position: relative;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__preview-mask {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__preview-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 96px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__upload {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin: -2px 0 -2px -4px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #666;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-loading {
|
||||||
|
animation: jd-spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes jd-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__tags {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 88px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f2f2f2;
|
||||||
|
color: #888;
|
||||||
|
font-size: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__tag-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__tag-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer__send {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #3a8e4d;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<template>
|
||||||
|
<section class="jd-stage" aria-live="polite">
|
||||||
|
<div class="jd-stage__visual">
|
||||||
|
<img
|
||||||
|
class="jd-stage__gif"
|
||||||
|
:src="loadingGif"
|
||||||
|
alt=""
|
||||||
|
draggable="false"
|
||||||
|
:style="gifStyle"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-if="resultUrl"
|
||||||
|
class="jd-stage__result"
|
||||||
|
:src="resultUrl"
|
||||||
|
alt=""
|
||||||
|
:style="resultStyle"
|
||||||
|
@load="onResultLoad"
|
||||||
|
/>
|
||||||
|
<p class="jd-stage__status" :style="{ opacity: overlayOpacity }">
|
||||||
|
{{ statusLabel }}
|
||||||
|
</p>
|
||||||
|
<span class="jd-stage__percent" aria-label="生成进度" :style="{ opacity: overlayOpacity }">
|
||||||
|
{{ progressLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import loadingGif from '@/assets/jingdong/picture_cloud.gif'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
photoUrl?: string
|
||||||
|
resultUrl?: string
|
||||||
|
startedAt?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'done'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const WAIT_STATUS = ['正在创建图像', '正在构思构图', '正在丰富细节', '正在打磨光影'] as const
|
||||||
|
/** 交叉淡入总时长:偏慢,避免突兀闪切 */
|
||||||
|
const REVEAL_MS = 2800
|
||||||
|
/** 效果图已完全显现后再停留片刻,再交给结果卡片,减少组件切换闪烁 */
|
||||||
|
const HOLD_MS = 480
|
||||||
|
/** gif 从何时开始明显淡出(0~1) */
|
||||||
|
const GIF_FADE_START = 0.28
|
||||||
|
|
||||||
|
const phase = ref<'waiting' | 'reveal' | 'hold'>('waiting')
|
||||||
|
const progress = ref(0)
|
||||||
|
const displayProgress = ref(0)
|
||||||
|
const reveal = ref(0)
|
||||||
|
const gifOpacity = ref(1)
|
||||||
|
const clock = ref(performance.now())
|
||||||
|
const resultReady = ref(false)
|
||||||
|
|
||||||
|
let raf = 0
|
||||||
|
let finished = false
|
||||||
|
let startedAt = performance.now()
|
||||||
|
let revealStarted = 0
|
||||||
|
let holdStarted = 0
|
||||||
|
let readyToken = 0
|
||||||
|
|
||||||
|
const progressLabel = computed(() => {
|
||||||
|
const value = Number.isFinite(displayProgress.value) ? displayProgress.value : 0
|
||||||
|
return `${Math.max(0, Math.min(100, Math.round(value)))}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
if (phase.value === 'reveal' || phase.value === 'hold') return '即将完成'
|
||||||
|
const elapsed = Math.max(0, clock.value - startedAt)
|
||||||
|
const index = Math.floor(elapsed / 3200) % WAIT_STATUS.length
|
||||||
|
return WAIT_STATUS[index]
|
||||||
|
})
|
||||||
|
|
||||||
|
const overlayOpacity = computed(() => {
|
||||||
|
if (phase.value === 'hold') return 0
|
||||||
|
return Math.max(0, 1 - reveal.value * 1.15)
|
||||||
|
})
|
||||||
|
|
||||||
|
const gifStyle = computed(() => ({
|
||||||
|
opacity: gifOpacity.value,
|
||||||
|
transform: `scale(${1 + reveal.value * 0.02})`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const resultStyle = computed(() => ({
|
||||||
|
opacity: reveal.value,
|
||||||
|
transform: `scale(${1.03 - reveal.value * 0.03})`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
function safeProgress(value: number) {
|
||||||
|
if (!Number.isFinite(value)) return 0
|
||||||
|
return Math.max(0, Math.min(100, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitingProgress(elapsedMs: number) {
|
||||||
|
const seconds = Math.max(0, elapsedMs) / 1000
|
||||||
|
return Math.min(88, 88 * (1 - Math.exp(-seconds / 18)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoother(value: number) {
|
||||||
|
const amount = Math.max(0, Math.min(1, value))
|
||||||
|
return amount * amount * amount * (amount * (amount * 6 - 15) + 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结果先淡入,gif 稍后错开淡出,中间始终有一层可见,避免空闪 */
|
||||||
|
function applyRevealAmount(raw: number) {
|
||||||
|
const amount = smoother(raw)
|
||||||
|
reveal.value = amount
|
||||||
|
if (raw <= GIF_FADE_START) {
|
||||||
|
gifOpacity.value = 1
|
||||||
|
} else {
|
||||||
|
const gifRaw = (raw - GIF_FADE_START) / (1 - GIF_FADE_START)
|
||||||
|
gifOpacity.value = Math.max(0, 1 - smoother(gifRaw))
|
||||||
|
}
|
||||||
|
progress.value = safeProgress(88 + amount * 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
if (finished) return
|
||||||
|
finished = true
|
||||||
|
progress.value = 100
|
||||||
|
displayProgress.value = 100
|
||||||
|
gifOpacity.value = 0
|
||||||
|
reveal.value = 1
|
||||||
|
emit('done')
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(now: number) {
|
||||||
|
clock.value = now
|
||||||
|
if (phase.value === 'waiting') {
|
||||||
|
const elapsed = Math.max(0, now - startedAt)
|
||||||
|
progress.value = safeProgress(waitingProgress(elapsed))
|
||||||
|
gifOpacity.value = 1
|
||||||
|
reveal.value = 0
|
||||||
|
if (resultReady.value && props.resultUrl) {
|
||||||
|
phase.value = 'reveal'
|
||||||
|
revealStarted = now
|
||||||
|
}
|
||||||
|
} else if (phase.value === 'reveal') {
|
||||||
|
const raw = Math.min(1, (now - revealStarted) / REVEAL_MS)
|
||||||
|
applyRevealAmount(raw)
|
||||||
|
if (raw >= 1) {
|
||||||
|
phase.value = 'hold'
|
||||||
|
holdStarted = now
|
||||||
|
gifOpacity.value = 0
|
||||||
|
reveal.value = 1
|
||||||
|
progress.value = 100
|
||||||
|
}
|
||||||
|
} else if (phase.value === 'hold') {
|
||||||
|
gifOpacity.value = 0
|
||||||
|
reveal.value = 1
|
||||||
|
progress.value = 100
|
||||||
|
if (now - holdStarted >= HOLD_MS) {
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gap = progress.value - displayProgress.value
|
||||||
|
displayProgress.value = safeProgress(displayProgress.value + gap * 0.08)
|
||||||
|
if (Math.abs(gap) < 0.05) displayProgress.value = progress.value
|
||||||
|
|
||||||
|
if (!finished) raf = window.requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareResult(url: string) {
|
||||||
|
const token = ++readyToken
|
||||||
|
resultReady.value = false
|
||||||
|
if (!url) return
|
||||||
|
try {
|
||||||
|
const img = new Image()
|
||||||
|
img.src = url
|
||||||
|
if (typeof img.decode === 'function') {
|
||||||
|
await img.decode()
|
||||||
|
} else {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve()
|
||||||
|
img.onerror = () => reject(new Error('load failed'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 解码失败仍继续,避免卡住
|
||||||
|
}
|
||||||
|
if (token !== readyToken) return
|
||||||
|
await nextTick()
|
||||||
|
// 再等一帧,确保 DOM 已挂上结果层再开淡入
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
window.requestAnimationFrame(() => resolve())
|
||||||
|
})
|
||||||
|
if (token !== readyToken) return
|
||||||
|
resultReady.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResultLoad() {
|
||||||
|
if (props.resultUrl) void prepareResult(props.resultUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.resultUrl,
|
||||||
|
(url) => {
|
||||||
|
if (!url) {
|
||||||
|
readyToken += 1
|
||||||
|
resultReady.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void prepareResult(url)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startedAt = props.startedAt && props.startedAt > 0 ? props.startedAt : performance.now()
|
||||||
|
// startedAt 可能是 Date.now(),与 performance.now() 混用时做校正
|
||||||
|
if (startedAt > 1e12) {
|
||||||
|
const wallElapsed = Date.now() - startedAt
|
||||||
|
startedAt = performance.now() - Math.max(0, wallElapsed)
|
||||||
|
}
|
||||||
|
progress.value = 0
|
||||||
|
displayProgress.value = 0
|
||||||
|
gifOpacity.value = 1
|
||||||
|
reveal.value = 0
|
||||||
|
finished = false
|
||||||
|
raf = window.requestAnimationFrame(tick)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
readyToken += 1
|
||||||
|
window.cancelAnimationFrame(raf)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.jd-stage {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-stage__visual {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-stage__gif,
|
||||||
|
.jd-stage__result {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center top;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: opacity, transform;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-stage__gif {
|
||||||
|
z-index: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-stage__result {
|
||||||
|
z-index: 3;
|
||||||
|
/* 与结果卡片一致:完整显示效果图 */
|
||||||
|
background: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-stage__status {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 10px;
|
||||||
|
z-index: 4;
|
||||||
|
margin: 0;
|
||||||
|
color: #0b1220;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-stage__percent {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 4;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 46px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0b1220;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<header class="jd-nav">
|
||||||
|
<div class="jd-nav__left">
|
||||||
|
<img class="jd-nav__logo" :src="logo" alt="速哇" />
|
||||||
|
<h1 class="jd-nav__title">{{ title }}</h1>
|
||||||
|
</div>
|
||||||
|
<button class="jd-nav__remain" type="button" @click="emit('remain')">
|
||||||
|
<el-icon class="jd-nav__remain-icon" :size="14"><MagicStick /></el-icon>
|
||||||
|
<span>生图剩余 {{ remain }}次</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { MagicStick } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
remain?: number
|
||||||
|
logo: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: '速哇手办制作Agent',
|
||||||
|
remain: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'remain'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.jd-nav {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 48px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
padding-top: calc(6px + env(safe-area-inset-top));
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-nav__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-nav__logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center 35%;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-nav__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: #1a1a1a;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-nav__remain {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px 0 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eaf6ec;
|
||||||
|
color: #3a8e4d;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.jd-nav__remain-icon {
|
||||||
|
color: #3a8e4d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
<template>
|
||||||
|
<div class="result-card" :class="{ 'is-editing': editing }">
|
||||||
|
<div class="result-card__media">
|
||||||
|
<img
|
||||||
|
ref="imgRef"
|
||||||
|
class="result-card__img"
|
||||||
|
:src="image"
|
||||||
|
alt="效果图"
|
||||||
|
draggable="false"
|
||||||
|
@click="onImageClick"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointerleave="onPointerUp"
|
||||||
|
@pointercancel="onPointerUp"
|
||||||
|
@contextmenu.prevent
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="result-card__edit"
|
||||||
|
:class="{ 'is-exit': editing }"
|
||||||
|
type="button"
|
||||||
|
@click="onEditClick"
|
||||||
|
>
|
||||||
|
<template v-if="editing">
|
||||||
|
<el-icon :size="12"><Close /></el-icon>
|
||||||
|
<span>{{ exitEditLabel }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<el-icon :size="12"><EditPen /></el-icon>
|
||||||
|
<span>{{ editLabel }}</span>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="result-card__meta">{{ meta }}</div>
|
||||||
|
<button
|
||||||
|
class="result-card__buy"
|
||||||
|
type="button"
|
||||||
|
:disabled="editing"
|
||||||
|
:aria-disabled="editing"
|
||||||
|
@click="onBuyClick"
|
||||||
|
>
|
||||||
|
<el-icon :size="16"><ShoppingCart /></el-icon>
|
||||||
|
<span>{{ buyLabel }}</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="editing" class="result-card__buy-tip">{{ buyDisabledTip }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, ref } from 'vue'
|
||||||
|
import { Close, EditPen, ShoppingCart } from '@element-plus/icons-vue'
|
||||||
|
import { rectFromElement, type FlyRect } from '@/utils/fly-image'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
image: string
|
||||||
|
meta: string
|
||||||
|
editing?: boolean
|
||||||
|
editLabel?: string
|
||||||
|
exitEditLabel?: string
|
||||||
|
buyLabel?: string
|
||||||
|
buyDisabledTip?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
editing: false,
|
||||||
|
editLabel: '编辑',
|
||||||
|
exitEditLabel: '退出编辑',
|
||||||
|
buyLabel: '立即购买',
|
||||||
|
buyDisabledTip: '编辑调整中,暂不可购买',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'view'): void
|
||||||
|
(e: 'edit', payload: { sourceRect: FlyRect | null }): void
|
||||||
|
(e: 'exit-edit'): void
|
||||||
|
(e: 'download'): void
|
||||||
|
(e: 'buy'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const imgRef = ref<HTMLImageElement | null>(null)
|
||||||
|
const LONG_PRESS_MS = 520
|
||||||
|
let pressTimer: number | null = null
|
||||||
|
let longPressed = false
|
||||||
|
|
||||||
|
function clearPressTimer() {
|
||||||
|
if (pressTimer != null) {
|
||||||
|
window.clearTimeout(pressTimer)
|
||||||
|
pressTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown() {
|
||||||
|
longPressed = false
|
||||||
|
clearPressTimer()
|
||||||
|
pressTimer = window.setTimeout(() => {
|
||||||
|
longPressed = true
|
||||||
|
pressTimer = null
|
||||||
|
emit('download')
|
||||||
|
}, LONG_PRESS_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
clearPressTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageClick() {
|
||||||
|
// 长按保存后不再触发查看大图
|
||||||
|
if (longPressed) {
|
||||||
|
longPressed = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('view')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditClick() {
|
||||||
|
if (props.editing) {
|
||||||
|
emit('exit-edit')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('edit', { sourceRect: rectFromElement(imgRef.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBuyClick() {
|
||||||
|
if (props.editing) return
|
||||||
|
emit('buy')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearPressTimer()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.result-card {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card__media {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #f7f7f7;
|
||||||
|
border: 1.5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 从生成态切过来时,操作层缓入,避免整卡突兀弹出 */
|
||||||
|
@keyframes jd-result-chrome-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card.is-editing .result-card__media {
|
||||||
|
border-color: #3a8e4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card__img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center top;
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
user-select: none;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card__edit {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
font-size: 12px;
|
||||||
|
animation: jd-result-chrome-in 0.55s ease both;
|
||||||
|
|
||||||
|
&.is-exit {
|
||||||
|
color: #1a1a1a;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card__meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.4;
|
||||||
|
animation: jd-result-chrome-in 0.55s ease 0.06s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card__buy {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
margin-top: 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #3a8e4d;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease, opacity 0.15s ease;
|
||||||
|
animation: jd-result-chrome-in 0.55s ease 0.12s both;
|
||||||
|
|
||||||
|
&:active:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #ececec;
|
||||||
|
color: #b0b0b0;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card__buy-tip {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #b0b0b0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||