first commit

This commit is contained in:
2026-09-11 01:45:04 +08:00
commit 0a8d60d87f
145 changed files with 40978 additions and 0 deletions
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
@@ -0,0 +1,199 @@
import { request } from '../../utils/request'
import { ClothesItem } from '../../types/models'
import { ProcessStatus } from '../../utils/config'
const 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' },
{ 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' },
{ key: 'formality', label: '正式程度', type: 'score' },
{ key: 'occasions', label: '适用场合', type: 'text' },
{ key: 'visual_impact', label: '视觉冲击力', type: 'score' },
]
// 将后端 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: '',
attrs: ATTRS,
detail: {} as ClothesItem,
formData: {} as ClothesItem,
imageStatus: 0,
step: 0,
failReason: '',
loading: true,
editMode: false,
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
this.setData({ uuid })
if (uuid) {
this.fetchDetail()
this.pollStatus()
}
},
fetchDetail(): Promise<void> {
return request<ClothesItem>({ url: `/clothes/${this.data.uuid}` })
.then((detail) => this.setData({ detail, loading: false }))
.catch(() => this.setData({ loading: false }))
},
pollStatus() {
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,
})
.then((res) => {
const status = res.image_process_status
if (status === ProcessStatus.SUCCESS) {
// 成功:先拉最新详情,再切换为详情视图,避免空属性闪烁
this.clearTimer()
this.fetchDetail().then(() => this.setData({ imageStatus: status, step: 2 }))
} else if (status === ProcessStatus.FAILED) {
this.clearTimer()
this.setData({ imageStatus: status, failReason: res.fail_reason || '识别失败' })
} else {
this.setData({ imageStatus: status, step: statusToStep(status) })
}
})
.catch(() => {})
if (count >= 120) {
this.clearTimer()
}
}, 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 })
} 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 = ATTRS.find((a) => a.key === key)
if (!attr) return
const current = (this.data.formData as any)[key] || ''
if (attr.type === 'score') {
wx.showActionSheet({
itemList: ['1', '2', '3', '4', '5'],
success: (res) => {
this.setData({ [`formData.${key}`]: String(res.tapIndex + 1) })
},
})
return
}
wx.showModal({
title: `编辑${attr.label}`,
editable: true,
placeholderText: `请输入${attr.label}`,
content: current,
success: (res) => {
if (res.confirm) {
this.setData({ [`formData.${key}`]: res.content })
}
},
})
},
onSave() {
const fd = this.data.formData as any
const body: Record<string, any> = {}
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(() => {
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({ editMode: false })
this.fetchDetail()
})
.catch(() => {})
},
onDelete() {
const canRefund = this.data.imageStatus !== ProcessStatus.FAILED
const content = canRefund ? '删除后将退回 1 次上传额度,确认删除?' : '确认删除?'
wx.showModal({
title: '删除这件衣物',
content,
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/clothes/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
})
},
})
@@ -0,0 +1,132 @@
<view class="page detail-page">
<navigation-bar title="衣物详情">
<view
wx:if="{{imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
slot="right"
class="nav-action"
bindtap="onToggleEdit"
>{{editMode ? '取消' : '编辑'}}</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<!-- 处理中:显示步骤进度 -->
<block wx:if="{{imageStatus === 1 || imageStatus === 2 || imageStatus === 3}}">
<view class="card processing-card">
<view class="proc-icon"><view class="icon icon-hanger"></view></view>
<view class="steps">
<view class="step">
<view class="step-dot {{step >= 0 ? 'active' : ''}}"></view>
<text class="step-text {{step >= 0 ? 'active-text' : ''}}">排队中</text>
</view>
<view class="step-line {{step >= 1 ? 'active' : ''}}"></view>
<view class="step">
<view class="step-dot {{step >= 1 ? 'active' : ''}}"></view>
<text class="step-text {{step >= 1 ? 'active-text' : ''}}">AI 抠图</text>
</view>
<view class="step-line {{step >= 2 ? 'active' : ''}}"></view>
<view class="step">
<view class="step-dot {{step >= 2 ? 'active' : ''}}"></view>
<text class="step-text {{step >= 2 ? 'active-text' : ''}}">智能分类</text>
</view>
</view>
<view class="proc-text">AI 正在识别你的衣物…</view>
<view class="proc-desc">通常需要 10 秒左右,请稍候</view>
</view>
</block>
<!-- 识别失败 -->
<block wx:elif="{{imageStatus === 5}}">
<view class="img-box failed-box">
<view class="fail-big-icon">!</view>
</view>
<view class="card name-card">
<view class="name-text">{{detail.name || '未命名衣物'}}</view>
</view>
<view class="card fail-card">
<view class="fail-title">识别失败:{{failReason || '未知原因'}}</view>
<view class="fail-desc">本次上传额度已自动退回,你可以删除或重新上传</view>
</view>
</block>
<!-- 正常详情 -->
<block wx:else>
<!-- 大图区 -->
<view class="img-box">
<image
wx:if="{{detail.processed_image}}"
class="img"
src="{{detail.processed_image}}"
mode="aspectFit"
/>
<view wx:else class="img-placeholder"><view class="icon icon-hanger"></view></view>
</view>
<!-- 名称 -->
<view class="card name-card">
<input
wx:if="{{editMode}}"
class="input"
value="{{formData.name}}"
bindinput="onNameInput"
placeholder="衣物名称"
/>
<view wx:else class="name-text">{{detail.name || '未命名衣物'}}</view>
</view>
<!-- 属性列表 -->
<view class="card attr-card">
<view
class="attr-row hairline"
wx:for="{{attrs}}"
wx:key="key"
data-key="{{item.key}}"
bindtap="onAttrTap"
>
<view class="attr-label">{{item.label}}</view>
<view class="attr-value">
<block wx:if="{{item.type === 'score'}}">
<view class="score-bar">
<view class="score-fill" style="width: {{(detail[item.key] || 0) * 20}}%"></view>
</view>
<text class="score-num">{{detail[item.key] || 0}}/5</text>
</block>
<block wx:else>
<text class="attr-text">{{detail[item.key] || '—'}}</text>
<view
wx:if="{{item.key === 'color_main' && detail.color_main}}"
class="color-dot"
style="background: {{detail.color_main}}"
></view>
</block>
</view>
<view wx:if="{{editMode}}" class="icon icon-chevron-right edit-arrow"></view>
</view>
<!-- 备注 -->
<view class="remark-row">
<textarea
wx:if="{{editMode}}"
class="textarea"
value="{{formData.remark}}"
bindinput="onRemarkInput"
placeholder="备注"
/>
<view wx:elif="{{detail.remark}}" class="remark-text">备注:{{detail.remark}}</view>
</view>
</view>
</block>
<!-- 底部操作:处理中隐藏,其他显示 -->
<view
wx:if="{{imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
class="footer"
>
<block wx:if="{{editMode}}">
<view class="btn btn-primary" bindtap="onSave">保存</view>
</block>
<block wx:else>
<view class="btn-danger-text delete-btn" bindtap="onDelete">删除这件衣物</view>
</block>
</view>
</scroll-view>
</view>
@@ -0,0 +1,257 @@
.detail-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.nav-action {
font-size: 30rpx;
color: #007AFF;
padding: 0 8rpx;
}
.img-box {
margin: 24rpx;
height: 640rpx;
background: #fff;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.img {
width: 100%;
height: 100%;
}
.img-placeholder {
color: #C7C7CC;
}
.img-placeholder .icon {
width: 120rpx;
height: 120rpx;
}
.name-card {
margin: 0 24rpx 24rpx;
}
.name-text {
font-size: 40rpx;
font-weight: 700;
color: #000;
}
.attr-card {
margin: 0 24rpx 24rpx;
padding: 0 32rpx;
}
.attr-row {
display: flex;
align-items: center;
min-height: 88rpx;
}
.attr-label {
width: 200rpx;
font-size: 28rpx;
color: #000;
flex-shrink: 0;
}
.attr-value {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
}
.attr-text {
font-size: 28rpx;
color: #3C3C43;
}
.color-dot {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
margin-left: 16rpx;
border: 1rpx solid #E5E5EA;
}
.score-bar {
width: 160rpx;
height: 8rpx;
border-radius: 4rpx;
background: #F2F2F7;
overflow: hidden;
}
.score-fill {
height: 100%;
background: #007AFF;
border-radius: 4rpx;
}
.score-num {
font-size: 26rpx;
color: #8E8E93;
margin-left: 16rpx;
min-width: 60rpx;
text-align: right;
}
.edit-arrow {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
margin-left: 12rpx;
}
.remark-row {
padding: 24rpx 0;
}
.remark-text {
font-size: 26rpx;
color: #8E8E93;
line-height: 1.6;
}
.footer {
padding: 16rpx 24rpx 48rpx;
}
.delete-btn {
text-align: center;
padding: 24rpx 0;
}
/* 处理中 */
.processing-card {
margin: 24rpx;
padding: 64rpx 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.proc-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: #F2F2F7;
color: #C7C7CC;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
}
.proc-icon .icon {
width: 96rpx;
height: 96rpx;
}
.steps {
display: flex;
align-items: center;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
font-size: 22rpx;
color: #C7C7CC;
}
.step-text {
font-size: 22rpx;
color: #C7C7CC;
}
.step-text.active-text {
color: #007AFF;
}
.step-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #E5E5EA;
margin-bottom: 12rpx;
}
.step-dot.active {
background: #007AFF;
}
.step-line {
width: 100rpx;
height: 2rpx;
background: #E5E5EA;
margin: 0 16rpx 34rpx;
}
.step-line.active {
background: #007AFF;
}
.proc-text {
margin-top: 48rpx;
font-size: 30rpx;
font-weight: 500;
color: #000;
}
.proc-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
}
/* 识别失败 */
.failed-box {
background: #FFF3F3;
}
.fail-big-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #FF3B30;
color: #fff;
font-size: 72rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.fail-card {
margin: 0 24rpx 24rpx;
padding: 24rpx 32rpx;
}
.fail-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
}
.fail-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
line-height: 1.5;
}