Files
zt-closet-wxmini-frontend/miniprogram/pages/clothes-detail/clothes-detail.ts
T

394 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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,隐藏编辑/删除
shareUUID: '',
readonly: 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 uuid = query.uuid || ''
const shareUUID = query.share_uuid || ''
const readonly = !!shareUUID
logger.debug(LOG, 'onLoad, uuid=', uuid, 'readonly=', readonly)
this.setData({ uuid, shareUUID, 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,后端据此放行非属主读取衣物详情
const url = this.data.readonly
? `/clothes/${this.data.uuid}?share_uuid=${this.data.shareUUID}`
: `/clothes/${this.data.uuid}`
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))
},
})