Files
ece.suwa3d.com/src/views/jingdong/index.vue
T
LinzmandCursor 2f9d443a6a
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/prod Build is passing
修改:京东下单页面样式与引导图、编辑购买交互优化
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-10 17:51:23 +08:00

1373 lines
38 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">
<img :src="config.images.userAvatar" alt="" />
</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">
<button
class="msg__guide"
type="button"
:aria-label="config.messages.caseIntro"
@click="previewImages([config.images.guide], 0)"
>
<img class="msg__guide-img" :src="config.images.guide" :alt="config.messages.caseIntro" />
</button>
</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"
:edit-label="config.messages.edit"
:exit-edit-label="config.messages.exitEdit"
:buy-label="config.messages.buyNow"
:buy-disabled-tip="config.messages.buyDisabledTip"
@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"
:custom-text-label="titleSpec?.label || config.messages.customTextLabel"
:custom-text-placeholder="titleSpec?.placeholder || config.messages.customTextPlaceholder"
:custom-text-max="titleSpec?.max || config.messages.customTextMax"
:preview-url="pendingPreview"
:tags="composerTags"
:editing="isEditing"
:editing-hint="config.messages.editingHint"
:exit-edit-label="config.messages.exitEdit"
:disabled="isBusy"
:inspecting="inspecting || uploading"
: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 {
normalizeInspect,
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}`
}
/**
* 剩余次数:
* - 首屏用接口 remain_count 打底
* - 之后只按「本地点成功出图」递减
* - 接口若未扣减(仍返回原值),不会把次数加回去
*/
const remainCount = ref(0)
/** 打底时已统计到的成功次数;新增成功 = 当前成功数 - baselineUsed */
const remainBaselineUsed = ref(0)
const sessionId = ref('')
/** orders/stat 的 session_id 已通过 session/detail 确认可用 */
const sessionReady = ref(false)
/** 会话初始化结束但仍未就绪(需刷新) */
const sessionBootFailed = ref(false)
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)
/** 选图后预上传得到的 upload_id,发送时复用 */
const pendingUploadId = ref('')
const blobUrls = new Set<string>()
const editingResultId = ref<string | null>(null)
const isBusy = ref(false)
const uploading = ref(false)
const inspecting = ref(false)
const photoInspect = ref<ImageInspect | null>(null)
const lastUploadId = ref('')
const pendingFinish = ref<PendingFinish | null>(null)
let pollActive = false
/** 切换/清空图片时作废进行中的上传与识别 */
let uploadSerial = 0
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: 'lock' })
}
if (typeName.value) {
tags.push({ key: 'type', label: typeName.value, icon: 'lock' })
}
if (styleName.value) {
tags.push({ key: 'style', label: styleName.value, icon: 'lock' })
}
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 || uploading.value || inspecting.value) return false
if (remainCount.value <= 0) return false
if (!hasRequiredTitle.value) return false
if (isEditing.value) {
// 编辑:效果图预览 + 调整文案(不走 upload,用结果卡 image_id)
return !!pendingPreview.value && draft.value.trim().length > 0
}
// 导入生图:上传未完成时不可发(避免缺少 upload_id)
if (pendingPreview.value && !pendingUploadId.value) return false
// 主体不一致只提示,不拦截发送
return !!pendingPreview.value
})
const composerStatusTip = computed(() => {
if (isBusy.value) return config.messages.generatingTip
if (!sessionReady.value) {
return sessionBootFailed.value
? config.messages.sessionMissing
: config.messages.sessionLoadingTip
}
if (uploading.value) return config.messages.uploadingTip
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,
}
// 严格比对:订单「1人」不允许识别出「1人+1宠物」
const peopleOk = detected.headcount === orderSubject.headcount
const petOk = detected.petCount === orderSubject.petCount
const matched = peopleOk && petOk
subjectMatched.value = matched
if (!matched) {
const need = subjectNameFromStat.value || formatSubjectCountsLabel(orderSubject)
const got = formatSubjectCountsLabel(detected)
// 仅提示不一致,不拦截后续发送
showToast({
message: config.messages.subjectMismatchToast(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 isSessionMissingError(error: unknown) {
const msg =
error instanceof Error
? String(error.message || '')
: typeof error === 'string'
? error
: String((error as any)?.message || (error as any)?.msg || '')
return /会话不存在|session\s*(not\s*found|missing|invalid|expired)/i.test(msg)
}
function triggerUpload() {
if (isBusy.value) {
showToast(config.messages.generatingTip)
return
}
if (!sessionReady.value) {
showToast(config.messages.sessionLoadingTip)
void fetchOrderStat()
return
}
if (isEditing.value) {
showToast('编辑效果图时请直接描述想调整的内容')
return
}
if (uploading.value) {
showToast(config.messages.uploadingTip)
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 }) {
uploadSerial += 1
uploading.value = false
inspecting.value = false
pendingFile.value = null
pendingUploadId.value = ''
photoInspect.value = null
subjectMatched.value = true
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
if (options?.keepObjectUrl) {
// 交给 blobUrls 在页面卸载时统一释放
pendingObjectUrl.value = ''
} else {
revokePendingObjectUrl()
}
pendingPreview.value = ''
}
/**
* 导入阶段:upload/url(+ PUT OSS)→ subject/classify
* confirm 留到提交时再调
*/
async function startPendingUploadAndClassify(file: File) {
const serial = ++uploadSerial
pendingUploadId.value = ''
photoInspect.value = null
subjectMatched.value = true
let sid = String(sessionId.value || '').trim()
if (!sid || !sessionReady.value) {
await fetchOrderStat()
if (serial !== uploadSerial) return
sid = String(sessionId.value || '').trim()
}
if (!sid || !sessionReady.value) {
showToast(config.messages.sessionMissing)
return
}
uploading.value = true
inspecting.value = false
try {
const runPrepare = () =>
jingdongApi.prepareUpload(file, {
session_id: sid,
purpose: 'origin',
})
// 仅签发 url 并 PUT,不 confirm
let prepared
try {
prepared = await runPrepare()
} catch (error) {
// 偶发「会话不存在」:重拉会话后自动再试一次
if (!isSessionMissingError(error)) throw error
sessionReady.value = false
await fetchOrderStat()
if (serial !== uploadSerial) return
sid = String(sessionId.value || '').trim()
if (!sid || !sessionReady.value) throw error
prepared = await runPrepare()
}
if (serial !== uploadSerial) return
pendingUploadId.value = prepared.uploadId
lastUploadId.value = prepared.uploadId
} catch (error) {
if (serial !== uploadSerial) return
pendingUploadId.value = ''
showToast({
message: isSessionMissingError(error)
? config.messages.sessionMissing
: friendlyError(error, 'upload'),
duration: 2800,
})
return
} finally {
if (serial === uploadSerial) uploading.value = false
}
if (serial !== uploadSerial || !pendingUploadId.value) return
inspecting.value = true
try {
const classifyRaw = await jingdongApi.classifySubject({
session_id: sid,
upload_id: pendingUploadId.value,
})
if (serial !== uploadSerial) return
const inspectResult = normalizeInspect(classifyRaw)
photoInspect.value = inspectResult
if (subjectNameFromStat.value) subjectName.value = subjectNameFromStat.value
checkSubjectMatch(inspectResult)
} catch (error) {
if (serial !== uploadSerial) return
showToast({
message: friendlyError(error, 'inspect'),
duration: 2800,
})
} finally {
if (serial === uploadSerial) inspecting.value = false
}
}
function onClearPreview() {
clearPendingPreview()
// 编辑态清掉预览图时同步退出编辑,保留已输入提示词
if (isEditing.value) {
editingResultId.value = null
}
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadSerial += 1
uploading.value = false
inspecting.value = false
photoInspect.value = null
subjectMatched.value = true
pendingUploadId.value = ''
revokePendingObjectUrl()
const url = URL.createObjectURL(file)
trackBlobUrl(url)
pendingObjectUrl.value = url
pendingPreview.value = url
pendingFile.value = file
// 导入后立刻上传,成功后再识别主体
void startPendingUploadAndClassify(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
// 编辑直接用效果图 image_id 调 submit,不再走 upload/url + confirm
const imageId = String(msg.imageId || '').trim()
if (!imageId) {
showToast(config.messages.effectPreparing)
return
}
editingResultId.value = resultId
// 保留用户已输入的提示词,不清空 draft
clearPendingPreview()
pendingPreview.value = msg.image
subjectMatched.value = true
pendingUploadId.value = ''
pendingFile.value = null
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 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'
/** 导入生图:confirm + submit 用 upload_id */
uploadId?: string
/** 编辑效果图:直接 submit,传 image_id,不走 url/confirm */
imageId?: string
sceneProp?: string
customText?: string
}) {
if (remainCount.value <= 0) {
showToast(config.messages.noRemain)
return
}
const sid = String(sessionId.value || '').trim()
if (!sid) {
showToast(friendlyError('缺少会话信息,请刷新后重试', 'common'))
return
}
const isEdit = opts.mode === 'edit'
const uploadId = String(opts.uploadId || '').trim()
const editImageId = String(opts.imageId || '').trim()
if (isEdit) {
if (!editImageId) {
showToast(config.messages.effectPreparing)
return
}
} else if (!uploadId) {
showToast(config.messages.needPhoto)
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
if (uploadId) lastUploadId.value = uploadId
try {
// 导入生图:confirm → submit(upload_id);编辑:直接 submit(image_id)
if (!isEdit) {
await jingdongApi.confirmUpload({
session_id: sid,
upload_id: uploadId,
})
}
const sceneProp = String(opts.sceneProp || '').trim()
const customText = String(opts.customText || '').trim()
const task = await jingdongApi.submitImageGen({
session_id: sid,
...(isEdit ? { image_id: editImageId } : { upload_id: uploadId }),
prompt: opts.userText,
mode: opts.mode,
scene_prop: sceneProp,
custom_text: customText,
})
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: uploadId || '',
startedAt,
}
patchGeneratingResult(generatingId, resultUrl)
// 接口已成功出图:立刻扣剩余次数(不依赖过渡动画是否播完)
noteGenerationSuccess()
scrollToBottom()
} catch (error) {
removeGenerating(generatingId)
pendingFinish.value = null
isBusy.value = false
const tip = friendlyError(
error,
isEdit ? 'edit' : 'generate',
isEdit ? 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 (remainCount.value <= 0) {
showToast(config.messages.noRemain)
return
}
if (titleSpec.value && !productTitle.value.trim()) {
titleInvalid.value = true
showToast(
config.messages.needProductTitle(
titleSpec.value.label || config.messages.customTextLabel,
),
)
return
}
titleInvalid.value = false
const text = draft.value.trim()
// 必须在 reset 前取出:场景道具走 scene_prop,其余走 custom_text
const titleValue = productTitle.value.trim()
const isPropsTitle = titleSpec.value?.mode === 'props'
const sceneProp = isPropsTitle
? resolveScenePropFromOrder(orderStat.value, titleValue)
: ''
const customText = isPropsTitle ? '' : titleValue
if (isEditing.value) {
if (!text) {
showToast(config.messages.needEditText)
return
}
const image = pendingPreview.value
if (!image) {
showToast(config.messages.effectPreparing)
return
}
const editingMsg = messages.value.find(
(m) => m.id === editingResultId.value && m.type === 'result',
)
const imageId =
editingMsg && editingMsg.type === 'result'
? String(editingMsg.imageId || '').trim()
: ''
if (!imageId) {
showToast(config.messages.effectPreparing)
return
}
resetComposerAfterSubmit()
// 编辑:不走 url/confirm,submit 只传 image_id
void startGenerate({
userText: text,
mode: 'edit',
imageId,
sceneProp,
customText,
})
return
}
if (uploading.value) {
showToast(config.messages.uploadingTip)
return
}
if (inspecting.value) {
showToast(config.messages.inspectingTip)
return
}
// 必须在 resetComposer 之前取出:reset 会清空 pendingUploadId
const uploadId = String(pendingUploadId.value || '').trim()
if (!uploadId) {
showToast(config.messages.uploadingTip)
return
}
const image = pendingPreview.value
if (!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',
uploadId,
sceneProp,
customText,
})
}
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()
}
/** 拉取 session/detail;成功返回 true(用于确认会话已在 JD 侧可用) */
function fetchSessionDetail(sid: string): Promise<boolean> {
const id = String(sid || '').trim()
if (!id) {
sessionDetail.value = null
seedRemainFromApi(orderStat.value?.remain_count)
return Promise.resolve(false)
}
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)
return true
})
.catch(() => {
sessionDetail.value = null
seedRemainFromApi(orderStat.value?.remain_count)
return false
})
}
/** detail 偶发未就绪时短退避重试,避免过早放开上传 */
async function waitForSessionDetail(sid: string, attempts = 3): Promise<boolean> {
for (let i = 0; i < attempts; i += 1) {
const ok = await fetchSessionDetail(sid)
if (ok) return true
if (i < attempts - 1) await sleep(350 * (i + 1))
}
return false
}
function fetchOrderStat() {
sessionReady.value = false
sessionBootFailed.value = false
return badgeApi
.getOrderStat({})
.then(async (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()
// 产品名称展示取 cate_name
typeName.value = String(res?.cate_name || '').trim()
const sid = String(res?.session_id || '').trim()
sessionId.value = sid
subjectMatched.value = true
// 先记下接口值;等会话历史回来后再按已生成次数扣除
const apiRemain = Number(res?.remain_count)
remainCount.value = Number.isFinite(apiRemain) ? Math.max(0, apiRemain) : 0
remainBaselineUsed.value = 0
if (!sid) {
sessionReady.value = false
sessionBootFailed.value = true
return false
}
const ok = await waitForSessionDetail(sid)
sessionReady.value = ok
sessionBootFailed.value = !ok
return ok
})
.catch(() => {
orderStat.value = null
resetRemainQuota()
subjectName.value = ''
subjectNameFromStat.value = ''
styleName.value = ''
typeName.value = ''
sessionId.value = ''
sessionDetail.value = null
sessionReady.value = false
sessionBootFailed.value = true
subjectMatched.value = true
return false
})
}
onMounted(() => {
fetchOrderStat()
scrollToBottom()
})
onBeforeUnmount(() => {
pollActive = false
blobUrls.forEach((url) => URL.revokeObjectURL(url))
blobUrls.clear()
pendingObjectUrl.value = ''
})
</script>
<style scoped lang="scss">
.jd-page {
--jd-primary: #3a8e4d;
--jd-bg: #ffffff;
--jd-avatar: 36px;
--jd-avatar-gap: 10px;
--jd-avatar-slot: calc(var(--jd-avatar) + var(--jd-avatar-gap));
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: var(--jd-avatar);
height: var(--jd-avatar);
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 {
background: #ffffff;
img {
object-fit: cover;
object-position: center;
}
}
.msg__body {
/* 左侧头像占位后,右侧再留一个头像宽度,与用户侧对称 */
max-width: calc(100% - var(--jd-avatar-slot) - var(--jd-avatar));
min-width: 0;
}
.msg.is-user .msg__body {
display: flex;
flex-direction: column;
align-items: flex-end;
max-width: min(268px, calc(100% - var(--jd-avatar-slot)));
}
.msg.is-case .msg__body {
max-width: calc(100% - var(--jd-avatar-slot) - var(--jd-avatar));
}
.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 4px 16px 16px;
}
.msg:not(.is-user) .msg__bubble {
border-radius: 4px 16px 16px 16px;
border: 1px solid #e6e6e6;
box-sizing: border-box;
}
.msg__bubble--user {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
width: 100%;
box-sizing: border-box;
padding: 10px;
background: #e9e9eb;
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: 0;
overflow: hidden;
background: #ffffff;
box-shadow: none;
}
.msg__guide {
display: block;
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
border-radius: 16px;
overflow: hidden;
}
.msg__guide-img {
display: block;
width: 100%;
height: auto;
vertical-align: top;
}
.msg__bubble--generating {
padding: 0;
background: #ffffff;
box-shadow: none;
overflow: hidden;
width: 60vw;
max-width: 100%;
box-sizing: border-box;
}
.msg__bubble--result {
padding: 10px;
/* 效果图最大占整屏宽度 60% */
width: 60vw;
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>