From 4979739521d0706f5ca86f2f1cbace338d2a89ce Mon Sep 17 00:00:00 2001 From: Linzm <1015157648@qq.com> Date: Wed, 4 Nov 2026 18:37:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=EF=BC=9Ajingdong=E7=9A=84?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E4=B8=BB=E4=BD=93=E6=8E=A5=E5=8F=A3=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/jingdong.ts | 45 ++++++- src/config/jingdong.ts | 3 +- src/services/jingdong/image-inspect.ts | 89 +++----------- src/views/jingdong/index.vue | 159 ++++++++++++++++++------- 4 files changed, 173 insertions(+), 123 deletions(-) diff --git a/src/api/jingdong.ts b/src/api/jingdong.ts index 9fbfa7d..8671353 100644 --- a/src/api/jingdong.ts +++ b/src/api/jingdong.ts @@ -754,6 +754,23 @@ export const confirmUpload = (input: { session_id: string; upload_id: string }) }) } +/** + * POST /jd/subject/classify + * 图片上传完成后识别主体(session_id + upload_id) + */ +export const classifySubject = (input: { + session_id: string + upload_id: string +}) => { + return request('jd/subject/classify', { + method: 'POST', + data: { + session_id: input.session_id, + upload_id: input.upload_id, + }, + }) +} + /** POST /jd/imagegen/submit */ export const submitImageGen = (input: ImageGenSubmitInput) => { return request(`${PREFIX}/imagegen/submit`, { @@ -838,9 +855,10 @@ export const confirmOrder = (input: OrderConfirmInput) => { } /** - * 上传原图:签发 URL → PUT OSS → confirm(需 session_id) + * 导入阶段:签发 URL → PUT OSS(不 confirm) + * 拿到 upload_id 后可立刻调 subject/classify */ -export async function uploadOriginal( +export async function prepareUpload( file: File | Blob, options: { session_id: string @@ -857,10 +875,6 @@ export async function uploadOriginal( purpose: options.purpose || 'origin', }) await putUpload(ticket.upload_url, file, ticket.content_type || contentType) - await confirmUpload({ - session_id: options.session_id, - upload_id: ticket.upload_id, - }) return { uploadId: ticket.upload_id, fileUrl: ticket.file_url, @@ -869,3 +883,22 @@ export async function uploadOriginal( } } +/** + * 完整上传:签发 URL → PUT OSS → confirm(需 session_id) + */ +export async function uploadOriginal( + file: File | Blob, + options: { + session_id: string + filename?: string + purpose?: UploadPurpose + }, +) { + const prepared = await prepareUpload(file, options) + await confirmUpload({ + session_id: options.session_id, + upload_id: prepared.uploadId, + }) + return prepared +} + diff --git a/src/config/jingdong.ts b/src/config/jingdong.ts index 28b284c..0812fe2 100644 --- a/src/config/jingdong.ts +++ b/src/config/jingdong.ts @@ -59,9 +59,10 @@ export const jingdongConfig = { needEditText: '请输入想调整的内容后再发送', needPhotoOrText: '请上传照片或输入描述后再发送', needProductTitle: (label: string) => `请填写${label}后再发送`, + uploadingTip: '正在上传照片,请稍候…', inspectingTip: '正在识别照片,请稍候…', /** 仅提示,不拦截发送 */ - subjectMismatchTip: '上传图片的主体数量与下单主体数量不一致', + subjectMismatchTip: '上传图片的主体数量与下单主体数量不一致,生图时候会按下单的主体数量自动调整主体', subjectMismatchToast: (got: string, need: string) => `上传图片主体(${got})与下单主体(${need})数量不一致`, generating: '正在生成效果图,请稍候…', diff --git a/src/services/jingdong/image-inspect.ts b/src/services/jingdong/image-inspect.ts index e19af08..56fd9f6 100644 --- a/src/services/jingdong/image-inspect.ts +++ b/src/services/jingdong/image-inspect.ts @@ -1,7 +1,5 @@ /** - * 原图分类 /classify(对齐 agent image-inspect) - * - 本地:Vite 代理 `/concept-classify` → `/v1/classify` - * - 线上:直连 seetacloud + * 图片主体识别结果(由后端 jd/subject/classify 返回后规范化) */ export type ImageKind = 'cartoon' | 'real_photo' | 'other' | 'uncertain' @@ -13,6 +11,8 @@ export interface ImageInspect { petCount: number reviewStatus: string conclusion: string + /** 主体识别 check_id,后续下单可用 */ + checkId: number } export class InspectCancelled extends Error { @@ -22,10 +22,6 @@ export class InspectCancelled extends Error { } } -const INSPECT_TIMEOUT_MS = 120_000 -const DEFAULT_INSPECT_HOST = 'https://u252424-8570-01f615a5.cqa1.seetacloud.com:8443' -const DEFAULT_INSPECT_REMOTE_URL = `${DEFAULT_INSPECT_HOST}/v1/classify` - function asRecord(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -42,24 +38,6 @@ export function mapInspectImageType(raw: unknown): ImageKind { return 'uncertain' } -function resolveInspectUrl() { - const fromEnv = String( - (import.meta as any).env?.VITE_CONCEPT_INSPECT_URL || '', - ) - .trim() - .replace(/\/+$/, '') - if (fromEnv) { - if (/\/v1\/classify$/i.test(fromEnv)) return fromEnv - return `${fromEnv}/v1/classify` - } - if ((import.meta as any).env?.DEV) return '/concept-classify' - return DEFAULT_INSPECT_REMOTE_URL -} - -function resolveInspectToken() { - return String((import.meta as any).env?.VITE_CONCEPT_INSPECT_TOKEN || '').trim() -} - function countField(data: Record, keys: string[]) { for (const key of keys) { const value = Number(data[key]) @@ -68,9 +46,19 @@ function countField(data: Record, keys: string[]) { return 0 } -function normalizeInspect(body: unknown): ImageInspect { +/** 规范化 classify 接口 data(拦截器已解包 code=1000 的 data) */ +export function normalizeInspect(body: unknown): ImageInspect { const root = asRecord(body) || {} - const data = asRecord(root.data) || root + const nested = asRecord(root.data) + // 兼容完整响应 { code, data } / 已解包的 data 本体 + const data = + nested && + (nested.image_type != null || + nested.person_count != null || + nested.is_cartoon != null || + nested.check_id != null) + ? nested + : root const imageType = mapInspectImageType(data.image_type ?? data.imageType) const isCartoonRaw = data.is_cartoon ?? data.isCartoon const isCartoon = @@ -87,6 +75,7 @@ function normalizeInspect(body: unknown): ImageInspect { petCount: countField(data, ['pet_count', 'petCount']), reviewStatus: String(data.review_status || ''), conclusion: String(data.conclusion || ''), + checkId: countField(data, ['check_id', 'checkId']), } } @@ -104,49 +93,3 @@ export function formatSubjectFromInspect(result: ImageInspect) { if (result.petCount > 0) parts.push(`${result.petCount}宠物`) return parts.join('+') } - -export async function inspectImage(file: File, signal?: AbortSignal) { - if (signal?.aborted) throw new InspectCancelled() - const url = resolveInspectUrl() - const token = resolveInspectToken() - const form = new FormData() - form.append('image', file, file.name || 'image.jpg') - const headers: Record = {} - if (token) headers.Authorization = `Bearer ${token}` - - const controller = new AbortController() - let timedOut = false - const timer = window.setTimeout(() => { - timedOut = true - controller.abort() - }, INSPECT_TIMEOUT_MS) - const onAbort = () => controller.abort() - signal?.addEventListener('abort', onAbort, { once: true }) - - try { - const response = await fetch(url, { - method: 'POST', - body: form, - headers, - signal: controller.signal, - }) - if (!response.ok) { - if (response.status === 400) throw new Error('图片上传错误,请检查文件后重试') - if (response.status === 401) throw new Error('图片识别鉴权失败') - if (response.status === 413) throw new Error('图片过大,请压缩后重试') - throw new Error(`图片识别失败(${response.status})`) - } - const raw: unknown = await response.json() - return normalizeInspect(raw) - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - if (timedOut) throw new Error('图片识别超时,请稍后重试') - throw new InspectCancelled() - } - if (error instanceof TypeError) throw new Error('图片识别失败,请稍后重试') - throw error instanceof Error ? error : new Error('图片识别失败') - } finally { - window.clearTimeout(timer) - signal?.removeEventListener('abort', onAbort) - } -} diff --git a/src/views/jingdong/index.vue b/src/views/jingdong/index.vue index c82b779..71ac773 100644 --- a/src/views/jingdong/index.vue +++ b/src/views/jingdong/index.vue @@ -95,7 +95,7 @@ :editing-hint="config.messages.editingHint" :exit-edit-label="config.messages.exitEdit" :disabled="isBusy" - :inspecting="inspecting" + :inspecting="inspecting || uploading" :can-send="canSend" :status-tip="composerStatusTip" :tip-warn="composerTipWarn" @@ -124,8 +124,7 @@ import * as badgeApi from '@/api/badge' import * as jingdongApi from '@/api/jingdong' import { jingdongConfig as config } from '@/config/jingdong' import { - InspectCancelled, - inspectImage, + normalizeInspect, type ImageInspect, } from '@/services/jingdong/image-inspect' import { flyImageToRect, rectFromElement, type FlyRect } from '@/utils/fly-image' @@ -219,16 +218,19 @@ const titleInvalid = ref(false) const pendingPreview = ref('') const pendingObjectUrl = ref('') const pendingFile = ref(null) +/** 选图后预上传得到的 upload_id,发送时复用 */ +const pendingUploadId = ref('') const blobUrls = new Set() const editingResultId = ref(null) const isBusy = ref(false) +const uploading = ref(false) const inspecting = ref(false) const photoInspect = ref(null) const lastUploadId = ref('') const pendingFinish = ref(null) -let inspectSerial = 0 -let inspectAbort: AbortController | null = null let pollActive = false +/** 切换/清空图片时作废进行中的上传与识别 */ +let uploadSerial = 0 function createBaseMessages(): ChatMessage[] { return [ @@ -348,9 +350,11 @@ watch( }, ) const canSend = computed(() => { - if (isBusy.value || inspecting.value) return false + if (isBusy.value || uploading.value || inspecting.value) return false if (remainCount.value <= 0) return false if (!hasRequiredTitle.value) return false + // 上传未完成时不可发(避免缺少 upload_id) + if (pendingPreview.value && !pendingUploadId.value) return false if (isEditing.value) { // 编辑:需有飞入的效果图 + 调整文案 return !!pendingPreview.value && draft.value.trim().length > 0 @@ -362,6 +366,7 @@ const canSend = computed(() => { const composerStatusTip = computed(() => { if (isBusy.value) return config.messages.generatingTip + 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 @@ -448,6 +453,10 @@ function triggerUpload() { showToast('编辑效果图时请直接描述想调整的内容') return } + if (uploading.value) { + showToast(config.messages.uploadingTip) + return + } if (inspecting.value) { showToast(config.messages.inspectingTip) return @@ -472,10 +481,11 @@ function revokePendingObjectUrl() { * keepObjectUrl=true:气泡仍要用 blob 预览,只断开 pending 引用,不 revoke。 */ function clearPendingPreview(options?: { keepObjectUrl?: boolean }) { - inspectAbort?.abort() - inspectAbort = null + 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 @@ -488,6 +498,69 @@ function clearPendingPreview(options?: { keepObjectUrl?: boolean }) { 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 + + const sid = String(sessionId.value || '').trim() + if (!sid) { + showToast(friendlyError('缺少会话信息,请刷新后重试', 'common')) + return + } + + uploading.value = true + inspecting.value = false + try { + // 仅签发 url 并 PUT,不 confirm + const prepared = await jingdongApi.prepareUpload(file, { + session_id: sid, + purpose: 'origin', + }) + if (serial !== uploadSerial) return + pendingUploadId.value = prepared.uploadId + lastUploadId.value = prepared.uploadId + } catch (error) { + if (serial !== uploadSerial) return + pendingUploadId.value = '' + showToast({ + message: 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() // 编辑态清掉预览图时同步退出编辑,保留已输入提示词 @@ -505,44 +578,25 @@ async function fileFromImageUrl(url: string, filename = '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 + 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 runPhotoRecognize(file) + // 导入后立刻上传,成功后再识别主体 + void startPendingUploadAndClassify(file) } function previewImages(images: string[], startPosition = 0) { @@ -601,10 +655,12 @@ async function enterEdit(resultId: string, sourceRect?: FlyRect | null) { // 先放入预览(飞入过程中隐藏),并准备重新上传用的文件 pendingPreview.value = msg.image subjectMatched.value = true + pendingUploadId.value = '' void fileFromImageUrl(msg.image) .then((file) => { if (editingResultId.value !== resultId) return pendingFile.value = file + void startPendingUploadAndClassify(file) }) .catch((error) => { if (editingResultId.value === resultId) { @@ -778,17 +834,27 @@ async function startGenerate(opts: { pendingFinish.value = null try { - let uploadId = '' - if (!opts.file) { + if (!opts.file && !pendingUploadId.value) { throw new Error(opts.mode === 'edit' ? '缺少可编辑的效果图' : '请先上传照片') } - // 卡通 / 真人统一走生图:上传 purpose=origin,再 imagegen/submit - const uploaded = await jingdongApi.uploadOriginal(opts.file, { + // 复用导入阶段的 upload_id;若无则兜底:url+PUT(仍不在这里 classify) + let uploadId = String(pendingUploadId.value || '').trim() + if (!uploadId) { + if (!opts.file) throw new Error(opts.mode === 'edit' ? '缺少可编辑的效果图' : '请先上传照片') + const prepared = await jingdongApi.prepareUpload(opts.file, { + session_id: sid, + purpose: 'origin', + }) + uploadId = prepared.uploadId + pendingUploadId.value = uploadId + lastUploadId.value = uploadId + } + + // 提交阶段:confirm → imagegen/submit + await jingdongApi.confirmUpload({ session_id: sid, - purpose: 'origin', + upload_id: uploadId, }) - uploadId = uploaded.uploadId - lastUploadId.value = uploadId const styleId = resolveStyleId() const sceneProp = String(opts.sceneProp || '').trim() @@ -854,10 +920,18 @@ function onSend() { showToast(config.messages.generatingTip) return } + if (uploading.value) { + showToast(config.messages.uploadingTip) + return + } if (inspecting.value) { showToast(config.messages.inspectingTip) return } + if (pendingPreview.value && !pendingUploadId.value) { + showToast(config.messages.uploadingTip) + return + } if (remainCount.value <= 0) { showToast(config.messages.noRemain) @@ -1023,7 +1097,6 @@ onMounted(() => { onBeforeUnmount(() => { pollActive = false - inspectAbort?.abort() blobUrls.forEach((url) => URL.revokeObjectURL(url)) blobUrls.clear() pendingObjectUrl.value = ''