forked from natuka/web.puabadge.com
1258 lines
34 KiB
Vue
1258 lines
34 KiB
Vue
<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>
|