418 lines
14 KiB
TypeScript
418 lines
14 KiB
TypeScript
import { request } from '../../utils/request'
|
||
import { ClothesItem } from '../../types/models'
|
||
import { ProcessStatus } from '../../utils/config'
|
||
import { isProcessTerminal } from '../../utils/clothes-status'
|
||
import { removePendingClothes } from '../../utils/pending-clothes'
|
||
import { findDamoClothes } from '../../damo/damo'
|
||
import { navScroll } from '../../utils/nav'
|
||
import { logger } from '../../utils/logger'
|
||
|
||
// 日志模块名
|
||
const LOG = 'clothes-detail'
|
||
|
||
// 属性分组定义(对齐设计稿:基础属性 / 版型与材质 / 智能量化评估)
|
||
const ATTR_GROUPS = [
|
||
{
|
||
title: '基础属性',
|
||
attrs: [
|
||
{ key: 'category_main', label: '大类', type: 'text' },
|
||
{ key: 'category_sub', label: '小类', type: 'text' },
|
||
{ key: 'style', label: '风格', type: 'text' },
|
||
{ key: 'color_main', label: '主色', type: 'color' },
|
||
{ key: 'color_sub', label: '副色', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
title: '版型与材质',
|
||
attrs: [
|
||
{ key: 'thickness', label: '厚度', type: 'text' },
|
||
{ key: 'version', label: '版型', type: 'text' },
|
||
{ key: 'material', label: '材质', type: 'text' },
|
||
{ key: 'pattern', label: '花纹', type: 'text' },
|
||
{ key: 'sleeve_type', label: '袖型', type: 'text' },
|
||
{ key: 'clothes_type', label: '衣长', type: 'text' },
|
||
{ key: 'neckline_type', label: '领型', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
title: '智能量化评估',
|
||
attrs: [
|
||
{ key: 'formality', label: '正式程度', type: 'score' },
|
||
{ key: 'occasions', label: '适用场合', type: 'multi' },
|
||
{ key: 'visual_impact', label: '视觉冲击力', type: 'score' },
|
||
],
|
||
},
|
||
]
|
||
|
||
// 属性快捷选项(选择面板内点选,仍支持自定义输入)
|
||
const ATTR_OPTIONS: Record<string, string[]> = {
|
||
category_main: ['上装', '下装', '连衣裙', '外套', '鞋', '配饰'],
|
||
style: ['简约', '休闲', '通勤', '运动', '甜美', '复古'],
|
||
thickness: ['薄', '适中', '厚'],
|
||
version: ['宽松', '修身', '阔腿', '紧身', '常规合身'],
|
||
material: ['纯棉', '牛仔布', '针织', '皮质', '羊毛', '涤纶'],
|
||
pattern: ['纯色', '条纹', '格纹', '印花', '波点'],
|
||
sleeve_type: ['无袖', '短袖', '长袖'],
|
||
clothes_type: ['短款', '常规', '中长', '长款'],
|
||
neckline_type: ['圆领', 'V领', '方领', '翻领', '高领'],
|
||
occasions: ['休闲', '通勤', '约会', '运动', '聚会', '正式', '度假'],
|
||
}
|
||
|
||
// 主色色板(名称 → 色值),供面板点选
|
||
const COLOR_PRESETS = [
|
||
{ name: '白色', value: '#FFFFFF' },
|
||
{ name: '黑色', value: '#1C1C1E' },
|
||
{ name: '灰色', value: '#8E8E93' },
|
||
{ name: '红色', value: '#FF3B30' },
|
||
{ name: '粉色', value: '#FF2D55' },
|
||
{ name: '橙色', value: '#FF9500' },
|
||
{ name: '黄色', value: '#FFCC00' },
|
||
{ name: '绿色', value: '#34C759' },
|
||
{ name: '蓝色', value: '#007AFF' },
|
||
{ name: '紫色', value: '#5856D6' },
|
||
{ name: '棕色', value: '#A2845E' },
|
||
{ name: '米色', value: '#F5EFE0' },
|
||
]
|
||
|
||
interface AttrMeta {
|
||
key: string
|
||
label: string
|
||
type: string
|
||
}
|
||
|
||
// 多值字段(适用场合)按分隔符拆分为标签
|
||
function splitValues(v: string): string[] {
|
||
if (!v) return []
|
||
return v.split(/[、,,/|;;\s]+/).filter((s) => !!s)
|
||
}
|
||
|
||
function findAttr(key: string): AttrMeta | null {
|
||
for (const group of ATTR_GROUPS) {
|
||
const hit = (group.attrs as AttrMeta[]).find((a) => a.key === key)
|
||
if (hit) return hit
|
||
}
|
||
return null
|
||
}
|
||
|
||
// 将后端 image_process_status 映射为前端展示步骤:0=排队中 1=AI 抠图 2=智能分类
|
||
function statusToStep(status: number): number {
|
||
if (status === ProcessStatus.QUEUED) return 0
|
||
if (status === ProcessStatus.MATTING) return 1
|
||
if (status === ProcessStatus.CLASSIFYING) return 2
|
||
return 2
|
||
}
|
||
|
||
Page({
|
||
data: {
|
||
uuid: '',
|
||
// 只读查看的两个凭证:share_uuid(穿搭分享)/ outfit_uuid(社区公开穿搭),命中即隐藏编辑/删除
|
||
shareUUID: '',
|
||
outfitUUID: '',
|
||
readonly: false,
|
||
// 游客态示例:由本地 damo 素材渲染的只读详情,零请求
|
||
demo: false,
|
||
attrGroups: ATTR_GROUPS,
|
||
colorPresets: COLOR_PRESETS,
|
||
detail: {} as ClothesItem,
|
||
formData: {} as ClothesItem,
|
||
imageStatus: 0,
|
||
step: 0,
|
||
failReason: '',
|
||
loading: true,
|
||
editMode: false,
|
||
notFound: false,
|
||
// 底部属性选择面板
|
||
sheetVisible: false,
|
||
sheetKey: '',
|
||
sheetLabel: '',
|
||
sheetType: 'text',
|
||
sheetOptions: [] as string[],
|
||
sheetValue: '',
|
||
sheetValues: [] as string[],
|
||
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
|
||
navScrolled: false,
|
||
// 删除确认:v2 玻璃弹窗
|
||
delVisible: false,
|
||
delText: '',
|
||
delButtons: [
|
||
{ text: '取消', value: '' },
|
||
{ text: '确认删除', value: 'ok', type: 'main' },
|
||
],
|
||
},
|
||
|
||
// scroll-view 滚动 → 导航收缩为玻璃小标题
|
||
onNavScroll: navScroll,
|
||
|
||
// 抽屉内容点击不穿透到遮罩
|
||
noop() {},
|
||
|
||
onLoad(query: Record<string, string>) {
|
||
const demo = query.demo || ''
|
||
// 示例模式:游客从衣橱示例卡片进入,本地数据只读渲染,不发请求、不轮询
|
||
if (demo) {
|
||
const detail = findDamoClothes(demo)
|
||
logger.info(LOG, '示例模式加载, demo=', demo, '命中=', !!detail)
|
||
if (!detail) {
|
||
this.setData({ demo: true, readonly: true, loading: false, notFound: true })
|
||
return
|
||
}
|
||
this.setData({
|
||
demo: true,
|
||
readonly: true,
|
||
uuid: detail.clothes_uuid,
|
||
detail,
|
||
loading: false,
|
||
})
|
||
return
|
||
}
|
||
|
||
const uuid = query.uuid || ''
|
||
const shareUUID = query.share_uuid || ''
|
||
const outfitUUID = query.outfit_uuid || ''
|
||
const readonly = !!(shareUUID || outfitUUID)
|
||
logger.debug(LOG, 'onLoad, uuid=', uuid, 'readonly=', readonly)
|
||
this.setData({ uuid, shareUUID, outfitUUID, readonly })
|
||
if (!uuid) {
|
||
// uuid 缺失时不请求不轮询,否则会停在空白页且无法退出
|
||
logger.warn(LOG, 'onLoad 缺少 uuid 参数')
|
||
this.setData({ loading: false, notFound: true })
|
||
return
|
||
}
|
||
this.fetchDetail().then(() => {
|
||
// 只读(来自穿搭分享 / 社区公开穿搭)场景无需轮询 AI 处理状态
|
||
if (readonly) return
|
||
// 详情响应已带处理状态:成功/失败等终态直接定态,仅处理中才轮询
|
||
if (isProcessTerminal(this.data.detail)) return
|
||
this.pollStatus()
|
||
})
|
||
},
|
||
|
||
onBackWardrobe() {
|
||
wx.switchTab({
|
||
url: '/pages/wardrobe/wardrobe',
|
||
fail: (e) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e && e.errMsg),
|
||
})
|
||
},
|
||
|
||
fetchDetail(): Promise<void> {
|
||
// 只读场景需携带放行凭证:穿搭分享用 share_uuid,社区公开穿搭用 outfit_uuid
|
||
let url = `/clothes/${this.data.uuid}`
|
||
if (this.data.shareUUID) url += `?share_uuid=${this.data.shareUUID}`
|
||
else if (this.data.outfitUUID) url += `?outfit_uuid=${this.data.outfitUUID}`
|
||
return request<ClothesItem>({ url })
|
||
.then((detail) => {
|
||
logger.debug(LOG, '详情加载成功, name=', detail && detail.name)
|
||
const patch: Record<string, any> = { detail, loading: false }
|
||
const status = Number(detail && detail.image_process_status)
|
||
if (!this.data.readonly && status) {
|
||
// 详情响应带处理状态:进入页面即渲染正确视图,避免先闪正常详情再切回处理中
|
||
patch.imageStatus = status
|
||
patch.step = statusToStep(status)
|
||
if (status === ProcessStatus.FAILED) {
|
||
patch.failReason = (detail && detail.fail_reason) || '识别失败'
|
||
}
|
||
// 终态即可清掉待核对记录(幂等,未登记时为空操作)
|
||
if (isProcessTerminal(detail)) removePendingClothes(this.data.uuid)
|
||
}
|
||
this.setData(patch)
|
||
})
|
||
.catch((e: any) => {
|
||
logger.warn(LOG, '详情加载失败, code=', e && e.code)
|
||
this.setData({ loading: false })
|
||
})
|
||
},
|
||
|
||
pollStatus() {
|
||
// 先清旧定时器,避免重复进入时叠加成多份轮询
|
||
this.clearTimer()
|
||
let count = 0
|
||
;(this as any).pollTimer = setInterval(() => {
|
||
count++
|
||
request<{ image_process_status: number; fail_reason?: string }>({
|
||
url: `/clothes/${this.data.uuid}/status`,
|
||
showError: false,
|
||
silentAuth: true,
|
||
})
|
||
.then((res) => {
|
||
const status = res.image_process_status
|
||
if (status === ProcessStatus.SUCCESS) {
|
||
// 成功:先拉最新详情,再切换为详情视图,避免空属性闪烁
|
||
this.clearTimer()
|
||
removePendingClothes(this.data.uuid)
|
||
logger.info(LOG, 'AI 处理完成')
|
||
this.fetchDetail().then(() => this.setData({ imageStatus: status, step: 2 }))
|
||
} else if (status === ProcessStatus.FAILED) {
|
||
this.clearTimer()
|
||
removePendingClothes(this.data.uuid)
|
||
logger.warn(LOG, 'AI 识别失败, 原因=', res.fail_reason)
|
||
this.setData({ imageStatus: status, failReason: res.fail_reason || '识别失败' })
|
||
} else {
|
||
this.setData({ imageStatus: status, step: statusToStep(status) })
|
||
}
|
||
})
|
||
.catch((e: any) => logger.warn(LOG, '状态轮询失败, code=', e && e.code))
|
||
if (count >= 120) {
|
||
// 只停轮询,保持处理中视图(后端有恢复调度器兜底),不误报失败
|
||
this.clearTimer()
|
||
logger.warn(LOG, '状态轮询超时(240s),保持处理中视图并停止轮询')
|
||
}
|
||
}, 2000)
|
||
},
|
||
|
||
clearTimer() {
|
||
const t = (this as any).pollTimer
|
||
if (t) {
|
||
clearInterval(t)
|
||
;(this as any).pollTimer = null
|
||
}
|
||
},
|
||
|
||
onUnload() {
|
||
this.clearTimer()
|
||
},
|
||
|
||
onToggleEdit() {
|
||
if (this.data.editMode) {
|
||
this.setData({ editMode: false, sheetVisible: false })
|
||
} else {
|
||
this.setData({ editMode: true, formData: { ...this.data.detail } })
|
||
}
|
||
},
|
||
|
||
onNameInput(e: WechatMiniprogram.Input) {
|
||
this.setData({ 'formData.name': e.detail.value })
|
||
},
|
||
|
||
onRemarkInput(e: WechatMiniprogram.Input) {
|
||
this.setData({ 'formData.remark': e.detail.value })
|
||
},
|
||
|
||
// 点击属性行:打开底部选择面板
|
||
onAttrTap(e: WechatMiniprogram.TouchEvent) {
|
||
if (!this.data.editMode) return
|
||
const key = e.currentTarget.dataset.key as string
|
||
const attr = findAttr(key)
|
||
if (!attr) return
|
||
const current = (((this.data.formData as any)[key] || '') + '') as string
|
||
logger.debug(LOG, '打开属性面板, key=', key, 'type=', attr.type)
|
||
this.setData({
|
||
sheetVisible: true,
|
||
sheetKey: key,
|
||
sheetLabel: attr.label,
|
||
sheetType: attr.type,
|
||
sheetOptions: ATTR_OPTIONS[key] || [],
|
||
sheetValue: attr.type === 'multi' ? '' : current,
|
||
sheetValues: attr.type === 'multi' ? splitValues(current) : [],
|
||
})
|
||
},
|
||
|
||
onSheetClose() {
|
||
this.setData({ sheetVisible: false })
|
||
},
|
||
|
||
onSheetInput(e: WechatMiniprogram.Input) {
|
||
this.setData({ sheetValue: e.detail.value })
|
||
},
|
||
|
||
// 单选快捷选项
|
||
onSheetOptionTap(e: WechatMiniprogram.TouchEvent) {
|
||
this.setData({ sheetValue: e.currentTarget.dataset.value as string })
|
||
},
|
||
|
||
// 多选快捷选项(适用场合)
|
||
onSheetValueTap(e: WechatMiniprogram.TouchEvent) {
|
||
const value = e.currentTarget.dataset.value as string
|
||
const list = this.data.sheetValues.slice()
|
||
const idx = list.indexOf(value)
|
||
if (idx >= 0) list.splice(idx, 1)
|
||
else list.push(value)
|
||
this.setData({ sheetValues: list })
|
||
},
|
||
|
||
// 色板点选
|
||
onSheetColorTap(e: WechatMiniprogram.TouchEvent) {
|
||
this.setData({ sheetValue: e.currentTarget.dataset.name as string })
|
||
},
|
||
|
||
// 评分点选
|
||
onSheetScoreTap(e: WechatMiniprogram.TouchEvent) {
|
||
this.setData({ sheetValue: String(e.currentTarget.dataset.value) })
|
||
},
|
||
|
||
onSheetConfirm() {
|
||
const { sheetKey, sheetType, sheetValue, sheetValues } = this.data
|
||
if (!sheetKey) return
|
||
const value = sheetType === 'multi' ? sheetValues.join('、') : (sheetValue || '').trim()
|
||
logger.info(LOG, '属性更新, key=', sheetKey, 'value=', value)
|
||
this.setData({ [`formData.${sheetKey}`]: value, sheetVisible: false })
|
||
},
|
||
|
||
onSave() {
|
||
const fd = this.data.formData as any
|
||
// 后端 ClothesUpdateByUUIDRequest.ClothesUUID 为必填(binding required),请求体需携带
|
||
const body: Record<string, any> = { clothes_uuid: this.data.uuid }
|
||
const fields: Array<[string, string]> = [
|
||
['name', 'name'],
|
||
['remark', 'remark'],
|
||
['category_main', 'category_main'],
|
||
['category_sub', 'category_sub'],
|
||
['style', 'style'],
|
||
['color_main', 'color'], // 主色请求字段名是 color
|
||
['color_sub', 'color_sub'],
|
||
['thickness', 'thickness'],
|
||
['version', 'version'],
|
||
['material', 'material'],
|
||
['pattern', 'pattern'],
|
||
['sleeve_type', 'sleeve_type'],
|
||
['clothes_type', 'clothes_type'],
|
||
['neckline_type', 'neckline_type'],
|
||
['formality', 'formality'],
|
||
['occasions', 'occasions'],
|
||
['visual_impact', 'visual_impact'],
|
||
]
|
||
fields.forEach(([src, dest]) => {
|
||
if (fd[src]) body[dest] = fd[src]
|
||
})
|
||
|
||
request({ url: `/clothes/${this.data.uuid}`, method: 'PUT', data: body })
|
||
.then(() => {
|
||
logger.info(LOG, '衣物信息保存成功')
|
||
wx.showToast({ title: '已保存', icon: 'success' })
|
||
this.setData({ editMode: false })
|
||
this.fetchDetail()
|
||
})
|
||
.catch((e: any) => logger.warn(LOG, '衣物信息保存失败, code=', e && e.code))
|
||
},
|
||
|
||
// 删除入口:打开 v2 玻璃确认弹窗
|
||
onDelete() {
|
||
const canRefund = this.data.imageStatus !== ProcessStatus.FAILED
|
||
this.setData({
|
||
delVisible: true,
|
||
delText: canRefund ? '删除后将退回 1 次上传额度,确认删除?' : '确认删除?',
|
||
})
|
||
},
|
||
|
||
onDelCancel() {
|
||
this.setData({ delVisible: false })
|
||
},
|
||
|
||
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
|
||
this.setData({ delVisible: false })
|
||
if (e.detail.value !== 'ok') return
|
||
request({ url: `/clothes/${this.data.uuid}`, method: 'DELETE' })
|
||
.then(() => {
|
||
logger.info(LOG, '衣物删除成功')
|
||
wx.showToast({ title: '已删除', icon: 'success' })
|
||
setTimeout(
|
||
() =>
|
||
wx.navigateBack({
|
||
fail: (e2) => logger.warn(LOG, 'navigateBack 失败, err=', e2 && e2.errMsg),
|
||
}),
|
||
500,
|
||
)
|
||
})
|
||
.catch((e2: any) => logger.warn(LOG, '衣物删除失败, code=', e2 && e2.code))
|
||
},
|
||
})
|