修改:京东下单页面优化
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing

This commit is contained in:
Linzm committed 2026-11-03 15:08:18 +08:00
1 parent 007c675603
commit 9124c07ad2
7 files changed
+600 -39

No files matched your search

+21 -4
View File
@@ -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:
'上传图片的主体数量与下单主体不一致,请更换与订单相符的照片后再试',
+116
View File
@@ -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
}
+98
View File
@@ -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()
}
+192 -6
View File
@@ -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 {
+7 -1
View File
@@ -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
View File
@@ -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 {