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,
|
pollTimeoutMs: 5 * 60 * 1000,
|
||||||
|
|
||||||
messages: {
|
messages: {
|
||||||
welcome: '你好,上传一张照片,或直接说说想做成什么样的手办。',
|
welcome: '你好~先上传一张照片,我来帮你做成专属手办效果图。',
|
||||||
caseIntro: '先看看效果案例:原图 → 效果图 → 场景应用',
|
caseIntro: '先看看效果案例:原图 → 效果图 → 场景应用',
|
||||||
caseLabels: ['原图', '效果图', '场景应用'] as const,
|
caseLabels: ['原图', '效果图', '场景应用'] as const,
|
||||||
placeholder: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
|
placeholder: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
|
||||||
|
placeholderNeedPhoto: '请先点击左侧按钮上传照片',
|
||||||
|
placeholderEditing: '说说想怎么改,例如:换成蓝色围巾、表情更开心…',
|
||||||
|
/** 识别为卡通效果图时 */
|
||||||
|
placeholderCartoon: '已识别为效果图,可直接发送;也可补充想调整的地方',
|
||||||
|
/** 卡通效果图且用户未补充提示词时的气泡文案 */
|
||||||
|
cartoonDirectPrompt: '这是我在别的平台生成的卡通效果图,不用再调整了,直接帮我下单吧~',
|
||||||
defaultPrompt: '按所选产品、风格生成手办效果图~',
|
defaultPrompt: '按所选产品、风格生成手办效果图~',
|
||||||
editingHint: '正在编辑效果图 · 输入文字描述想调整的地方',
|
editingHint: '正在编辑效果图 · 描述想调整的地方后发送',
|
||||||
exitEdit: '退出编辑',
|
exitEdit: '退出编辑',
|
||||||
view: '查看',
|
view: '查看',
|
||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
buyNow: '立即购买',
|
buyNow: '立即购买',
|
||||||
buyToast: '购买功能即将开放',
|
buyToast: '购买功能即将开放',
|
||||||
noRemain: '生图剩余次数不足',
|
noRemain: '生图次数已用完,暂时无法继续生成',
|
||||||
|
remainTip: (n: number) => (n > 0 ? `还可以生成 ${n} 次效果图` : '生图次数已用完'),
|
||||||
|
needPhoto: '请先上传一张照片再发送',
|
||||||
|
needEditText: '请输入想调整的内容后再发送',
|
||||||
needPhotoOrText: '请上传照片或输入描述后再发送',
|
needPhotoOrText: '请上传照片或输入描述后再发送',
|
||||||
generating: '正在生成效果图…',
|
needProductTitle: (label: string) => `请填写${label}后再发送`,
|
||||||
|
inspectingTip: '正在识别照片,请稍候…',
|
||||||
|
subjectMismatchTip: '照片主体与订单不符,请更换照片',
|
||||||
|
generating: '正在生成效果图,请稍候…',
|
||||||
|
generatingTip: '效果图生成中,完成后可继续操作',
|
||||||
|
downloadOk: '已开始下载效果图',
|
||||||
downloadFail: '下载失败,请稍后重试',
|
downloadFail: '下载失败,请稍后重试',
|
||||||
|
effectPreparing: '效果图准备中,请稍后再试',
|
||||||
|
generateFail: '这次没生成成功,请换个描述或照片再试',
|
||||||
|
editFail: '这次调整没成功,请换个描述再试',
|
||||||
/** 识别主体与订单主体不一致 */
|
/** 识别主体与订单主体不一致 */
|
||||||
subjectMismatch:
|
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>
|
</button>
|
||||||
</div>
|
</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">
|
<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="已选图片" />
|
||||||
<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>
|
<el-icon :size="10" color="#fff"><Close /></el-icon>
|
||||||
</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"
|
||||||
rows="2"
|
rows="2"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:placeholder="placeholder"
|
:placeholder="resolvedPlaceholder"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@input="onInput"
|
@input="onInput"
|
||||||
|
@keydown="onKeydown"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="composer__toolbar">
|
<div class="composer__toolbar">
|
||||||
<button
|
<button
|
||||||
class="composer__upload"
|
class="composer__upload"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="上传图片"
|
:aria-label="editing ? '编辑中不可更换照片' : '上传图片'"
|
||||||
:disabled="disabled || editing || inspecting"
|
:disabled="disabled || editing || inspecting"
|
||||||
@click="emit('upload')"
|
@click="emit('upload')"
|
||||||
>
|
>
|
||||||
@@ -65,6 +96,7 @@
|
|||||||
<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, Picture, Present, Star, Top, User } from '@element-plus/icons-vue'
|
||||||
|
import type { ProductTitleSpec } from '@/utils/jingdong-product-title'
|
||||||
|
|
||||||
export type ComposerTag = {
|
export type ComposerTag = {
|
||||||
key: string
|
key: string
|
||||||
@@ -76,6 +108,9 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
modelValue?: string
|
modelValue?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
placeholderNeedPhoto?: string
|
||||||
|
placeholderEditing?: string
|
||||||
|
placeholderCartoon?: string
|
||||||
previewUrl?: string
|
previewUrl?: string
|
||||||
tags?: ComposerTag[]
|
tags?: ComposerTag[]
|
||||||
editing?: boolean
|
editing?: boolean
|
||||||
@@ -85,10 +120,23 @@ const props = withDefaults(
|
|||||||
canSend?: boolean
|
canSend?: boolean
|
||||||
/** 图片识别 / 主体识别进行中 */
|
/** 图片识别 / 主体识别进行中 */
|
||||||
inspecting?: boolean
|
inspecting?: boolean
|
||||||
|
/** 当前预览图是否为卡通效果图 */
|
||||||
|
cartoonEffect?: boolean
|
||||||
|
/** 底部状态提示 */
|
||||||
|
statusTip?: string
|
||||||
|
tipWarn?: boolean
|
||||||
|
/** 产品标题规则(有值时展示标题输入) */
|
||||||
|
titleSpec?: ProductTitleSpec | null
|
||||||
|
productTitle?: string
|
||||||
|
/** 标题未填时高亮错误 */
|
||||||
|
titleInvalid?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
modelValue: '',
|
modelValue: '',
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
|
placeholderNeedPhoto: '请先点击左侧按钮上传照片',
|
||||||
|
placeholderEditing: '说说想怎么改…',
|
||||||
|
placeholderCartoon: '已识别为效果图,可直接发送',
|
||||||
previewUrl: '',
|
previewUrl: '',
|
||||||
tags: () => [],
|
tags: () => [],
|
||||||
editing: false,
|
editing: false,
|
||||||
@@ -97,17 +145,27 @@ const props = withDefaults(
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
canSend: false,
|
canSend: false,
|
||||||
inspecting: false,
|
inspecting: false,
|
||||||
|
cartoonEffect: false,
|
||||||
|
statusTip: '',
|
||||||
|
tipWarn: false,
|
||||||
|
titleSpec: null,
|
||||||
|
productTitle: '',
|
||||||
|
titleInvalid: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: string): void
|
(e: 'update:modelValue', value: string): void
|
||||||
|
(e: 'update:productTitle', value: string): void
|
||||||
(e: 'upload'): void
|
(e: 'upload'): void
|
||||||
(e: 'clear-preview'): void
|
(e: 'clear-preview'): void
|
||||||
(e: 'send'): void
|
(e: 'send'): void
|
||||||
(e: 'exit-edit'): void
|
(e: 'exit-edit'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const titleInputId = 'jd-composer-product-title'
|
||||||
|
const productTitleLength = computed(() => String(props.productTitle || '').length)
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
user: User,
|
user: User,
|
||||||
star: Star,
|
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 textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||||
const boxRef = ref<HTMLElement | null>(null)
|
const boxRef = ref<HTMLElement | null>(null)
|
||||||
const previewRef = 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) {
|
function onInput(e: Event) {
|
||||||
const target = e.target as HTMLTextAreaElement
|
const target = e.target as HTMLTextAreaElement
|
||||||
emit('update:modelValue', target.value)
|
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() {
|
function focus() {
|
||||||
@@ -155,6 +240,18 @@ defineExpose({ focus, getPreviewEl, getFlyTargetEl })
|
|||||||
background: transparent;
|
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 {
|
.composer__edit-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -194,6 +291,16 @@ 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);
|
||||||
|
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 {
|
.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 {
|
.composer__preview-close {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 2px;
|
top: 2px;
|
||||||
right: 2px;
|
right: 2px;
|
||||||
width: 16px;
|
width: 18px;
|
||||||
height: 16px;
|
height: 18px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(0, 0, 0, 0.55);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
@@ -229,6 +348,73 @@ 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%;
|
||||||
|
|||||||
@@ -90,6 +90,12 @@ const emit = defineEmits<{
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.jd-nav__remain-icon {
|
.jd-nav__remain-icon {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ function onEditClick() {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 44px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
@@ -165,5 +165,11 @@ 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;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
+160
-28
@@ -4,6 +4,7 @@
|
|||||||
:title="config.brandName"
|
:title="config.brandName"
|
||||||
:remain="remainCount"
|
:remain="remainCount"
|
||||||
:logo="config.images.logo"
|
:logo="config.images.logo"
|
||||||
|
@remain="onRemainClick"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div ref="listRef" class="jd-page__list">
|
<div ref="listRef" class="jd-page__list">
|
||||||
@@ -39,7 +40,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="" />
|
<img v-if="msg.image" class="msg__user-thumb" :src="msg.image" alt="" />
|
||||||
<p class="msg__user-text">{{ msg.text }}</p>
|
<p class="msg__user-text">{{ msg.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,15 +85,24 @@
|
|||||||
<ChatComposer
|
<ChatComposer
|
||||||
ref="composerRef"
|
ref="composerRef"
|
||||||
v-model="draft"
|
v-model="draft"
|
||||||
|
v-model:product-title="productTitle"
|
||||||
:placeholder="config.messages.placeholder"
|
:placeholder="config.messages.placeholder"
|
||||||
|
:placeholder-need-photo="config.messages.placeholderNeedPhoto"
|
||||||
|
:placeholder-editing="config.messages.placeholderEditing"
|
||||||
|
:placeholder-cartoon="config.messages.placeholderCartoon"
|
||||||
:preview-url="pendingPreview"
|
:preview-url="pendingPreview"
|
||||||
:tags="composerTags"
|
:tags="composerTags"
|
||||||
:editing="isEditing"
|
:editing="isEditing"
|
||||||
:editing-hint="config.messages.editingHint"
|
:editing-hint="config.messages.editingHint"
|
||||||
:exit-edit-label="config.messages.exitEdit"
|
:exit-edit-label="config.messages.exitEdit"
|
||||||
:disabled="isBusy || inspecting"
|
:disabled="isBusy"
|
||||||
:inspecting="inspecting"
|
:inspecting="inspecting"
|
||||||
|
:cartoon-effect="isPendingCartoon"
|
||||||
:can-send="canSend"
|
:can-send="canSend"
|
||||||
|
:status-tip="composerStatusTip"
|
||||||
|
:tip-warn="composerTipWarn"
|
||||||
|
:title-spec="titleSpec"
|
||||||
|
:title-invalid="titleInvalid"
|
||||||
@upload="triggerUpload"
|
@upload="triggerUpload"
|
||||||
@clear-preview="onClearPreview"
|
@clear-preview="onClearPreview"
|
||||||
@send="onSend"
|
@send="onSend"
|
||||||
@@ -106,7 +120,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { showImagePreview, showToast } from 'vant'
|
||||||
import * as badgeApi from '@/api/badge'
|
import * as badgeApi from '@/api/badge'
|
||||||
import * as jingdongApi from '@/api/jingdong'
|
import * as jingdongApi from '@/api/jingdong'
|
||||||
@@ -118,6 +132,11 @@ import {
|
|||||||
type ImageInspect,
|
type ImageInspect,
|
||||||
} from '@/services/jingdong/image-inspect'
|
} from '@/services/jingdong/image-inspect'
|
||||||
import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image'
|
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 JdNavBar from './components/JdNavBar.vue'
|
||||||
import ChatComposer, { type ComposerTag } from './components/ChatComposer.vue'
|
import ChatComposer, { type ComposerTag } from './components/ChatComposer.vue'
|
||||||
import GenerationStage from './components/GenerationStage.vue'
|
import GenerationStage from './components/GenerationStage.vue'
|
||||||
@@ -189,6 +208,8 @@ const orderStat = ref<Record<string, any> | null>(null)
|
|||||||
const subjectMatched = ref(true)
|
const subjectMatched = ref(true)
|
||||||
const sessionDetail = ref<ReturnType<typeof jingdongApi.parseSessionDetail> | null>(null)
|
const sessionDetail = ref<ReturnType<typeof jingdongApi.parseSessionDetail> | null>(null)
|
||||||
const draft = ref('')
|
const draft = ref('')
|
||||||
|
const productTitle = ref('')
|
||||||
|
const titleInvalid = ref(false)
|
||||||
const pendingPreview = ref('')
|
const pendingPreview = ref('')
|
||||||
const pendingObjectUrl = ref('')
|
const pendingObjectUrl = ref('')
|
||||||
const pendingFile = ref<File | null>(null)
|
const pendingFile = ref<File | null>(null)
|
||||||
@@ -245,8 +266,30 @@ const composerRef = ref<{
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
|
|
||||||
const isEditing = computed(() => !!editingResultId.value)
|
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(() => {
|
const canSend = computed(() => {
|
||||||
if (isBusy.value || inspecting.value) return false
|
if (isBusy.value || inspecting.value) return false
|
||||||
|
if (remainCount.value <= 0) return false
|
||||||
|
if (!hasRequiredTitle.value) return false
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
// 编辑:需有飞入的效果图 + 调整文案
|
// 编辑:需有飞入的效果图 + 调整文案
|
||||||
return !!pendingPreview.value && draft.value.trim().length > 0
|
return !!pendingPreview.value && draft.value.trim().length > 0
|
||||||
@@ -256,6 +299,20 @@ const canSend = computed(() => {
|
|||||||
return !!pendingPreview.value
|
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宠物」等主体文案 */
|
/** 解析「1人」「2人+1宠物」等主体文案 */
|
||||||
function parseSubjectCounts(text: string) {
|
function parseSubjectCounts(text: string) {
|
||||||
const raw = String(text || '').trim()
|
const raw = String(text || '').trim()
|
||||||
@@ -321,7 +378,18 @@ function scrollToBottom() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function triggerUpload() {
|
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()
|
fileInputRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,7 +405,11 @@ function revokePendingObjectUrl() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPendingPreview() {
|
/**
|
||||||
|
* 清空输入区预览。
|
||||||
|
* keepObjectUrl=true:气泡仍要用 blob 预览,只断开 pending 引用,不 revoke。
|
||||||
|
*/
|
||||||
|
function clearPendingPreview(options?: { keepObjectUrl?: boolean }) {
|
||||||
inspectAbort?.abort()
|
inspectAbort?.abort()
|
||||||
inspectAbort = null
|
inspectAbort = null
|
||||||
inspecting.value = false
|
inspecting.value = false
|
||||||
@@ -345,7 +417,12 @@ function clearPendingPreview() {
|
|||||||
photoInspect.value = null
|
photoInspect.value = null
|
||||||
subjectMatched.value = true
|
subjectMatched.value = true
|
||||||
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
|
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
|
||||||
revokePendingObjectUrl()
|
if (options?.keepObjectUrl) {
|
||||||
|
// 交给 blobUrls 在页面卸载时统一释放
|
||||||
|
pendingObjectUrl.value = ''
|
||||||
|
} else {
|
||||||
|
revokePendingObjectUrl()
|
||||||
|
}
|
||||||
pendingPreview.value = ''
|
pendingPreview.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,13 +464,11 @@ async function runPhotoRecognize(file: File) {
|
|||||||
if (!matched) return
|
if (!matched) return
|
||||||
|
|
||||||
if (isCartoonImage(inspectResult)) {
|
if (isCartoonImage(inspectResult)) {
|
||||||
showToast('识别为卡通效果图,可直接描述后生成/下单')
|
showToast('已识别为效果图,可直接发送;也可补充想调整的地方')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (serial !== inspectSerial || error instanceof InspectCancelled) return
|
if (serial !== inspectSerial || error instanceof InspectCancelled) return
|
||||||
const message =
|
showToast(friendlyError(error, 'inspect'))
|
||||||
error instanceof Error && error.message ? error.message : '图片识别失败,请稍后重试'
|
|
||||||
showToast(message)
|
|
||||||
} finally {
|
} finally {
|
||||||
if (serial === inspectSerial) inspecting.value = false
|
if (serial === inspectSerial) inspecting.value = false
|
||||||
}
|
}
|
||||||
@@ -433,6 +508,7 @@ async function downloadImage(url: string) {
|
|||||||
a.click()
|
a.click()
|
||||||
a.remove()
|
a.remove()
|
||||||
URL.revokeObjectURL(objectUrl)
|
URL.revokeObjectURL(objectUrl)
|
||||||
|
showToast(config.messages.downloadOk)
|
||||||
} catch {
|
} catch {
|
||||||
// 跨域或本地资源失败时直接打开新窗口兜底
|
// 跨域或本地资源失败时直接打开新窗口兜底
|
||||||
try {
|
try {
|
||||||
@@ -444,8 +520,9 @@ async function downloadImage(url: string) {
|
|||||||
document.body.appendChild(a)
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
a.remove()
|
a.remove()
|
||||||
} catch {
|
showToast(config.messages.downloadOk)
|
||||||
showToast(config.messages.downloadFail)
|
} catch (error) {
|
||||||
|
showToast(friendlyError(error, 'download', config.messages.downloadFail))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,6 +531,10 @@ function onBuy() {
|
|||||||
showToast(config.messages.buyToast)
|
showToast(config.messages.buyToast)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onRemainClick() {
|
||||||
|
showToast(config.messages.remainTip(remainCount.value))
|
||||||
|
}
|
||||||
|
|
||||||
async function enterEdit(resultId: string, sourceRect?: FlyRect | null) {
|
async function enterEdit(resultId: string, sourceRect?: FlyRect | null) {
|
||||||
if (isBusy.value) return
|
if (isBusy.value) return
|
||||||
const msg = messages.value.find((m) => m.id === resultId && m.type === 'result')
|
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
|
if (editingResultId.value !== resultId) return
|
||||||
pendingFile.value = file
|
pendingFile.value = file
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
if (editingResultId.value === resultId) {
|
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
|
editingResultId.value = null
|
||||||
draft.value = ''
|
draft.value = ''
|
||||||
clearPendingPreview()
|
productTitle.value = ''
|
||||||
|
titleInvalid.value = false
|
||||||
|
clearPendingPreview(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveProductId() {
|
function resolveProductId() {
|
||||||
@@ -595,6 +678,7 @@ async function startGenerate(opts: {
|
|||||||
mode: 'generate' | 'edit'
|
mode: 'generate' | 'edit'
|
||||||
file?: File | null
|
file?: File | null
|
||||||
useCartoonEffect?: boolean
|
useCartoonEffect?: boolean
|
||||||
|
sceneProp?: string
|
||||||
}) {
|
}) {
|
||||||
if (remainCount.value <= 0) {
|
if (remainCount.value <= 0) {
|
||||||
showToast(config.messages.noRemain)
|
showToast(config.messages.noRemain)
|
||||||
@@ -603,13 +687,13 @@ async function startGenerate(opts: {
|
|||||||
|
|
||||||
const sid = String(sessionId.value || '').trim()
|
const sid = String(sessionId.value || '').trim()
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
showToast('缺少会话信息,请刷新后重试')
|
showToast(friendlyError('缺少会话信息,请刷新后重试', 'common'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const productId = resolveProductId()
|
const productId = resolveProductId()
|
||||||
if (!productId) {
|
if (!productId) {
|
||||||
showToast('缺少产品信息,请刷新后重试')
|
showToast(friendlyError('缺少产品信息,请刷新后重试', 'common'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,6 +743,8 @@ async function startGenerate(opts: {
|
|||||||
let imageId = ''
|
let imageId = ''
|
||||||
let resultUrl = ''
|
let resultUrl = ''
|
||||||
|
|
||||||
|
const sceneProp = String(opts.sceneProp || '').trim()
|
||||||
|
|
||||||
if (opts.useCartoonEffect && opts.mode === 'generate') {
|
if (opts.useCartoonEffect && opts.mode === 'generate') {
|
||||||
const effect = await jingdongApi.submitExternalEffect({
|
const effect = await jingdongApi.submitExternalEffect({
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
@@ -668,6 +754,7 @@ async function startGenerate(opts: {
|
|||||||
user_message: opts.userText,
|
user_message: opts.userText,
|
||||||
style_id: styleId,
|
style_id: styleId,
|
||||||
prompt: opts.userText,
|
prompt: opts.userText,
|
||||||
|
scene_prop: sceneProp,
|
||||||
})
|
})
|
||||||
taskId = effect.task_id
|
taskId = effect.task_id
|
||||||
resultUrl = effect.url || effect.images[0]?.url || ''
|
resultUrl = effect.url || effect.images[0]?.url || ''
|
||||||
@@ -691,6 +778,7 @@ async function startGenerate(opts: {
|
|||||||
prompt: opts.userText,
|
prompt: opts.userText,
|
||||||
n: 1,
|
n: 1,
|
||||||
mode: opts.mode,
|
mode: opts.mode,
|
||||||
|
scene_prop: sceneProp,
|
||||||
})
|
})
|
||||||
taskId = task.task_id
|
taskId = task.task_id
|
||||||
if (!taskId) throw new Error('提交生成失败')
|
if (!taskId) throw new Error('提交生成失败')
|
||||||
@@ -716,14 +804,32 @@ async function startGenerate(opts: {
|
|||||||
removeGenerating(generatingId)
|
removeGenerating(generatingId)
|
||||||
pendingFinish.value = null
|
pendingFinish.value = null
|
||||||
isBusy.value = false
|
isBusy.value = false
|
||||||
const message =
|
const tip = friendlyError(
|
||||||
error instanceof Error && error.message ? error.message : '生成失败,请稍后重试'
|
error,
|
||||||
showToast(message)
|
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() {
|
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) {
|
if (remainCount.value <= 0) {
|
||||||
showToast(config.messages.noRemain)
|
showToast(config.messages.noRemain)
|
||||||
@@ -736,17 +842,25 @@ function onSend() {
|
|||||||
return
|
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 text = draft.value.trim()
|
||||||
|
const sceneProp = resolveScenePropFromOrder(orderStat.value, productTitle.value)
|
||||||
|
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
showToast(config.messages.needPhotoOrText)
|
showToast(config.messages.needEditText)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const file = pendingFile.value
|
const file = pendingFile.value
|
||||||
const image = pendingPreview.value
|
const image = pendingPreview.value
|
||||||
if (!file || !image) {
|
if (!file || !image) {
|
||||||
showToast('效果图准备中,请稍后再试')
|
showToast(config.messages.effectPreparing)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resetComposerAfterSubmit()
|
resetComposerAfterSubmit()
|
||||||
@@ -755,6 +869,7 @@ function onSend() {
|
|||||||
userText: text,
|
userText: text,
|
||||||
mode: 'edit',
|
mode: 'edit',
|
||||||
file,
|
file,
|
||||||
|
sceneProp,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -762,7 +877,7 @@ function onSend() {
|
|||||||
const image = pendingPreview.value
|
const image = pendingPreview.value
|
||||||
const file = pendingFile.value
|
const file = pendingFile.value
|
||||||
if (!file || !image) {
|
if (!file || !image) {
|
||||||
showToast('请先上传照片')
|
showToast(config.messages.needPhoto)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!text && !image) {
|
if (!text && !image) {
|
||||||
@@ -770,19 +885,30 @@ function onSend() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendText = text || config.messages.defaultPrompt
|
|
||||||
const useCartoonEffect = Boolean(photoInspect.value && isCartoonImage(photoInspect.value))
|
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 只显示提示词
|
// 与 detail 一致:origin 右侧显示图;external_effect 只显示提示词
|
||||||
const sendImage = useCartoonEffect ? undefined : image || undefined
|
const sendImage = useCartoonEffect ? undefined : image || undefined
|
||||||
|
// 本地 blob 预览会进气泡,提交后不能 revoke,否则裂图
|
||||||
|
const keepObjectUrl = Boolean(sendImage && String(sendImage).startsWith('blob:'))
|
||||||
|
|
||||||
resetComposerAfterSubmit()
|
resetComposerAfterSubmit({ keepObjectUrl })
|
||||||
|
|
||||||
void startGenerate({
|
void startGenerate({
|
||||||
userText: sendText,
|
userText: sendText,
|
||||||
userImage: sendImage,
|
userImage: sendImage,
|
||||||
mode: 'generate',
|
mode,
|
||||||
file,
|
file,
|
||||||
useCartoonEffect,
|
useCartoonEffect,
|
||||||
|
sceneProp,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,6 +1133,12 @@ onBeforeUnmount(() => {
|
|||||||
background: #f0f1f3;
|
background: #f0f1f3;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
color: #222;
|
color: #222;
|
||||||
|
|
||||||
|
&.is-text-only {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.msg__user-thumb {
|
.msg__user-thumb {
|
||||||
|
|||||||
Reference in new issue
Block a user