新增:京东下单页面
No files matched your search
@@ -13,20 +13,22 @@ declare module '@vue/runtime-core' {
|
||||
Backup: typeof import('./src/components/arFrame/backup.vue')['default']
|
||||
Backup2: typeof import('./src/components/arFrame/backup2.vue')['default']
|
||||
Brand: typeof import('./src/components/brand/index.vue')['default']
|
||||
ChatComposer: typeof import('./src/views/jingdong/components/ChatComposer.vue')['default']
|
||||
Constants: typeof import('./src/views/douyin/components/image-point-cloud/constants.js')['default']
|
||||
DyNavBar: typeof import('./src/views/douyin/components/DyNavBar.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElImage: typeof import('element-plus/es')['ElImage']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
GenerationStage: typeof import('./src/views/jingdong/components/GenerationStage.vue')['default']
|
||||
Gsplat: typeof import('./src/components/arFrame/gsplat.vue')['default']
|
||||
Hint: typeof import('./src/components/hint/hint.vue')['default']
|
||||
ImagePointCloud: typeof import('./src/views/douyin/components/ImagePointCloud.vue')['default']
|
||||
JdNavBar: typeof import('./src/views/jingdong/components/JdNavBar.vue')['default']
|
||||
Loading: typeof import('./src/components/loading/index.vue')['default']
|
||||
LogoCloudCache: typeof import('./src/views/douyin/components/image-point-cloud/logo-cloud-cache.js')['default']
|
||||
MaskProcessor: typeof import('./src/views/douyin/components/image-point-cloud/mask-processor.js')['default']
|
||||
@@ -36,6 +38,7 @@ declare module '@vue/runtime-core' {
|
||||
PointCloudSampler: typeof import('./src/views/douyin/components/image-point-cloud/point-cloud-sampler.js')['default']
|
||||
PointCloudWait: typeof import('./src/views/douyin/components/PointCloudWait.vue')['default']
|
||||
Renderer: typeof import('./src/views/douyin/components/image-point-cloud/webgl/renderer.js')['default']
|
||||
ResultCard: typeof import('./src/views/jingdong/components/ResultCard.vue')['default']
|
||||
Shaders: typeof import('./src/views/douyin/components/image-point-cloud/webgl/shaders.js')['default']
|
||||
State: typeof import('./src/views/douyin/components/image-point-cloud/state.js')['default']
|
||||
SubjectOutline: typeof import('./src/views/douyin/components/image-point-cloud/subject-outline.js')['default']
|
||||
@@ -44,10 +47,12 @@ declare module '@vue/runtime-core' {
|
||||
TextureManager: typeof import('./src/views/douyin/components/image-point-cloud/webgl/texture-manager.js')['default']
|
||||
VanActionSheet: typeof import('vant/es')['ActionSheet']
|
||||
VanAddressEdit: typeof import('vant/es')['AddressEdit']
|
||||
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
|
||||
VanDivider: typeof import('vant/es')['Divider']
|
||||
VanIcon: typeof import('vant/es')['Icon']
|
||||
VanList: typeof import('vant/es')['List']
|
||||
VanLoading: typeof import('vant/es')['Loading']
|
||||
VanOverlay: typeof import('vant/es')['Overlay']
|
||||
VanPullRefresh: typeof import('vant/es')['PullRefresh']
|
||||
VanStepper: typeof import('vant/es')['Stepper']
|
||||
VanUploader: typeof import('vant/es')['Uploader']
|
||||
|
||||
@@ -14,8 +14,13 @@ const { i18next, t } = useTranslation();
|
||||
i18n.set(t);
|
||||
|
||||
const route = useRoute()
|
||||
/** 仅抖音页使用独立布局壳,避免影响 cartoon / badge 等页面 */
|
||||
const isDouyinRoute = computed(() => String(route.path || '').startsWith('/douyin'))
|
||||
/** 各渠道独立布局壳,互不影响 */
|
||||
const shellClass = computed(() => {
|
||||
const path = String(route.path || '')
|
||||
if (path.startsWith('/douyin')) return 'app-shell app-shell--douyin'
|
||||
if (path.startsWith('/jingdong')) return 'app-shell app-shell--jingdong'
|
||||
return 'app-shell'
|
||||
})
|
||||
|
||||
// 根据 i18next 语言获取对应的 Vant Locale
|
||||
function getVantLocale() {
|
||||
@@ -67,7 +72,7 @@ onMounted(() => {
|
||||
<template>
|
||||
<router-view v-slot="{ Component }">
|
||||
<Suspense>
|
||||
<div :class="isDouyinRoute ? 'app-shell app-shell--douyin' : 'app-shell'">
|
||||
<div :class="shellClass">
|
||||
<component :is="Component" />
|
||||
</div>
|
||||
</Suspense>
|
||||
@@ -84,7 +89,7 @@ onMounted(() => {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
/* 仅抖音链路:桌面居中 + fixed 相对本容器(不影响 cartoon) */
|
||||
/* 抖音链路:桌面居中(不影响 cartoon / jingdong) */
|
||||
.app-shell--douyin {
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
@@ -93,4 +98,14 @@ onMounted(() => {
|
||||
transform: translateZ(0);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* 京东链路:桌面居中(不影响 cartoon / douyin) */
|
||||
.app-shell--jingdong {
|
||||
max-width: 540px;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
transform: translateZ(0);
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 774 KiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
@@ -0,0 +1,62 @@
|
||||
import logoAvatar from '@/assets/jingdong/logo-mark.png'
|
||||
import logoIcon from '@/assets/jingdong/logo_1.png'
|
||||
import caseOriginal from '@/assets/jingdong/原图.png'
|
||||
import caseEffect from '@/assets/jingdong/效果图.png'
|
||||
import caseScene from '@/assets/jingdong/场景图.png'
|
||||
import demoResult1 from '@/assets/jingdong/jd-2.png'
|
||||
import demoResult2 from '@/assets/jingdong/jd-3.png'
|
||||
import demoUpload from '@/assets/jingdong/jd-1.png'
|
||||
import pictureCloud from '@/assets/jingdong/picture_cloud.gif'
|
||||
|
||||
export const jingdongConfig = {
|
||||
routes: {
|
||||
home: '/jingdong',
|
||||
},
|
||||
|
||||
brandName: '速哇手办制作Agent',
|
||||
primaryColor: '#3A8E4D',
|
||||
primarySoft: '#E8F5E9',
|
||||
background: '#F5F5F5',
|
||||
|
||||
images: {
|
||||
/** 顶栏 / 对话头像 */
|
||||
avatar: logoAvatar,
|
||||
logo: logoAvatar,
|
||||
/** 浏览器标签 favicon */
|
||||
logoIcon,
|
||||
caseOriginal,
|
||||
caseEffect,
|
||||
caseScene,
|
||||
demoResult1,
|
||||
demoResult2,
|
||||
demoUpload,
|
||||
pictureCloud,
|
||||
},
|
||||
|
||||
remainInitial: 5,
|
||||
|
||||
/** 生图状态轮询间隔(毫秒),对齐 agent */
|
||||
pollIntervalMs: 10000,
|
||||
pollTimeoutMs: 5 * 60 * 1000,
|
||||
|
||||
messages: {
|
||||
welcome: '你好,上传一张照片,或直接说说想做成什么样的手办。',
|
||||
caseIntro: '先看看效果案例:原图 → 效果图 → 场景应用',
|
||||
caseLabels: ['原图', '效果图', '场景应用'] as const,
|
||||
placeholder: '描述你想要的手办,例如:橘猫,戴黄色围巾...',
|
||||
defaultPrompt: '按所选产品、风格生成手办效果图~',
|
||||
editingHint: '正在编辑效果图 · 输入文字描述想调整的地方',
|
||||
exitEdit: '退出编辑',
|
||||
view: '查看',
|
||||
edit: '编辑',
|
||||
buyNow: '立即购买',
|
||||
buyToast: '购买功能即将开放',
|
||||
noRemain: '生图剩余次数不足',
|
||||
needPhotoOrText: '请上传照片或输入描述后再发送',
|
||||
generating: '正在生成效果图…',
|
||||
downloadFail: '下载失败,请稍后重试',
|
||||
/** 识别主体与订单主体不一致 */
|
||||
subjectMismatch:
|
||||
'上传图片的主体数量与下单主体不一致,请更换与订单相符的照片后再试',
|
||||
},
|
||||
}
|
||||
@@ -14,6 +14,19 @@ const stripCorsHeaders = (proxyRes: { headers: Record<string, any> }) => {
|
||||
}
|
||||
|
||||
export const proxy = {
|
||||
/** 图片 classify,需排在 /api 之前避免被 shop 代理吞掉 */
|
||||
"/concept-classify": {
|
||||
target: "https://u252424-8570-01f615a5.cqa1.seetacloud.com:8443",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: () => "/v1/classify",
|
||||
},
|
||||
"/concept-review": {
|
||||
target: "https://u252424-8570-01f615a5.cqa1.seetacloud.com:8443",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
rewrite: () => "/v1/review",
|
||||
},
|
||||
"/api": {
|
||||
target: "https://shop.api.suwa3d.com/api/web/",
|
||||
// target: 'http://127.0.0.1:28499',
|
||||
|
||||
@@ -6,11 +6,13 @@ import 'nprogress/nprogress.css'
|
||||
// import * as badgeRouter from './badge'
|
||||
import * as cartoonRouter from './cartoon'
|
||||
import * as douyinRouter from './douyin'
|
||||
import * as jingdongRouter from './jingdong'
|
||||
// import badge from '@/views/badge/index.vue'
|
||||
import cartoon from '@/views/cartoon/index.vue'
|
||||
import {useStore} from '@/stores'
|
||||
import { config } from '@/config/config'
|
||||
import { douyinConfig } from '@/config/douyin'
|
||||
import { jingdongConfig } from '@/config/jingdong'
|
||||
import { localStorage } from '@/utils/local-storage'
|
||||
import {
|
||||
getCachedShopBrand,
|
||||
@@ -37,6 +39,7 @@ const routes = [
|
||||
// badgeRouter.mergeRoutes(routes)
|
||||
cartoonRouter.mergeRoutes(routes)
|
||||
douyinRouter.mergeRoutes(routes)
|
||||
jingdongRouter.mergeRoutes(routes)
|
||||
// 创建路由实例并传递 `routes` 配置
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(process.env.VUE_APP_PUBLIC_PATH),
|
||||
@@ -65,7 +68,9 @@ router.beforeEach((_to, _from, next) => {
|
||||
if (code) {
|
||||
let url = store.redirectUrl()
|
||||
|
||||
if (_to.path !== '/cartoon' && !_to.path.includes('douyin')) {
|
||||
const isChannelPath =
|
||||
_to.path.includes('douyin') || _to.path.includes('jingdong')
|
||||
if (_to.path !== '/cartoon' && !isChannelPath) {
|
||||
if (url) {
|
||||
store.setRedirect('')
|
||||
next(url)
|
||||
@@ -82,12 +87,17 @@ router.beforeEach((_to, _from, next) => {
|
||||
const titleText = pageTitle.$t || pageTitle
|
||||
const brandText = config.brand?.$t || config.brand || ''
|
||||
const isDouyin = _to.meta.channel === 'douyin' || _to.path.includes('douyin')
|
||||
const isJingdong =
|
||||
_to.meta.channel === 'jingdong' || _to.path.includes('jingdong')
|
||||
// 抖音页优先使用 shopBrand 缓存的门店名/logo,否则回退默认品牌
|
||||
if (isDouyin) {
|
||||
const shopBrand = getCachedShopBrand()
|
||||
const brandName = shopBrand.name || douyinConfig.brandName
|
||||
window.document.title = `${brandName} - ${titleText}`
|
||||
setDocumentFavicon(shopBrand.logo || douyinConfig.images.logo)
|
||||
} else if (isJingdong) {
|
||||
window.document.title = `${jingdongConfig.brandName} - ${titleText}`
|
||||
setDocumentFavicon(jingdongConfig.images.logoIcon)
|
||||
} else {
|
||||
window.document.title = config.showBrandInTitle && brandText
|
||||
? `${brandText} - ${titleText}`
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import jingdongHome from '@/views/jingdong/index.vue'
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: '/jingdong',
|
||||
name: 'jingdong',
|
||||
component: jingdongHome,
|
||||
meta: {
|
||||
needGuard: true,
|
||||
title: '速哇手办制作Agent',
|
||||
channel: 'jingdong',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function mergeRoutes(allRoutes: any[]) {
|
||||
allRoutes.push(...routes)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 原图分类 /classify(对齐 agent image-inspect)
|
||||
* - 本地:Vite 代理 `/concept-classify` → `/v1/classify`
|
||||
* - 线上:直连 seetacloud
|
||||
*/
|
||||
|
||||
export type ImageKind = 'cartoon' | 'real_photo' | 'other' | 'uncertain'
|
||||
|
||||
export interface ImageInspect {
|
||||
imageType: ImageKind
|
||||
isCartoon: boolean
|
||||
personCount: number
|
||||
petCount: number
|
||||
reviewStatus: string
|
||||
conclusion: string
|
||||
}
|
||||
|
||||
export class InspectCancelled extends Error {
|
||||
constructor() {
|
||||
super('cancelled')
|
||||
this.name = 'InspectCancelled'
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown>)
|
||||
: null
|
||||
}
|
||||
|
||||
export function mapInspectImageType(raw: unknown): ImageKind {
|
||||
const text = String(raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (text === 'cartoon' || text === '1') return 'cartoon'
|
||||
if (text === 'real_photo' || text === 'real' || text === '2') return 'real_photo'
|
||||
if (text === 'other') return 'other'
|
||||
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<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = Number(data[key])
|
||||
if (Number.isFinite(value)) return Math.max(0, Math.trunc(value))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function normalizeInspect(body: unknown): ImageInspect {
|
||||
const root = asRecord(body) || {}
|
||||
const data = asRecord(root.data) || root
|
||||
const imageType = mapInspectImageType(data.image_type ?? data.imageType)
|
||||
const isCartoonRaw = data.is_cartoon ?? data.isCartoon
|
||||
const isCartoon =
|
||||
typeof isCartoonRaw === 'boolean' ? isCartoonRaw : imageType === 'cartoon'
|
||||
return {
|
||||
imageType,
|
||||
isCartoon,
|
||||
personCount: countField(data, [
|
||||
'person_count',
|
||||
'personCount',
|
||||
'headcount',
|
||||
'head_count',
|
||||
]),
|
||||
petCount: countField(data, ['pet_count', 'petCount']),
|
||||
reviewStatus: String(data.review_status || ''),
|
||||
conclusion: String(data.conclusion || ''),
|
||||
}
|
||||
}
|
||||
|
||||
export function isCartoonImage(result: ImageInspect) {
|
||||
return result.isCartoon || result.imageType === 'cartoon'
|
||||
}
|
||||
|
||||
export function figureTypeOf(result: ImageInspect) {
|
||||
return result.imageType === 'real_photo' ? 1 : 0
|
||||
}
|
||||
|
||||
export function formatSubjectFromInspect(result: ImageInspect) {
|
||||
const parts: string[] = []
|
||||
if (result.personCount > 0) parts.push(`${result.personCount}人`)
|
||||
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<string, string> = {}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
export type FlyRect = {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
/** 二次贝塞尔:起点 → 控制点 → 终点 */
|
||||
function quad(p0: number, p1: number, p2: number, t: number) {
|
||||
const u = 1 - t
|
||||
return u * u * p0 + 2 * u * t * p1 + t * t * p2
|
||||
}
|
||||
|
||||
function easeInOut(t: number) {
|
||||
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
|
||||
}
|
||||
|
||||
/** 将图片从源矩形沿抛物线飞到目标矩形,动画结束后移除 */
|
||||
export function flyImageToRect(url: string, from: FlyRect, to: FlyRect, duration = 860): Promise<void> {
|
||||
if (typeof document === 'undefined' || !url || from.width <= 0 || from.height <= 0) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
if (prefersReducedMotion()) return Promise.resolve()
|
||||
|
||||
const ghost = document.createElement('img')
|
||||
ghost.src = url
|
||||
ghost.alt = ''
|
||||
ghost.className = 'jd-img-fly'
|
||||
ghost.setAttribute('aria-hidden', 'true')
|
||||
Object.assign(ghost.style, {
|
||||
position: 'fixed',
|
||||
left: `${from.left}px`,
|
||||
top: `${from.top}px`,
|
||||
width: `${from.width}px`,
|
||||
height: `${from.height}px`,
|
||||
margin: '0',
|
||||
padding: '0',
|
||||
border: '0',
|
||||
borderRadius: '12px',
|
||||
objectFit: 'contain',
|
||||
objectPosition: 'center',
|
||||
background: '#e8eaed',
|
||||
zIndex: '10000',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.22)',
|
||||
transformOrigin: 'top left',
|
||||
willChange: 'transform, opacity, border-radius',
|
||||
} as CSSStyleDeclaration)
|
||||
|
||||
document.body.appendChild(ghost)
|
||||
|
||||
const sxEnd = to.width / Math.max(1, from.width)
|
||||
const syEnd = to.height / Math.max(1, from.height)
|
||||
const dx = to.left - from.left
|
||||
const dy = to.top - from.top
|
||||
|
||||
const lift = Math.min(160, Math.max(72, Math.hypot(dx, dy) * 0.28))
|
||||
const cx = dx * 0.5
|
||||
const cy = dy * 0.5 - lift
|
||||
|
||||
const steps = 18
|
||||
const keyframes: Keyframe[] = []
|
||||
for (let i = 0; i <= steps; i += 1) {
|
||||
const raw = i / steps
|
||||
const t = easeInOut(raw)
|
||||
const x = quad(0, cx, dx, t)
|
||||
const y = quad(0, cy, dy, t)
|
||||
const scaleX = 1 + (sxEnd - 1) * t
|
||||
const scaleY = 1 + (syEnd - 1) * t
|
||||
const radius = 12 + (10 - 12) * t
|
||||
keyframes.push({
|
||||
offset: raw,
|
||||
transform: `translate(${x}px, ${y}px) scale(${scaleX}, ${scaleY})`,
|
||||
opacity: String(1 - 0.08 * t),
|
||||
borderRadius: `${radius}px`,
|
||||
})
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const finish = () => {
|
||||
ghost.remove()
|
||||
resolve()
|
||||
}
|
||||
const anim = ghost.animate(keyframes, {
|
||||
duration,
|
||||
easing: 'linear',
|
||||
fill: 'forwards',
|
||||
})
|
||||
anim.onfinish = finish
|
||||
anim.oncancel = finish
|
||||
})
|
||||
}
|
||||
|
||||
export function rectFromElement(el: Element | null | undefined): FlyRect | null {
|
||||
if (!el) return null
|
||||
const box = el.getBoundingClientRect()
|
||||
if (box.width <= 0 || box.height <= 0) return null
|
||||
return { left: box.left, top: box.top, width: box.width, height: box.height }
|
||||
}
|
||||
@@ -69,30 +69,65 @@ const requestHandler = (config: InternalAxiosRequestConfig): InternalAxiosReques
|
||||
// Add a request interceptor
|
||||
request.interceptors.request.use(requestHandler, errorHandler)
|
||||
|
||||
function coerceJsonPayload(payload: any) {
|
||||
// 后端若用 text/plain 返回 JSON,axios 不会自动 parse,这里补一层
|
||||
if (typeof payload !== 'string') return payload
|
||||
const text = payload.replace(/^\uFEFF/, '').trim()
|
||||
if (!text) return payload
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
const start = text.indexOf('{')
|
||||
const end = text.lastIndexOf('}')
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
return JSON.parse(text.slice(start, end + 1))
|
||||
} catch {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
}
|
||||
|
||||
// 响应拦截器
|
||||
const responseHandler = (response: { data: any }) => {
|
||||
if (!response?.data)
|
||||
if (!response?.data && response?.data !== 0 && response?.data !== false)
|
||||
return response
|
||||
|
||||
const { code, data, message } = response.data
|
||||
|
||||
if (!code)
|
||||
return response.data
|
||||
|
||||
// 拦截时间
|
||||
switch (code) {
|
||||
case 1000:
|
||||
if (Array.isArray(data)) {
|
||||
// transfter(data, timezone.info);
|
||||
} else if (data && data.list !== undefined && data.list?.length > 0) {
|
||||
// transfter(data.list, timezone.info);
|
||||
}
|
||||
return data
|
||||
default:
|
||||
return Promise.reject(new Error(message))
|
||||
// 兼容偶发双层:{ data: { code, data, message } }
|
||||
let payload = coerceJsonPayload(response.data)
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
payload.code == null &&
|
||||
payload.data &&
|
||||
typeof payload.data === 'object' &&
|
||||
payload.data.code != null
|
||||
) {
|
||||
payload = payload.data
|
||||
}
|
||||
|
||||
return response.data
|
||||
if (!payload || typeof payload !== 'object')
|
||||
return payload
|
||||
|
||||
const { code, data, message } = payload
|
||||
|
||||
if (code == null || code === '')
|
||||
return payload
|
||||
|
||||
// 兼容 code 为字符串 "1000"
|
||||
const numericCode = Number(code)
|
||||
if (numericCode === 1000) {
|
||||
if (Array.isArray(data)) {
|
||||
// transfter(data, timezone.info);
|
||||
} else if (data && data.list !== undefined && data.list?.length > 0) {
|
||||
// transfter(data.list, timezone.info);
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(message || '请求失败'))
|
||||
}
|
||||
|
||||
// Add a response interceptor
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div class="composer">
|
||||
<div v-if="editing" class="composer__edit-bar">
|
||||
<span class="composer__edit-left">
|
||||
<el-icon :size="14"><EditPen /></el-icon>
|
||||
<span>{{ editingHint }}</span>
|
||||
</span>
|
||||
<button class="composer__exit" type="button" @click="emit('exit-edit')">
|
||||
<el-icon :size="12"><Close /></el-icon>
|
||||
<span>{{ exitEditLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="boxRef" class="composer__box">
|
||||
<div v-if="previewUrl" ref="previewRef" class="composer__preview">
|
||||
<img class="composer__preview-img" :src="previewUrl" alt="已选图片" />
|
||||
<button class="composer__preview-close" type="button" aria-label="移除图片" @click="emit('clear-preview')">
|
||||
<el-icon :size="10" color="#fff"><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
class="composer__input"
|
||||
rows="2"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
<div class="composer__toolbar">
|
||||
<button
|
||||
class="composer__upload"
|
||||
type="button"
|
||||
aria-label="上传图片"
|
||||
:disabled="disabled || editing || inspecting"
|
||||
@click="emit('upload')"
|
||||
>
|
||||
<el-icon v-if="inspecting" class="is-loading" :size="20"><Loading /></el-icon>
|
||||
<el-icon v-else :size="22"><Picture /></el-icon>
|
||||
</button>
|
||||
|
||||
<div class="composer__tags">
|
||||
<span v-for="tag in tagItems" :key="tag.key" class="composer__tag" :title="tag.label">
|
||||
<el-icon class="composer__tag-icon" :size="12"><component :is="tag.icon" /></el-icon>
|
||||
<span class="composer__tag-text">{{ tag.label }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="composer__send"
|
||||
type="button"
|
||||
aria-label="发送"
|
||||
:disabled="disabled || !canSend"
|
||||
@click="emit('send')"
|
||||
>
|
||||
<el-icon :size="18"><Top /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { Close, EditPen, Loading, Picture, Present, Star, Top, User } from '@element-plus/icons-vue'
|
||||
|
||||
export type ComposerTag = {
|
||||
key: string
|
||||
label: string
|
||||
icon?: 'user' | 'star' | 'present'
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
placeholder?: string
|
||||
previewUrl?: string
|
||||
tags?: ComposerTag[]
|
||||
editing?: boolean
|
||||
editingHint?: string
|
||||
exitEditLabel?: string
|
||||
disabled?: boolean
|
||||
canSend?: boolean
|
||||
/** 图片识别 / 主体识别进行中 */
|
||||
inspecting?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: '',
|
||||
previewUrl: '',
|
||||
tags: () => [],
|
||||
editing: false,
|
||||
editingHint: '',
|
||||
exitEditLabel: '退出编辑',
|
||||
disabled: false,
|
||||
canSend: false,
|
||||
inspecting: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'upload'): void
|
||||
(e: 'clear-preview'): void
|
||||
(e: 'send'): void
|
||||
(e: 'exit-edit'): void
|
||||
}>()
|
||||
|
||||
const iconMap = {
|
||||
user: User,
|
||||
star: Star,
|
||||
present: Present,
|
||||
}
|
||||
|
||||
const tagItems = computed(() =>
|
||||
(props.tags || [])
|
||||
.filter((tag) => tag && String(tag.label || '').trim())
|
||||
.map((tag) => ({
|
||||
key: tag.key || tag.label,
|
||||
label: String(tag.label).trim(),
|
||||
icon: iconMap[tag.icon || 'present'] || Present,
|
||||
})),
|
||||
)
|
||||
|
||||
const textareaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const boxRef = ref<HTMLElement | null>(null)
|
||||
const previewRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function onInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
function focus() {
|
||||
textareaRef.value?.focus()
|
||||
}
|
||||
|
||||
function getPreviewEl() {
|
||||
return previewRef.value
|
||||
}
|
||||
|
||||
function getFlyTargetEl() {
|
||||
return previewRef.value || boxRef.value
|
||||
}
|
||||
|
||||
defineExpose({ focus, getPreviewEl, getFlyTargetEl })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.composer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px calc(10px + env(safe-area-inset-bottom));
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.composer__edit-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: #eaf6ec;
|
||||
color: #3a8e4d;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.composer__edit-left {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.composer__exit {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #3a8e4d;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.composer__box {
|
||||
padding: 12px 12px 10px;
|
||||
border-radius: 18px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #ebebeb;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.composer__preview {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.composer__preview-close {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.composer__input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
max-height: 96px;
|
||||
margin: 0 0 10px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
resize: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1a1a1a;
|
||||
font-family: inherit;
|
||||
|
||||
&::placeholder {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.composer__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.composer__upload {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.is-loading {
|
||||
animation: jd-spin 0.9s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jd-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.composer__tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.composer__tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 88px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #f2f2f2;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.composer__tag-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.composer__tag-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.composer__send {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #3a8e4d;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,237 @@
|
||||
<template>
|
||||
<section class="jd-stage" aria-live="polite">
|
||||
<div class="jd-stage__visual">
|
||||
<img
|
||||
class="jd-stage__gif"
|
||||
:src="loadingGif"
|
||||
alt=""
|
||||
draggable="false"
|
||||
:style="{ opacity: gifOpacity }"
|
||||
/>
|
||||
<img
|
||||
v-if="resultUrl"
|
||||
class="jd-stage__result"
|
||||
:src="resultUrl"
|
||||
alt=""
|
||||
:style="{ opacity: reveal }"
|
||||
@load="onResultLoad"
|
||||
/>
|
||||
<p class="jd-stage__status" :style="{ opacity: Math.max(0, 1 - reveal * 1.4) }">
|
||||
{{ statusLabel }}
|
||||
</p>
|
||||
<span class="jd-stage__percent" aria-label="生成进度" :style="{ opacity: Math.max(0, 1 - reveal * 1.2) }">
|
||||
{{ progressLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import loadingGif from '@/assets/jingdong/picture_cloud.gif'
|
||||
|
||||
const props = defineProps<{
|
||||
photoUrl?: string
|
||||
resultUrl?: string
|
||||
startedAt?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'done'): void
|
||||
}>()
|
||||
|
||||
const WAIT_STATUS = ['正在创建图像', '正在构思构图', '正在丰富细节', '正在打磨光影'] as const
|
||||
const REVEAL_MS = 1600
|
||||
|
||||
const phase = ref<'waiting' | 'reveal'>('waiting')
|
||||
const progress = ref(0)
|
||||
const displayProgress = ref(0)
|
||||
const reveal = ref(0)
|
||||
const gifOpacity = ref(1)
|
||||
const clock = ref(performance.now())
|
||||
const resultReady = ref(false)
|
||||
|
||||
let raf = 0
|
||||
let finished = false
|
||||
let startedAt = performance.now()
|
||||
let revealStarted = 0
|
||||
|
||||
const progressLabel = computed(() => {
|
||||
const value = Number.isFinite(displayProgress.value) ? displayProgress.value : 0
|
||||
return `${Math.max(0, Math.min(100, Math.round(value)))}%`
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (phase.value === 'reveal') return '即将完成'
|
||||
const elapsed = Math.max(0, clock.value - startedAt)
|
||||
const index = Math.floor(elapsed / 3200) % WAIT_STATUS.length
|
||||
return WAIT_STATUS[index]
|
||||
})
|
||||
|
||||
function safeProgress(value: number) {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, Math.min(100, value))
|
||||
}
|
||||
|
||||
function waitingProgress(elapsedMs: number) {
|
||||
const seconds = Math.max(0, elapsedMs) / 1000
|
||||
return Math.min(88, 88 * (1 - Math.exp(-seconds / 18)))
|
||||
}
|
||||
|
||||
function smoother(value: number) {
|
||||
const amount = Math.max(0, Math.min(1, value))
|
||||
return amount * amount * amount * (amount * (amount * 6 - 15) + 10)
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (finished) return
|
||||
finished = true
|
||||
progress.value = 100
|
||||
displayProgress.value = 100
|
||||
gifOpacity.value = 0
|
||||
reveal.value = 1
|
||||
emit('done')
|
||||
}
|
||||
|
||||
function tick(now: number) {
|
||||
clock.value = now
|
||||
if (phase.value === 'waiting') {
|
||||
const elapsed = Math.max(0, now - startedAt)
|
||||
progress.value = safeProgress(waitingProgress(elapsed))
|
||||
gifOpacity.value = 1
|
||||
reveal.value = 0
|
||||
if (resultReady.value && props.resultUrl) {
|
||||
phase.value = 'reveal'
|
||||
revealStarted = now
|
||||
}
|
||||
} else if (phase.value === 'reveal') {
|
||||
const amount = smoother((now - revealStarted) / REVEAL_MS)
|
||||
reveal.value = amount
|
||||
gifOpacity.value = Math.max(0, 1 - amount)
|
||||
progress.value = safeProgress(88 + amount * 12)
|
||||
if (amount >= 1) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const gap = progress.value - displayProgress.value
|
||||
displayProgress.value = safeProgress(displayProgress.value + gap * 0.12)
|
||||
if (Math.abs(gap) < 0.05) displayProgress.value = progress.value
|
||||
|
||||
if (!finished) raf = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
function onResultLoad() {
|
||||
resultReady.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.resultUrl,
|
||||
(url) => {
|
||||
if (!url) {
|
||||
resultReady.value = false
|
||||
return
|
||||
}
|
||||
// 可能已缓存,直接进入 reveal
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
resultReady.value = true
|
||||
}
|
||||
img.src = url
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
startedAt = props.startedAt && props.startedAt > 0 ? props.startedAt : performance.now()
|
||||
// startedAt 可能是 Date.now(),与 performance.now() 混用时做校正
|
||||
if (startedAt > 1e12) {
|
||||
const wallElapsed = Date.now() - startedAt
|
||||
startedAt = performance.now() - Math.max(0, wallElapsed)
|
||||
}
|
||||
progress.value = 0
|
||||
displayProgress.value = 0
|
||||
gifOpacity.value = 1
|
||||
reveal.value = 0
|
||||
finished = false
|
||||
raf = window.requestAnimationFrame(tick)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.cancelAnimationFrame(raf)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.jd-stage {
|
||||
width: min(280px, 72vw);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.jd-stage__visual {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: #f0f4f9;
|
||||
}
|
||||
|
||||
.jd-stage__gif,
|
||||
.jd-stage__result {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jd-stage__gif {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.jd-stage__result {
|
||||
z-index: 3;
|
||||
object-fit: contain;
|
||||
background: #f0f4f9;
|
||||
}
|
||||
|
||||
.jd-stage__status {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 10px;
|
||||
z-index: 4;
|
||||
margin: 0;
|
||||
color: #0b1220;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jd-stage__percent {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 46px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #0b1220;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<header class="jd-nav">
|
||||
<div class="jd-nav__left">
|
||||
<img class="jd-nav__logo" :src="logo" alt="速哇" />
|
||||
<h1 class="jd-nav__title">{{ title }}</h1>
|
||||
</div>
|
||||
<button class="jd-nav__remain" type="button" @click="emit('remain')">
|
||||
<el-icon class="jd-nav__remain-icon" :size="14"><MagicStick /></el-icon>
|
||||
<span>生图剩余 {{ remain }}次</span>
|
||||
</button>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MagicStick } from '@element-plus/icons-vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
remain?: number
|
||||
logo: string
|
||||
}>(),
|
||||
{
|
||||
title: '速哇手办制作Agent',
|
||||
remain: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'remain'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.jd-nav {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 6px 12px;
|
||||
padding-top: calc(6px + env(safe-area-inset-top));
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.jd-nav__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.jd-nav__logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
object-position: center 35%;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.jd-nav__title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: #1a1a1a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.jd-nav__remain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
padding: 0 10px 0 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #eaf6ec;
|
||||
color: #3a8e4d;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jd-nav__remain-icon {
|
||||
color: #3a8e4d;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="result-card" :class="{ 'is-editing': editing }">
|
||||
<div class="result-card__media">
|
||||
<img ref="imgRef" class="result-card__img" :src="image" alt="效果图" />
|
||||
<button class="result-card__view" type="button" @click="emit('view')">
|
||||
<span>{{ viewLabel }}</span>
|
||||
<el-icon :size="12"><FullScreen /></el-icon>
|
||||
</button>
|
||||
<button
|
||||
class="result-card__edit"
|
||||
type="button"
|
||||
@click="onEditClick"
|
||||
>
|
||||
<template v-if="editing">
|
||||
<el-icon :size="12"><Close /></el-icon>
|
||||
<span>{{ exitEditLabel }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-icon :size="12"><EditPen /></el-icon>
|
||||
<span>{{ editLabel }}</span>
|
||||
</template>
|
||||
</button>
|
||||
<button class="result-card__download" type="button" aria-label="下载" @click="emit('download')">
|
||||
<el-icon :size="16"><Download /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="result-card__meta">{{ meta }}</div>
|
||||
<button v-if="showBuy" class="result-card__buy" type="button" @click="emit('buy')">
|
||||
<el-icon :size="16"><ShoppingCart /></el-icon>
|
||||
<span>{{ buyLabel }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Close, Download, EditPen, FullScreen, ShoppingCart } from '@element-plus/icons-vue'
|
||||
import { rectFromElement, type FlyRect } from '@/utils/fly-image'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
image: string
|
||||
meta: string
|
||||
editing?: boolean
|
||||
showBuy?: boolean
|
||||
viewLabel?: string
|
||||
editLabel?: string
|
||||
exitEditLabel?: string
|
||||
buyLabel?: string
|
||||
}>(),
|
||||
{
|
||||
editing: false,
|
||||
showBuy: true,
|
||||
viewLabel: '查看',
|
||||
editLabel: '编辑',
|
||||
exitEditLabel: '退出编辑',
|
||||
buyLabel: '立即购买',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'view'): void
|
||||
(e: 'edit', payload: { sourceRect: FlyRect | null }): void
|
||||
(e: 'exit-edit'): void
|
||||
(e: 'download'): void
|
||||
(e: 'buy'): void
|
||||
}>()
|
||||
|
||||
const imgRef = ref<HTMLImageElement | null>(null)
|
||||
|
||||
function onEditClick() {
|
||||
if (props.editing) {
|
||||
emit('exit-edit')
|
||||
return
|
||||
}
|
||||
emit('edit', { sourceRect: rectFromElement(imgRef.value) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.result-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-card__media {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
background: #f0f0f0;
|
||||
border: 1.5px solid transparent;
|
||||
}
|
||||
|
||||
.result-card.is-editing .result-card__media {
|
||||
border-color: #3a8e4d;
|
||||
}
|
||||
|
||||
.result-card__img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
object-position: center top;
|
||||
}
|
||||
|
||||
.result-card__view,
|
||||
.result-card__edit,
|
||||
.result-card__download {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.result-card__view {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-card__edit {
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.result-card__download {
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.result-card__meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.result-card__buy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin-top: 10px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: #3a8e4d;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||