diff --git a/src/config/jingdong.ts b/src/config/jingdong.ts index 2ebfb25..8cfbace 100644 --- a/src/config/jingdong.ts +++ b/src/config/jingdong.ts @@ -40,21 +40,38 @@ export const jingdongConfig = { pollTimeoutMs: 5 * 60 * 1000, messages: { - welcome: '你好,上传一张照片,或直接说说想做成什么样的手办。', + welcome: '你好~先上传一张照片,我来帮你做成专属手办效果图。', caseIntro: '先看看效果案例:原图 → 效果图 → 场景应用', caseLabels: ['原图', '效果图', '场景应用'] as const, placeholder: '描述你想要的手办,例如:橘猫,戴黄色围巾...', + placeholderNeedPhoto: '请先点击左侧按钮上传照片', + placeholderEditing: '说说想怎么改,例如:换成蓝色围巾、表情更开心…', + /** 识别为卡通效果图时 */ + placeholderCartoon: '已识别为效果图,可直接发送;也可补充想调整的地方', + /** 卡通效果图且用户未补充提示词时的气泡文案 */ + cartoonDirectPrompt: '这是我在别的平台生成的卡通效果图,不用再调整了,直接帮我下单吧~', defaultPrompt: '按所选产品、风格生成手办效果图~', - editingHint: '正在编辑效果图 · 输入文字描述想调整的地方', + editingHint: '正在编辑效果图 · 描述想调整的地方后发送', exitEdit: '退出编辑', view: '查看', edit: '编辑', buyNow: '立即购买', buyToast: '购买功能即将开放', - noRemain: '生图剩余次数不足', + noRemain: '生图次数已用完,暂时无法继续生成', + remainTip: (n: number) => (n > 0 ? `还可以生成 ${n} 次效果图` : '生图次数已用完'), + needPhoto: '请先上传一张照片再发送', + needEditText: '请输入想调整的内容后再发送', needPhotoOrText: '请上传照片或输入描述后再发送', - generating: '正在生成效果图…', + needProductTitle: (label: string) => `请填写${label}后再发送`, + inspectingTip: '正在识别照片,请稍候…', + subjectMismatchTip: '照片主体与订单不符,请更换照片', + generating: '正在生成效果图,请稍候…', + generatingTip: '效果图生成中,完成后可继续操作', + downloadOk: '已开始下载效果图', downloadFail: '下载失败,请稍后重试', + effectPreparing: '效果图准备中,请稍后再试', + generateFail: '这次没生成成功,请换个描述或照片再试', + editFail: '这次调整没成功,请换个描述再试', /** 识别主体与订单主体不一致 */ subjectMismatch: '上传图片的主体数量与下单主体不一致,请更换与订单相符的照片后再试', diff --git a/src/utils/jingdong-error.ts b/src/utils/jingdong-error.ts new file mode 100644 index 0000000..127d82c --- /dev/null +++ b/src/utils/jingdong-error.ts @@ -0,0 +1,116 @@ +/** 将后端 / 网络 / 前端技术错误转成用户可读提示 */ + +const KNOWN: Record = { + 未登录: '登录已失效,请关闭后重新进入再试', + 请求失败: '服务繁忙,请稍后重试', + 接口返回无法解析: '服务暂时没有响应,请稍后重试', + 'Failed to fetch': '网络异常,请检查网络后重试', + 'Network Error': '网络异常,请检查网络后重试', + 'NetworkError when attempting to fetch resource.': '网络异常,请检查网络后重试', + 'Load failed': '网络异常,请检查网络后重试', + timeout: '等待超时,请稍后重试', + Timeout: '等待超时,请稍后重试', + ECONNABORTED: '等待超时,请稍后重试', + 图片上传失败: '照片上传失败,请换一张或稍后重试', + 签发上传地址失败: '照片上传失败,请稍后重试', + '签发上传地址失败:缺少 upload_id': '照片上传失败,请稍后重试', + '签发上传地址失败:缺少 upload_url': '照片上传失败,请稍后重试', + '获取 upload_id 失败': '照片上传失败,请稍后重试', + 'upload/url 响应无法解析': '照片上传失败,请稍后重试', + 生成失败: '这次没生成成功,请换个描述或照片再试', + 提交生成失败: '提交失败了,请稍后重试', + 没有生成结果: '这次没有生成出效果图,请再试一次', + 生成超时: '生成时间有点久,请稍后重试', + '生成超时,请稍后重试': '生成时间有点久,请稍后重试', + 已取消生成: '已取消本次生成', + 请先上传照片: '请先上传一张照片再发送', + 缺少可编辑的效果图: '效果图还没准备好,请稍后再试', + '缺少会话信息,请刷新后重试': '页面信息已失效,请刷新后再试', + '缺少产品信息,请刷新后重试': '订单产品信息异常,请刷新后再试', + 效果图加载失败: '效果图加载失败,请重试', + '效果图加载失败,请重试': '效果图加载失败,请重试', + 'product_id不在Agent开放分类': '当前产品暂不支持在线生图,请联系客服', +} + +const PATTERN_RULES: Array<{ test: RegExp; tip: string }> = [ + { test: /timeout|超时|ETIMEDOUT|ECONNABORTED/i, tip: '等待超时,请稍后重试' }, + { test: /network|fetch failed|ERR_NETWORK|断网|网络/i, tip: '网络异常,请检查网络后重试' }, + { test: /upload|上传|oss|签名/i, tip: '照片上传失败,请换一张或稍后重试' }, + { test: /login|auth|401|未登录|登录|鉴权|token/i, tip: '登录已失效,请关闭后重新进入再试' }, + { test: /403|无权限|forbidden/i, tip: '暂时没有操作权限,请稍后重试' }, + { test: /404|not found/i, tip: '服务接口暂不可用,请稍后重试' }, + { test: /5\d{2}|server error|内部错误|系统繁忙/i, tip: '服务繁忙,请稍后重试' }, + { test: /product_id|开放分类|产品/i, tip: '当前产品暂不支持在线生图,请联系客服' }, + { test: /次数|remain|quota|不足/i, tip: '生图次数不足,暂时无法继续生成' }, + { test: /主体|不符|不一致/i, tip: '照片主体与订单不符,请更换照片后再试' }, + { test: /cancel|取消|aborted/i, tip: '已取消本次操作' }, +] + +export type FriendlyErrorScene = 'generate' | 'edit' | 'upload' | 'inspect' | 'download' | 'common' + +const SCENE_FALLBACK: Record = { + generate: '这次没生成成功,请换个描述或照片再试', + edit: '这次调整没成功,请换个描述再试', + upload: '照片上传失败,请换一张或稍后重试', + inspect: '照片识别失败,请换一张或稍后重试', + download: '下载失败,请稍后重试', + common: '操作失败了,请稍后重试', +} + +function rawMessage(error: unknown): string { + if (error == null) return '' + if (typeof error === 'string') return error.trim() + if (error instanceof Error) return String(error.message || '').trim() + if (typeof error === 'object') { + const record = error as Record + const nested = record.response + if (nested && typeof nested === 'object') { + const data = (nested as Record).data + if (data && typeof data === 'object') { + const msg = (data as Record).message || (data as Record).msg + if (msg != null && String(msg).trim()) return String(msg).trim() + } + } + const msg = record.message || record.msg || record.error + if (msg != null && String(msg).trim()) return String(msg).trim() + } + return '' +} + +function looksTechnical(text: string) { + if (!text) return true + if (text.length > 48) return true + if (/[{}<>]|https?:|undefined|null|NaN|\[object /i.test(text)) return true + if (/[A-Za-z]{3,}[_./][A-Za-z0-9_./-]{2,}/.test(text)) return true + if (/^[A-Za-z0-9_.:\-\s/]+$/.test(text) && /[A-Za-z]{4,}/.test(text)) return true + return false +} + +/** + * 任意错误 → 友好中文提示。 + * - 优先命中已知文案 / 规则 + * - 短中文业务提示可原样展示 + * - 技术性内容回落场景默认文案 + */ +export function friendlyError( + error: unknown, + scene: FriendlyErrorScene = 'common', + fallback?: string, +): string { + const tip = fallback || SCENE_FALLBACK[scene] || SCENE_FALLBACK.common + const text = rawMessage(error) + if (!text) return tip + + if (KNOWN[text]) return KNOWN[text] + + for (const [key, value] of Object.entries(KNOWN)) { + if (key.length >= 4 && text.includes(key)) return value + } + + for (const rule of PATTERN_RULES) { + if (rule.test.test(text)) return rule.tip + } + + if (looksTechnical(text)) return tip + return text +} diff --git a/src/utils/jingdong-product-title.ts b/src/utils/jingdong-product-title.ts new file mode 100644 index 0000000..ba43464 --- /dev/null +++ b/src/utils/jingdong-product-title.ts @@ -0,0 +1,98 @@ +/** 对齐 agent catalog.productTitleSpec:哪些产品需要标题输入,以及提交 scene_prop */ + +export interface ProductTitleSpec { + label: string + placeholder: string + max: number +} + +export function isRibbonVariant(name: string) { + return /B\s*款/.test(String(name || '').trim()) +} + +export function productTitleSpec(label: string, variantName = ''): ProductTitleSpec | null { + const name = String(label || '').trim() + if (name.includes('场景手办')) { + return { label: '场景道具', placeholder: '可输入1-4个场景道具', max: 4 } + } + if (name.includes('相框手办挂饰') || (name.includes('相框') && name.includes('挂饰'))) { + return { label: '相框标题', placeholder: '输入相框名称,如「我们的小家」', max: 18 } + } + if (name.includes('相框手办') && isRibbonVariant(variantName)) { + return { label: '飘带标题', placeholder: '输入飘带名称,如「生日快乐」', max: 18 } + } + return null +} + +/** 生成接口 scene_prop:仅当产品需要标题字段且已填写时传原文 */ +export function resolveSceneProp( + productName: string, + title: string, + variantName = '', +): string { + if (!productTitleSpec(productName, variantName)) return '' + return String(title || '').trim() +} + +function textOf(...values: unknown[]) { + for (const value of values) { + const text = String(value ?? '').trim() + if (text) return text + } + return '' +} + +function collectVariantName(orderStat: Record | null | undefined) { + if (!orderStat) return '' + return [ + orderStat.shape_name, + orderStat.kind_name, + orderStat.mounts_name, + orderStat.mount_name, + orderStat.frame_name, + orderStat.prod_kind_name, + orderStat.variant_name, + orderStat.sub_type_name, + ] + .map((item) => String(item ?? '').trim()) + .filter(Boolean) + .join(' ') +} + +/** + * 从 orders/stat 解析标题规则。 + * 产品名:type_name → prod_name → product_name + * 变体名:shape/kind/mounts 等拼接,供 B 款飘带判断 + * 兜底:type_id 9 → 场景道具;type_id 11 → 相框标题(与 cartoon 一致) + */ +export function productTitleSpecFromOrder( + orderStat: Record | null | undefined, +): ProductTitleSpec | null { + if (!orderStat) return null + + const productName = textOf( + orderStat.type_name, + orderStat.prod_name, + orderStat.product_name, + ) + const variantName = collectVariantName(orderStat) + const byName = productTitleSpec(productName, variantName) + if (byName) return byName + + const typeId = Number(orderStat.type_id || 0) + if (typeId === 9) { + return { label: '场景道具', placeholder: '可输入1-4个场景道具', max: 4 } + } + if (typeId === 11) { + return { label: '相框标题', placeholder: '输入相框名称,如「我们的小家」', max: 18 } + } + return null +} + +export function resolveScenePropFromOrder( + orderStat: Record | null | undefined, + title: string, +): string { + if (!productTitleSpecFromOrder(orderStat)) return '' + return String(title || '').trim() +} diff --git a/src/views/jingdong/components/ChatComposer.vue b/src/views/jingdong/components/ChatComposer.vue index 278824f..d526238 100644 --- a/src/views/jingdong/components/ChatComposer.vue +++ b/src/views/jingdong/components/ChatComposer.vue @@ -11,29 +11,60 @@ -
+

{{ statusTip }}

+ +
已选图片 -
+
+ +
+ + {{ productTitleLength }} / {{ titleSpec.max }} +
+ +
+