forked from natuka/web.puabadge.com
Compare commits
13
Commits
6c55986337
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
500bdbbf66 | ||
|
|
2f9d443a6a | ||
|
|
789d4f106c | ||
|
|
3b10685876 | ||
|
|
3c3c71ebee | ||
|
|
31754ef398 | ||
|
|
02d926e91e | ||
|
|
0199b924ac | ||
|
|
e1da41c2f8 | ||
|
|
4979739521 | ||
|
|
b25e3ca473 | ||
|
|
ff30dbb6b4 | ||
|
|
1887e8b2b3 |
No files matched your search
+63
-37
@@ -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 |
+26
-14
@@ -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,8 +69,14 @@ 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: '已开始下载效果图',
|
||||||
@@ -74,8 +86,8 @@ export const jingdongConfig = {
|
|||||||
effectPreparing: '效果图准备中,请稍后再试',
|
effectPreparing: '效果图准备中,请稍后再试',
|
||||||
generateFail: '这次没生成成功,请换个描述或照片再试',
|
generateFail: '这次没生成成功,请换个描述或照片再试',
|
||||||
editFail: '这次调整没成功,请换个描述再试',
|
editFail: '这次调整没成功,请换个描述再试',
|
||||||
/** 识别主体与订单主体不一致 */
|
/** 内容安全策略拒绝(未成年 / 公众人物等) */
|
||||||
subjectMismatch:
|
contentPolicyFail:
|
||||||
'上传图片的主体数量与下单主体不一致,请更换与订单相符的照片后再试',
|
'当前照片不符合内容规范,可能涉及未成年或公众人物,请换一张照片再试',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -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,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,8 +78,8 @@ 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: '下载失败,请换用其他浏览器,或长按效果图保存',
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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,7 +76,7 @@
|
|||||||
:disabled="disabled || editing || inspecting"
|
:disabled="disabled || editing || inspecting"
|
||||||
@click="emit('upload')"
|
@click="emit('upload')"
|
||||||
>
|
>
|
||||||
<el-icon v-if="inspecting" class="is-loading" :size="26"><Loading /></el-icon>
|
<el-icon v-if="inspecting" class="is-loading" :size="28"><Loading /></el-icon>
|
||||||
<el-icon v-else :size="28"><Picture /></el-icon>
|
<el-icon v-else :size="28"><Picture /></el-icon>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -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,9 +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: 44px;
|
width: 40px;
|
||||||
height: 44px;
|
height: 40px;
|
||||||
margin: -6px 0 -6px -6px;
|
margin: -2px 0 -2px -4px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -510,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 {
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.jd-stage {
|
.jd-stage {
|
||||||
width: min(280px, 72vw);
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,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,
|
||||||
@@ -254,8 +253,8 @@ 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;
|
will-change: opacity, transform;
|
||||||
backface-visibility: hidden;
|
backface-visibility: hidden;
|
||||||
@@ -263,13 +262,14 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.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-position: center top;
|
background: #f7f7f7;
|
||||||
background: #f0f4f9;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.jd-stage__status {
|
.jd-stage__status {
|
||||||
|
|||||||
@@ -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,7 +139,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,51 +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);
|
||||||
|
font-size: 12px;
|
||||||
animation: jd-result-chrome-in 0.55s ease both;
|
animation: jd-result-chrome-in 0.55s ease both;
|
||||||
}
|
|
||||||
|
|
||||||
.result-card__view {
|
&.is-exit {
|
||||||
top: 10px;
|
color: #1a1a1a;
|
||||||
right: 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__edit {
|
|
||||||
left: 10px;
|
|
||||||
bottom: 10px;
|
|
||||||
height: 26px;
|
|
||||||
padding: 0 8px;
|
|
||||||
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 {
|
||||||
@@ -179,12 +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;
|
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>
|
||||||
+366
-288
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user