forked from natuka/web.puabadge.com
1373 lines
38 KiB
Vue
1373 lines
38 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">
|
||
<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>
|