新增:京东下单页面

This commit is contained in:
Linzm committed 2026-11-03 14:13:25 +08:00
1 parent 8e8cde9ef5
commit 007c675603
25 files changed
+3264 -25

No files matched your search

+868
View File
@@ -0,0 +1,868 @@
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<string, string> = {
'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<string, unknown>)
: 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<string, unknown>) {
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: string
product_id: number
product_name?: string
style_id?: number
prompt?: string
n?: number
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
/** 效果图编辑 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<string, unknown> => 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<string, any> {
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<string, any>)
: {}
} 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<string, any>)
: {}
} catch {
return {}
}
}
return {}
}
}
if (typeof res === 'object' && !Array.isArray(res)) return res as Record<string, any>
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<string, any>)
: 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<string, any>) => {
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<string, any> = {
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<UploadTicket> => {
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/imagegen/submit */
export const submitImageGen = (input: ImageGenSubmitInput) => {
return request(`${PREFIX}/imagegen/submit`, {
method: 'POST',
data: {
session_id: input.session_id,
upload_id: input.upload_id,
product_id: input.product_id,
product_name: input.product_name || '',
style_id: input.style_id ?? 0,
prompt: input.prompt || '',
n: input.n ?? 1,
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,
mode: input.mode || 'generate',
},
}) as Promise<ImageGenTask>
}
/** 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<OrderConfirmResult>
}
/**
* 上传原图:签发 URL → PUT OSS → confirm(需 session_id)
*/
export async function uploadOriginal(
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)
await confirmUpload({
session_id: options.session_id,
upload_id: ticket.upload_id,
})
return {
uploadId: ticket.upload_id,
fileUrl: ticket.file_url,
contentType: ticket.content_type || contentType,
filename: name,
}
}