forked from natuka/web.puabadge.com
修改:京东下单页面优化
This commit is contained in:
1 parent
007c675603
commit
9124c07ad2
7 files changed
+600
-39
No files matched your search
+21
-4
@@ -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:
|
||||
'上传图片的主体数量与下单主体不一致,请更换与订单相符的照片后再试',
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/** 将后端 / 网络 / 前端技术错误转成用户可读提示 */
|
||||
|
||||
const KNOWN: Record<string, string> = {
|
||||
未登录: '登录已失效,请关闭后重新进入再试',
|
||||
请求失败: '服务繁忙,请稍后重试',
|
||||
接口返回无法解析: '服务暂时没有响应,请稍后重试',
|
||||
'Failed to fetch': '网络异常,请检查网络后重试',
|
||||
'Network Error': '网络异常,请检查网络后重试',
|
||||
'NetworkError when attempting to fetch resource.': '网络异常,请检查网络后重试',
|
||||
'Load failed': '网络异常,请检查网络后重试',
|
||||
timeout: '等待超时,请稍后重试',
|
||||
Timeout: '等待超时,请稍后重试',
|
||||
ECONNABORTED: '等待超时,请稍后重试',
|
||||
图片上传失败: '照片上传失败,请换一张或稍后重试',
|
||||
签发上传地址失败: '照片上传失败,请稍后重试',
|
||||
'签发上传地址失败:缺少 upload_id': '照片上传失败,请稍后重试',
|
||||
'签发上传地址失败:缺少 upload_url': '照片上传失败,请稍后重试',
|
||||
'获取 upload_id 失败': '照片上传失败,请稍后重试',
|
||||
'upload/url 响应无法解析': '照片上传失败,请稍后重试',
|
||||
生成失败: '这次没生成成功,请换个描述或照片再试',
|
||||
提交生成失败: '提交失败了,请稍后重试',
|
||||
没有生成结果: '这次没有生成出效果图,请再试一次',
|
||||
生成超时: '生成时间有点久,请稍后重试',
|
||||
'生成超时,请稍后重试': '生成时间有点久,请稍后重试',
|
||||
已取消生成: '已取消本次生成',
|
||||
请先上传照片: '请先上传一张照片再发送',
|
||||
缺少可编辑的效果图: '效果图还没准备好,请稍后再试',
|
||||
'缺少会话信息,请刷新后重试': '页面信息已失效,请刷新后再试',
|
||||
'缺少产品信息,请刷新后重试': '订单产品信息异常,请刷新后再试',
|
||||
效果图加载失败: '效果图加载失败,请重试',
|
||||
'效果图加载失败,请重试': '效果图加载失败,请重试',
|
||||
'product_id不在Agent开放分类': '当前产品暂不支持在线生图,请联系客服',
|
||||
}
|
||||
|
||||
const PATTERN_RULES: Array<{ test: RegExp; tip: string }> = [
|
||||
{ test: /timeout|超时|ETIMEDOUT|ECONNABORTED/i, tip: '等待超时,请稍后重试' },
|
||||
{ test: /network|fetch failed|ERR_NETWORK|断网|网络/i, tip: '网络异常,请检查网络后重试' },
|
||||
{ test: /upload|上传|oss|签名/i, tip: '照片上传失败,请换一张或稍后重试' },
|
||||
{ test: /login|auth|401|未登录|登录|鉴权|token/i, tip: '登录已失效,请关闭后重新进入再试' },
|
||||
{ test: /403|无权限|forbidden/i, tip: '暂时没有操作权限,请稍后重试' },
|
||||
{ test: /404|not found/i, tip: '服务接口暂不可用,请稍后重试' },
|
||||
{ test: /5\d{2}|server error|内部错误|系统繁忙/i, tip: '服务繁忙,请稍后重试' },
|
||||
{ test: /product_id|开放分类|产品/i, tip: '当前产品暂不支持在线生图,请联系客服' },
|
||||
{ test: /次数|remain|quota|不足/i, tip: '生图次数不足,暂时无法继续生成' },
|
||||
{ test: /主体|不符|不一致/i, tip: '照片主体与订单不符,请更换照片后再试' },
|
||||
{ test: /cancel|取消|aborted/i, tip: '已取消本次操作' },
|
||||
]
|
||||
|
||||
export type FriendlyErrorScene = 'generate' | 'edit' | 'upload' | 'inspect' | 'download' | 'common'
|
||||
|
||||
const SCENE_FALLBACK: Record<FriendlyErrorScene, string> = {
|
||||
generate: '这次没生成成功,请换个描述或照片再试',
|
||||
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<string, unknown>
|
||||
const nested = record.response
|
||||
if (nested && typeof nested === 'object') {
|
||||
const data = (nested as Record<string, unknown>).data
|
||||
if (data && typeof data === 'object') {
|
||||
const msg = (data as Record<string, unknown>).message || (data as Record<string, unknown>).msg
|
||||
if (msg != null && String(msg).trim()) return String(msg).trim()
|
||||
}
|
||||
}
|
||||
const msg = record.message || record.msg || record.error
|
||||
if (msg != null && String(msg).trim()) return String(msg).trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function looksTechnical(text: string) {
|
||||
if (!text) return true
|
||||
if (text.length > 48) return true
|
||||
if (/[{}<>]|https?:|undefined|null|NaN|\[object /i.test(text)) return true
|
||||
if (/[A-Za-z]{3,}[_./][A-Za-z0-9_./-]{2,}/.test(text)) return true
|
||||
if (/^[A-Za-z0-9_.:\-\s/]+$/.test(text) && /[A-Za-z]{4,}/.test(text)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 任意错误 → 友好中文提示。
|
||||
* - 优先命中已知文案 / 规则
|
||||
* - 短中文业务提示可原样展示
|
||||
* - 技术性内容回落场景默认文案
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -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<string, any> | 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<string, any> | null | undefined,
|
||||
): ProductTitleSpec | null {
|
||||
if (!orderStat) return null
|
||||
|
||||
const productName = textOf(
|
||||
orderStat.type_name,
|
||||
orderStat.prod_name,
|
||||
orderStat.product_name,
|
||||
)
|
||||
const variantName = collectVariantName(orderStat)
|
||||
const byName = productTitleSpec(productName, variantName)
|
||||
if (byName) return byName
|
||||
|
||||
const typeId = Number(orderStat.type_id || 0)
|
||||
if (typeId === 9) {
|
||||
return { label: '场景道具', placeholder: '可输入1-4个场景道具', max: 4 }
|
||||
}
|
||||
if (typeId === 11) {
|
||||
return { label: '相框标题', placeholder: '输入相框名称,如「我们的小家」', max: 18 }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveScenePropFromOrder(
|
||||
orderStat: Record<string, any> | null | undefined,
|
||||
title: string,
|
||||
): string {
|
||||
if (!productTitleSpecFromOrder(orderStat)) return ''
|
||||
return String(title || '').trim()
|
||||
}
|
||||
@@ -11,29 +11,60 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="boxRef" class="composer__box">
|
||||
<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 v-if="previewUrl" ref="previewRef" class="composer__preview">
|
||||
<img class="composer__preview-img" :src="previewUrl" alt="已选图片" />
|
||||
<button class="composer__preview-close" type="button" aria-label="移除图片" @click="emit('clear-preview')">
|
||||
<span v-if="inspecting" class="composer__preview-mask">识别中</span>
|
||||
<button
|
||||
v-else
|
||||
class="composer__preview-close"
|
||||
type="button"
|
||||
aria-label="移除图片"
|
||||
@click="emit('clear-preview')"
|
||||
>
|
||||
<el-icon :size="10" color="#fff"><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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
|
||||
ref="textareaRef"
|
||||
class="composer__input"
|
||||
rows="2"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:placeholder="resolvedPlaceholder"
|
||||
:disabled="disabled"
|
||||
@input="onInput"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
|
||||
<div class="composer__toolbar">
|
||||
<button
|
||||
class="composer__upload"
|
||||
type="button"
|
||||
aria-label="上传图片"
|
||||
:aria-label="editing ? '编辑中不可更换照片' : '上传图片'"
|
||||
:disabled="disabled || editing || inspecting"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
@@ -65,6 +96,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Close, EditPen, Loading, Picture, Present, Star, Top, User } from '@element-plus/icons-vue'
|
||||
import type { ProductTitleSpec } from '@/utils/jingdong-product-title'
|
||||
|
||||
export type ComposerTag = {
|
||||
key: string
|
||||
@@ -76,6 +108,9 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
placeholderNeedPhoto?: string
|
||||
placeholderEditing?: string
|
||||
placeholderCartoon?: string
|
||||
previewUrl?: string
|
||||
tags?: ComposerTag[]
|
||||
editing?: boolean
|
||||
@@ -85,10 +120,23 @@ const props = withDefaults(
|
||||
canSend?: boolean
|
||||
/** 图片识别 / 主体识别进行中 */
|
||||
inspecting?: boolean
|
||||
/** 当前预览图是否为卡通效果图 */
|
||||
cartoonEffect?: boolean
|
||||
/** 底部状态提示 */
|
||||
statusTip?: string
|
||||
tipWarn?: boolean
|
||||
/** 产品标题规则(有值时展示标题输入) */
|
||||
titleSpec?: ProductTitleSpec | null
|
||||
productTitle?: string
|
||||
/** 标题未填时高亮错误 */
|
||||
titleInvalid?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: '',
|
||||
placeholderNeedPhoto: '请先点击左侧按钮上传照片',
|
||||
placeholderEditing: '说说想怎么改…',
|
||||
placeholderCartoon: '已识别为效果图,可直接发送',
|
||||
previewUrl: '',
|
||||
tags: () => [],
|
||||
editing: false,
|
||||
@@ -97,17 +145,27 @@ const props = withDefaults(
|
||||
disabled: false,
|
||||
canSend: false,
|
||||
inspecting: false,
|
||||
cartoonEffect: false,
|
||||
statusTip: '',
|
||||
tipWarn: false,
|
||||
titleSpec: null,
|
||||
productTitle: '',
|
||||
titleInvalid: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'update:productTitle', value: string): void
|
||||
(e: 'upload'): void
|
||||
(e: 'clear-preview'): void
|
||||
(e: 'send'): void
|
||||
(e: 'exit-edit'): void
|
||||
}>()
|
||||
|
||||
const titleInputId = 'jd-composer-product-title'
|
||||
const productTitleLength = computed(() => String(props.productTitle || '').length)
|
||||
|
||||
const iconMap = {
|
||||
user: User,
|
||||
star: Star,
|
||||
@@ -124,13 +182,40 @@ const tagItems = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
const resolvedPlaceholder = computed(() => {
|
||||
if (props.editing) return props.placeholderEditing || props.placeholder
|
||||
if (!props.previewUrl) return props.placeholderNeedPhoto || props.placeholder
|
||||
if (props.cartoonEffect) return props.placeholderCartoon || props.placeholder
|
||||
return props.placeholder
|
||||
})
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const boxRef = ref<HTMLElement | null>(null)
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function autosize() {
|
||||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`
|
||||
}
|
||||
|
||||
function onInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
emit('update:modelValue', target.value)
|
||||
autosize()
|
||||
}
|
||||
|
||||
function onTitleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
emit('update:productTitle', target.value)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Enter' || e.shiftKey) return
|
||||
// 移动端/桌面:Enter 发送,Shift+Enter 换行
|
||||
e.preventDefault()
|
||||
if (!props.disabled && props.canSend) emit('send')
|
||||
}
|
||||
|
||||
function focus() {
|
||||
@@ -155,6 +240,18 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.composer__tip {
|
||||
margin: 0 2px 8px;
|
||||
padding: 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #888;
|
||||
|
||||
&.is-warn {
|
||||
color: #c45c26;
|
||||
}
|
||||
}
|
||||
|
||||
.composer__edit-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -194,6 +291,16 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
|
||||
background: #ffffff;
|
||||
border: 1px solid #ebebeb;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&.is-editing {
|
||||
border-color: rgba(58, 142, 77, 0.45);
|
||||
box-shadow: 0 4px 16px rgba(58, 142, 77, 0.08);
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.92;
|
||||
}
|
||||
}
|
||||
|
||||
.composer__preview {
|
||||
@@ -213,12 +320,24 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
|
||||
}
|
||||
}
|
||||
|
||||
.composer__preview-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.composer__preview-close {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
@@ -229,6 +348,73 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
|
||||
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 {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -90,6 +90,12 @@ const emit = defineEmits<{
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.96);
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.jd-nav__remain-icon {
|
||||
|
||||
@@ -156,7 +156,7 @@ function onEditClick() {
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
height: 44px;
|
||||
margin-top: 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
@@ -165,5 +165,11 @@ function onEditClick() {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+160
-28
@@ -4,6 +4,7 @@
|
||||
:title="config.brandName"
|
||||
:remain="remainCount"
|
||||
:logo="config.images.logo"
|
||||
@remain="onRemainClick"
|
||||
/>
|
||||
|
||||
<div ref="listRef" class="jd-page__list">
|
||||
@@ -39,7 +40,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="msg.type === 'user'" class="msg__bubble msg__bubble--user">
|
||||
<div
|
||||
v-else-if="msg.type === 'user'"
|
||||
class="msg__bubble msg__bubble--user"
|
||||
:class="{ 'is-text-only': !msg.image }"
|
||||
>
|
||||
<img v-if="msg.image" class="msg__user-thumb" :src="msg.image" alt="" />
|
||||
<p class="msg__user-text">{{ msg.text }}</p>
|
||||
</div>
|
||||
@@ -80,15 +85,24 @@
|
||||
<ChatComposer
|
||||
ref="composerRef"
|
||||
v-model="draft"
|
||||
v-model:product-title="productTitle"
|
||||
:placeholder="config.messages.placeholder"
|
||||
:placeholder-need-photo="config.messages.placeholderNeedPhoto"
|
||||
:placeholder-editing="config.messages.placeholderEditing"
|
||||
:placeholder-cartoon="config.messages.placeholderCartoon"
|
||||
:preview-url="pendingPreview"
|
||||
:tags="composerTags"
|
||||
:editing="isEditing"
|
||||
:editing-hint="config.messages.editingHint"
|
||||
:exit-edit-label="config.messages.exitEdit"
|
||||
:disabled="isBusy || inspecting"
|
||||
:disabled="isBusy"
|
||||
:inspecting="inspecting"
|
||||
:cartoon-effect="isPendingCartoon"
|
||||
:can-send="canSend"
|
||||
:status-tip="composerStatusTip"
|
||||
:tip-warn="composerTipWarn"
|
||||
:title-spec="titleSpec"
|
||||
:title-invalid="titleInvalid"
|
||||
@upload="triggerUpload"
|
||||
@clear-preview="onClearPreview"
|
||||
@send="onSend"
|
||||
@@ -106,7 +120,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { showImagePreview, showToast } from 'vant'
|
||||
import * as badgeApi from '@/api/badge'
|
||||
import * as jingdongApi from '@/api/jingdong'
|
||||
@@ -118,6 +132,11 @@ import {
|
||||
type ImageInspect,
|
||||
} from '@/services/jingdong/image-inspect'
|
||||
import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image'
|
||||
import { friendlyError } from '@/utils/jingdong-error'
|
||||
import {
|
||||
productTitleSpecFromOrder,
|
||||
resolveScenePropFromOrder,
|
||||
} from '@/utils/jingdong-product-title'
|
||||
import JdNavBar from './components/JdNavBar.vue'
|
||||
import ChatComposer, { type ComposerTag } from './components/ChatComposer.vue'
|
||||
import GenerationStage from './components/GenerationStage.vue'
|
||||
@@ -189,6 +208,8 @@ const orderStat = ref<Record<string, any> | null>(null)
|
||||
const subjectMatched = ref(true)
|
||||
const sessionDetail = ref<ReturnType<typeof jingdongApi.parseSessionDetail> | null>(null)
|
||||
const draft = ref('')
|
||||
const productTitle = ref('')
|
||||
const titleInvalid = ref(false)
|
||||
const pendingPreview = ref('')
|
||||
const pendingObjectUrl = ref('')
|
||||
const pendingFile = ref<File | null>(null)
|
||||
@@ -245,8 +266,30 @@ const composerRef = ref<{
|
||||
} | null>(null)
|
||||
|
||||
const isEditing = computed(() => !!editingResultId.value)
|
||||
const isPendingCartoon = computed(
|
||||
() => Boolean(pendingPreview.value && photoInspect.value && isCartoonImage(photoInspect.value)),
|
||||
)
|
||||
const titleSpec = computed(() => productTitleSpecFromOrder(orderStat.value))
|
||||
const hasRequiredTitle = computed(() => {
|
||||
if (!titleSpec.value) return true
|
||||
return productTitle.value.trim().length > 0
|
||||
})
|
||||
|
||||
watch(productTitle, (value) => {
|
||||
if (String(value || '').trim()) titleInvalid.value = false
|
||||
})
|
||||
|
||||
watch(
|
||||
() => titleSpec.value?.label || '',
|
||||
() => {
|
||||
productTitle.value = ''
|
||||
titleInvalid.value = false
|
||||
},
|
||||
)
|
||||
const canSend = computed(() => {
|
||||
if (isBusy.value || inspecting.value) return false
|
||||
if (remainCount.value <= 0) return false
|
||||
if (!hasRequiredTitle.value) return false
|
||||
if (isEditing.value) {
|
||||
// 编辑:需有飞入的效果图 + 调整文案
|
||||
return !!pendingPreview.value && draft.value.trim().length > 0
|
||||
@@ -256,6 +299,20 @@ const canSend = computed(() => {
|
||||
return !!pendingPreview.value
|
||||
})
|
||||
|
||||
const composerStatusTip = computed(() => {
|
||||
if (isBusy.value) return config.messages.generatingTip
|
||||
if (inspecting.value) return config.messages.inspectingTip
|
||||
if (remainCount.value <= 0) return config.messages.noRemain
|
||||
if (pendingPreview.value && !subjectMatched.value) return config.messages.subjectMismatchTip
|
||||
return ''
|
||||
})
|
||||
|
||||
const composerTipWarn = computed(
|
||||
() =>
|
||||
remainCount.value <= 0 ||
|
||||
(Boolean(pendingPreview.value) && !subjectMatched.value),
|
||||
)
|
||||
|
||||
/** 解析「1人」「2人+1宠物」等主体文案 */
|
||||
function parseSubjectCounts(text: string) {
|
||||
const raw = String(text || '').trim()
|
||||
@@ -321,7 +378,18 @@ function scrollToBottom() {
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
if (isBusy.value || isEditing.value || inspecting.value) return
|
||||
if (isBusy.value) {
|
||||
showToast(config.messages.generatingTip)
|
||||
return
|
||||
}
|
||||
if (isEditing.value) {
|
||||
showToast('编辑效果图时请直接描述想调整的内容')
|
||||
return
|
||||
}
|
||||
if (inspecting.value) {
|
||||
showToast(config.messages.inspectingTip)
|
||||
return
|
||||
}
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
@@ -337,7 +405,11 @@ function revokePendingObjectUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingPreview() {
|
||||
/**
|
||||
* 清空输入区预览。
|
||||
* keepObjectUrl=true:气泡仍要用 blob 预览,只断开 pending 引用,不 revoke。
|
||||
*/
|
||||
function clearPendingPreview(options?: { keepObjectUrl?: boolean }) {
|
||||
inspectAbort?.abort()
|
||||
inspectAbort = null
|
||||
inspecting.value = false
|
||||
@@ -345,7 +417,12 @@ function clearPendingPreview() {
|
||||
photoInspect.value = null
|
||||
subjectMatched.value = true
|
||||
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
|
||||
revokePendingObjectUrl()
|
||||
if (options?.keepObjectUrl) {
|
||||
// 交给 blobUrls 在页面卸载时统一释放
|
||||
pendingObjectUrl.value = ''
|
||||
} else {
|
||||
revokePendingObjectUrl()
|
||||
}
|
||||
pendingPreview.value = ''
|
||||
}
|
||||
|
||||
@@ -387,13 +464,11 @@ async function runPhotoRecognize(file: File) {
|
||||
if (!matched) return
|
||||
|
||||
if (isCartoonImage(inspectResult)) {
|
||||
showToast('识别为卡通效果图,可直接描述后生成/下单')
|
||||
showToast('已识别为效果图,可直接发送;也可补充想调整的地方')
|
||||
}
|
||||
} catch (error) {
|
||||
if (serial !== inspectSerial || error instanceof InspectCancelled) return
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : '图片识别失败,请稍后重试'
|
||||
showToast(message)
|
||||
showToast(friendlyError(error, 'inspect'))
|
||||
} finally {
|
||||
if (serial === inspectSerial) inspecting.value = false
|
||||
}
|
||||
@@ -433,6 +508,7 @@ async function downloadImage(url: string) {
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
showToast(config.messages.downloadOk)
|
||||
} catch {
|
||||
// 跨域或本地资源失败时直接打开新窗口兜底
|
||||
try {
|
||||
@@ -444,8 +520,9 @@ async function downloadImage(url: string) {
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
} catch {
|
||||
showToast(config.messages.downloadFail)
|
||||
showToast(config.messages.downloadOk)
|
||||
} catch (error) {
|
||||
showToast(friendlyError(error, 'download', config.messages.downloadFail))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,6 +531,10 @@ function onBuy() {
|
||||
showToast(config.messages.buyToast)
|
||||
}
|
||||
|
||||
function onRemainClick() {
|
||||
showToast(config.messages.remainTip(remainCount.value))
|
||||
}
|
||||
|
||||
async function enterEdit(resultId: string, sourceRect?: FlyRect | null) {
|
||||
if (isBusy.value) return
|
||||
const msg = messages.value.find((m) => m.id === resultId && m.type === 'result')
|
||||
@@ -471,9 +552,9 @@ async function enterEdit(resultId: string, sourceRect?: FlyRect | null) {
|
||||
if (editingResultId.value !== resultId) return
|
||||
pendingFile.value = file
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
if (editingResultId.value === resultId) {
|
||||
showToast('效果图加载失败,请重试')
|
||||
showToast(friendlyError(error, 'common', '效果图加载失败,请重试'))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -499,10 +580,12 @@ function exitEdit() {
|
||||
}
|
||||
|
||||
/** 提交后恢复编辑态与输入区文案 */
|
||||
function resetComposerAfterSubmit() {
|
||||
function resetComposerAfterSubmit(options?: { keepObjectUrl?: boolean }) {
|
||||
editingResultId.value = null
|
||||
draft.value = ''
|
||||
clearPendingPreview()
|
||||
productTitle.value = ''
|
||||
titleInvalid.value = false
|
||||
clearPendingPreview(options)
|
||||
}
|
||||
|
||||
function resolveProductId() {
|
||||
@@ -595,6 +678,7 @@ async function startGenerate(opts: {
|
||||
mode: 'generate' | 'edit'
|
||||
file?: File | null
|
||||
useCartoonEffect?: boolean
|
||||
sceneProp?: string
|
||||
}) {
|
||||
if (remainCount.value <= 0) {
|
||||
showToast(config.messages.noRemain)
|
||||
@@ -603,13 +687,13 @@ async function startGenerate(opts: {
|
||||
|
||||
const sid = String(sessionId.value || '').trim()
|
||||
if (!sid) {
|
||||
showToast('缺少会话信息,请刷新后重试')
|
||||
showToast(friendlyError('缺少会话信息,请刷新后重试', 'common'))
|
||||
return
|
||||
}
|
||||
|
||||
const productId = resolveProductId()
|
||||
if (!productId) {
|
||||
showToast('缺少产品信息,请刷新后重试')
|
||||
showToast(friendlyError('缺少产品信息,请刷新后重试', 'common'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -659,6 +743,8 @@ async function startGenerate(opts: {
|
||||
let imageId = ''
|
||||
let resultUrl = ''
|
||||
|
||||
const sceneProp = String(opts.sceneProp || '').trim()
|
||||
|
||||
if (opts.useCartoonEffect && opts.mode === 'generate') {
|
||||
const effect = await jingdongApi.submitExternalEffect({
|
||||
session_id: sid,
|
||||
@@ -668,6 +754,7 @@ async function startGenerate(opts: {
|
||||
user_message: opts.userText,
|
||||
style_id: styleId,
|
||||
prompt: opts.userText,
|
||||
scene_prop: sceneProp,
|
||||
})
|
||||
taskId = effect.task_id
|
||||
resultUrl = effect.url || effect.images[0]?.url || ''
|
||||
@@ -691,6 +778,7 @@ async function startGenerate(opts: {
|
||||
prompt: opts.userText,
|
||||
n: 1,
|
||||
mode: opts.mode,
|
||||
scene_prop: sceneProp,
|
||||
})
|
||||
taskId = task.task_id
|
||||
if (!taskId) throw new Error('提交生成失败')
|
||||
@@ -716,14 +804,32 @@ async function startGenerate(opts: {
|
||||
removeGenerating(generatingId)
|
||||
pendingFinish.value = null
|
||||
isBusy.value = false
|
||||
const message =
|
||||
error instanceof Error && error.message ? error.message : '生成失败,请稍后重试'
|
||||
showToast(message)
|
||||
const tip = friendlyError(
|
||||
error,
|
||||
opts.mode === 'edit' ? 'edit' : 'generate',
|
||||
opts.mode === 'edit' ? config.messages.editFail : config.messages.generateFail,
|
||||
)
|
||||
showToast({ message: tip, duration: 2800 })
|
||||
// 对话里留下可读失败提示,避免用户错过 Toast
|
||||
messages.value.push({
|
||||
id: uid('fail'),
|
||||
role: 'agent',
|
||||
type: 'text',
|
||||
text: tip,
|
||||
})
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
function onSend() {
|
||||
if (isBusy.value || inspecting.value) return
|
||||
if (isBusy.value) {
|
||||
showToast(config.messages.generatingTip)
|
||||
return
|
||||
}
|
||||
if (inspecting.value) {
|
||||
showToast(config.messages.inspectingTip)
|
||||
return
|
||||
}
|
||||
|
||||
if (remainCount.value <= 0) {
|
||||
showToast(config.messages.noRemain)
|
||||
@@ -736,17 +842,25 @@ function onSend() {
|
||||
return
|
||||
}
|
||||
|
||||
if (titleSpec.value && !productTitle.value.trim()) {
|
||||
titleInvalid.value = true
|
||||
showToast(config.messages.needProductTitle(titleSpec.value.label))
|
||||
return
|
||||
}
|
||||
titleInvalid.value = false
|
||||
|
||||
const text = draft.value.trim()
|
||||
const sceneProp = resolveScenePropFromOrder(orderStat.value, productTitle.value)
|
||||
|
||||
if (isEditing.value) {
|
||||
if (!text) {
|
||||
showToast(config.messages.needPhotoOrText)
|
||||
showToast(config.messages.needEditText)
|
||||
return
|
||||
}
|
||||
const file = pendingFile.value
|
||||
const image = pendingPreview.value
|
||||
if (!file || !image) {
|
||||
showToast('效果图准备中,请稍后再试')
|
||||
showToast(config.messages.effectPreparing)
|
||||
return
|
||||
}
|
||||
resetComposerAfterSubmit()
|
||||
@@ -755,6 +869,7 @@ function onSend() {
|
||||
userText: text,
|
||||
mode: 'edit',
|
||||
file,
|
||||
sceneProp,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -762,7 +877,7 @@ function onSend() {
|
||||
const image = pendingPreview.value
|
||||
const file = pendingFile.value
|
||||
if (!file || !image) {
|
||||
showToast('请先上传照片')
|
||||
showToast(config.messages.needPhoto)
|
||||
return
|
||||
}
|
||||
if (!text && !image) {
|
||||
@@ -770,19 +885,30 @@ function onSend() {
|
||||
return
|
||||
}
|
||||
|
||||
const sendText = text || config.messages.defaultPrompt
|
||||
const useCartoonEffect = Boolean(photoInspect.value && isCartoonImage(photoInspect.value))
|
||||
// 卡通效果图:无文案 → 直接提交;有文案 → 按效果图编辑(传 mode=edit)
|
||||
const hasUserPrompt = text.length > 0
|
||||
const mode: 'generate' | 'edit' =
|
||||
useCartoonEffect && hasUserPrompt ? 'edit' : 'generate'
|
||||
const sendText = hasUserPrompt
|
||||
? text
|
||||
: useCartoonEffect
|
||||
? config.messages.cartoonDirectPrompt
|
||||
: config.messages.defaultPrompt
|
||||
// 与 detail 一致:origin 右侧显示图;external_effect 只显示提示词
|
||||
const sendImage = useCartoonEffect ? undefined : image || undefined
|
||||
// 本地 blob 预览会进气泡,提交后不能 revoke,否则裂图
|
||||
const keepObjectUrl = Boolean(sendImage && String(sendImage).startsWith('blob:'))
|
||||
|
||||
resetComposerAfterSubmit()
|
||||
resetComposerAfterSubmit({ keepObjectUrl })
|
||||
|
||||
void startGenerate({
|
||||
userText: sendText,
|
||||
userImage: sendImage,
|
||||
mode: 'generate',
|
||||
mode,
|
||||
file,
|
||||
useCartoonEffect,
|
||||
sceneProp,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1007,6 +1133,12 @@ onBeforeUnmount(() => {
|
||||
background: #f0f1f3;
|
||||
box-shadow: none;
|
||||
color: #222;
|
||||
|
||||
&.is-text-only {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.msg__user-thumb {
|
||||
|
||||
Reference in new issue
Block a user