import axios from 'axios' import request from '@/utils/request' import { localStorage } from '@/utils/local-storage' /** 京东 BFF:与 agent 对话页同构接口,路径前缀为 /jd */ const PREFIX = 'jd' function buildApiUrl(path: string) { const base = String(process.env.VUE_APP_API_BASE_URL || '/api/').replace(/\/+$/, '') return `${base}/${String(path).replace(/^\/+/, '')}` } function buildAuthHeaders() { const headers: Record = { 'Content-Type': 'application/json', } const code = localStorage.get('code') const userId = localStorage.get('userId') if (code) headers.Authorization = String(code) if (userId) headers.Userid = String(userId) return headers } function asRecord(value: unknown) { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null } function textOf(value: unknown) { return value == null ? '' : String(value) } function listOf(value: unknown) { return Array.isArray(value) ? value : [] } function asCount(value: unknown) { const number = Number(value) return Number.isFinite(number) ? number : 0 } export function normalizeOssUrl(url: string) { if (!url) return '' let s = String(url).replace(/\\u0026/gi, '&').replace(/\\u003d/gi, '=').trim() if (s.indexOf('//') === 0) s = `https:${s}` if (/^http:\/\//i.test(s)) s = `https://${s.slice(7)}` return s } function imageUrlOf(item: Record) { return normalizeOssUrl( textOf(item.url || item.file_url || item.image_url || item.cover_url || item.result_url), ) } function guessContentType(file: File | Blob, filename?: string) { if (file.type) return file.type const name = String(filename || '').toLowerCase() if (name.endsWith('.png')) return 'image/png' if (name.endsWith('.webp')) return 'image/webp' if (name.endsWith('.gif')) return 'image/gif' return 'image/jpeg' } function guessFilename(file?: File) { if (file && (file as File).name) return (file as File).name return 'photo.jpg' } function formatClock(value: unknown) { const n = asCount(value) if (!n) { const s = textOf(value) if (/^\d{1,2}:\d{2}$/.test(s)) return s return '' } const ms = n > 1e12 ? n : n * 1000 const d = new Date(ms) if (Number.isNaN(d.getTime())) return '' const h = String(d.getHours()).padStart(2, '0') const m = String(d.getMinutes()).padStart(2, '0') return `${h}:${m}` } // ---------- 类型(对齐 agent-api) ---------- export type UploadPurpose = 'origin' | 'external_effect' export type ImageGenStatusCode = 'pending' | 'processing' | 'success' | 'failed' export interface UploadTicket { upload_id: string upload_url: string file_url: string content_type: string expire_at: number } export interface ImageGenSubmitInput { session_id: string /** 导入生图传 upload_id;编辑效果图不传 */ upload_id?: string /** 编辑效果图传 image_id;导入生图不传 */ image_id?: string prompt?: string scene_prop?: string custom_text?: string /** 效果图编辑 edit;原图编辑 / 普通立即生成 generate */ mode?: 'generate' | 'edit' } export interface ImageGenImage { image_id: string path: string url: string index: number } export interface ImageGenTask { task_id: string status: ImageGenStatusCode } export interface ImageGenStatus { task_id: string status: ImageGenStatusCode error: string style: string prompt: string images: ImageGenImage[] } export interface EffectSubmitInput { session_id: string upload_id: string product_id: number product_name?: string user_message?: string mounts_id?: number scene_prop?: string custom_text?: string base_type?: number frame_type?: number mount_id?: number pid?: number proportion_id?: number pose_id?: number style_id?: number prompt?: string } export interface EffectSubmitResult { task_id: string status: ImageGenStatusCode error: string image_id: string url: string images: ImageGenImage[] } export interface OrderConfirmInput { task_id: string image_id?: string cate_id: number product_id?: number top_prod_id?: number shop_id: number print_size?: number quantity?: number contact_name?: string contact_mobile?: string address?: string country_id?: number province_id?: string city_id?: string county_id?: string figure_type: number check_id?: number out_trade_no?: string remark?: string shape_id?: number font_id?: number custom_text?: string back_text?: string up_text?: string down_text?: string } export interface OrderConfirmResult { order_id: number pid: number message: string out_trade_no?: string } export interface SessionDetailUpload { upload_id: string status: string purpose: UploadPurpose | '' path: string url: string } export interface SessionDetailImage { image_id: string path: string url: string index: number } export interface SessionDetailTask { task_id: string upload_id: string status: string error: string source: string provider: string product_name: string style_code: string style_name: string prompt: string n: number pid: number /** generate | edit,编辑效果图时右侧气泡只展示提示词 */ mode: 'generate' | 'edit' | '' created_at: number completed_at: number duration: number images: SessionDetailImage[] } export const SESSION_RECORD_TYPES = [ 'session_created', 'upload', 'upload_confirmed', 'imagegen_submit', 'imagegen_success', 'imagegen_failed', 'external_effect', 'order', ] as const export type SessionRecordType = (typeof SESSION_RECORD_TYPES)[number] export interface SessionDetailRecord { type: SessionRecordType | '' at: number message: string upload_id: string task_id: string status: string product_name: string image_count: number } export interface SessionDetail { session_id: string shop_id: number customer_name: string subtitle: string created_at: number updated_at: number uploads: SessionDetailUpload[] tasks: SessionDetailTask[] records: SessionDetailRecord[] } export type SessionHistoryItem = | { role: 'user' text: string image?: string time?: string taskId?: string uploadId?: string } | { role: 'agent' type: 'result' image: string meta?: string time?: string durationSec?: number taskId?: string imageId?: string uploadId?: string prompt?: string } export function isExternalEffect(value: string | undefined) { return value === 'external_effect' } // ---------- 解析 ---------- function uploadPurpose(value: unknown): UploadPurpose | '' { return value === 'origin' || value === 'external_effect' ? value : '' } function sessionRecordType(value: unknown): SessionRecordType | '' { const text = textOf(value).trim().toLowerCase() return (SESSION_RECORD_TYPES as readonly string[]).includes(text) ? (text as SessionRecordType) : '' } function normalizeDetailImage(raw: unknown): SessionDetailImage | null { const item = asRecord(raw) if (!item) return null const url = imageUrlOf(item) if (!url) return null return { image_id: textOf(item.image_id || item.id), path: textOf(item.path), url, index: asCount(item.index), } } function normalizeDetailTask(raw: unknown): SessionDetailTask | null { const item = asRecord(raw) if (!item || !item.task_id) return null const images = listOf(item.images ?? item.image_list ?? item.results) .map((image) => normalizeDetailImage(image)) .filter((image): image is SessionDetailImage => Boolean(image)) .sort((a, b) => a.index - b.index) if (!images.length) { const url = imageUrlOf(item) if (url) { images.push({ image_id: textOf(item.image_id || item.id), path: textOf(item.path), url, index: 0, }) } } const modeRaw = textOf(item.mode || item.image_gen_mode || item.imageGenMode).toLowerCase() const mode: SessionDetailTask['mode'] = modeRaw === 'edit' || modeRaw === 'generate' ? modeRaw : '' return { task_id: textOf(item.task_id), upload_id: textOf(item.upload_id), status: textOf(item.status), error: textOf(item.error || item.message), source: textOf(item.source), provider: textOf(item.provider), product_name: textOf(item.product_name), style_code: textOf(item.style_code), style_name: textOf(item.style_name), prompt: textOf(item.prompt), n: asCount(item.n), pid: asCount(item.pid), mode, created_at: asCount(item.created_at), completed_at: asCount(item.completed_at), duration: asCount(item.duration), images, } } function normalizeDetailRecord(raw: unknown): SessionDetailRecord | null { const item = asRecord(raw) if (!item) return null return { type: sessionRecordType(item.type), at: asCount(item.at), message: textOf(item.message), upload_id: textOf(item.upload_id), task_id: textOf(item.task_id), status: textOf(item.status), product_name: textOf(item.product_name), image_count: asCount(item.image_count), } } /** 解析 session/detail(对齐 agent normalizeSessionDetail) */ export function normalizeSessionDetail(data: unknown): SessionDetail { const record = asRecord(data) || {} const uploads = listOf(record.uploads) .map((item) => asRecord(item)) .filter((item): item is Record => Boolean(item && item.upload_id)) .map((item) => ({ upload_id: textOf(item.upload_id), status: textOf(item.status), purpose: uploadPurpose(item.purpose), path: textOf(item.path), url: normalizeOssUrl(textOf(item.url)), })) const tasks = listOf(record.tasks) .map((item) => normalizeDetailTask(item)) .filter((item): item is SessionDetailTask => Boolean(item)) .sort((a, b) => a.created_at - b.created_at) const records = listOf(record.records) .map((item) => normalizeDetailRecord(item)) .filter((item): item is SessionDetailRecord => Boolean(item)) .sort((a, b) => a.at - b.at) return { session_id: textOf(record.session_id), shop_id: asCount(record.shop_id), customer_name: textOf(record.customer_name), subtitle: textOf(record.subtitle), created_at: asCount(record.created_at), updated_at: asCount(record.updated_at || record.created_at), uploads, tasks, records, } } function uploadOf(detail: SessionDetail, uploadId: string) { return uploadId ? detail.uploads.find((item) => item.upload_id === uploadId) : undefined } function isBroughtTask(detail: SessionDetail, task: SessionDetailTask) { const upload = uploadOf(detail, task.upload_id) return ( isExternalEffect(upload?.purpose) || isExternalEffect(task.source) || task.provider === 'user_effect' ) } /** * 将 session/detail 转为对话气泡历史(对齐 agent applySessionDetail → turns) */ export function sessionDetailToHistory(detail: SessionDetail): SessionHistoryItem[] { const history: SessionHistoryItem[] = [] const defaultPrompt = '按所选产品、风格生成手办效果图~' detail.tasks.forEach((task) => { const upload = uploadOf(detail, task.upload_id) const photo = upload?.url || '' const brought = isBroughtTask(detail, task) const time = formatClock(task.completed_at || task.created_at) const seconds = task.duration > 0 ? task.duration : task.completed_at > task.created_at ? task.completed_at - task.created_at : 0 const images = task.images.filter((image) => image.url) const resultImages = images.length > 0 ? images : brought && photo ? [{ image_id: '', path: '', url: photo, index: 0 }] : [] if (!resultImages.length && task.status !== 'success') return // 右侧气泡:purpose=origin 显示原图;external_effect 只展示提示词 const userImage = upload?.purpose === 'origin' && photo ? photo : undefined const userText = task.prompt || (brought || upload?.purpose === 'external_effect' ? '这是我在别的平台生成的卡通效果图,不用再调整了,直接帮我下单吧~' : defaultPrompt) history.push({ role: 'user', text: userText, image: userImage, time: time || undefined, taskId: task.task_id, uploadId: task.upload_id, }) resultImages.forEach((image) => { history.push({ role: 'agent', type: 'result', image: image.url, time: time || undefined, durationSec: seconds, meta: time ? `${time}${seconds > 0 ? ` · 生图用时 ${seconds}s` : ''}` : seconds > 0 ? `生图用时 ${seconds}s` : '', taskId: task.task_id, imageId: image.image_id || undefined, uploadId: task.upload_id, prompt: task.prompt || undefined, }) }) }) return history } /** 兼容旧调用:解析 detail 并产出 history */ export function parseSessionDetail(res: any): SessionDetail & { sessionId: string history: SessionHistoryItem[] raw: any } { const detail = normalizeSessionDetail(res) return { ...detail, sessionId: detail.session_id, history: sessionDetailToHistory(detail), raw: res, } } /** 将接口 body 归一成对象(后端若返回 text/plain,axios 会得到 JSON 字符串) */ function coerceResponseBody(res: unknown): Record { if (res == null) return {} if (typeof res === 'string') { const text = res.replace(/^\uFEFF/, '').trim() if (!text) return {} try { const parsed = JSON.parse(text) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} } catch { const start = text.indexOf('{') const end = text.lastIndexOf('}') if (start >= 0 && end > start) { try { const parsed = JSON.parse(text.slice(start, end + 1)) return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {} } catch { return {} } } return {} } } if (typeof res === 'object' && !Array.isArray(res)) return res as Record return {} } /** 从 upload/url 业务体提取票据(兼容整包 / 已解包 / JSON 字符串) */ export function parseUploadUrlResult(res: any): UploadTicket { const root = coerceResponseBody(res) // 优先整包 { code, data: { upload_id, upload_url } } const fromEnvelope = root.data && typeof root.data === 'object' && !Array.isArray(root.data) ? (root.data as Record) : null const data = fromEnvelope && (fromEnvelope.upload_id || fromEnvelope.uploadId || fromEnvelope.upload_url) ? fromEnvelope : root return { upload_id: textOf(data.upload_id || data.uploadId), upload_url: normalizeOssUrl( textOf(data.upload_url || data.uploadUrl || data.put_url || data.putUrl), ), file_url: normalizeOssUrl(textOf(data.file_url || data.fileUrl)), content_type: textOf(data.content_type || data.contentType) || 'image/jpeg', expire_at: asCount(data.expire_at || data.expireAt), } } function normalizeImageGenStatus(data: unknown, taskId: string): ImageGenStatus { const record = asRecord(data) || {} const images = listOf(record.images ?? record.image_list ?? record.results) .map((item) => normalizeDetailImage(item)) .filter((item): item is SessionDetailImage => Boolean(item)) .map((item) => ({ image_id: item.image_id, path: item.path, url: item.url, index: item.index, })) if (!images.length) { const url = imageUrlOf(record) if (url) { images.push({ image_id: textOf(record.image_id || record.id), path: textOf(record.path), url, index: 0, }) } } const statusText = textOf(record.status).toLowerCase() const status: ImageGenStatusCode = statusText === 'pending' || statusText === 'processing' || statusText === 'success' || statusText === 'failed' ? statusText : images.length ? 'success' : 'pending' return { task_id: textOf(record.task_id) || taskId, status, error: textOf(record.error), style: textOf(record.style), prompt: textOf(record.prompt), images, } } /** 兼容旧 parseImageGenStatus 调用 */ export function parseImageGenStatus(res: any, taskId = '') { const normalized = normalizeImageGenStatus(res, taskId) const first = normalized.images[0] return { taskId: normalized.task_id, status: normalized.status, done: normalized.status === 'success' || Boolean(first?.url), failed: normalized.status === 'failed', resultUrl: first?.url || '', imageId: first?.image_id || '', images: normalized.images, error: normalized.error, raw: res, } } function normalizeEffectSubmit(data: unknown): EffectSubmitResult { const record = asRecord(data) || {} const images = listOf(record.images) .map((item) => normalizeDetailImage(item)) .filter((item): item is SessionDetailImage => Boolean(item)) .map((item) => ({ image_id: item.image_id, path: item.path, url: item.url, index: item.index, })) const statusText = textOf(record.status) const status: ImageGenStatusCode = statusText === 'pending' || statusText === 'processing' || statusText === 'success' || statusText === 'failed' ? statusText : 'success' return { task_id: textOf(record.task_id), status, error: textOf(record.error), image_id: textOf(record.image_id) || images[0]?.image_id || '', url: normalizeOssUrl(textOf(record.url || record.file_url || record.effect_url)) || images[0]?.url || '', images, } } // ---------- 接口(参数对齐 agent-api,路径用 /jd) ---------- /** GET /jd/session/detail?session_id= */ export const getSessionDetail = (params: { session_id: string } | Record) => { const sessionId = typeof params === 'object' && params ? textOf((params as any).session_id).trim() : '' return request(`${PREFIX}/session/detail`, { method: 'GET', params: { ...params, session_id: sessionId || textOf((params as any)?.session_id), }, }).then((res) => normalizeSessionDetail(res)) } /** GET /jd/session/images */ export const getSessionImages = (query: { shop_id?: number session_id?: string order?: 'asc' | 'desc' page?: number size?: number } = {}) => { const params: Record = { page: query.page && query.page > 0 ? query.page : 1, size: Math.min(100, Math.max(1, query.size || 20)), } if (query.shop_id) params.shop_id = query.shop_id if (query.session_id?.trim()) params.session_id = query.session_id.trim() if (query.order === 'asc' || query.order === 'desc') params.order = query.order return request(`${PREFIX}/session/images`, { method: 'GET', params, }) } /** * POST /jd/upload/url * 使用独立 axios(不走全局 response 拦截器),直接读 response.data */ export const getUploadUrl = async (input: { session_id: string filename?: string content_type?: string purpose?: UploadPurpose }): Promise => { const response = await axios.post( buildApiUrl(`${PREFIX}/upload/url`), { session_id: input.session_id, filename: input.filename || '', content_type: input.content_type || 'image/jpeg', purpose: input.purpose || 'origin', }, { headers: buildAuthHeaders(), timeout: 30000, }, ) // 注意:若 Content-Type 不是 application/json,axios 会把 body 留成 string const ticket = parseUploadUrlResult(response?.data) if (!ticket.upload_id) { throw new Error('签发上传地址失败:缺少 upload_id') } if (!ticket.upload_url) { throw new Error('签发上传地址失败:缺少 upload_url') } return ticket } /** PUT OSS */ export async function putUpload(uploadUrl: string, file: Blob, contentType: string) { const response = await fetch(uploadUrl, { method: 'PUT', headers: contentType ? { 'Content-Type': contentType } : undefined, body: file, }) if (!response.ok) throw new Error('图片上传失败') } /** POST /jd/upload/confirm */ export const confirmUpload = (input: { session_id: string; upload_id: string }) => { return request(`${PREFIX}/upload/confirm`, { method: 'POST', data: { session_id: input.session_id, upload_id: input.upload_id, }, }) } /** * 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) => { const data: Record = { session_id: input.session_id, prompt: input.prompt || '', scene_prop: input.scene_prop || '', custom_text: input.custom_text || '', mode: input.mode || 'generate', } const uploadId = String(input.upload_id || '').trim() const imageId = String(input.image_id || '').trim() // 导入生图:upload_id;编辑效果图:image_id(互斥) if (uploadId) data.upload_id = uploadId if (imageId) data.image_id = imageId return request(`${PREFIX}/imagegen/submit`, { method: 'POST', data, }) as Promise } /** POST /jd/effect/submit */ export const submitExternalEffect = (input: EffectSubmitInput) => { return request(`${PREFIX}/effect/submit`, { method: 'POST', data: { session_id: input.session_id, upload_id: input.upload_id, product_id: input.product_id, product_name: input.product_name || '', user_message: input.user_message || '', mounts_id: input.mounts_id ?? 0, scene_prop: input.scene_prop || '', custom_text: input.custom_text || '', base_type: input.base_type ?? 0, frame_type: input.frame_type ?? 0, mount_id: input.mount_id ?? 0, pid: input.pid ?? 0, proportion_id: input.proportion_id ?? 0, pose_id: input.pose_id ?? 0, style_id: input.style_id ?? 0, prompt: input.prompt || '', }, }).then(normalizeEffectSubmit) } /** GET /jd/imagegen/status?task_id= */ export const getImageGenStatus = (taskId: string) => { return request(`${PREFIX}/imagegen/status`, { method: 'GET', params: { task_id: taskId }, }).then((data) => normalizeImageGenStatus(data, taskId)) } /** POST /jd/order/confirm */ export const confirmOrder = (input: OrderConfirmInput) => { const shapeId = input.shape_id ?? 0 return request(`${PREFIX}/order/confirm`, { method: 'POST', data: { ...input, top_prod_id: input.top_prod_id ?? shapeId ?? 0, shape_id: shapeId, font_id: input.font_id ?? 0, custom_text: input.custom_text || '', back_text: input.back_text || '', up_text: input.up_text || '', down_text: input.down_text || '', check_id: input.check_id ?? 0, country_id: input.country_id ?? 45, province_id: input.province_id || '', city_id: input.city_id || '', county_id: input.county_id || '', }, }) as Promise } /** * 导入阶段:签发 URL → PUT OSS(不 confirm) * 拿到 upload_id 后可立刻调 subject/classify */ export async function prepareUpload( file: File | Blob, options: { session_id: string filename?: string purpose?: UploadPurpose }, ) { const name = options.filename || guessFilename(file as File) const contentType = guessContentType(file, name) const ticket = await getUploadUrl({ session_id: options.session_id, filename: name, content_type: contentType, purpose: options.purpose || 'origin', }) await putUpload(ticket.upload_url, file, ticket.content_type || contentType) return { uploadId: ticket.upload_id, fileUrl: ticket.file_url, contentType: ticket.content_type || contentType, filename: name, } } /** * 完整上传:签发 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 }