Files
ece.suwa3d.com/src/views/jingdong/index.vue
T
Linzm 1887e8b2b3
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
修改:京东端下单不考虑效果图导入
2026-11-03 17:39:11 +08:00

1258 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="jd-page">
<JdNavBar
:title="config.brandName"
:remain="remainCount"
:logo="config.images.logo"
@remain="onRemainClick"
/>
<div ref="listRef" class="jd-page__list">
<div
v-for="msg in messages"
:key="msg.id"
class="msg"
:class="[`is-${msg.role}`, { 'is-case': msg.type === 'case' }]"
>
<div v-if="msg.role === 'agent'" class="msg__avatar" aria-hidden="true">
<img :src="config.images.avatar" alt="" />
</div>
<div v-else class="msg__avatar msg__avatar--user" aria-hidden="true">柚</div>
<div class="msg__body">
<div v-if="msg.type === 'text'" class="msg__bubble">
{{ msg.text }}
</div>
<div v-else-if="msg.type === 'case'" class="msg__bubble msg__bubble--case">
<p class="msg__case-intro">{{ config.messages.caseIntro }}</p>
<div class="msg__case-grid">
<figure
v-for="(item, idx) in caseItems"
:key="item.label"
class="msg__case-item"
>
<div class="msg__case-thumb">
<img :src="item.src" :alt="item.label" @click="previewImages(caseItems.map((c) => c.src), idx)" />
</div>
<figcaption>{{ item.label }}</figcaption>
</figure>
</div>
</div>
<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>
<div v-else-if="msg.type === 'generating'" class="msg__bubble msg__bubble--generating">
<GenerationStage
:photo-url="msg.photoUrl"
:result-url="msg.resultUrl"
:started-at="msg.startedAt"
@done="onGenerateStageDone(msg.id)"
/>
</div>
<div v-else-if="msg.type === 'result'" class="msg__bubble msg__bubble--result">
<ResultCard
:image="msg.image"
:meta="msg.meta"
:editing="editingResultId === msg.id"
:show-buy="editingResultId !== msg.id"
:view-label="config.messages.view"
:edit-label="config.messages.edit"
:exit-edit-label="config.messages.exitEdit"
:buy-label="config.messages.buyNow"
@view="previewImages([msg.image], 0)"
@edit="(payload) => enterEdit(msg.id, payload?.sourceRect)"
@exit-edit="exitEdit"
@download="downloadImage(msg.image)"
@buy="onBuy"
/>
</div>
<div v-if="msg.role === 'user' && msg.time" class="msg__time">{{ msg.time }}</div>
</div>
</div>
<div ref="bottomAnchor" class="jd-page__anchor" />
</div>
<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"
:preview-url="pendingPreview"
:tags="composerTags"
:editing="isEditing"
:editing-hint="config.messages.editingHint"
:exit-edit-label="config.messages.exitEdit"
:disabled="isBusy"
:inspecting="inspecting"
:can-send="canSend"
:status-tip="composerStatusTip"
:tip-warn="composerTipWarn"
:title-spec="titleSpec"
:title-invalid="titleInvalid"
@upload="triggerUpload"
@clear-preview="onClearPreview"
@send="onSend"
@exit-edit="exitEdit"
/>
<input
ref="fileInputRef"
class="jd-page__file"
type="file"
accept="image/*"
@change="onFileChange"
/>
</div>
</template>
<script setup lang="ts">
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'
import { jingdongConfig as config } from '@/config/jingdong'
import {
InspectCancelled,
inspectImage,
type ImageInspect,
} from '@/services/jingdong/image-inspect'
import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image'
import { downloadFallbackTip, downloadImageCompat } from '@/utils/jingdong-download'
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'
import ResultCard from './components/ResultCard.vue'
type MsgRole = 'agent' | 'user'
type ChatMessage =
| { id: string; role: MsgRole; type: 'text'; text: string; time?: string }
| { id: string; role: 'agent'; type: 'case'; time?: string }
| { id: string; role: 'user'; type: 'user'; text: string; image?: string; time?: string }
| {
id: string
role: 'agent'
type: 'generating'
photoUrl?: string
resultUrl: string
startedAt: number
}
| {
id: string
role: 'agent'
type: 'result'
image: string
meta: string
time?: string
durationSec: number
taskId?: string
imageId?: string
uploadId?: string
}
type PendingFinish = {
generatingId: string
resultUrl: string
taskId: string
imageId: string
uploadId: string
startedAt: number
}
let msgSeq = 0
function uid(prefix = 'm') {
msgSeq += 1
return `${prefix}-${Date.now()}-${msgSeq}`
}
function formatTime(date = new Date()) {
const h = String(date.getHours()).padStart(2, '0')
const m = String(date.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
const caseItems = [
{ label: config.messages.caseLabels[0], src: config.images.caseOriginal },
{ label: config.messages.caseLabels[1], src: config.images.caseEffect },
{ label: config.messages.caseLabels[2], src: config.images.caseScene },
]
/**
* 剩余次数:
* - 首屏用接口 remain_count 打底
* - 之后只按「本地点成功出图」递减
* - 接口若未扣减(仍返回原值),不会把次数加回去
*/
const remainCount = ref(0)
/** 打底时已统计到的成功次数;新增成功 = 当前成功数 - baselineUsed */
const remainBaselineUsed = ref(0)
const sessionId = ref('')
const subjectName = ref('')
/** stat 返回的主体文案(订单约定主体,标签始终展示此值) */
const subjectNameFromStat = ref('')
const typeName = ref('')
const styleName = ref('')
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)
const blobUrls = new Set<string>()
const editingResultId = ref<string | null>(null)
const isBusy = ref(false)
const inspecting = ref(false)
const photoInspect = ref<ImageInspect | null>(null)
const lastUploadId = ref('')
const pendingFinish = ref<PendingFinish | null>(null)
let inspectSerial = 0
let inspectAbort: AbortController | null = null
let pollActive = false
function createBaseMessages(): ChatMessage[] {
return [
{
id: uid('welcome'),
role: 'agent',
type: 'text',
text: config.messages.welcome,
},
{
id: uid('case'),
role: 'agent',
type: 'case',
},
]
}
const messages = ref<ChatMessage[]>(createBaseMessages())
const composerTags = computed<ComposerTag[]>(() => {
const tags: ComposerTag[] = []
if (subjectName.value) {
tags.push({ key: 'subject', label: subjectName.value, icon: 'user' })
}
if (styleName.value) {
tags.push({ key: 'style', label: styleName.value, icon: 'star' })
}
if (typeName.value) {
tags.push({ key: 'type', label: typeName.value, icon: 'present' })
}
return tags
})
const listRef = ref<HTMLElement | null>(null)
const bottomAnchor = ref<HTMLElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const composerRef = ref<{
focus: () => void
getPreviewEl: () => HTMLElement | null
getFlyTargetEl: () => HTMLElement | null
} | null>(null)
const isEditing = computed(() => !!editingResultId.value)
/** 统计已消耗的生图次数:成功 task + 对话里已出的结果卡 */
function countUsedGenerations() {
const ids = new Set<string>()
const detail = sessionDetail.value as { tasks?: Array<Record<string, any>> } | null
detail?.tasks?.forEach((task) => {
const taskId = String(task?.task_id || '').trim()
if (!taskId) return
const images = Array.isArray(task.images) ? task.images : []
const hasImage = images.some((image: any) => Boolean(image?.url))
const status = String(task.status || '').toLowerCase()
if (status === 'success' || hasImage) ids.add(`task:${taskId}`)
})
messages.value.forEach((msg) => {
if (msg.type !== 'result') return
const taskId = String(msg.taskId || '').trim()
// 与 task 去重;无 taskId 的本地结果用消息 id
ids.add(taskId ? `task:${taskId}` : `msg:${msg.id}`)
})
return ids.size
}
/**
* 首屏打底:接口 remain_count 在京东侧常不随出图减少,这里当作可生图总额,
* 再按会话内已成功次数扣除;后续只减不增。
*/
function seedRemainFromApi(apiRemainRaw?: unknown) {
const apiRemain = Number(apiRemainRaw)
remainBaselineUsed.value = 0
remainCount.value = Number.isFinite(apiRemain) ? Math.max(0, apiRemain) : 0
refreshRemainByUsed()
}
/** 按已成功出图数重算剩余(只减不加) */
function refreshRemainByUsed(apiRemainRaw?: unknown) {
const used = countUsedGenerations()
const earned = Math.max(0, used - remainBaselineUsed.value)
remainCount.value = Math.max(0, remainCount.value - earned)
remainBaselineUsed.value = used
const apiRemain = Number(apiRemainRaw)
if (!Number.isFinite(apiRemain)) return
// 接口若已扣得更少,跟随接口;偏高(未扣)则忽略,避免加回次数
if (apiRemain < remainCount.value) {
remainCount.value = Math.max(0, apiRemain)
}
}
/** 生图接口成功即扣 1 次(不等待过渡动画),并抬高 baseline 避免结果入列后重复扣 */
function noteGenerationSuccess() {
remainCount.value = Math.max(0, remainCount.value - 1)
remainBaselineUsed.value += 1
}
function resetRemainQuota() {
remainCount.value = 0
remainBaselineUsed.value = 0
}
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
}
if (!subjectMatched.value && pendingPreview.value) return false
// 首轮生图需上传照片(对齐 agent:upload → submit → poll)
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()
const head = raw.match(/(\d+)\s*人/)
const pet = raw.match(/(\d+)\s*宠(?:物)?/)
const prop = raw.match(/(\d+)\s*道(?:具)?/)
return {
headcount: head ? Number(head[1]) : 0,
petCount: pet ? Number(pet[1]) : 0,
propCount: prop ? Number(prop[1]) : 0,
hasAny: Boolean(head || pet || prop),
}
}
function formatSubjectCountsLabel(counts: {
headcount: number
petCount: number
propCount?: number
}) {
const parts: string[] = []
if (counts.headcount > 0) parts.push(`${counts.headcount}人`)
if (counts.petCount > 0) parts.push(`${counts.petCount}宠物`)
if ((counts.propCount || 0) > 0) parts.push(`${counts.propCount}道具`)
return parts.join('+') || '未识别到主体'
}
function checkSubjectMatch(inspectResult: ImageInspect) {
const orderSubject = parseSubjectCounts(subjectNameFromStat.value)
// 订单未约定主体数量时不做校验
if (!orderSubject.hasAny) {
subjectMatched.value = true
return true
}
const detected = {
headcount: Number(inspectResult.personCount) || 0,
petCount: Number(inspectResult.petCount) || 0,
}
// 订单只写了人数时,只比对人数;写了宠物则一并比对
const peopleOk = detected.headcount === orderSubject.headcount
const petOk =
orderSubject.petCount > 0 ? detected.petCount === orderSubject.petCount : true
const matched = peopleOk && petOk
subjectMatched.value = matched
if (!matched) {
const need = subjectNameFromStat.value || formatSubjectCountsLabel(orderSubject)
const got = formatSubjectCountsLabel(detected)
showToast({
message: `图片主体(${got})与下单主体(${need})不一致,请更换与订单相符的照片`,
duration: 3200,
})
}
return matched
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
function scrollToBottom() {
nextTick(() => {
bottomAnchor.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
function triggerUpload() {
if (isBusy.value) {
showToast(config.messages.generatingTip)
return
}
if (isEditing.value) {
showToast('编辑效果图时请直接描述想调整的内容')
return
}
if (inspecting.value) {
showToast(config.messages.inspectingTip)
return
}
fileInputRef.value?.click()
}
function trackBlobUrl(url: string) {
blobUrls.add(url)
}
function revokePendingObjectUrl() {
if (pendingObjectUrl.value) {
URL.revokeObjectURL(pendingObjectUrl.value)
blobUrls.delete(pendingObjectUrl.value)
pendingObjectUrl.value = ''
}
}
/**
* 清空输入区预览。
* keepObjectUrl=true:气泡仍要用 blob 预览,只断开 pending 引用,不 revoke。
*/
function clearPendingPreview(options?: { keepObjectUrl?: boolean }) {
inspectAbort?.abort()
inspectAbort = null
inspecting.value = false
pendingFile.value = null
photoInspect.value = null
subjectMatched.value = true
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
if (options?.keepObjectUrl) {
// 交给 blobUrls 在页面卸载时统一释放
pendingObjectUrl.value = ''
} else {
revokePendingObjectUrl()
}
pendingPreview.value = ''
}
function onClearPreview() {
clearPendingPreview()
// 编辑态清掉预览图时同步退出编辑,保留已输入提示词
if (isEditing.value) {
editingResultId.value = null
}
}
async function fileFromImageUrl(url: string, filename = 'effect.jpg') {
const res = await fetch(url)
if (!res.ok) throw new Error('效果图加载失败')
const blob = await res.blob()
const type = blob.type || 'image/jpeg'
const name = filename || (type.includes('png') ? 'effect.png' : 'effect.jpg')
return new File([blob], name, { type })
}
async function runPhotoRecognize(file: File) {
inspectAbort?.abort()
const controller = new AbortController()
inspectAbort = controller
const serial = ++inspectSerial
inspecting.value = true
photoInspect.value = null
subjectMatched.value = true
try {
const inspectResult = await inspectImage(file, controller.signal)
if (serial !== inspectSerial) return
photoInspect.value = inspectResult
// 标签仍展示订单主体;识别结果只用于校验
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
checkSubjectMatch(inspectResult)
} catch (error) {
if (serial !== inspectSerial || error instanceof InspectCancelled) return
showToast(friendlyError(error, 'inspect'))
} finally {
if (serial === inspectSerial) inspecting.value = false
}
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
revokePendingObjectUrl()
const url = URL.createObjectURL(file)
trackBlobUrl(url)
pendingObjectUrl.value = url
pendingPreview.value = url
pendingFile.value = file
void runPhotoRecognize(file)
}
function previewImages(images: string[], startPosition = 0) {
showImagePreview({
images,
startPosition,
closeable: true,
})
}
async function downloadImage(url: string) {
const href = String(url || '').trim()
if (!href) {
showToast(config.messages.downloadFail)
return
}
try {
const outcome = await downloadImageCompat(href, `suwa-figure-${Date.now()}.png`)
if (outcome === 'saved') {
showToast(config.messages.downloadOk)
return
}
if (outcome === 'opened') {
showToast({
message: downloadFallbackTip() || config.messages.downloadOpened,
duration: 3600,
})
return
}
showToast({ message: config.messages.downloadFail, duration: 3200 })
} catch (error) {
showToast({
message: friendlyError(error, 'download', config.messages.downloadFail),
duration: 3200,
})
}
}
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')
if (!msg || msg.type !== 'result' || !msg.image) return
editingResultId.value = resultId
// 保留用户已输入的提示词,不清空 draft
clearPendingPreview()
// 先放入预览(飞入过程中隐藏),并准备重新上传用的文件
pendingPreview.value = msg.image
subjectMatched.value = true
void fileFromImageUrl(msg.image)
.then((file) => {
if (editingResultId.value !== resultId) return
pendingFile.value = file
})
.catch((error) => {
if (editingResultId.value === resultId) {
showToast(friendlyError(error, 'common', '效果图加载失败,请重试'))
}
})
await nextTick()
const thumb = composerRef.value?.getPreviewEl()
const toRect = rectFromElement(composerRef.value?.getFlyTargetEl())
const fromRect = sourceRect || null
if (thumb) thumb.style.visibility = 'hidden'
try {
if (fromRect && toRect) {
await flyImageToRect(msg.image, fromRect, toRect)
}
} finally {
if (thumb) thumb.style.visibility = ''
}
composerRef.value?.focus()
}
function exitEdit() {
editingResultId.value = null
// 退出编辑时保留已输入提示词
clearPendingPreview()
}
/** 提交后恢复编辑态与输入区文案 */
function resetComposerAfterSubmit(options?: { keepObjectUrl?: boolean }) {
editingResultId.value = null
draft.value = ''
productTitle.value = ''
titleInvalid.value = false
clearPendingPreview(options)
}
function resolveProductId() {
// submit 的 product_id 取自 orders/stat 的 type_id
const id = Number(orderStat.value?.type_id || 0)
return Number.isFinite(id) && id > 0 ? id : 0
}
function resolveStyleId() {
const id = Number(orderStat.value?.style_id || 0)
return Number.isFinite(id) && id > 0 ? id : 0
}
function patchGeneratingResult(generatingId: string, resultUrl: string) {
const idx = messages.value.findIndex((m) => m.id === generatingId && m.type === 'generating')
if (idx < 0) return
const current = messages.value[idx]
if (current.type !== 'generating') return
messages.value.splice(idx, 1, { ...current, resultUrl })
}
function removeGenerating(generatingId: string) {
const idx = messages.value.findIndex((m) => m.id === generatingId && m.type === 'generating')
if (idx >= 0) messages.value.splice(idx, 1)
}
async function pollImageGen(taskId: string) {
const started = Date.now()
pollActive = true
try {
while (Date.now() - started < config.pollTimeoutMs) {
if (!pollActive) throw new Error('已取消生成')
const status = await jingdongApi.getImageGenStatus(taskId)
if (status.status === 'success') return status
if (status.status === 'failed') throw new Error(status.error || '生成失败')
await sleep(config.pollIntervalMs)
}
throw new Error('生成超时,请稍后重试')
} finally {
pollActive = false
}
}
function onGenerateStageDone(generatingId: string) {
const pending = pendingFinish.value
if (!pending || pending.generatingId !== generatingId) return
pendingFinish.value = null
const durationSec = Number(
Math.max(0.8, (Date.now() - pending.startedAt) / 1000).toFixed(1),
)
const doneAt = new Date()
const meta = `${formatTime(doneAt)} · 生图用时 ${durationSec}s`
const idx = messages.value.findIndex((m) => m.id === generatingId)
if (idx >= 0) {
messages.value.splice(idx, 1, {
id: uid('result'),
role: 'agent',
type: 'result',
image: pending.resultUrl,
meta,
time: formatTime(doneAt),
durationSec,
taskId: pending.taskId || undefined,
imageId: pending.imageId || undefined,
uploadId: pending.uploadId || undefined,
})
}
if (pending.uploadId) lastUploadId.value = pending.uploadId
// 成功扣次已在 noteGenerationSuccess;这里只把 baseline 与结果卡对齐
remainBaselineUsed.value = Math.max(remainBaselineUsed.value, countUsedGenerations())
isBusy.value = false
photoInspect.value = null
scrollToBottom()
// 仅同步剩余次数;接口未扣减时不会把次数加回去
void badgeApi
.getOrderStat({})
.then((res: any) => {
orderStat.value = res || orderStat.value
refreshRemainByUsed(res?.remain_count)
})
.catch(() => {})
}
async function startGenerate(opts: {
userText: string
userImage?: string
mode: 'generate' | 'edit'
file?: File | null
sceneProp?: string
}) {
if (remainCount.value <= 0) {
showToast(config.messages.noRemain)
return
}
const sid = String(sessionId.value || '').trim()
if (!sid) {
showToast(friendlyError('缺少会话信息,请刷新后重试', 'common'))
return
}
const productId = resolveProductId()
if (!productId) {
showToast(friendlyError('缺少产品信息,请刷新后重试', 'common'))
return
}
const now = new Date()
const time = formatTime(now)
const startedAt = Date.now()
messages.value.push({
id: uid('user'),
role: 'user',
type: 'user',
text: opts.userText,
image: opts.userImage,
time,
})
const generatingId = uid('gen')
messages.value.push({
id: generatingId,
role: 'agent',
type: 'generating',
photoUrl: opts.userImage,
resultUrl: '',
startedAt,
})
scrollToBottom()
isBusy.value = true
pendingFinish.value = null
try {
let uploadId = ''
if (!opts.file) {
throw new Error(opts.mode === 'edit' ? '缺少可编辑的效果图' : '请先上传照片')
}
// 卡通 / 真人统一走生图:上传 purpose=origin,再 imagegen/submit
const uploaded = await jingdongApi.uploadOriginal(opts.file, {
session_id: sid,
purpose: 'origin',
})
uploadId = uploaded.uploadId
lastUploadId.value = uploadId
const styleId = resolveStyleId()
const sceneProp = String(opts.sceneProp || '').trim()
const task = await jingdongApi.submitImageGen({
session_id: sid,
upload_id: uploadId,
product_id: productId,
product_name: String(orderStat.value?.prod_name || orderStat.value?.product_name || ''),
style_id: styleId,
prompt: opts.userText,
n: 1,
mode: opts.mode,
scene_prop: sceneProp,
})
let taskId = task.task_id
if (!taskId) throw new Error('提交生成失败')
const done = await pollImageGen(taskId)
const image = [...(done.images ?? [])].sort((a, b) => a.index - b.index)[0]
if (!image?.url) throw new Error(done.error || '没有生成结果')
const resultUrl = image.url
const imageId = image.image_id || ''
taskId = done.task_id || taskId
pendingFinish.value = {
generatingId,
resultUrl,
taskId,
imageId,
uploadId,
startedAt,
}
patchGeneratingResult(generatingId, resultUrl)
// 接口已成功出图:立刻扣剩余次数(不依赖过渡动画是否播完)
noteGenerationSuccess()
scrollToBottom()
} catch (error) {
removeGenerating(generatingId)
pendingFinish.value = null
isBusy.value = false
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) {
showToast(config.messages.generatingTip)
return
}
if (inspecting.value) {
showToast(config.messages.inspectingTip)
return
}
if (remainCount.value <= 0) {
showToast(config.messages.noRemain)
return
}
if (pendingPreview.value && !subjectMatched.value) {
const need = subjectNameFromStat.value || '订单主体'
showToast(`图片主体与下单主体(${need})不一致,请更换照片`)
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.needEditText)
return
}
const file = pendingFile.value
const image = pendingPreview.value
if (!file || !image) {
showToast(config.messages.effectPreparing)
return
}
resetComposerAfterSubmit()
// 编辑提交:右侧气泡只显示提示词(与 session/detail 一致)
void startGenerate({
userText: text,
mode: 'edit',
file,
sceneProp,
})
return
}
const image = pendingPreview.value
const file = pendingFile.value
if (!file || !image) {
showToast(config.messages.needPhoto)
return
}
if (!text && !image) {
showToast(config.messages.needPhotoOrText)
return
}
const sendText = text || config.messages.defaultPrompt
const sendImage = image || undefined
// 本地 blob 预览会进气泡,提交后不能 revoke,否则裂图
const keepObjectUrl = Boolean(sendImage && String(sendImage).startsWith('blob:'))
resetComposerAfterSubmit({ keepObjectUrl })
void startGenerate({
userText: sendText,
userImage: sendImage,
mode: 'generate',
file,
sceneProp,
})
}
function applySessionHistory(detail: ReturnType<typeof jingdongApi.parseSessionDetail>) {
const next = createBaseMessages()
let latestUpload = ''
detail.history.forEach((item) => {
if (item.role === 'user') {
next.push({
id: uid('user'),
role: 'user',
type: 'user',
text: item.text,
image: item.image,
time: item.time,
})
if (item.uploadId) latestUpload = item.uploadId
return
}
next.push({
id: uid('result'),
role: 'agent',
type: 'result',
image: item.image,
meta: item.meta || (item.time ? `${item.time}` : ''),
time: item.time,
durationSec: item.durationSec || 0,
taskId: item.taskId,
imageId: item.imageId,
uploadId: item.uploadId,
})
if (item.uploadId) latestUpload = item.uploadId
})
messages.value = next
if (latestUpload) lastUploadId.value = latestUpload
// 历史回填后:用接口总额 - 已成功次数
seedRemainFromApi(orderStat.value?.remain_count)
scrollToBottom()
}
function fetchSessionDetail(sid: string) {
const id = String(sid || '').trim()
if (!id) {
sessionDetail.value = null
seedRemainFromApi(orderStat.value?.remain_count)
return Promise.resolve()
}
return jingdongApi
.getSessionDetail({ session_id: id })
.then((detail) => {
const parsed = {
...detail,
sessionId: detail.session_id || id,
history: jingdongApi.sessionDetailToHistory(detail),
raw: detail,
}
sessionDetail.value = parsed
applySessionHistory(parsed)
})
.catch(() => {
sessionDetail.value = null
seedRemainFromApi(orderStat.value?.remain_count)
})
}
function fetchOrderStat() {
return badgeApi
.getOrderStat({})
.then((res: any) => {
// interceptor 已解包 data:remain_count / subject_name / type_name / session_id / prod_id ...
orderStat.value = res || {}
subjectNameFromStat.value = String(res?.subject_name || '').trim()
subjectName.value = subjectNameFromStat.value
styleName.value = String(res?.style_name || '').trim()
typeName.value = String(res?.type_name || '').trim()
sessionId.value = String(res?.session_id || '').trim()
subjectMatched.value = true
// 先记下接口值;等会话历史回来后再按已生成次数扣除
const apiRemain = Number(res?.remain_count)
remainCount.value = Number.isFinite(apiRemain) ? Math.max(0, apiRemain) : 0
remainBaselineUsed.value = 0
return fetchSessionDetail(sessionId.value)
})
.catch(() => {
orderStat.value = null
resetRemainQuota()
subjectName.value = ''
subjectNameFromStat.value = ''
styleName.value = ''
typeName.value = ''
sessionId.value = ''
sessionDetail.value = null
subjectMatched.value = true
})
}
onMounted(() => {
fetchOrderStat()
scrollToBottom()
})
onBeforeUnmount(() => {
pollActive = false
inspectAbort?.abort()
blobUrls.forEach((url) => URL.revokeObjectURL(url))
blobUrls.clear()
pendingObjectUrl.value = ''
})
</script>
<style scoped lang="scss">
.jd-page {
--jd-primary: #3a8e4d;
--jd-bg: #f5f5f5;
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
max-width: 540px;
margin: 0 auto;
background: var(--jd-bg);
box-sizing: border-box;
overflow: hidden;
}
.jd-page__list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 14px 12px 8px;
-webkit-overflow-scrolling: touch;
}
.jd-page__anchor {
height: 1px;
}
.jd-page__file {
display: none;
}
.msg {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
}
.msg.is-user {
flex-direction: row-reverse;
}
.msg__avatar {
width: 36px;
height: 36px;
flex-shrink: 0;
border-radius: 8px;
overflow: hidden;
background: #ffffff;
border: 1px solid rgba(0, 0, 0, 0.06);
box-sizing: border-box;
img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center 35%;
background: #fff;
}
}
.msg__avatar--user {
display: inline-flex;
align-items: center;
justify-content: center;
background: #3a8e4d;
border: 0;
color: #fff;
font-size: 13px;
font-weight: 600;
line-height: 1;
}
.msg__body {
max-width: calc(100% - 52px);
min-width: 0;
}
.msg.is-user .msg__body {
display: flex;
flex-direction: column;
align-items: flex-end;
max-width: min(268px, calc(100% - 52px));
}
.msg.is-case .msg__body {
max-width: calc(100% - 46px);
}
.msg__bubble {
padding: 12px 14px;
border-radius: 16px;
background: #ffffff;
color: #222;
font-size: 14px;
line-height: 1.55;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
word-break: break-word;
}
.msg.is-user .msg__bubble {
border-radius: 16px 16px 4px 16px;
}
.msg:not(.is-user) .msg__bubble {
border-radius: 4px 16px 16px 16px;
}
.msg__bubble--user {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
width: 100%;
box-sizing: border-box;
padding: 10px;
background: #f0f1f3;
box-shadow: none;
color: #222;
&.is-text-only {
width: auto;
max-width: 100%;
padding: 10px 12px;
}
}
.msg__user-thumb {
width: 56px;
height: 56px;
flex-shrink: 0;
border-radius: 10px;
object-fit: cover;
object-position: center top;
display: block;
background: #e5e5e5;
}
.msg__user-text {
margin: 0;
flex: 1;
min-width: 0;
padding: 2px 0;
font-size: 14px;
line-height: 1.5;
color: #333;
text-align: left;
}
.msg__bubble--case {
padding: 12px;
}
.msg__case-intro {
margin: 0 0 10px;
font-size: 14px;
line-height: 1.5;
color: #222;
}
.msg__case-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.msg__case-item {
margin: 0;
min-width: 0;
figcaption {
margin-top: 6px;
text-align: center;
font-size: 12px;
color: #999;
line-height: 1.2;
}
}
.msg__case-thumb {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 10px;
overflow: hidden;
background: #f0f0f0;
img {
display: block;
width: 100%;
height: 200%;
object-fit: contain;
object-position: center top;
cursor: pointer;
}
}
.msg__bubble--generating {
padding: 0;
background: transparent;
box-shadow: none;
overflow: hidden;
}
.msg__bubble--result {
padding: 10px;
width: min(280px, 72vw);
max-width: 100%;
box-sizing: border-box;
}
.msg__time {
margin-top: 6px;
font-size: 11px;
color: #b0b0b0;
line-height: 1;
padding: 0 2px;
}
.msg.is-user .msg__time {
align-self: flex-end;
text-align: right;
}
</style>