Compare commits

..
18 Commits
Author SHA1 Message Date
Linzm 500bdbbf66 修改:主体识别严格对比
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-11 16:35:32 +08:00
LinzmandCursor 2f9d443a6a 修改:京东下单页面样式与引导图、编辑购买交互优化
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 17:51:23 +08:00
Linzm 789d4f106c 修改:处理偶发会话不存在的问题
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-10 15:25:57 +08:00
Linzm 3b10685876 修改:相框手办标题输入
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-10 15:05:29 +08:00
Linzm 3c3c71ebee 修改:场景道具bug处理
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-10 10:58:44 +08:00
Linzm 31754ef398 修改:生图失败文案提示和用户头像调整
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-10 10:44:41 +08:00
Linzm 02d926e91e 修改:严格对比下单主体的数量和导入图片的主体识别数量
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-09 16:10:34 +08:00
Linzm 0199b924ac 修改:京东demo页面编辑效果图流程调整
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-09 15:53:14 +08:00
Linzm e1da41c2f8 修改:去掉重复上传图片
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-04 18:42:25 +08:00
Linzm 4979739521 修改:jingdong的识别主体接口替换
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build was killed
2026-11-04 18:37:18 +08:00
Linzm b25e3ca473 修改:主体不一致不拦截,只提示
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is failing
2026-11-04 09:59:24 +08:00
Linzm ff30dbb6b4 修改:京东提交生图的时候,再新增传一个竖提数量字段
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-04 09:48:01 +08:00
Linzm 1887e8b2b3 修改:京东端下单不考虑效果图导入
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-03 17:39:11 +08:00
Linzm 6c55986337 修改:优化cartoon的裁剪比例
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-03 16:05:50 +08:00
Linzm 482f6a57ce 修改:优化cartoon的图片裁剪比例为3:4 2026-11-03 16:05:31 +08:00
Linzm 505c585772 修改:前端判断剩余生图次数
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-03 15:52:24 +08:00
Linzm 6dd4b4a724 修改:下单页面优化
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-03 15:30:19 +08:00
Linzm add2409f1f 修改:左下角的图标放大
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
2026-11-03 15:19:42 +08:00
16 changed files with 1709 additions and 749 deletions

No files matched your search

+63 -37
View File
@@ -98,21 +98,13 @@ export interface UploadTicket {
export interface ImageGenSubmitInput { export interface ImageGenSubmitInput {
session_id: string session_id: string
upload_id: string /** 导入生图传 upload_id;编辑效果图不传 */
product_id: number upload_id?: string
product_name?: string /** 编辑效果图传 image_id;导入生图不传 */
style_id?: number image_id?: string
prompt?: string prompt?: string
n?: number
mounts_id?: number
scene_prop?: string scene_prop?: string
custom_text?: string custom_text?: string
base_type?: number
frame_type?: number
mount_id?: number
pid?: number
proportion_id?: number
pose_id?: number
/** 效果图编辑 edit;原图编辑 / 普通立即生成 generate */ /** 效果图编辑 edit;原图编辑 / 普通立即生成 generate */
mode?: 'generate' | 'edit' mode?: 'generate' | 'edit'
} }
@@ -720,7 +712,14 @@ export const getUploadUrl = async (input: {
) )
// 注意:若 Content-Type 不是 application/json,axios 会把 body 留成 string // 注意:若 Content-Type 不是 application/json,axios 会把 body 留成 string
const ticket = parseUploadUrlResult(response?.data) 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) { if (!ticket.upload_id) {
throw new Error('签发上传地址失败:缺少 upload_id') throw new Error('签发上传地址失败:缺少 upload_id')
@@ -752,29 +751,40 @@ export const confirmUpload = (input: { session_id: string; upload_id: string })
}) })
} }
/** POST /jd/imagegen/submit */ /**
export const submitImageGen = (input: ImageGenSubmitInput) => { * POST /jd/subject/classify
return request(`${PREFIX}/imagegen/submit`, { * 图片上传完成后识别主体(session_id + upload_id)
*/
export const classifySubject = (input: {
session_id: string
upload_id: string
}) => {
return request('jd/subject/classify', {
method: 'POST', method: 'POST',
data: { data: {
session_id: input.session_id, session_id: input.session_id,
upload_id: input.upload_id, upload_id: input.upload_id,
product_id: input.product_id,
product_name: input.product_name || '',
style_id: input.style_id ?? 0,
prompt: input.prompt || '',
n: input.n ?? 1,
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,
mode: input.mode || 'generate',
}, },
})
}
/** 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> }) as Promise<ImageGenTask>
} }
@@ -835,9 +845,10 @@ export const confirmOrder = (input: OrderConfirmInput) => {
} }
/** /**
* 上传原图:签发 URL → PUT OSS → confirm(需 session_id) * 导入阶段:签发 URL → PUT OSS(不 confirm)
* 拿到 upload_id 后可立刻调 subject/classify
*/ */
export async function uploadOriginal( export async function prepareUpload(
file: File | Blob, file: File | Blob,
options: { options: {
session_id: string session_id: string
@@ -854,10 +865,6 @@ export async function uploadOriginal(
purpose: options.purpose || 'origin', purpose: options.purpose || 'origin',
}) })
await putUpload(ticket.upload_url, file, ticket.content_type || contentType) await putUpload(ticket.upload_url, file, ticket.content_type || contentType)
await confirmUpload({
session_id: options.session_id,
upload_id: ticket.upload_id,
})
return { return {
uploadId: ticket.upload_id, uploadId: ticket.upload_id,
fileUrl: ticket.file_url, fileUrl: ticket.file_url,
@@ -866,3 +873,22 @@ export async function uploadOriginal(
} }
} }
/**
* 完整上传:签发 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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

+6 -11
View File
@@ -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
} }
}, },
+29 -15
View File
@@ -1,5 +1,7 @@
import logoAvatar from '@/assets/jingdong/logo-mark.png' import logoAvatar from '@/assets/jingdong/logo-mark.png'
import logoIcon from '@/assets/jingdong/logo_1.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 caseOriginal from '@/assets/jingdong/原图.png'
import caseEffect from '@/assets/jingdong/效果图.png' import caseEffect from '@/assets/jingdong/效果图.png'
import caseScene from '@/assets/jingdong/场景图.png' import caseScene from '@/assets/jingdong/场景图.png'
@@ -16,14 +18,18 @@ export const jingdongConfig = {
brandName: '速哇手办制作Agent', brandName: '速哇手办制作Agent',
primaryColor: '#3A8E4D', primaryColor: '#3A8E4D',
primarySoft: '#E8F5E9', primarySoft: '#E8F5E9',
background: '#F5F5F5', background: '#FFFFFF',
images: { images: {
/** 顶栏 / 对话头像 */ /** 顶栏 / Agent 对话头像 */
avatar: logoAvatar, avatar: logoAvatar,
logo: logoAvatar, logo: logoAvatar,
/** 用户侧对话头像 */
userAvatar,
/** 浏览器标签 favicon */ /** 浏览器标签 favicon */
logoIcon, logoIcon,
/** 对话引导图(照片要求) */
guide: guideImage,
caseOriginal, caseOriginal,
caseEffect, caseEffect,
caseScene, caseScene,
@@ -40,22 +46,22 @@ export const jingdongConfig = {
pollTimeoutMs: 5 * 60 * 1000, pollTimeoutMs: 5 * 60 * 1000,
messages: { messages: {
welcome: '你好~先上传一张照片,我来帮你做成专属手办效果图。', welcome: '你好,上传一张照片,或直接说说想做成什么样的手办。',
caseIntro: '先看看效果案例:原图 → 效果图 → 场景应用', caseIntro: '上传照片前,先看下照片要求',
caseLabels: ['原图', '效果图', '场景应用'] as const, caseLabels: ['原图', '效果图', '场景应用'] as const,
placeholder: '描述你想要的手办,例如:橘猫,戴黄色围巾...', placeholder: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
placeholderNeedPhoto: '请先点击左侧按钮上传照片', placeholderNeedPhoto: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
placeholderEditing: '说说想怎么改,例如:换成蓝色围巾、表情更开心…', placeholderEditing: '说说想怎么改,例如:换成蓝色围巾、表情更开心…',
/** 识别为卡通效果图时 */ customTextLabel: '自定义文字',
placeholderCartoon: '已识别为效果图,可直接发送;也可补充想调整的地方', customTextPlaceholder: '输入自定义文字...',
/** 卡通效果图且用户未补充提示词时的气泡文案 */ customTextMax: 18,
cartoonDirectPrompt: '这是我在别的平台生成的卡通效果图,不用再调整了,直接帮我下单吧~',
defaultPrompt: '按所选产品、风格生成手办效果图~', defaultPrompt: '按所选产品、风格生成手办效果图~',
editingHint: '正在编辑效果图 · 描述想调整的地方后发送', editingHint: '正在编辑效果图 · 输入文字描述想调整的地方',
exitEdit: '退出编辑', exitEdit: '退出编辑',
view: '查看', view: '查看',
edit: '编辑', edit: '编辑',
buyNow: '立即购买', buyNow: '立即购买',
buyDisabledTip: '编辑调整中,暂不可购买',
buyToast: '购买功能即将开放', buyToast: '购买功能即将开放',
noRemain: '生图次数已用完,暂时无法继续生成', noRemain: '生图次数已用完,暂时无法继续生成',
remainTip: (n: number) => (n > 0 ? `还可以生成 ${n} 次效果图` : '生图次数已用完'), remainTip: (n: number) => (n > 0 ? `还可以生成 ${n} 次效果图` : '生图次数已用完'),
@@ -63,17 +69,25 @@ export const jingdongConfig = {
needEditText: '请输入想调整的内容后再发送', needEditText: '请输入想调整的内容后再发送',
needPhotoOrText: '请上传照片或输入描述后再发送', needPhotoOrText: '请上传照片或输入描述后再发送',
needProductTitle: (label: string) => `请填写${label}后再发送`, needProductTitle: (label: string) => `请填写${label}后再发送`,
uploadingTip: '正在上传照片,请稍候…',
inspectingTip: '正在识别照片,请稍候…', inspectingTip: '正在识别照片,请稍候…',
subjectMismatchTip: '照片主体与订单不符,请更换照片', sessionLoadingTip: '页面加载中,请稍候再上传…',
sessionMissing: '会话未就绪,请稍候再试或刷新页面',
/** 仅提示,不拦截发送 */
subjectMismatchTip: '上传图片的主体数量与下单主体数量不一致,生图时候会按下单的主体数量自动调整主体',
subjectMismatchToast: (got: string, need: string) =>
`上传图片主体(${got})与下单主体(${need})数量不一致`,
generating: '正在生成效果图,请稍候…', generating: '正在生成效果图,请稍候…',
generatingTip: '效果图生成中,完成后可继续操作', generatingTip: '效果图生成中,完成后可继续操作',
downloadOk: '已开始下载效果图', downloadOk: '已开始下载效果图',
downloadFail: '下载失败,请稍后重试', downloadOpened:
'当前浏览器无法直接下载,已打开图片,请长按保存;或换用 Safari / Chrome 下载',
downloadFail: '下载失败,请换用其他浏览器,或长按效果图保存',
effectPreparing: '效果图准备中,请稍后再试', effectPreparing: '效果图准备中,请稍后再试',
generateFail: '这次没生成成功,请换个描述或照片再试', generateFail: '这次没生成成功,请换个描述或照片再试',
editFail: '这次调整没成功,请换个描述再试', editFail: '这次调整没成功,请换个描述再试',
/** 识别主体与订单主体不一致 */ /** 内容安全策略拒绝(未成年 / 公众人物等) */
subjectMismatch: contentPolicyFail:
'上传图片的主体数量与下单主体不一致,请更换与订单相符的照片后再试', '当前照片不符合内容规范,可能涉及未成年或公众人物,请换一张照片再试',
}, },
} }
+4 -1
View File
@@ -96,7 +96,10 @@ router.beforeEach((_to, _from, next) => {
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) { } else if (isJingdong) {
window.document.title = `${jingdongConfig.brandName} - ${titleText}` // 品牌名与页面 title 相同时只显示一份,避免「速哇手办制作Agent - 速哇手办制作Agent」
const brandName = jingdongConfig.brandName
window.document.title =
titleText && titleText !== brandName ? `${brandName} - ${titleText}` : brandName
setDocumentFavicon(jingdongConfig.images.logoIcon) setDocumentFavicon(jingdongConfig.images.logoIcon)
} else { } else {
window.document.title = config.showBrandInTitle && brandText window.document.title = config.showBrandInTitle && brandText
+119 -80
View File
@@ -1,7 +1,5 @@
/** /**
* 原图分类 /classify(对齐 agent image-inspect) * 图片主体识别结果(由后端 jd/subject/classify 返回后规范化)
* - 本地:Vite 代理 `/concept-classify` → `/v1/classify`
* - 线上:直连 seetacloud
*/ */
export type ImageKind = 'cartoon' | 'real_photo' | 'other' | 'uncertain' export type ImageKind = 'cartoon' | 'real_photo' | 'other' | 'uncertain'
@@ -11,8 +9,12 @@ export interface ImageInspect {
isCartoon: boolean isCartoon: boolean
personCount: number personCount: number
petCount: number petCount: number
/** 道具数量(若接口有返回) */
propCount: number
reviewStatus: string reviewStatus: string
conclusion: string conclusion: string
/** 主体识别 check_id,后续下单可用 */
checkId: number
} }
export class InspectCancelled extends Error { export class InspectCancelled extends Error {
@@ -22,10 +24,6 @@ export class InspectCancelled extends Error {
} }
} }
const INSPECT_TIMEOUT_MS = 120_000
const DEFAULT_INSPECT_HOST = 'https://u252424-8570-01f615a5.cqa1.seetacloud.com:8443'
const DEFAULT_INSPECT_REMOTE_URL = `${DEFAULT_INSPECT_HOST}/v1/classify`
function asRecord(value: unknown) { function asRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>) ? (value as Record<string, unknown>)
@@ -42,51 +40,137 @@ export function mapInspectImageType(raw: unknown): ImageKind {
return 'uncertain' return 'uncertain'
} }
function resolveInspectUrl() {
const fromEnv = String(
(import.meta as any).env?.VITE_CONCEPT_INSPECT_URL || '',
)
.trim()
.replace(/\/+$/, '')
if (fromEnv) {
if (/\/v1\/classify$/i.test(fromEnv)) return fromEnv
return `${fromEnv}/v1/classify`
}
if ((import.meta as any).env?.DEV) return '/concept-classify'
return DEFAULT_INSPECT_REMOTE_URL
}
function resolveInspectToken() {
return String((import.meta as any).env?.VITE_CONCEPT_INSPECT_TOKEN || '').trim()
}
function countField(data: Record<string, unknown>, keys: string[]) { function countField(data: Record<string, unknown>, keys: string[]) {
for (const key of keys) { for (const key of keys) {
if (!(key in data)) continue
const value = Number(data[key]) const value = Number(data[key])
if (Number.isFinite(value)) return Math.max(0, Math.trunc(value)) if (Number.isFinite(value)) return Math.max(0, Math.trunc(value))
} }
return 0 return 0
} }
function normalizeInspect(body: unknown): ImageInspect { 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 root = asRecord(body) || {}
const data = asRecord(root.data) || root 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 imageType = mapInspectImageType(data.image_type ?? data.imageType)
const isCartoonRaw = data.is_cartoon ?? data.isCartoon const isCartoonRaw = data.is_cartoon ?? data.isCartoon
const isCartoon = const isCartoon =
typeof isCartoonRaw === 'boolean' ? isCartoonRaw : imageType === 'cartoon' 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 { return {
imageType, imageType,
isCartoon, isCartoon,
personCount: countField(data, [ personCount,
'person_count', petCount,
'personCount', propCount,
'headcount',
'head_count',
]),
petCount: countField(data, ['pet_count', 'petCount']),
reviewStatus: String(data.review_status || ''), reviewStatus: String(data.review_status || ''),
conclusion: String(data.conclusion || ''), conclusion: String(data.conclusion || ''),
checkId: countField(data, ['check_id', 'checkId']),
} }
} }
@@ -102,51 +186,6 @@ export function formatSubjectFromInspect(result: ImageInspect) {
const parts: string[] = [] const parts: string[] = []
if (result.personCount > 0) parts.push(`${result.personCount}人`) if (result.personCount > 0) parts.push(`${result.personCount}人`)
if (result.petCount > 0) parts.push(`${result.petCount}宠物`) if (result.petCount > 0) parts.push(`${result.petCount}宠物`)
if (result.propCount > 0) parts.push(`${result.propCount}道具`)
return parts.join('+') return parts.join('+')
} }
export async function inspectImage(file: File, signal?: AbortSignal) {
if (signal?.aborted) throw new InspectCancelled()
const url = resolveInspectUrl()
const token = resolveInspectToken()
const form = new FormData()
form.append('image', file, file.name || 'image.jpg')
const headers: Record<string, string> = {}
if (token) headers.Authorization = `Bearer ${token}`
const controller = new AbortController()
let timedOut = false
const timer = window.setTimeout(() => {
timedOut = true
controller.abort()
}, INSPECT_TIMEOUT_MS)
const onAbort = () => controller.abort()
signal?.addEventListener('abort', onAbort, { once: true })
try {
const response = await fetch(url, {
method: 'POST',
body: form,
headers,
signal: controller.signal,
})
if (!response.ok) {
if (response.status === 400) throw new Error('图片上传错误,请检查文件后重试')
if (response.status === 401) throw new Error('图片识别鉴权失败')
if (response.status === 413) throw new Error('图片过大,请压缩后重试')
throw new Error(`图片识别失败(${response.status})`)
}
const raw: unknown = await response.json()
return normalizeInspect(raw)
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
if (timedOut) throw new Error('图片识别超时,请稍后重试')
throw new InspectCancelled()
}
if (error instanceof TypeError) throw new Error('图片识别失败,请稍后重试')
throw error instanceof Error ? error : new Error('图片识别失败')
} finally {
window.clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ export function flyImageToRect(url: string, from: FlyRect, to: FlyRect, duration
borderRadius: '12px', borderRadius: '12px',
objectFit: 'contain', objectFit: 'contain',
objectPosition: 'center', objectPosition: 'center',
background: '#e8eaed', background: '#ffffff',
zIndex: '10000', zIndex: '10000',
pointerEvents: 'none', pointerEvents: 'none',
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.22)', boxShadow: '0 12px 28px rgba(15, 23, 42, 0.22)',
+187
View File
@@ -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,或长按图片保存'
}
+56 -7
View File
@@ -1,5 +1,7 @@
/** 将后端 / 网络 / 前端技术错误转成用户可读提示 */ /** 将后端 / 网络 / 前端技术错误转成用户可读提示 */
import { jingdongConfig } from '@/config/jingdong'
const KNOWN: Record<string, string> = { const KNOWN: Record<string, string> = {
未登录: '登录已失效,请关闭后重新进入再试', 未登录: '登录已失效,请关闭后重新进入再试',
请求失败: '服务繁忙,请稍后重试', 请求失败: '服务繁忙,请稍后重试',
@@ -17,7 +19,7 @@ const KNOWN: Record<string, string> = {
'签发上传地址失败:缺少 upload_url': '照片上传失败,请稍后重试', '签发上传地址失败:缺少 upload_url': '照片上传失败,请稍后重试',
'获取 upload_id 失败': '照片上传失败,请稍后重试', '获取 upload_id 失败': '照片上传失败,请稍后重试',
'upload/url 响应无法解析': '照片上传失败,请稍后重试', 'upload/url 响应无法解析': '照片上传失败,请稍后重试',
生成失败: '这次没生成成功,请换个描述或照片再试', 生成失败: jingdongConfig.messages.generateFail,
提交生成失败: '提交失败了,请稍后重试', 提交生成失败: '提交失败了,请稍后重试',
没有生成结果: '这次没有生成出效果图,请再试一次', 没有生成结果: '这次没有生成出效果图,请再试一次',
生成超时: '生成时间有点久,请稍后重试', 生成超时: '生成时间有点久,请稍后重试',
@@ -30,8 +32,35 @@ const KNOWN: Record<string, string> = {
效果图加载失败: '效果图加载失败,请重试', 效果图加载失败: '效果图加载失败,请重试',
'效果图加载失败,请重试': '效果图加载失败,请重试', '效果图加载失败,请重试': '效果图加载失败,请重试',
'product_id不在Agent开放分类': '当前产品暂不支持在线生图,请联系客服', '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 }> = [ const PATTERN_RULES: Array<{ test: RegExp; tip: string }> = [
{ test: /timeout|超时|ETIMEDOUT|ECONNABORTED/i, tip: '等待超时,请稍后重试' }, { test: /timeout|超时|ETIMEDOUT|ECONNABORTED/i, tip: '等待超时,请稍后重试' },
{ test: /network|fetch failed|ERR_NETWORK|断网|网络/i, tip: '网络异常,请检查网络后重试' }, { test: /network|fetch failed|ERR_NETWORK|断网|网络/i, tip: '网络异常,请检查网络后重试' },
@@ -49,11 +78,11 @@ const PATTERN_RULES: Array<{ test: RegExp; tip: string }> = [
export type FriendlyErrorScene = 'generate' | 'edit' | 'upload' | 'inspect' | 'download' | 'common' export type FriendlyErrorScene = 'generate' | 'edit' | 'upload' | 'inspect' | 'download' | 'common'
const SCENE_FALLBACK: Record<FriendlyErrorScene, string> = { const SCENE_FALLBACK: Record<FriendlyErrorScene, string> = {
generate: '这次没生成成功,请换个描述或照片再试', generate: jingdongConfig.messages.generateFail,
edit: '这次调整没成功,请换个描述再试', edit: jingdongConfig.messages.editFail,
upload: '照片上传失败,请换一张或稍后重试', upload: '照片上传失败,请换一张或稍后重试',
inspect: '照片识别失败,请换一张或稍后重试', inspect: '照片识别失败,请换一张或稍后重试',
download: '下载失败,请稍后重试', download: '下载失败,请换用其他浏览器,或长按效果图保存',
common: '操作失败了,请稍后重试', common: '操作失败了,请稍后重试',
} }
@@ -86,11 +115,27 @@ function looksTechnical(text: string) {
return false 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( export function friendlyError(
error: unknown, error: unknown,
@@ -101,6 +146,10 @@ export function friendlyError(
const text = rawMessage(error) const text = rawMessage(error)
if (!text) return tip if (!text) return tip
if (scene === 'generate' || scene === 'edit') {
return mapGenerateEditError(text, tip)
}
if (KNOWN[text]) return KNOWN[text] if (KNOWN[text]) return KNOWN[text]
for (const [key, value] of Object.entries(KNOWN)) { for (const [key, value] of Object.entries(KNOWN)) {
+67 -10
View File
@@ -1,9 +1,23 @@
/** 对齐 agent catalog.productTitleSpec:哪些产品需要标题输入,以及提交 scene_prop */ /** 对齐 agent catalog.productTitleSpec:哪些产品需要标题输入,以及提交 scene_prop */
export type ProductTitleMode = 'chars' | 'props'
export interface ProductTitleSpec { export interface ProductTitleSpec {
label: string label: string
placeholder: string placeholder: string
/** chars:按字符数;props:按分号分隔的道具个数 */
mode: ProductTitleMode
max: number max: number
/** 输入框下方辅助说明 */
hint?: string
}
const SCENE_PROP_SPEC: ProductTitleSpec = {
label: '场景道具',
placeholder: '例如:沙发;台灯;花瓶;地毯',
mode: 'props',
max: 4,
hint: '最多填写4个场景道具,用英文或中文分号隔开',
} }
export function isRibbonVariant(name: string) { export function isRibbonVariant(name: string) {
@@ -13,25 +27,58 @@ export function isRibbonVariant(name: string) {
export function productTitleSpec(label: string, variantName = ''): ProductTitleSpec | null { export function productTitleSpec(label: string, variantName = ''): ProductTitleSpec | null {
const name = String(label || '').trim() const name = String(label || '').trim()
if (name.includes('场景手办')) { if (name.includes('场景手办')) {
return { label: '场景道具', placeholder: '可输入1-4个场景道具', max: 4 } return { ...SCENE_PROP_SPEC }
} }
if (name.includes('相框手办挂饰') || (name.includes('相框') && name.includes('挂饰'))) { if (name.includes('相框手办挂饰') || (name.includes('相框') && name.includes('挂饰'))) {
return { label: '相框标题', placeholder: '输入相框名称,如「我们的小家」', max: 18 } return {
label: '相框标题',
placeholder: '输入相框名称,如「我们的小家」',
mode: 'chars',
max: 18,
}
} }
if (name.includes('相框手办') && isRibbonVariant(variantName)) { if (name.includes('相框手办') && isRibbonVariant(variantName)) {
return { label: '飘带标题', placeholder: '输入飘带名称,如「生日快乐」', max: 18 } return {
label: '飘带标题',
placeholder: '输入飘带名称,如「生日快乐」',
mode: 'chars',
max: 18,
}
} }
return null 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:仅当产品需要标题字段且已填写时传原文 */ /** 生成接口 scene_prop:仅当产品需要标题字段且已填写时传原文 */
export function resolveSceneProp( export function resolveSceneProp(
productName: string, productName: string,
title: string, title: string,
variantName = '', variantName = '',
): string { ): string {
if (!productTitleSpec(productName, variantName)) return '' const spec = productTitleSpec(productName, variantName)
return String(title || '').trim() 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[]) { function textOf(...values: unknown[]) {
@@ -45,6 +92,7 @@ function textOf(...values: unknown[]) {
function collectVariantName(orderStat: Record<string, any> | null | undefined) { function collectVariantName(orderStat: Record<string, any> | null | undefined) {
if (!orderStat) return '' if (!orderStat) return ''
return [ return [
orderStat.sub_cate_name,
orderStat.shape_name, orderStat.shape_name,
orderStat.kind_name, orderStat.kind_name,
orderStat.mounts_name, orderStat.mounts_name,
@@ -62,7 +110,7 @@ function collectVariantName(orderStat: Record<string, any> | null | undefined) {
/** /**
* 从 orders/stat 解析标题规则。 * 从 orders/stat 解析标题规则。
* 产品名:type_name → prod_name → product_name * 产品名:type_name → prod_name → product_name
* 变体名:shape/kind/mounts 等拼接,供 B 款飘带判断 * 变体名:sub_cate_name(如 B款)+ shape/kind/mounts 等,供飘带标题判断
* 兜底:type_id 9 → 场景道具;type_id 11 → 相框标题(与 cartoon 一致) * 兜底:type_id 9 → 场景道具;type_id 11 → 相框标题(与 cartoon 一致)
*/ */
export function productTitleSpecFromOrder( export function productTitleSpecFromOrder(
@@ -81,10 +129,15 @@ export function productTitleSpecFromOrder(
const typeId = Number(orderStat.type_id || 0) const typeId = Number(orderStat.type_id || 0)
if (typeId === 9) { if (typeId === 9) {
return { label: '场景道具', placeholder: '可输入1-4个场景道具', max: 4 } return { ...SCENE_PROP_SPEC }
} }
if (typeId === 11) { if (typeId === 11) {
return { label: '相框标题', placeholder: '输入相框名称,如「我们的小家」', max: 18 } return {
label: '相框标题',
placeholder: '输入相框名称,如「我们的小家」',
mode: 'chars',
max: 18,
}
} }
return null return null
} }
@@ -93,6 +146,10 @@ export function resolveScenePropFromOrder(
orderStat: Record<string, any> | null | undefined, orderStat: Record<string, any> | null | undefined,
title: string, title: string,
): string { ): string {
if (!productTitleSpecFromOrder(orderStat)) return '' const spec = productTitleSpecFromOrder(orderStat)
return String(title || '').trim() if (!spec) return ''
const raw = String(title || '').trim()
if (!raw) return ''
if (spec.mode === 'props') return clampSceneProps(raw, spec.max)
return raw
} }
+276 -67
View File
@@ -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"
</div> type="file"
accept="image/*"
class="photo-upload-input"
@change="onFileChange"
@click.stop
>
</div> </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>
</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;
+212 -124
View File
@@ -2,8 +2,8 @@
<div class="composer"> <div class="composer">
<div v-if="editing" class="composer__edit-bar"> <div v-if="editing" class="composer__edit-bar">
<span class="composer__edit-left"> <span class="composer__edit-left">
<el-icon :size="14"><EditPen /></el-icon> <el-icon class="composer__edit-icon" :size="14"><EditPen /></el-icon>
<span>{{ editingHint }}</span> <span class="composer__edit-text">{{ editingHint }}</span>
</span> </span>
<button class="composer__exit" type="button" @click="emit('exit-edit')"> <button class="composer__exit" type="button" @click="emit('exit-edit')">
<el-icon :size="12"><Close /></el-icon> <el-icon :size="12"><Close /></el-icon>
@@ -13,7 +13,36 @@
<p v-if="statusTip" class="composer__tip" :class="{ 'is-warn': tipWarn }">{{ statusTip }}</p> <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 }"> <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"> <div v-if="previewUrl" ref="previewRef" class="composer__preview">
<img class="composer__preview-img" :src="previewUrl" alt="已选图片" /> <img class="composer__preview-img" :src="previewUrl" alt="已选图片" />
<span v-if="inspecting" class="composer__preview-mask">识别中</span> <span v-if="inspecting" class="composer__preview-mask">识别中</span>
@@ -28,27 +57,6 @@
</button> </button>
</div> </div>
<div v-if="titleSpec" class="composer__title" :class="{ 'is-invalid': titleInvalid }">
<label class="composer__title-label" :for="titleInputId">{{ titleSpec.label }}</label>
<div class="composer__title-field">
<input
:id="titleInputId"
class="composer__title-input"
type="text"
:value="productTitle"
:maxlength="titleSpec.max"
:placeholder="titleSpec.placeholder"
:disabled="disabled"
:aria-invalid="titleInvalid"
@input="onTitleInput"
/>
<span class="composer__title-count">{{ productTitleLength }} / {{ titleSpec.max }}</span>
</div>
<p v-if="titleInvalid" class="composer__title-error" role="alert">
请填写{{ titleSpec.label }}
</p>
</div>
<textarea <textarea
ref="textareaRef" ref="textareaRef"
class="composer__input" class="composer__input"
@@ -68,8 +76,8 @@
:disabled="disabled || editing || inspecting" :disabled="disabled || editing || inspecting"
@click="emit('upload')" @click="emit('upload')"
> >
<el-icon v-if="inspecting" class="is-loading" :size="20"><Loading /></el-icon> <el-icon v-if="inspecting" class="is-loading" :size="28"><Loading /></el-icon>
<el-icon v-else :size="22"><Picture /></el-icon> <el-icon v-else :size="28"><Picture /></el-icon>
</button> </button>
<div class="composer__tags"> <div class="composer__tags">
@@ -95,13 +103,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { Close, EditPen, Loading, Picture, Present, Star, Top, User } from '@element-plus/icons-vue' import { Close, EditPen, Loading, Lock, Picture, Top } from '@element-plus/icons-vue'
import type { ProductTitleSpec } from '@/utils/jingdong-product-title' import {
clampSceneProps,
splitSceneProps,
type ProductTitleSpec,
} from '@/utils/jingdong-product-title'
export type ComposerTag = { export type ComposerTag = {
key: string key: string
label: string label: string
icon?: 'user' | 'star' | 'present' icon?: 'lock'
} }
const props = withDefaults( const props = withDefaults(
@@ -110,7 +122,9 @@ const props = withDefaults(
placeholder?: string placeholder?: string
placeholderNeedPhoto?: string placeholderNeedPhoto?: string
placeholderEditing?: string placeholderEditing?: string
placeholderCartoon?: string customTextLabel?: string
customTextPlaceholder?: string
customTextMax?: number
previewUrl?: string previewUrl?: string
tags?: ComposerTag[] tags?: ComposerTag[]
editing?: boolean editing?: boolean
@@ -120,12 +134,10 @@ const props = withDefaults(
canSend?: boolean canSend?: boolean
/** 图片识别 / 主体识别进行中 */ /** 图片识别 / 主体识别进行中 */
inspecting?: boolean inspecting?: boolean
/** 当前预览图是否为卡通效果图 */
cartoonEffect?: boolean
/** 底部状态提示 */ /** 底部状态提示 */
statusTip?: string statusTip?: string
tipWarn?: boolean tipWarn?: boolean
/** 产品标题规则(有值时展示标题输入) */ /** 产品标题规则(有值时参与校验 / 场景道具模式) */
titleSpec?: ProductTitleSpec | null titleSpec?: ProductTitleSpec | null
productTitle?: string productTitle?: string
/** 标题未填时高亮错误 */ /** 标题未填时高亮错误 */
@@ -134,9 +146,11 @@ const props = withDefaults(
{ {
modelValue: '', modelValue: '',
placeholder: '', placeholder: '',
placeholderNeedPhoto: '请先点击左侧按钮上传照片', placeholderNeedPhoto: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
placeholderEditing: '说说想怎么改…', placeholderEditing: '说说想怎么改…',
placeholderCartoon: '已识别为效果图,可直接发送', customTextLabel: '自定义文字',
customTextPlaceholder: '输入自定义文字...',
customTextMax: 18,
previewUrl: '', previewUrl: '',
tags: () => [], tags: () => [],
editing: false, editing: false,
@@ -145,7 +159,6 @@ const props = withDefaults(
disabled: false, disabled: false,
canSend: false, canSend: false,
inspecting: false, inspecting: false,
cartoonEffect: false,
statusTip: '', statusTip: '',
tipWarn: false, tipWarn: false,
titleSpec: null, titleSpec: null,
@@ -164,12 +177,35 @@ const emit = defineEmits<{
}>() }>()
const titleInputId = 'jd-composer-product-title' const titleInputId = 'jd-composer-product-title'
const productTitleLength = computed(() => String(props.productTitle || '').length) 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 = { const iconMap = {
user: User, lock: Lock,
star: Star,
present: Present,
} }
const tagItems = computed(() => const tagItems = computed(() =>
@@ -178,14 +214,13 @@ const tagItems = computed(() =>
.map((tag) => ({ .map((tag) => ({
key: tag.key || tag.label, key: tag.key || tag.label,
label: String(tag.label).trim(), label: String(tag.label).trim(),
icon: iconMap[tag.icon || 'present'] || Present, icon: iconMap[tag.icon || 'lock'] || Lock,
})), })),
) )
const resolvedPlaceholder = computed(() => { const resolvedPlaceholder = computed(() => {
if (props.editing) return props.placeholderEditing || props.placeholder if (props.editing) return props.placeholderEditing || props.placeholder
if (!props.previewUrl) return props.placeholderNeedPhoto || props.placeholder if (!props.previewUrl) return props.placeholderNeedPhoto || props.placeholder
if (props.cartoonEffect) return props.placeholderCartoon || props.placeholder
return props.placeholder return props.placeholder
}) })
@@ -208,7 +243,14 @@ function onInput(e: Event) {
function onTitleInput(e: Event) { function onTitleInput(e: Event) {
const target = e.target as HTMLInputElement const target = e.target as HTMLInputElement
emit('update:productTitle', target.value) 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) { function onKeydown(e: KeyboardEvent) {
@@ -256,33 +298,57 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 10px;
margin-bottom: 8px; margin-bottom: 8px;
padding: 8px 12px; padding: 8px 8px 8px 12px;
border-radius: 12px; border-radius: 999px;
background: #eaf6ec; background: #f2f2f2;
color: #3a8e4d; box-sizing: border-box;
font-size: 12px;
} }
.composer__edit-left { .composer__edit-left {
display: inline-flex; display: inline-flex;
align-items: center; align-items: flex-start;
gap: 6px; gap: 6px;
min-width: 0; 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 { .composer__exit {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 3px; justify-content: center;
gap: 4px;
flex-shrink: 0; flex-shrink: 0;
height: 28px;
padding: 0 10px;
border: 0; border: 0;
background: transparent; border-radius: 999px;
color: #3a8e4d; background: #ffffff;
color: #1a1a1a;
font-size: 12px; font-size: 12px;
font-weight: 500;
line-height: 1;
cursor: pointer; cursor: pointer;
padding: 0; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
&:active {
opacity: 0.88;
}
} }
.composer__box { .composer__box {
@@ -291,6 +357,7 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
background: #ffffff; background: #ffffff;
border: 1px solid #ebebeb; border: 1px solid #ebebeb;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04); 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; transition: border-color 0.2s ease, box-shadow 0.2s ease;
&.is-editing { &.is-editing {
@@ -303,6 +370,93 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
} }
} }
.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 { .composer__preview {
position: relative; position: relative;
width: 56px; width: 56px;
@@ -348,73 +502,6 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
padding: 0; padding: 0;
} }
.composer__title {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 12px;
background: #f7f8f7;
border: 1px solid transparent;
&.is-invalid {
border-color: rgba(196, 92, 38, 0.45);
background: #fff8f4;
}
}
.composer__title-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #3a8e4d;
}
.composer__title-field {
display: flex;
align-items: center;
gap: 8px;
}
.composer__title-input {
flex: 1;
min-width: 0;
height: 32px;
padding: 0 10px;
border: 1px solid #e6e6e6;
border-radius: 8px;
outline: none;
background: #fff;
font-size: 13px;
color: #1a1a1a;
font-family: inherit;
&::placeholder {
color: #b0b0b0;
}
&:disabled {
opacity: 0.6;
}
&:focus {
border-color: rgba(58, 142, 77, 0.55);
}
}
.composer__title-count {
flex-shrink: 0;
font-size: 11px;
color: #999;
white-space: nowrap;
}
.composer__title-error {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.3;
color: #c45c26;
}
.composer__input { .composer__input {
display: block; display: block;
width: 100%; width: 100%;
@@ -450,8 +537,9 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 32px; width: 40px;
height: 32px; height: 40px;
margin: -2px 0 -2px -4px;
padding: 0; padding: 0;
border: 0; border: 0;
background: transparent; background: transparent;
@@ -509,7 +597,7 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: clip;
} }
.composer__send { .composer__send {
+102 -28
View File
@@ -6,20 +6,20 @@
:src="loadingGif" :src="loadingGif"
alt="" alt=""
draggable="false" draggable="false"
:style="{ opacity: gifOpacity }" :style="gifStyle"
/> />
<img <img
v-if="resultUrl" v-if="resultUrl"
class="jd-stage__result" class="jd-stage__result"
:src="resultUrl" :src="resultUrl"
alt="" alt=""
:style="{ opacity: reveal }" :style="resultStyle"
@load="onResultLoad" @load="onResultLoad"
/> />
<p class="jd-stage__status" :style="{ opacity: Math.max(0, 1 - reveal * 1.4) }"> <p class="jd-stage__status" :style="{ opacity: overlayOpacity }">
{{ statusLabel }} {{ statusLabel }}
</p> </p>
<span class="jd-stage__percent" aria-label="生成进度" :style="{ opacity: Math.max(0, 1 - reveal * 1.2) }"> <span class="jd-stage__percent" aria-label="生成进度" :style="{ opacity: overlayOpacity }">
{{ progressLabel }} {{ progressLabel }}
</span> </span>
</div> </div>
@@ -27,7 +27,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import loadingGif from '@/assets/jingdong/picture_cloud.gif' import loadingGif from '@/assets/jingdong/picture_cloud.gif'
const props = defineProps<{ const props = defineProps<{
@@ -41,9 +41,14 @@ const emit = defineEmits<{
}>() }>()
const WAIT_STATUS = ['正在创建图像', '正在构思构图', '正在丰富细节', '正在打磨光影'] as const const WAIT_STATUS = ['正在创建图像', '正在构思构图', '正在丰富细节', '正在打磨光影'] as const
const REVEAL_MS = 1600 /** 交叉淡入总时长:偏慢,避免突兀闪切 */
const REVEAL_MS = 2800
/** 效果图已完全显现后再停留片刻,再交给结果卡片,减少组件切换闪烁 */
const HOLD_MS = 480
/** gif 从何时开始明显淡出(0~1) */
const GIF_FADE_START = 0.28
const phase = ref<'waiting' | 'reveal'>('waiting') const phase = ref<'waiting' | 'reveal' | 'hold'>('waiting')
const progress = ref(0) const progress = ref(0)
const displayProgress = ref(0) const displayProgress = ref(0)
const reveal = ref(0) const reveal = ref(0)
@@ -55,6 +60,8 @@ let raf = 0
let finished = false let finished = false
let startedAt = performance.now() let startedAt = performance.now()
let revealStarted = 0 let revealStarted = 0
let holdStarted = 0
let readyToken = 0
const progressLabel = computed(() => { const progressLabel = computed(() => {
const value = Number.isFinite(displayProgress.value) ? displayProgress.value : 0 const value = Number.isFinite(displayProgress.value) ? displayProgress.value : 0
@@ -62,12 +69,27 @@ const progressLabel = computed(() => {
}) })
const statusLabel = computed(() => { const statusLabel = computed(() => {
if (phase.value === 'reveal') return '即将完成' if (phase.value === 'reveal' || phase.value === 'hold') return '即将完成'
const elapsed = Math.max(0, clock.value - startedAt) const elapsed = Math.max(0, clock.value - startedAt)
const index = Math.floor(elapsed / 3200) % WAIT_STATUS.length const index = Math.floor(elapsed / 3200) % WAIT_STATUS.length
return WAIT_STATUS[index] 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) { function safeProgress(value: number) {
if (!Number.isFinite(value)) return 0 if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(100, value)) return Math.max(0, Math.min(100, value))
@@ -83,6 +105,19 @@ function smoother(value: number) {
return amount * amount * amount * (amount * (amount * 6 - 15) + 10) 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() { function finish() {
if (finished) return if (finished) return
finished = true finished = true
@@ -105,40 +140,73 @@ function tick(now: number) {
revealStarted = now revealStarted = now
} }
} else if (phase.value === 'reveal') { } else if (phase.value === 'reveal') {
const amount = smoother((now - revealStarted) / REVEAL_MS) const raw = Math.min(1, (now - revealStarted) / REVEAL_MS)
reveal.value = amount applyRevealAmount(raw)
gifOpacity.value = Math.max(0, 1 - amount) if (raw >= 1) {
progress.value = safeProgress(88 + amount * 12) phase.value = 'hold'
if (amount >= 1) { 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() finish()
return return
} }
} }
const gap = progress.value - displayProgress.value const gap = progress.value - displayProgress.value
displayProgress.value = safeProgress(displayProgress.value + gap * 0.12) displayProgress.value = safeProgress(displayProgress.value + gap * 0.08)
if (Math.abs(gap) < 0.05) displayProgress.value = progress.value if (Math.abs(gap) < 0.05) displayProgress.value = progress.value
if (!finished) raf = window.requestAnimationFrame(tick) if (!finished) raf = window.requestAnimationFrame(tick)
} }
function onResultLoad() { 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 resultReady.value = true
} }
function onResultLoad() {
if (props.resultUrl) void prepareResult(props.resultUrl)
}
watch( watch(
() => props.resultUrl, () => props.resultUrl,
(url) => { (url) => {
if (!url) { if (!url) {
readyToken += 1
resultReady.value = false resultReady.value = false
return return
} }
// 可能已缓存,直接进入 reveal void prepareResult(url)
const img = new Image()
img.onload = () => {
resultReady.value = true
}
img.src = url
}, },
{ immediate: true }, { immediate: true },
) )
@@ -159,13 +227,14 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
readyToken += 1
window.cancelAnimationFrame(raf) window.cancelAnimationFrame(raf)
}) })
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.jd-stage { .jd-stage {
width: min(280px, 72vw); width: 100%;
max-width: 100%; max-width: 100%;
} }
@@ -173,10 +242,9 @@ onUnmounted(() => {
position: relative; position: relative;
width: 100%; width: 100%;
aspect-ratio: 1 / 1; aspect-ratio: 1 / 1;
max-width: 360px;
overflow: hidden; overflow: hidden;
border-radius: 12px; border-radius: 12px;
background: #f0f4f9; background: #f7f7f7;
} }
.jd-stage__gif, .jd-stage__gif,
@@ -185,19 +253,23 @@ onUnmounted(() => {
inset: 0; inset: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: contain;
object-position: center; object-position: center top;
pointer-events: none; pointer-events: none;
will-change: opacity, transform;
backface-visibility: hidden;
} }
.jd-stage__gif { .jd-stage__gif {
z-index: 1; z-index: 1;
object-fit: cover;
object-position: center;
} }
.jd-stage__result { .jd-stage__result {
z-index: 3; z-index: 3;
object-fit: contain; /* 与结果卡片一致:完整显示效果图 */
background: #f0f4f9; background: #f7f7f7;
} }
.jd-stage__status { .jd-stage__status {
@@ -212,6 +284,7 @@ onUnmounted(() => {
line-height: 1.3; line-height: 1.3;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9); text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9);
pointer-events: none; pointer-events: none;
transition: opacity 0.25s ease;
} }
.jd-stage__percent { .jd-stage__percent {
@@ -233,5 +306,6 @@ onUnmounted(() => {
line-height: 1; line-height: 1;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
pointer-events: none; pointer-events: none;
transition: opacity 0.25s ease;
} }
</style> </style>
+118 -49
View File
@@ -1,13 +1,22 @@
<template> <template>
<div class="result-card" :class="{ 'is-editing': editing }"> <div class="result-card" :class="{ 'is-editing': editing }">
<div class="result-card__media"> <div class="result-card__media">
<img ref="imgRef" class="result-card__img" :src="image" alt="效果图" /> <img
<button class="result-card__view" type="button" @click="emit('view')"> ref="imgRef"
<span>{{ viewLabel }}</span> class="result-card__img"
<el-icon :size="12"><FullScreen /></el-icon> :src="image"
</button> alt="效果图"
draggable="false"
@click="onImageClick"
@pointerdown="onPointerDown"
@pointerup="onPointerUp"
@pointerleave="onPointerUp"
@pointercancel="onPointerUp"
@contextmenu.prevent
/>
<button <button
class="result-card__edit" class="result-card__edit"
:class="{ 'is-exit': editing }"
type="button" type="button"
@click="onEditClick" @click="onEditClick"
> >
@@ -20,21 +29,25 @@
<span>{{ editLabel }}</span> <span>{{ editLabel }}</span>
</template> </template>
</button> </button>
<button class="result-card__download" type="button" aria-label="下载" @click="emit('download')">
<el-icon :size="16"><Download /></el-icon>
</button>
</div> </div>
<div class="result-card__meta">{{ meta }}</div> <div class="result-card__meta">{{ meta }}</div>
<button v-if="showBuy" class="result-card__buy" type="button" @click="emit('buy')"> <button
class="result-card__buy"
type="button"
:disabled="editing"
:aria-disabled="editing"
@click="onBuyClick"
>
<el-icon :size="16"><ShoppingCart /></el-icon> <el-icon :size="16"><ShoppingCart /></el-icon>
<span>{{ buyLabel }}</span> <span>{{ buyLabel }}</span>
</button> </button>
<p v-if="editing" class="result-card__buy-tip">{{ buyDisabledTip }}</p>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { onBeforeUnmount, ref } from 'vue'
import { Close, Download, EditPen, FullScreen, ShoppingCart } from '@element-plus/icons-vue' import { Close, EditPen, ShoppingCart } from '@element-plus/icons-vue'
import { rectFromElement, type FlyRect } from '@/utils/fly-image' import { rectFromElement, type FlyRect } from '@/utils/fly-image'
const props = withDefaults( const props = withDefaults(
@@ -42,19 +55,17 @@ const props = withDefaults(
image: string image: string
meta: string meta: string
editing?: boolean editing?: boolean
showBuy?: boolean
viewLabel?: string
editLabel?: string editLabel?: string
exitEditLabel?: string exitEditLabel?: string
buyLabel?: string buyLabel?: string
buyDisabledTip?: string
}>(), }>(),
{ {
editing: false, editing: false,
showBuy: true,
viewLabel: '查看',
editLabel: '编辑', editLabel: '编辑',
exitEditLabel: '退出编辑', exitEditLabel: '退出编辑',
buyLabel: '立即购买', buyLabel: '立即购买',
buyDisabledTip: '编辑调整中,暂不可购买',
}, },
) )
@@ -67,6 +78,39 @@ const emit = defineEmits<{
}>() }>()
const imgRef = ref<HTMLImageElement | null>(null) 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() { function onEditClick() {
if (props.editing) { if (props.editing) {
@@ -75,6 +119,15 @@ function onEditClick() {
} }
emit('edit', { sourceRect: rectFromElement(imgRef.value) }) emit('edit', { sourceRect: rectFromElement(imgRef.value) })
} }
function onBuyClick() {
if (props.editing) return
emit('buy')
}
onBeforeUnmount(() => {
clearPressTimer()
})
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -86,10 +139,22 @@ function onEditClick() {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 14px; border-radius: 14px;
background: #f0f0f0; background: #f7f7f7;
border: 1.5px solid transparent; 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 { .result-card.is-editing .result-card__media {
border-color: #3a8e4d; border-color: #3a8e4d;
} }
@@ -97,50 +162,39 @@ function onEditClick() {
.result-card__img { .result-card__img {
display: block; display: block;
width: 100%; width: 100%;
aspect-ratio: 1 / 1; height: auto;
object-fit: cover; object-fit: contain;
object-position: center top; object-position: center top;
cursor: pointer;
-webkit-touch-callout: none;
user-select: none;
vertical-align: top;
} }
.result-card__view, .result-card__edit {
.result-card__edit,
.result-card__download {
position: absolute; position: absolute;
left: 10px;
bottom: 10px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
height: 26px;
padding: 0 8px;
border: 0; border: 0;
border-radius: 8px;
cursor: pointer; cursor: pointer;
color: #fff; color: #fff;
background: rgba(0, 0, 0, 0.45); background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
}
.result-card__view {
top: 10px;
right: 10px;
height: 26px;
padding: 0 8px;
border-radius: 8px;
font-size: 12px; font-size: 12px;
} animation: jd-result-chrome-in 0.55s ease both;
.result-card__edit { &.is-exit {
left: 10px; color: #1a1a1a;
bottom: 10px; background: #ffffff;
height: 26px; border-radius: 999px;
padding: 0 8px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12);
border-radius: 8px; }
font-size: 12px;
}
.result-card__download {
right: 10px;
bottom: 10px;
width: 30px;
height: 30px;
justify-content: center;
border-radius: 8px;
} }
.result-card__meta { .result-card__meta {
@@ -148,6 +202,7 @@ function onEditClick() {
font-size: 12px; font-size: 12px;
color: #999; color: #999;
line-height: 1.4; line-height: 1.4;
animation: jd-result-chrome-in 0.55s ease 0.06s both;
} }
.result-card__buy { .result-card__buy {
@@ -165,11 +220,25 @@ function onEditClick() {
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: opacity 0.15s ease, transform 0.15s ease; 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 { &:active:not(:disabled) {
opacity: 0.9; opacity: 0.9;
transform: scale(0.98); }
&: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> </style>
File diff suppressed because it is too large. Load diff