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 = { 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) { 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 { // 只读场景需携带放行凭证:穿搭分享用 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({ url }) .then((detail) => { logger.debug(LOG, '详情加载成功, name=', detail && detail.name) const patch: Record = { 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 = { 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)) }, })