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,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
@@ -0,0 +1,126 @@
import { request } from '../../utils/request'
import { AiRecommendResult, ClothesItem } from '../../types/models'
Page({
data: {
state: 'scene' as 'scene' | 'generating' | 'result' | 'empty',
showSceneSheet: true,
sceneType: 1,
scene: '',
result: null as AiRecommendResult | null,
clothes: [] as ClothesItem[],
generating: false,
},
noop() {},
selectDaily() {
this.setData({ sceneType: 1 })
},
selectCustom() {
this.setData({ sceneType: 2 })
},
onSceneInput(e: WechatMiniprogram.Input) {
this.setData({ scene: e.detail.value })
},
onSkipScene() {
this.setData({ showSceneSheet: false })
this.startGenerate(1, '')
},
onStartGenerate() {
this.setData({ showSceneSheet: false })
this.startGenerate(this.data.sceneType, this.data.scene)
},
startGenerate(sceneType: number, scene: string) {
if (this.data.generating) return
wx.showModal({
title: 'AI 穿搭推荐',
content: '本次将消耗 1 次 AI 推荐额度,生成后可保存到我的穿搭',
confirmText: '开始生成',
cancelText: '取消',
success: (res) => {
if (res.confirm) this.doGenerate(sceneType, scene)
},
})
},
doGenerate(sceneType: number, scene: string) {
if (this.data.generating) return
this.setData({ state: 'generating', generating: true })
const body: Record<string, any> = { scene_type: sceneType }
if (sceneType === 2 && scene) body.scene = scene
request<AiRecommendResult>({ url: '/ai-recommend', method: 'POST', data: body, showError: false })
.then(async (res) => {
const clothes = await Promise.all(
res.outfit_item.map(async (it) => {
try {
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
} catch (e) {
return { clothes_uuid: it.clothes_uuid } as ClothesItem
}
})
)
this.setData({ result: res, clothes, state: 'result', generating: false })
})
.catch((e: any) => {
this.setData({ generating: false })
const code = e && e.code
if (code === 90400) {
this.setData({ state: 'empty' })
} else if (code === 90401) {
this.setData({ state: 'scene', showSceneSheet: true })
this.showLimitModal()
} else if (code === 10003) {
wx.showToast({ title: '正在生成中,请稍候', icon: 'none' })
this.setData({ state: 'scene', showSceneSheet: true })
} else {
this.setData({ state: 'scene', showSceneSheet: true })
}
})
},
showLimitModal() {
wx.showModal({
title: 'AI 推荐额度不足',
content: '购买额度包后可继续生成 AI 穿搭推荐',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/mall/mall' })
},
})
},
onSave() {
if (!this.data.result) return
request({
url: '/ai-recommend/save',
method: 'POST',
data: { outfit_uuid: this.data.result.outfit_uuid },
})
.then(() => {
wx.showToast({ title: '已保存到我的穿搭', icon: 'success' })
setTimeout(() => {
wx.redirectTo({
url: `/pages/outfit-detail/outfit-detail?uuid=${this.data.result!.outfit_uuid}&owner=1`,
})
}, 500)
})
.catch(() => {})
},
onReroll() {
this.startGenerate(this.data.sceneType, this.data.scene)
},
onGoUpload() {
wx.navigateTo({ url: '/pages/upload/upload' })
},
})
@@ -0,0 +1,90 @@
<view class="page rec-page">
<navigation-bar title="AI 穿搭推荐" />
<!-- 生成中 -->
<view wx:if="{{state === 'generating'}}" class="generating">
<view class="gen-ani">
<view class="icon icon-shirt gen-icon"></view>
<view class="gen-ring"></view>
</view>
<view class="gen-title">AI 正在为你搭配…</view>
<view class="gen-desc">结合今日运势与你的衣橱</view>
</view>
<!-- 衣橱为空 -->
<empty-state
wx:elif="{{state === 'empty'}}"
icon="icon-hanger"
title="还没有可用衣物"
desc="先上传几件处理成功的衣物吧"
buttonText="去上传"
bind:action="onGoUpload"
/>
<!-- 结果页 -->
<scroll-view wx:elif="{{state === 'result'}}" scroll-y class="scroll-body">
<view class="card result-cover">
<view class="rc-item rc-{{index}}" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 5}}">
<image wx:if="{{item.processed_image}}" class="rc-img" src="{{item.processed_image}}" mode="aspectFit" />
<view wx:else class="icon icon-hanger rc-placeholder"></view>
</view>
</view>
<view class="card result-info">
<view class="ri-title">{{result.outfit_name}}</view>
<view class="ri-desc">{{result.outfit_desc}}</view>
<view class="tag-row">
<text wx:if="{{result.scene}}" class="tag tag-gray">{{result.scene}}</text>
<text class="tag tag-indigo">AI 推荐</text>
</view>
<view class="items-title">推荐单品</view>
<scroll-view scroll-x class="items-scroll" show-scrollbar="{{false}}">
<view class="items-row">
<view class="item-chip" wx:for="{{clothes}}" wx:key="clothes_uuid">
<image wx:if="{{item.processed_image}}" class="item-img" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="item-img item-img-empty"><view class="icon icon-hanger"></view></view>
<text class="item-name">{{item.name || '未命名'}}</text>
</view>
</view>
</scroll-view>
</view>
<view class="footer">
<view class="btn btn-primary" bindtap="onSave">保存到我的穿搭</view>
<view class="btn btn-outline reroll" bindtap="onReroll">换一套</view>
<view class="reroll-tip">换一套将再消耗 1 次推荐额度</view>
</view>
</scroll-view>
<!-- 场景选择弹窗 -->
<view wx:if="{{showSceneSheet}}" class="mask" bindtap="onSkipScene">
<view class="sheet" catchtap="noop">
<view class="sheet-grabber"></view>
<view class="sheet-title">选择推荐场景</view>
<view class="scene-card {{sceneType === 1 ? 'active' : ''}}" bindtap="selectDaily">
<view class="scene-name">每日推荐</view>
<view class="scene-desc">按今日运势与衣橱自动搭配</view>
<view wx:if="{{sceneType === 1}}" class="icon icon-check scene-check"></view>
</view>
<view class="scene-card {{sceneType === 2 ? 'active' : ''}}" bindtap="selectCustom">
<view class="scene-name">自定义场景</view>
<view class="scene-desc">描述你想去的场合</view>
<view wx:if="{{sceneType === 2}}" class="icon icon-check scene-check"></view>
</view>
<input
wx:if="{{sceneType === 2}}"
class="input scene-input"
value="{{scene}}"
bindinput="onSceneInput"
placeholder="例如:海边度假、周一例会、初次约会"
/>
<view class="btn btn-primary sheet-btn" bindtap="onStartGenerate">开始生成(消耗 1 次推荐额度)</view>
<view class="sheet-skip" bindtap="onSkipScene">跳过,使用每日推荐</view>
</view>
</view>
</view>
@@ -0,0 +1,281 @@
.rec-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
/* 生成中 */
.generating {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
}
.gen-ani {
position: relative;
width: 160rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
}
.gen-icon {
width: 72rpx;
height: 72rpx;
color: #007AFF;
}
.gen-ring {
position: absolute;
width: 160rpx;
height: 160rpx;
border-radius: 50%;
border: 4rpx solid #E5E5EA;
border-top-color: #007AFF;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.gen-title {
margin-top: 48rpx;
font-size: 32rpx;
font-weight: 600;
color: #000;
}
.gen-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
}
/* 结果封面 */
.result-cover {
margin: 24rpx;
height: 420rpx;
position: relative;
overflow: hidden;
}
.rc-item {
position: absolute;
width: 180rpx;
height: 240rpx;
background: #F8F8FA;
border-radius: 20rpx;
border: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.rc-0 { left: 30rpx; top: 70rpx; transform: rotate(-5deg); }
.rc-1 { left: 130rpx; top: 130rpx; transform: rotate(3deg); }
.rc-2 { left: 240rpx; top: 60rpx; transform: rotate(6deg); }
.rc-3 { left: 350rpx; top: 140rpx; transform: rotate(-3deg); }
.rc-4 { left: 460rpx; top: 80rpx; transform: rotate(8deg); }
.rc-img {
width: 100%;
height: 100%;
}
.rc-placeholder {
width: 64rpx;
height: 64rpx;
color: #C7C7CC;
}
/* 结果信息 */
.result-info {
margin: 0 24rpx 24rpx;
}
.ri-title {
font-size: 36rpx;
font-weight: 700;
color: #000;
}
.ri-desc {
font-size: 28rpx;
color: #3C3C43;
margin-top: 16rpx;
line-height: 1.6;
}
.tag-row {
display: flex;
gap: 12rpx;
margin-top: 20rpx;
}
.items-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
margin-top: 32rpx;
}
.items-scroll {
margin-top: 20rpx;
white-space: nowrap;
}
.items-row {
display: inline-flex;
}
.item-chip {
display: flex;
flex-direction: column;
align-items: center;
width: 140rpx;
margin-right: 20rpx;
}
.item-img {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background: #F2F2F7;
}
.item-img-empty {
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.item-img-empty .icon {
width: 56rpx;
height: 56rpx;
}
.item-name {
font-size: 20rpx;
color: #8E8E93;
margin-top: 8rpx;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer {
padding: 16rpx 24rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.reroll {
margin-top: 20rpx;
}
.reroll-tip {
text-align: center;
font-size: 22rpx;
color: #8E8E93;
margin-top: 16rpx;
}
/* 场景弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 20rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.sheet-grabber {
width: 72rpx;
height: 10rpx;
border-radius: 5rpx;
background: #E5E5EA;
margin: 0 auto 24rpx;
}
.sheet-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
text-align: center;
margin-bottom: 24rpx;
}
.scene-card {
position: relative;
border: 2rpx solid #E5E5EA;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 20rpx;
}
.scene-card.active {
border-color: #007AFF;
background: rgba(0, 122, 255, 0.04);
}
.scene-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.scene-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.scene-check {
position: absolute;
top: 28rpx;
right: 28rpx;
width: 36rpx;
height: 36rpx;
color: #007AFF;
}
.scene-input {
margin-bottom: 20rpx;
}
.sheet-btn {
width: 100%;
}
.sheet-skip {
text-align: center;
font-size: 26rpx;
color: #8E8E93;
margin-top: 20rpx;
padding: 12rpx;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card"
}
}
@@ -0,0 +1,148 @@
import { request } from '../../utils/request'
import { ClothesItem, PagedResult, UserInfo } from '../../types/models'
import { STORAGE_KEYS } from '../../utils/config'
const CATEGORIES = [
{ name: '全部', value: '' },
{ name: '上装', value: '上装' },
{ name: '下装', value: '下装' },
{ name: '连衣裙', value: '连衣裙' },
{ name: '外套', value: '外套' },
{ name: '鞋', value: '鞋' },
{ name: '配饰', value: '配饰' },
]
const EXPIRE_OPTIONS = [
{ label: '1 天', days: 1 },
{ label: '7 天', days: 7 },
{ label: '30 天', days: 30 },
{ label: '长期有效', days: 0 },
]
Page({
data: {
view: 'config' as 'config' | 'select' | 'success',
shareType: 1,
expireDays: 7,
expireOptions: EXPIRE_OPTIONS,
selectedUuids: [] as string[],
categories: CATEGORIES,
activeCategory: 0,
allClothes: [] as ClothesItem[],
selectLoading: true,
shareUuid: '',
nickname: '微信用户',
avatar: '',
selectedCount: 0,
},
onLoad() {
this.loadUserInfo()
},
loadUserInfo() {
const cached = wx.getStorageSync(STORAGE_KEYS.USER_INFO) as Record<string, any>
if (cached) {
this.setData({ nickname: cached.username || '微信用户', avatar: cached.avatar || '' })
}
// 头像为带时间戳的签名 URL,缓存可能过期,实时刷新并回写缓存。
request<UserInfo>({ url: '/user/info', showError: false })
.then((user) => {
wx.setStorageSync(STORAGE_KEYS.USER_INFO, user)
this.setData({ nickname: user.username || '微信用户', avatar: user.avatar || '' })
})
.catch(() => {})
},
selectPartial() {
this.setData({ shareType: 1 })
},
selectFull() {
this.setData({ shareType: 2 })
},
selectExpire(e: WechatMiniprogram.TouchEvent) {
const days = Number(e.currentTarget.dataset.days)
this.setData({ expireDays: days })
},
openSelect() {
this.setData({ view: 'select', selectLoading: true })
this.fetchAllClothes()
},
fetchAllClothes() {
const cat = CATEGORIES[this.data.activeCategory].value
const params: Record<string, any> = { page: 1, page_size: 50 }
if (cat) params.category_main = cat
request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
...it,
_selected: this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0,
}))
this.setData({ allClothes: list, selectLoading: false })
})
.catch(() => this.setData({ selectLoading: false }))
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeCategory) return
this.setData({ activeCategory: index, selectLoading: true })
this.fetchAllClothes()
},
onToggleSelect(e: WechatMiniprogram.CustomEvent) {
const item = e.detail.item as ClothesItem
const uuids = this.data.selectedUuids
const idx = uuids.indexOf(item.clothes_uuid)
if (idx >= 0) {
this.syncSelect(uuids.filter((u) => u !== item.clothes_uuid))
} else {
this.syncSelect([...uuids, item.clothes_uuid])
}
},
syncSelect(uuids: string[]) {
this.setData({
selectedUuids: uuids,
allClothes: this.data.allClothes.map((it) => ({
...it,
_selected: uuids.indexOf(it.clothes_uuid) >= 0,
})),
})
},
confirmSelect() {
this.setData({ view: 'config', selectedCount: this.data.selectedUuids.length })
},
backToConfig() {
this.setData({ view: 'config' })
},
onConfirm() {
const body: Record<string, any> = { share_type: this.data.shareType }
if (this.data.shareType === 1) {
if (!this.data.selectedUuids.length) {
wx.showToast({ title: '请选择要分享的衣物', icon: 'none' })
return
}
body.clothes_uuids = this.data.selectedUuids
}
const days = this.data.expireDays
body.expire_time = days > 0 ? Math.floor(Date.now() / 1000) + days * 86400 : 0
request<{ share_uuid: string }>({ url: '/closet-share', method: 'POST', data: body })
.then((res) => this.setData({ shareUuid: res.share_uuid, view: 'success' }))
.catch(() => {})
},
onViewShare() {
wx.redirectTo({ url: `/pages/closet-share-detail/closet-share-detail?uuid=${this.data.shareUuid}` })
},
onDone() {
wx.navigateBack()
},
})
@@ -0,0 +1,102 @@
<view class="page create-page">
<navigation-bar title="创建分享" />
<!-- 配置视图 -->
<scroll-view wx:if="{{view === 'config'}}" scroll-y class="scroll-body">
<view class="section-title">分享方式</view>
<view class="type-card card {{shareType === 1 ? 'active' : ''}}" bindtap="selectPartial">
<view class="type-name">部分衣物</view>
<view class="type-desc">挑选几件衣物分享给好友</view>
<view wx:if="{{shareType === 1}}" class="icon icon-check type-check"></view>
</view>
<view class="type-card card {{shareType === 2 ? 'active' : ''}}" bindtap="selectFull">
<view class="type-name">全量快照</view>
<view class="type-desc">分享当前衣橱的全部衣物</view>
<view wx:if="{{shareType === 2}}" class="icon icon-check type-check"></view>
</view>
<!-- 选择衣物入口(部分衣物) -->
<view wx:if="{{shareType === 1}}" class="card select-entry" bindtap="openSelect">
<view class="entry-left">
<view class="entry-icon"><view class="icon icon-shirt"></view></view>
<view class="entry-title">选择衣物</view>
</view>
<view class="entry-right">
<text class="entry-count {{selectedCount ? 'on' : ''}}">{{selectedCount ? '已选 ' + selectedCount + ' 件' : '未选择'}}</text>
<view class="icon icon-chevron-right"></view>
</view>
</view>
<view class="section-title">有效期</view>
<view class="card expire-card">
<view
class="expire-item hairline"
wx:for="{{expireOptions}}"
wx:key="days"
data-days="{{item.days}}"
bindtap="selectExpire"
>
<text>{{item.label}}</text>
<view wx:if="{{expireDays === item.days}}" class="icon icon-check expire-check"></view>
</view>
</view>
<view class="footer">
<view class="btn btn-primary" bindtap="onConfirm">确认分享(消耗 1 次衣柜分享额度)</view>
</view>
</scroll-view>
<!-- 选择衣物视图 -->
<view wx:elif="{{view === 'select'}}" class="select-view">
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
<view class="cat-row">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
<scroll-view scroll-y class="select-body">
<view wx:if="{{selectLoading}}" class="grid">
<view class="grid-item" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
<view class="skeleton sk-card"></view>
</view>
</view>
<view wx:else class="grid">
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
</view>
</view>
</scroll-view>
<view class="select-footer">
<view class="sf-count">已选 <text class="t-primary">{{selectedUuids.length}}</text> 件</view>
<view class="sf-confirm btn btn-primary" bindtap="confirmSelect">确定</view>
</view>
</view>
<!-- 成功预览 -->
<view wx:else class="success-view">
<view class="success-icon"><view class="icon icon-check"></view></view>
<view class="success-title">分享已创建</view>
<view class="preview-card">
<view class="preview-top">
<view class="avatar">
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user avatar-placeholder"></view>
</view>
<text class="preview-name">{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物</text>
</view>
<view class="preview-body">
<view class="icon icon-hanger"></view>
</view>
<view class="preview-btn">查看衣橱</view>
</view>
<view class="success-btns">
<view class="btn btn-outline" bindtap="onDone">完成</view>
<view class="btn btn-primary" bindtap="onViewShare">查看分享</view>
</view>
</view>
</view>
@@ -0,0 +1,322 @@
.create-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.section-title {
font-size: 26rpx;
color: #8E8E93;
padding: 24rpx 24rpx 16rpx;
}
.type-card {
position: relative;
margin: 0 24rpx 20rpx;
border: 2rpx solid transparent;
}
.type-card.active {
border-color: #007AFF;
}
.type-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.type-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.type-check {
position: absolute;
top: 32rpx;
right: 32rpx;
width: 36rpx;
height: 36rpx;
color: #007AFF;
}
.select-entry {
margin: 0 24rpx 20rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.entry-left {
display: flex;
align-items: center;
}
.entry-icon {
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: rgba(0, 122, 255, 0.1);
color: #007AFF;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.entry-icon .icon {
width: 40rpx;
height: 40rpx;
}
.entry-title {
font-size: 28rpx;
font-weight: 600;
color: #1C1C1E;
}
.entry-right {
display: flex;
align-items: center;
color: #8E8E93;
}
.entry-count {
font-size: 26rpx;
margin-right: 8rpx;
}
.entry-count.on {
color: #007AFF;
}
.entry-right .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
}
.expire-card {
margin: 0 24rpx;
padding: 0 32rpx;
}
.expire-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
font-size: 28rpx;
color: #000;
}
.expire-check {
width: 36rpx;
height: 36rpx;
color: #007AFF;
}
.footer {
padding: 24rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
/* 选择视图 */
.select-view {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
flex-shrink: 0;
}
.cat-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.cat-item {
flex-shrink: 0;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.cat-item.active {
background: #007AFF;
color: #fff;
}
.select-body {
flex: 1;
height: 0;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
}
.sk-card {
height: 360rpx;
border-radius: 32rpx;
}
.select-footer {
flex-shrink: 0;
background: #fff;
border-top: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.sf-count {
font-size: 30rpx;
font-weight: 600;
color: #1C1C1E;
}
.sf-confirm {
width: 200rpx;
height: 80rpx;
font-size: 28rpx;
}
/* 成功视图 */
.success-view {
flex: 1;
padding: 48rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.success-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: #34C759;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.success-icon .icon {
width: 64rpx;
height: 64rpx;
}
.success-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
margin-top: 24rpx;
}
.preview-card {
width: 100%;
background: #fff;
border-radius: 32rpx;
margin-top: 40rpx;
padding: 32rpx;
box-sizing: border-box;
}
.preview-top {
display: flex;
align-items: center;
}
.avatar {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: #F2F2F7;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
margin-right: 16rpx;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 40rpx;
height: 40rpx;
}
.preview-name {
font-size: 26rpx;
color: #000;
}
.preview-body {
height: 320rpx;
background: #F8F8FA;
border-radius: 24rpx;
margin-top: 24rpx;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.preview-body .icon {
width: 96rpx;
height: 96rpx;
}
.preview-btn {
margin-top: 24rpx;
height: 88rpx;
border-radius: 24rpx;
background: #007AFF;
color: #fff;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
}
.success-btns {
width: 100%;
display: flex;
gap: 20rpx;
margin-top: 40rpx;
}
.success-btns .btn {
flex: 1;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
@@ -0,0 +1,57 @@
import { request } from '../../utils/request'
import { ClosetShareDetail } from '../../types/models'
import { formatDateTime } from '../../utils/format'
Page({
data: {
uuid: '',
detail: null as ClosetShareDetail | null,
expireText: '',
loading: true,
invalid: false,
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
this.setData({ uuid })
if (uuid) this.fetchDetail()
},
fetchDetail() {
request<ClosetShareDetail>({ url: `/closet-share/${this.data.uuid}`, showError: false })
.then((detail) => {
const expireText =
detail.expire_time === 0 ? '长期有效' : `有效期至 ${formatDateTime(detail.expire_time)}`
this.setData({ detail, expireText, loading: false })
})
.catch((e: any) => {
this.setData({ loading: false })
const code = e && e.code
if (code === 90100 || code === 90101 || code === 90102) {
this.setData({ invalid: true })
}
})
},
onCancel() {
wx.showModal({
title: '取消分享',
content: '取消后该分享链接将失效,且不返还衣柜分享额度',
confirmText: '取消分享',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/closet-share/${this.data.uuid}/cancel`, method: 'POST' })
.then(() => {
wx.showToast({ title: '已取消', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
})
},
onBackHome() {
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
},
})
@@ -0,0 +1,42 @@
<view class="page detail-page">
<navigation-bar title="分享详情" />
<empty-state
wx:if="{{invalid}}"
icon="icon-share"
title="该分享已过期或已被取消"
desc="分享链接已失效"
buttonText="返回首页"
bind:action="onBackHome"
/>
<scroll-view wx:else scroll-y class="scroll-body">
<!-- 分享者信息 -->
<view class="card owner-card">
<view class="avatar">
<image wx:if="{{detail.owner_avatar}}" class="avatar-img" src="{{detail.owner_avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user avatar-placeholder"></view>
</view>
<view class="owner-info">
<view class="owner-name">{{detail.owner_name}} 分享了 {{detail.clothes.length}} 件衣物</view>
<view class="owner-expire">{{expireText}}</view>
</view>
</view>
<!-- 衣物网格 -->
<view class="grid">
<view class="grid-item" wx:for="{{detail.clothes}}" wx:key="clothes_uuid">
<view class="clothes-card">
<image wx:if="{{item.processed_image}}" class="clothes-img" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="clothes-placeholder"><view class="icon icon-hanger"></view></view>
<view class="clothes-name">{{item.name || '未命名'}}</view>
<view class="clothes-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}</view>
</view>
</view>
</view>
<!-- 属主取消 -->
<view wx:if="{{detail.is_owner}}" class="cancel-btn btn-danger-text" bindtap="onCancel">取消分享</view>
<view class="footer-space"></view>
</scroll-view>
</view>
@@ -0,0 +1,122 @@
.detail-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.owner-card {
margin: 24rpx;
display: flex;
align-items: center;
}
.avatar {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: #F2F2F7;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 56rpx;
height: 56rpx;
}
.owner-info {
margin-left: 24rpx;
}
.owner-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.owner-expire {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 0 24rpx;
gap: 24rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
}
.clothes-card {
background: #fff;
border-radius: 32rpx;
padding: 20rpx;
box-sizing: border-box;
}
.clothes-img {
width: 100%;
aspect-ratio: 1;
border-radius: 24rpx;
background: #F2F2F7;
}
.clothes-placeholder {
width: 100%;
aspect-ratio: 1;
border-radius: 24rpx;
background: #F2F2F7;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.clothes-placeholder .icon {
width: 72rpx;
height: 72rpx;
}
.clothes-name {
font-size: 26rpx;
font-weight: 500;
color: #1C1C1E;
margin-top: 12rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.clothes-sub {
font-size: 22rpx;
color: #8E8E93;
margin-top: 4rpx;
}
.cancel-btn {
text-align: center;
padding: 32rpx 0;
}
.footer-space {
height: 40rpx;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
@@ -0,0 +1,92 @@
import { request } from '../../utils/request'
import { PagedResult, ClosetShareItem } from '../../types/models'
Page({
data: {
tabs: [
{ name: '我的分享' },
{ name: '分享给我的' },
],
activeTab: 0, // 0 我的分享 1 分享给我的
list: [] as ClosetShareItem[],
loading: true,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.syncTabBar()
this.refresh()
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) tabBar.setData({ selected: 3 })
},
onTabTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeTab) return
this.setData({ activeTab: index, list: [], loading: true })
this.refresh()
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number) {
const url = this.data.activeTab === 0 ? '/closet-share' : '/closet-share/received'
request<PagedResult<ClosetShareItem>>({ url, data: { page, page_size: this.data.pageSize } })
.then((res) => {
const list = res.list.map((it) => this.decorate(it))
const merged = page === 1 ? list : this.data.list.concat(list)
this.setData({
list: merged,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => this.setData({ loading: false }))
},
decorate(it: ClosetShareItem) {
const now = Math.floor(Date.now() / 1000)
let status = it.status
if (status === 1 && it.expire_time > 0 && it.expire_time < now) status = 3
let expireText = '长期有效'
if (status === 2) expireText = '已取消'
else if (status === 3) expireText = '已过期'
else if (status === 4) expireText = '已封禁'
else if (it.expire_time > 0) {
const days = Math.ceil((it.expire_time - now) / 86400)
expireText = days > 0 ? `${days} 天后过期` : '已过期'
}
return {
...it,
_status: status,
_expireText: expireText,
_typeText: it.share_type === 1 ? '部分衣物' : '全量衣橱',
_invalid: status === 2 || status === 3 || status === 4,
}
},
onCreate() {
wx.navigateTo({ url: '/pages/closet-share-create/closet-share-create' })
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
wx.navigateTo({ url: `/pages/closet-share-detail/closet-share-detail?uuid=${uuid}` })
},
})
@@ -0,0 +1,59 @@
<view class="page page-flex">
<navigation-bar title="分享" back="{{false}}" />
<!-- 分段切换 -->
<view class="seg-wrap">
<view class="seg-control">
<view
wx:for="{{tabs}}"
wx:key="name"
class="seg-item {{activeTab === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onTabTap"
>{{item.name}}</view>
</view>
</view>
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom">
<view wx:if="{{activeTab === 0}}" class="new-btn btn btn-outline" bindtap="onCreate">+ 新建分享</view>
<empty-state
wx:if="{{!loading && !list.length}}"
icon="icon-share"
title="{{activeTab === 0 ? '还没有分享' : '还没有收到分享'}}"
desc="{{activeTab === 0 ? '把衣橱或精选衣物分享给好友' : '好友分享给你的衣橱会出现在这里'}}"
buttonText="{{activeTab === 0 ? '新建分享' : ''}}"
bind:action="onCreate"
/>
<view class="list">
<view
class="share-card card {{item._invalid ? 'invalid' : ''}}"
wx:for="{{list}}"
wx:key="share_uuid"
data-uuid="{{item.share_uuid}}"
bindtap="onItemTap"
>
<view class="preview-grid">
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
</view>
<view class="share-info">
<view wx:if="{{activeTab === 1}}" class="owner-row">
<image wx:if="{{item.owner_avatar}}" class="owner-avatar" src="{{item.owner_avatar}}" mode="aspectFill" />
<view wx:else class="owner-avatar owner-avatar-placeholder">
<view class="icon icon-user owner-avatar-icon"></view>
</view>
<text class="owner-name">{{item.owner_name || '好友'}}</text>
</view>
<view class="share-title">衣柜分享 · {{item.clothes_count}} 件</view>
<view class="tag tag-gray share-type">{{item._typeText}}</view>
<view class="share-expire">{{item._expireText}}</view>
</view>
<view wx:if="{{item._invalid}}" class="share-badge">{{item._status === 2 ? '已取消' : '已过期'}}</view>
</view>
</view>
</scroll-view>
</view>
@@ -0,0 +1,151 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.seg-wrap {
background: #fff;
padding: 20rpx 24rpx;
}
.seg-control {
display: flex;
background: #F2F2F7;
border-radius: 16rpx;
padding: 4rpx;
}
.seg-item {
flex: 1;
height: 64rpx;
line-height: 64rpx;
text-align: center;
border-radius: 12rpx;
font-size: 28rpx;
color: #3C3C43;
transition: all 0.2s;
}
.seg-item.active {
background: #007AFF;
color: #fff;
font-weight: 600;
}
.new-btn {
margin: 24rpx;
width: calc(100% - 48rpx);
}
.list {
padding: 0 24rpx 24rpx;
}
.share-card {
position: relative;
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding: 24rpx;
}
.share-card.invalid {
opacity: 0.5;
}
.preview-grid {
display: flex;
flex-wrap: wrap;
width: 160rpx;
height: 160rpx;
gap: 8rpx;
flex-shrink: 0;
}
.pg-cell {
width: 76rpx;
height: 76rpx;
background: #F2F2F7;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.pg-cell .icon {
width: 40rpx;
height: 40rpx;
}
.owner-row {
display: flex;
align-items: center;
margin-bottom: 10rpx;
}
.owner-avatar {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
margin-right: 12rpx;
flex-shrink: 0;
background: #F2F2F7;
overflow: hidden;
}
.owner-avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.owner-avatar-icon {
width: 24rpx;
height: 24rpx;
}
.owner-name {
font-size: 24rpx;
color: #8E8E93;
}
.share-info {
flex: 1;
margin-left: 24rpx;
}
.share-title {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.share-type {
margin-top: 12rpx;
}
.share-expire {
font-size: 22rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.share-badge {
position: absolute;
top: 16rpx;
right: 16rpx;
font-size: 20rpx;
color: #8E8E93;
background: #F2F2F7;
padding: 4rpx 16rpx;
border-radius: 8rpx;
}
@@ -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;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
+75
View File
@@ -0,0 +1,75 @@
import { request } from '../../utils/request'
import { HoroscopeData } from '../../types/models'
const COLOR_MAP: Record<string, string> = {
红色: '#FF3B30',
橙色: '#FF9500',
黄色: '#FFCC00',
绿色: '#34C759',
蓝色: '#007AFF',
紫色: '#5856D6',
粉色: '#FF2D55',
白色: '#FFFFFF',
黑色: '#000000',
灰色: '#8E8E93',
}
Page({
data: {
horoscope: null as HoroscopeData | null,
statDate: '',
luckyColorHex: '#007AFF',
loading: true,
noBirth: false,
},
onShow() {
this.syncTabBar()
if (!this.data.horoscope) this.fetchHoroscope(0)
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) tabBar.setData({ selected: 2 })
},
fetchHoroscope(retry: number) {
this.setData({ loading: true })
request<HoroscopeData>({ url: '/horoscope/today', showError: false })
.then((res) => {
this.setData({
horoscope: res,
statDate: this.formatStatDate(res.stat_date),
luckyColorHex: COLOR_MAP[res.lucky_color] || '#007AFF',
loading: false,
noBirth: false,
})
})
.catch((e: any) => {
const code = e && e.code
const details = (e && e.details) || []
const noBirth = details.some((d: string) => d.indexOf('生日') >= 0)
if (code === 10000 && noBirth) {
this.setData({ noBirth: true, loading: false })
} else if (code === 90300 && retry < 3) {
wx.showToast({ title: '运势生成中,请稍后重试', icon: 'none' })
setTimeout(() => this.fetchHoroscope(retry + 1), 4000)
} else {
this.setData({ loading: false })
}
})
},
formatStatDate(d: number): string {
const s = String(d)
return `${s.slice(0, 4)}.${s.slice(4, 6)}.${s.slice(6, 8)}`
},
onGoProfile() {
wx.switchTab({ url: '/pages/profile/profile' })
},
onGenerate() {
wx.navigateTo({ url: '/pages/ai-recommend/ai-recommend' })
},
})
@@ -0,0 +1,92 @@
<view class="page page-flex">
<navigation-bar title="每日运势" back="{{false}}">
<view slot="right" class="nav-action" bindtap="onGoProfile">去设置生日</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="sk-wrap">
<view class="skeleton sk-big"></view>
<view class="skeleton sk-mid"></view>
<view class="skeleton sk-mid"></view>
</view>
<!-- 未设置生日 -->
<empty-state
wx:elif="{{noBirth}}"
icon="icon-calendar"
title="还没有设置生日"
desc="设置生日后即可查看你的星座运势"
buttonText="去完善资料"
bind:action="onGoProfile"
/>
<!-- 运势内容 -->
<view wx:else class="content">
<!-- 星座大卡 -->
<view class="card horo-card">
<view class="horo-top">
<view class="zodiac">
<view class="icon icon-sparkles zodiac-icon"></view>
<text class="zodiac-name">{{horoscope.zodiac}}</text>
</view>
<view class="horo-date">
<text>{{statDate}}</text>
<text class="nav-action" bindtap="onGoProfile">去设置生日</text>
</view>
</view>
<view class="overall">
<text class="overall-label">综合运势</text>
<view class="overall-score">
<text class="score-num">{{horoscope.overall_score}}.0</text>
<view class="stars">
<view
class="icon star {{index < horoscope.overall_score ? 'icon-star-fill on' : 'icon-star'}}"
wx:for="{{[1, 2, 3, 4, 5]}}"
wx:key="*this"
></view>
</view>
</view>
</view>
<!-- 三项评分 -->
<view class="scores">
<view class="score-row">
<text class="score-label">爱情运势</text>
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.love_score * 20}}%"></view></view>
<text class="score-value">{{horoscope.love_score}}/5</text>
</view>
<view class="score-row">
<text class="score-label">工作运势</text>
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.career_score * 20}}%"></view></view>
<text class="score-value">{{horoscope.career_score}}/5</text>
</view>
<view class="score-row">
<text class="score-label">财富运势</text>
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.wealth_score * 20}}%"></view></view>
<text class="score-value">{{horoscope.wealth_score}}/5</text>
</view>
</view>
<view class="daily-text">
<text class="daily-label">今日运势</text>
<text class="daily-content">{{horoscope.daily_text}}</text>
</view>
<view class="lucky-row">
<view class="lucky-card">
<view class="lucky-dot" style="background: {{luckyColorHex}}"></view>
<text class="lucky-label">幸运色 · {{horoscope.lucky_color}}</text>
</view>
<view class="lucky-card">
<text class="lucky-number">{{horoscope.lucky_number}}</text>
<text class="lucky-label">幸运数字</text>
</view>
</view>
</view>
<view class="gen-btn btn btn-outline" bindtap="onGenerate">生成今日推荐穿搭</view>
</view>
</scroll-view>
</view>
+212
View File
@@ -0,0 +1,212 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.nav-action {
font-size: 26rpx;
color: #007AFF;
padding: 0 8rpx;
}
.sk-wrap {
padding: 24rpx;
}
.sk-big {
height: 560rpx;
border-radius: 32rpx;
margin-bottom: 24rpx;
}
.sk-mid {
height: 200rpx;
border-radius: 32rpx;
margin-bottom: 24rpx;
}
.content {
padding: 24rpx;
}
.horo-card {
background: #E8F2FF;
}
.horo-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.zodiac {
display: flex;
align-items: center;
}
.zodiac-icon {
width: 56rpx;
height: 56rpx;
color: #007AFF;
margin-right: 16rpx;
}
.zodiac-name {
font-size: 40rpx;
font-weight: 700;
color: #000;
}
.horo-date {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 24rpx;
color: #8E8E93;
}
.overall {
margin-top: 40rpx;
}
.overall-label {
font-size: 26rpx;
color: #8E8E93;
}
.overall-score {
display: flex;
align-items: center;
margin-top: 12rpx;
}
.score-num {
font-size: 120rpx;
font-weight: 700;
color: #000;
line-height: 1;
margin-right: 32rpx;
}
.stars {
display: flex;
gap: 8rpx;
}
.star {
width: 40rpx;
height: 40rpx;
color: #C7C7CC;
}
.star.on {
color: #007AFF;
}
.scores {
margin-top: 32rpx;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
}
.score-row {
display: flex;
align-items: center;
padding: 16rpx 0;
}
.score-label {
width: 160rpx;
font-size: 26rpx;
color: #000;
}
.score-bar {
flex: 1;
height: 12rpx;
background: #F2F2F7;
border-radius: 6rpx;
overflow: hidden;
}
.score-fill {
height: 100%;
background: #007AFF;
border-radius: 6rpx;
}
.score-value {
width: 80rpx;
text-align: right;
font-size: 26rpx;
color: #8E8E93;
}
.daily-text {
margin-top: 24rpx;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
}
.daily-label {
display: block;
font-size: 26rpx;
font-weight: 600;
color: #000;
}
.daily-content {
display: block;
font-size: 26rpx;
color: #3C3C43;
margin-top: 12rpx;
line-height: 1.6;
}
.lucky-row {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.lucky-card {
flex: 1;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
display: flex;
align-items: center;
}
.lucky-dot {
width: 24rpx;
height: 24rpx;
border-radius: 50%;
margin-right: 16rpx;
}
.lucky-number {
font-size: 44rpx;
font-weight: 700;
color: #000;
margin-right: 16rpx;
}
.lucky-label {
font-size: 24rpx;
color: #8E8E93;
}
.gen-btn {
margin-top: 24rpx;
width: 100%;
}
+3
View File
@@ -0,0 +1,3 @@
{
"usingComponents": {}
}
+118
View File
@@ -0,0 +1,118 @@
import { request } from '../../utils/request'
import * as auth from '../../utils/auth'
import { STORAGE_KEYS, QuotaType } from '../../utils/config'
import { LoginResult, UserInfo, QuotaItem } from '../../types/models'
import { formatDate } from '../../utils/format'
interface GiftItem {
icon: string
name: string
value: string
}
Page({
data: {
loading: false,
userInfo: {} as UserInfo,
giftVisible: false,
giftItems: [] as GiftItem[],
},
onLoad() {
if (auth.isLoggedIn()) {
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
}
},
onLogin() {
if (this.data.loading) return
this.setData({ loading: true })
wx.login({
success: (res) => {
if (!res.code) {
this.setData({ loading: false })
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
return
}
request<LoginResult>({
url: '/user/wxlogin',
method: 'POST',
data: { code: res.code },
auth: false,
})
.then((data) => {
auth.setTokens({
accessToken: data.access_token,
refreshToken: data.refresh_token,
expireTime: data.expire_time,
})
wx.setStorageSync(STORAGE_KEYS.USER_INFO, data.user_info)
this.setData({ userInfo: data.user_info })
if (data.user_info.is_new_user) {
this.showWelcomeGift(data.user_info)
} else if (!data.user_info.birth_day) {
this.showBirthGuide()
} else {
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
}
})
.catch(() => {
this.setData({ loading: false })
})
},
fail: () => {
this.setData({ loading: false })
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
},
})
},
showWelcomeGift(userInfo: UserInfo) {
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
.then((res) => this.setData({ giftItems: this.buildGift(userInfo, res.quotas), giftVisible: true }))
.catch(() => this.setData({ giftItems: this.buildGift(userInfo, []), giftVisible: true }))
},
buildGift(userInfo: UserInfo, quotas: QuotaItem[]): GiftItem[] {
const total = (t: number) => {
const q = quotas.find((it) => it.quota_type === t)
return q ? q.total : 0
}
const items: GiftItem[] = []
if (userInfo.vip_type === 4 && userInfo.vip_expire) {
items.push({ icon: 'icon-crown', name: '体验会员', value: `有效期至 ${formatDate(userInfo.vip_expire)}` })
}
items.push({ icon: 'icon-camera', name: '上传额度', value: `${total(QuotaType.UPLOAD)} 次 · 永久` })
items.push({ icon: 'icon-sparkles', name: 'AI 推荐额度', value: `${total(QuotaType.AI)} 次/天` })
items.push({ icon: 'icon-shirt', name: '搭配分享额度', value: `${total(QuotaType.OUTFIT_SHARE)} 次/天` })
items.push({ icon: 'icon-hanger', name: '衣柜分享额度', value: `${total(QuotaType.CLOSET_SHARE)} 次/天` })
return items
},
onGiftConfirm() {
this.setData({ giftVisible: false })
if (!this.data.userInfo.birth_day) {
this.showBirthGuide()
} else {
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
}
},
showBirthGuide() {
wx.showModal({
title: '完善个人信息',
content: '设置生日后即可查看今日星座运势',
confirmText: '去完善',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/profile/profile' })
else wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
},
})
},
openAgreement() {
wx.showToast({ title: '协议内容敬请期待', icon: 'none' })
},
})
+50
View File
@@ -0,0 +1,50 @@
<view class="login-page">
<!-- 顶部插画区 -->
<view class="hero">
<view class="icon hero-icon hero-1 icon-hanger"></view>
<view class="icon hero-icon hero-2 icon-shirt"></view>
<view class="icon hero-icon hero-3 icon-sparkles"></view>
<view class="icon hero-icon hero-4 icon-camera"></view>
<view class="icon hero-icon hero-5 icon-star"></view>
</view>
<!-- Logo 与产品名 -->
<view class="brand">
<view class="brand-logo">
<view class="icon icon-hanger"></view>
</view>
<view class="brand-name">纸条AI衣橱</view>
<view class="brand-slogan">把你的衣橱,交给 AI 打理</view>
</view>
<!-- 底部登录区 -->
<view class="login-footer">
<view class="btn btn-primary login-btn {{loading ? 'disabled' : ''}}" bindtap="onLogin">
<view class="wechat-icon"></view>
<text>{{loading ? '登录中…' : '微信一键登录'}}</text>
</view>
<view class="agreement">
<text>登录即代表同意</text>
<text class="link" bindtap="openAgreement">《用户协议》</text>
<text>和</text>
<text class="link" bindtap="openAgreement">《隐私政策》</text>
</view>
</view>
<!-- 新手礼包弹窗 -->
<view wx:if="{{giftVisible}}" class="gift-mask">
<view class="gift-card">
<view class="gift-badge"><view class="icon icon-sparkles"></view></view>
<view class="gift-title">欢迎加入纸条AI衣橱</view>
<view class="gift-sub">新人礼包已到账,请查收</view>
<view class="gift-list">
<view class="gift-item" wx:for="{{giftItems}}" wx:key="name">
<view class="gift-item-icon icon {{item.icon}}"></view>
<text class="gift-item-name">{{item.name}}</text>
<text class="gift-item-val">{{item.value}}</text>
</view>
</view>
<view class="btn btn-primary gift-btn" bindtap="onGiftConfirm">开心收下</view>
</view>
</view>
</view>
+194
View File
@@ -0,0 +1,194 @@
.login-page {
min-height: 100vh;
background: #fff;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* 顶部插画区 */
.hero {
position: relative;
height: 480rpx;
background: #E8F2FF;
border-radius: 0 0 48rpx 48rpx;
overflow: hidden;
}
.hero-icon {
position: absolute;
color: #007AFF;
opacity: 0.85;
}
.hero-1 { width: 140rpx; height: 140rpx; left: 60rpx; top: 120rpx; transform: rotate(-12deg); }
.hero-2 { width: 120rpx; height: 120rpx; right: 80rpx; top: 80rpx; transform: rotate(10deg); color: #5856D6; }
.hero-3 { width: 100rpx; height: 100rpx; left: 160rpx; top: 280rpx; transform: rotate(8deg); }
.hero-4 { width: 110rpx; height: 110rpx; right: 170rpx; top: 260rpx; transform: rotate(-6deg); color: #5856D6; }
.hero-5 { width: 70rpx; height: 70rpx; right: 60rpx; top: 340rpx; transform: rotate(15deg); }
/* 品牌区 */
.brand {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
}
.brand-logo {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: #007AFF;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 32rpx;
box-shadow: 0 16rpx 40rpx rgba(0, 122, 255, 0.25);
}
.brand-logo .icon {
width: 72rpx;
height: 72rpx;
}
.brand-name {
font-size: 48rpx;
font-weight: 700;
color: #000;
}
.brand-slogan {
font-size: 28rpx;
color: #8E8E93;
margin-top: 16rpx;
}
/* 底部 */
.login-footer {
padding: 0 64rpx;
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
}
.login-btn {
width: 100%;
}
.wechat-icon {
width: 44rpx;
height: 44rpx;
margin-right: 12rpx;
background-color: #fff;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M9.5 3C5.36 3 2 5.69 2 9c0 1.87 1.03 3.54 2.62 4.65L4 16.5l3.27-1.72c.73.2 1.47.3 2.23.3h.5A6.2 6.2 0 0 1 9 12c0-3.3 3.36-6 7.5-6h.5A5.7 5.7 0 0 0 9.5 3z'/%3E%3Cpath d='M22 12c0-2.76-2.69-5-6-5s-6 2.24-6 5 2.69 5 6 5c.72 0 1.42-.1 2.06-.28L20.5 18l-.42-2.6c1.17-.88 1.92-2.06 1.92-3.4z'/%3E%3C/svg%3E") no-repeat center / 100% 100%;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M9.5 3C5.36 3 2 5.69 2 9c0 1.87 1.03 3.54 2.62 4.65L4 16.5l3.27-1.72c.73.2 1.47.3 2.23.3h.5A6.2 6.2 0 0 1 9 12c0-3.3 3.36-6 7.5-6h.5A5.7 5.7 0 0 0 9.5 3z'/%3E%3Cpath d='M22 12c0-2.76-2.69-5-6-5s-6 2.24-6 5 2.69 5 6 5c.72 0 1.42-.1 2.06-.28L20.5 18l-.42-2.6c1.17-.88 1.92-2.06 1.92-3.4z'/%3E%3C/svg%3E") no-repeat center / 100% 100%;
}
.agreement {
margin-top: 24rpx;
font-size: 22rpx;
color: #8E8E93;
text-align: center;
}
.link {
color: #007AFF;
}
/* 新手礼包弹窗 */
.gift-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
}
.gift-card {
width: 560rpx;
max-height: 80vh;
overflow-y: auto;
background: #fff;
border-radius: 32rpx;
padding: 48rpx 40rpx 40rpx;
display: flex;
flex-direction: column;
align-items: center;
box-sizing: border-box;
}
.gift-badge {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: linear-gradient(135deg, #FF9500, #FF2D55);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24rpx;
}
.gift-badge .icon {
width: 52rpx;
height: 52rpx;
}
.gift-title {
font-size: 36rpx;
font-weight: 700;
color: #000;
}
.gift-sub {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
margin-bottom: 32rpx;
}
.gift-list {
width: 100%;
margin-bottom: 40rpx;
}
.gift-item {
display: flex;
align-items: center;
padding: 22rpx 0;
border-bottom: 1rpx solid #F2F2F7;
}
.gift-item:last-child {
border-bottom: none;
}
.gift-item-icon {
width: 40rpx;
height: 40rpx;
color: #007AFF;
margin-right: 20rpx;
flex-shrink: 0;
}
.gift-item-name {
flex: 1;
font-size: 28rpx;
color: #000;
}
.gift-item-val {
font-size: 26rpx;
color: #8E8E93;
}
.gift-btn {
width: 100%;
}
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
+216
View File
@@ -0,0 +1,216 @@
import { request } from '../../utils/request'
import { QuotaItem, UserInfo, GoodsItem, QuotaFlowItem, PagedResult, VirtualPayResult, OrderItem } from '../../types/models'
import { QUOTA_NAMES, GoodsType, GOODS_TYPE_NAMES } from '../../utils/config'
import { fenToYuan, formatDate, formatDateTime } from '../../utils/format'
const GOODS_ICONS: Record<number, string> = {
[GoodsType.MEMBER]: 'icon-crown',
[GoodsType.UPLOAD]: 'icon-camera',
[GoodsType.AI]: 'icon-sparkles',
[GoodsType.CLOSET_SHARE]: 'icon-hanger',
[GoodsType.OUTFIT_SHARE]: 'icon-shirt',
}
const GOODS_FILTERS = [
{ name: '全部', value: 0 },
{ name: '会员包', value: 1 },
{ name: '上传包', value: 2 },
{ name: 'AI 推荐包', value: 3 },
{ name: '衣柜分享包', value: 4 },
{ name: '搭配分享包', value: 5 },
]
type PayPlatform = 'android' | 'ios' | 'harmony' | 'windows'
// 归一化平台:后端仅接受 android/ios/harmony/windows,devtools/mac 等调试环境默认按 android 处理
function normalizePlatform(): PayPlatform {
let p = 'android'
try {
p = wx.getSystemInfoSync().platform
} catch (e) {
// 获取失败时回退 android,避免请求体缺失导致参数校验失败
}
if (p === 'ios' || p === 'harmony' || p === 'windows') return p
return 'android'
}
Page({
data: {
view: 'mall' as 'mall' | 'flow',
userInfo: {} as UserInfo,
isVip: false,
vipExpireText: '',
quotas: [] as QuotaItem[],
goodsFilters: GOODS_FILTERS,
activeFilter: 0,
goods: [] as GoodsItem[],
flowList: [] as QuotaFlowItem[],
goodsIcons: GOODS_ICONS,
orderVisible: false,
orderGoods: null as GoodsItem | null,
loading: true,
},
onLoad(query: Record<string, string>) {
if (query.tab === 'flow') this.setData({ view: 'flow' })
},
onShow() {
this.fetchUser()
this.fetchQuota()
this.fetchGoods()
if (this.data.view === 'flow') this.fetchFlow()
},
fetchUser() {
request<UserInfo>({ url: '/user/info', showError: false })
.then((user) => {
const isVip = user.vip_type !== 1
this.setData({
userInfo: user,
isVip,
vipExpireText: isVip && user.vip_expire ? `有效期至 ${formatDate(user.vip_expire)}` : '',
})
})
.catch(() => {})
},
fetchQuota() {
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
.then((res) => {
const quotas = res.quotas.map((q) => ({
...q,
_name: QUOTA_NAMES[q.quota_type] || '额度',
_percent: q.total > 0 ? Math.round((q.used / q.total) * 100) : 0,
}))
this.setData({ quotas })
})
.catch(() => {})
},
fetchGoods() {
const params: Record<string, any> = { page: 1, page_size: 50 }
const filter = this.data.activeFilter
if (filter > 0) params.goods_type = filter
request<PagedResult<GoodsItem>>({ url: '/goods', data: params, auth: false })
.then((res) => {
const goods = res.list.map((g) => ({
...g,
_icon: GOODS_ICONS[g.goods_type] || 'icon-crown',
_typeName: GOODS_TYPE_NAMES[g.goods_type] || '',
_priceText: fenToYuan(g.discount_price),
_originText: fenToYuan(g.price),
}))
this.setData({ goods, loading: false })
})
.catch(() => this.setData({ loading: false }))
},
fetchFlow() {
request<PagedResult<QuotaFlowItem>>({ url: '/quota/flow', data: { page: 1, page_size: 50 } })
.then((res) => {
const list = res.list.map((it) => ({
...it,
_time: formatDateTime(it.created_at),
_sign: it.change_num > 0 ? `+${it.change_num}` : `${it.change_num}`,
_name: QUOTA_NAMES[it.quota_type] || '额度',
}))
this.setData({ flowList: list })
})
.catch(() => {})
},
onGoodsFilter(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeFilter) return
this.setData({ activeFilter: index, loading: true })
this.fetchGoods()
},
onTapMember() {
this.setData({ activeFilter: 1, loading: true })
this.fetchGoods()
},
onSwitchMall() {
this.setData({ view: 'mall' })
},
onSwitchFlow() {
this.setData({ view: 'flow' })
this.fetchFlow()
},
onBuy(e: WechatMiniprogram.TouchEvent) {
const goods = e.currentTarget.dataset.goods as GoodsItem
this.setData({ orderGoods: goods, orderVisible: true })
},
onCloseOrder() {
this.setData({ orderVisible: false })
},
noop() {},
confirmPay() {
if (!this.data.orderGoods) return
request<VirtualPayResult>({
url: '/order/virtual',
method: 'POST',
data: {
goods_code: this.data.orderGoods.goods_code,
platform: normalizePlatform(),
},
})
.then((res) => {
this.setData({ orderVisible: false })
this.doPayment(res)
})
.catch(() => {})
},
doPayment(res: VirtualPayResult) {
wx.requestVirtualPayment({
signData: res.sign_data,
paySig: res.pay_sig,
signature: res.signature,
mode: 'short_series_goods',
success: () => {
// 拉起成功仅代表进入支付页,最终到账以后端发货推送为准,轮询订单确认
this.pollOrder(res.order_no)
},
fail: () => {},
})
},
pollOrder(orderNo: string) {
let count = 0
const timer = setInterval(() => {
count++
request<PagedResult<OrderItem>>({ url: '/order', data: { page: 1, page_size: 50 }, showError: false })
.then((res) => {
const order = res.list.find((it) => it.order_no === orderNo)
if (order && order.order_status === 2) {
clearInterval(timer)
wx.showToast({ title: '支付成功', icon: 'success' })
this.fetchQuota()
this.fetchUser()
} else if (order && (order.order_status === 3 || order.order_status === 4 || order.order_status === 5)) {
clearInterval(timer)
const msg = order.order_status === 5 ? '订单已退款' : '订单未支付成功'
wx.showToast({ title: msg, icon: 'none' })
} else if (count >= 10) {
this.finishPayTimeout(timer)
}
})
.catch(() => {
if (count >= 10) this.finishPayTimeout(timer)
})
}, 1500)
},
finishPayTimeout(timer: ReturnType<typeof setInterval>) {
clearInterval(timer)
wx.showToast({ title: '到账可能有延迟,请稍后查看', icon: 'none' })
this.fetchQuota()
this.fetchUser()
},
})
+95
View File
@@ -0,0 +1,95 @@
<view class="page mall-page">
<navigation-bar title="额度与会员" />
<!-- 商城视图 -->
<scroll-view wx:if="{{view === 'mall'}}" scroll-y class="scroll-body">
<!-- 会员卡 -->
<view class="card member-card">
<view class="member-left">
<view class="icon icon-crown member-icon"></view>
<view>
<view class="member-name">{{isVip ? '月度会员' : '开通会员'}}</view>
<view class="member-desc">{{isVip ? vipExpireText : '开通会员,解锁更多 AI 与上传权益'}}</view>
</view>
</view>
<view class="member-btn" bindtap="onTapMember">{{isVip ? '立即续费' : '立即开通'}}</view>
</view>
<!-- 额度网格 -->
<view class="quota-grid">
<view class="quota-cell" wx:for="{{quotas}}" wx:key="quota_type">
<view class="quota-name">{{item._name}}</view>
<view class="quota-num">{{item.remain}}<text class="quota-total">/ {{item.total}}</text></view>
<view class="quota-bar">
<view class="quota-fill {{item._percent >= 80 ? 'warn' : ''}}" style="width: {{item._percent}}%"></view>
</view>
</view>
</view>
<!-- 额度明细入口 -->
<view class="card flow-entry" bindtap="onSwitchFlow">
<text>额度明细</text>
<view class="icon icon-chevron-right"></view>
</view>
<!-- 商品筛选 -->
<scroll-view scroll-x class="goods-filter" show-scrollbar="{{false}}">
<view class="filter-row">
<view
wx:for="{{goodsFilters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onGoodsFilter"
>{{item.name}}</view>
</view>
</scroll-view>
<!-- 商品列表 -->
<view class="goods-list">
<view class="goods-card card" wx:for="{{goods}}" wx:key="goods_code">
<view class="icon {{item._icon}} goods-icon"></view>
<view class="goods-info">
<view class="goods-name">{{item.goods_name}}</view>
<view class="goods-desc">{{item.description}}</view>
</view>
<view class="goods-right">
<view class="goods-price num">{{item._priceText}}</view>
<view class="goods-origin num" wx:if="{{item.price > item.discount_price}}">{{item._originText}}</view>
<view class="buy-btn" data-goods="{{item}}" bindtap="onBuy">购买</view>
</view>
</view>
</view>
<view class="footer-space"></view>
</scroll-view>
<!-- 额度明细视图 -->
<scroll-view wx:else scroll-y class="scroll-body">
<view class="flow-head" bindtap="onSwitchMall">← 返回商城</view>
<view class="flow-list">
<view class="flow-row card" wx:for="{{flowList}}" wx:key="quota_flow_uuid">
<view class="flow-left">
<view class="flow-reason">{{item.reason || item._name}}</view>
<view class="flow-time">{{item._time}}</view>
</view>
<view class="flow-right">
<view class="flow-num {{item.change_num > 0 ? 'plus' : ''}}">{{item._sign}}</view>
<view class="flow-after">剩余 {{item.new_value}}</view>
</view>
</view>
</view>
<empty-state wx:if="{{!flowList.length}}" icon="icon-doc" title="暂无额度明细" />
</scroll-view>
<!-- 订单确认弹窗 -->
<view wx:if="{{orderVisible}}" class="mask" bindtap="onCloseOrder">
<view class="sheet" catchtap="noop">
<view class="sheet-grabber"></view>
<view class="order-goods">{{orderGoods.goods_name}}</view>
<view class="order-desc">{{orderGoods.description}}</view>
<view class="order-amount">实付 <text class="order-price num">{{orderGoods._priceText}}</text></view>
<view class="btn btn-primary" bindtap="confirmPay">立即支付</view>
</view>
</view>
</view>
+330
View File
@@ -0,0 +1,330 @@
.mall-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
/* 会员卡 */
.member-card {
margin: 24rpx;
background: #E8F2FF;
display: flex;
align-items: center;
justify-content: space-between;
}
.member-left {
display: flex;
align-items: center;
}
.member-icon {
width: 72rpx;
height: 72rpx;
color: #007AFF;
margin-right: 24rpx;
}
.member-name {
font-size: 32rpx;
font-weight: 600;
color: #007AFF;
}
.member-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
}
.member-btn {
height: 64rpx;
padding: 0 32rpx;
border: 1rpx solid #007AFF;
border-radius: 32rpx;
color: #007AFF;
font-size: 24rpx;
display: flex;
align-items: center;
flex-shrink: 0;
}
/* 额度网格 */
.quota-grid {
display: flex;
flex-wrap: wrap;
padding: 0 24rpx;
gap: 20rpx;
}
.quota-cell {
width: calc(50% - 10rpx);
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
box-sizing: border-box;
}
.quota-name {
font-size: 24rpx;
color: #8E8E93;
}
.quota-num {
font-size: 48rpx;
font-weight: 700;
color: #000;
margin-top: 8rpx;
}
.quota-total {
font-size: 24rpx;
color: #C7C7CC;
font-weight: 400;
}
.quota-bar {
height: 10rpx;
background: #F2F2F7;
border-radius: 5rpx;
margin-top: 16rpx;
overflow: hidden;
}
.quota-fill {
height: 100%;
background: #007AFF;
border-radius: 5rpx;
}
.quota-fill.warn {
background: #FF9500;
}
.flow-entry {
margin: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #000;
}
.flow-entry .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
}
/* 商品筛选 */
.goods-filter {
background: #fff;
white-space: nowrap;
margin-bottom: 20rpx;
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.filter-item {
flex-shrink: 0;
padding: 0 28rpx;
height: 56rpx;
line-height: 56rpx;
border-radius: 28rpx;
font-size: 24rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.filter-item.active {
background: #007AFF;
color: #fff;
}
/* 商品列表 */
.goods-list {
padding: 0 24rpx;
}
.goods-card {
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding: 24rpx;
}
.goods-icon {
width: 72rpx;
height: 72rpx;
color: #007AFF;
margin-right: 20rpx;
flex-shrink: 0;
}
.goods-info {
flex: 1;
min-width: 0;
}
.goods-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.goods-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 6rpx;
}
.goods-right {
display: flex;
flex-direction: column;
align-items: flex-end;
flex-shrink: 0;
}
.goods-price {
font-size: 30rpx;
font-weight: 700;
color: #007AFF;
}
.goods-origin {
font-size: 22rpx;
color: #C7C7CC;
text-decoration: line-through;
}
.buy-btn {
margin-top: 12rpx;
height: 56rpx;
padding: 0 32rpx;
border-radius: 28rpx;
background: #007AFF;
color: #fff;
font-size: 24rpx;
display: flex;
align-items: center;
}
.footer-space {
height: 40rpx;
}
/* 额度明细视图 */
.flow-head {
padding: 24rpx;
font-size: 26rpx;
color: #007AFF;
}
.flow-list {
padding: 0 24rpx;
}
.flow-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
padding: 24rpx;
}
.flow-reason {
font-size: 28rpx;
color: #000;
}
.flow-time {
font-size: 22rpx;
color: #C7C7CC;
margin-top: 8rpx;
}
.flow-right {
text-align: right;
}
.flow-num {
font-size: 32rpx;
font-weight: 600;
color: #8E8E93;
}
.flow-num.plus {
color: #34C759;
}
.flow-after {
font-size: 22rpx;
color: #C7C7CC;
margin-top: 4rpx;
}
/* 订单弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 20rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.sheet-grabber {
width: 72rpx;
height: 10rpx;
border-radius: 5rpx;
background: #E5E5EA;
margin: 0 auto 24rpx;
}
.order-goods {
font-size: 34rpx;
font-weight: 600;
color: #000;
text-align: center;
}
.order-desc {
font-size: 24rpx;
color: #8E8E93;
text-align: center;
margin-top: 12rpx;
}
.order-amount {
font-size: 28rpx;
color: #000;
text-align: center;
margin: 32rpx 0;
}
.order-price {
font-size: 44rpx;
font-weight: 700;
color: #FF9500;
}
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
@@ -0,0 +1,26 @@
import { request } from '../../utils/request'
import { NotificationItem } from '../../types/models'
import { formatDateTime } from '../../utils/format'
Page({
data: {
uuid: '',
item: {} as NotificationItem,
time: '',
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
this.setData({ uuid })
if (uuid) {
this.fetchDetail()
request({ url: `/notification/${uuid}/read`, method: 'POST', showError: false }).catch(() => {})
}
},
fetchDetail() {
request<NotificationItem>({ url: `/notification/${this.data.uuid}` })
.then((item) => this.setData({ item, time: formatDateTime(item.created_at) }))
.catch(() => {})
},
})
@@ -0,0 +1,14 @@
<view class="page detail-page">
<navigation-bar title="通知详情" />
<view class="card detail-card">
<view class="icon-row">
<view class="n-icon {{item.msg_type === 1 ? 'like' : item.msg_type === 2 ? 'order' : 'sys'}}">
<view class="icon {{item.msg_type === 1 ? 'icon-heart' : item.msg_type === 2 ? 'icon-doc' : 'icon-bell'}}"></view>
</view>
</view>
<view class="detail-title">{{item.title}}</view>
<view class="detail-time">{{time}}</view>
<view class="detail-content">{{item.content}}</view>
</view>
</view>
@@ -0,0 +1,65 @@
.detail-page {
padding: 24rpx;
box-sizing: border-box;
}
.detail-card {
margin-top: 24rpx;
padding: 48rpx 40rpx;
}
.icon-row {
display: flex;
justify-content: center;
margin-bottom: 32rpx;
}
.n-icon {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.n-icon.like {
background: #FFE8EC;
color: #FF3B30;
}
.n-icon.order {
background: #E8F2FF;
color: #007AFF;
}
.n-icon.sys {
background: #F2F2F7;
color: #8E8E93;
}
.n-icon .icon {
width: 64rpx;
height: 64rpx;
}
.detail-title {
font-size: 36rpx;
font-weight: 700;
color: #000;
text-align: center;
}
.detail-time {
font-size: 24rpx;
color: #C7C7CC;
text-align: center;
margin-top: 16rpx;
}
.detail-content {
font-size: 28rpx;
color: #3C3C43;
margin-top: 32rpx;
line-height: 1.7;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
@@ -0,0 +1,78 @@
import { request } from '../../utils/request'
import { PagedResult, NotificationItem } from '../../types/models'
import { formatRelativeTime } from '../../utils/format'
const FILTERS = [
{ name: '全部', value: 0 },
{ name: '点赞', value: 1 },
{ name: '订单', value: 2 },
{ name: '系统', value: 3 },
]
Page({
data: {
filters: FILTERS,
activeFilter: 0,
list: [] as NotificationItem[],
loading: true,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.refresh()
this.fetchUnread()
},
fetchUnread() {
request<{ unread_count: number }>({ url: '/notification/unread', showError: false })
.then((res) => {
wx.setStorageSync('zt_closet_unread', res.unread_count)
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar && tabBar.refreshUnread) tabBar.refreshUnread()
})
.catch(() => {})
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number) {
const params: Record<string, any> = { page, page_size: this.data.pageSize }
const filter = this.data.activeFilter
if (filter > 0) params.msg_type = filter
request<PagedResult<NotificationItem>>({ url: '/notification', data: params })
.then((res) => {
const list = res.list.map((it) => ({ ...it, _time: formatRelativeTime(it.created_at) }))
const merged = page === 1 ? list : this.data.list.concat(list)
this.setData({
list: merged,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => this.setData({ loading: false }))
},
onFilterTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeFilter) return
this.setData({ activeFilter: index, list: [], loading: true })
this.refresh()
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
wx.navigateTo({ url: `/pages/notification-detail/notification-detail?uuid=${uuid}` })
},
})
@@ -0,0 +1,53 @@
<view class="page page-flex">
<navigation-bar title="通知" />
<scroll-view
scroll-y
class="scroll-body"
bindscrolltolower="onReachBottom"
>
<!-- 筛选 -->
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
<view class="filter-row">
<view
wx:for="{{filters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onFilterTap"
>{{item.name}}</view>
</view>
</scroll-view>
<!-- 空态 -->
<empty-state
wx:if="{{!loading && !list.length}}"
icon="icon-bell"
title="还没有消息"
desc="穿搭被点赞、订单动态都会出现在这里"
/>
<!-- 列表 -->
<view class="list">
<view
class="n-card card {{item.is_read === 1 ? 'unread' : ''}}"
wx:for="{{list}}"
wx:key="notification_uuid"
data-uuid="{{item.notification_uuid}}"
bindtap="onItemTap"
>
<view class="n-icon {{item.msg_type === 1 ? 'like' : item.msg_type === 2 ? 'order' : 'sys'}}">
<view class="icon {{item.msg_type === 1 ? 'icon-heart' : item.msg_type === 2 ? 'icon-doc' : 'icon-bell'}}"></view>
</view>
<view class="n-body">
<view class="n-title-row">
<view wx:if="{{item.is_read === 1}}" class="n-dot"></view>
<text class="n-title {{item.is_read === 2 ? 'read' : ''}}">{{item.title}}</text>
</view>
<view class="n-content">{{item.content}}</view>
<view class="n-time">{{item._time}}</view>
</view>
</view>
</view>
</scroll-view>
</view>
@@ -0,0 +1,126 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.filter-scroll {
background: #fff;
white-space: nowrap;
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.filter-item {
flex-shrink: 0;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.filter-item.active {
background: #007AFF;
color: #fff;
}
.list {
padding: 24rpx;
}
.n-card {
display: flex;
align-items: flex-start;
padding: 28rpx;
margin-bottom: 20rpx;
}
.n-icon {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
}
.n-icon.like {
background: #FFE8EC;
color: #FF3B30;
}
.n-icon.order {
background: #E8F2FF;
color: #007AFF;
}
.n-icon.sys {
background: #F2F2F7;
color: #8E8E93;
}
.n-icon .icon {
width: 44rpx;
height: 44rpx;
}
.n-body {
flex: 1;
min-width: 0;
}
.n-title-row {
display: flex;
align-items: center;
}
.n-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #007AFF;
margin-right: 12rpx;
flex-shrink: 0;
}
.n-title {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.n-title.read {
color: #8E8E93;
font-weight: 500;
}
.n-content {
font-size: 26rpx;
color: #3C3C43;
margin-top: 8rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.n-time {
font-size: 22rpx;
color: #C7C7CC;
margin-top: 8rpx;
}
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
+127
View File
@@ -0,0 +1,127 @@
import { request } from '../../utils/request'
import { PagedResult, OrderItem } from '../../types/models'
import { fenToYuan, formatDateTime, formatCountdown } from '../../utils/format'
const STATUS_FILTERS = [
{ name: '全部', value: 0 },
{ name: '待支付', value: 1 },
{ name: '已支付', value: 2 },
{ name: '已取消', value: 3 },
{ name: '已过期', value: 4 },
{ name: '已退款', value: 5 },
]
const STATUS_TEXT: Record<number, string> = { 1: '待支付', 2: '已支付', 3: '已取消', 4: '已过期', 5: '已退款' }
const STATUS_TYPE: Record<number, string> = { 1: 'orange', 2: 'green', 3: 'gray', 4: 'gray', 5: 'blue' }
Page({
data: {
filters: STATUS_FILTERS,
activeFilter: 0,
list: [] as OrderItem[],
loading: true,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.refresh()
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number) {
const params: Record<string, any> = { page, page_size: this.data.pageSize }
const filter = this.data.activeFilter
if (filter > 0) params.status = filter
request<PagedResult<OrderItem>>({ url: '/order', data: params })
.then((res) => {
const now = Math.floor(Date.now() / 1000)
const list = res.list.map((it) => ({
...it,
_statusText: STATUS_TEXT[it.order_status] || '',
_statusType: STATUS_TYPE[it.order_status] || 'gray',
_amount: fenToYuan(it.pay_amount),
_time: formatDateTime(it.created_at),
_orderTail: `…${it.order_no.slice(-4)}`,
_countdown: it.order_status === 1 && it.expire_time > now ? formatCountdown(it.expire_time - now) : '',
_benefit: it.member_days > 0 ? `${it.member_days} 天会员` : '',
_canCancel: it.order_status === 1 && it.pay_channel !== 2,
_payChannelText: it.pay_channel === 2 ? '虚拟支付' : '',
}))
const merged = page === 1 ? list : this.data.list.concat(list)
this.setData({
list: merged,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => this.setData({ loading: false }))
},
onFilterTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeFilter) return
this.setData({ activeFilter: index, list: [], loading: true })
this.refresh()
},
onCancel(e: WechatMiniprogram.TouchEvent) {
const orderNo = e.currentTarget.dataset.no as string
wx.showModal({
title: '取消订单',
content: '确认取消该订单?',
success: (res) => {
if (!res.confirm) return
request({ url: `/order/${orderNo}/cancel`, method: 'POST' })
.then(() => {
wx.showToast({ title: '已取消', icon: 'success' })
this.refresh()
})
.catch(() => {})
},
})
},
onPay() {
wx.showToast({ title: '请返回商城重新下单支付', icon: 'none' })
},
onRefund(e: WechatMiniprogram.TouchEvent) {
const orderNo = e.currentTarget.dataset.no as string
wx.showModal({
title: '申请退款',
editable: true,
placeholderText: '请填写退款原因',
success: (res) => {
if (!res.confirm) return
const reason = (res.content || '').trim()
if (!reason) {
wx.showToast({ title: '请填写退款原因', icon: 'none' })
return
}
request({ url: '/order/refund', method: 'POST', data: { order_no: orderNo, reason } })
.then(() => {
wx.showToast({ title: '退款申请已提交,等待审核', icon: 'none' })
this.refresh()
})
.catch(() => {})
},
})
},
onGoMall() {
wx.navigateTo({ url: '/pages/mall/mall' })
},
})
+55
View File
@@ -0,0 +1,55 @@
<view class="page page-flex">
<navigation-bar title="我的订单" />
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom">
<!-- 筛选 -->
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
<view class="filter-row">
<view
wx:for="{{filters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onFilterTap"
>{{item.name}}</view>
</view>
</scroll-view>
<empty-state
wx:if="{{!loading && !list.length}}"
icon="icon-doc"
title="还没有订单"
desc=""
buttonText="去看看额度包"
bind:action="onGoMall"
/>
<!-- 订单列表 -->
<view class="list">
<view class="order-card card" wx:for="{{list}}" wx:key="order_no">
<view class="order-top">
<text class="order-no">订单 {{item._orderTail}}</text>
<text wx:if="{{item._payChannelText}}" class="tag tag-gray">{{item._payChannelText}}</text>
<text class="tag tag-{{item._statusType}}">{{item._statusText}}</text>
</view>
<view class="order-body">
<view class="order-goods">{{item.goods_name}}</view>
<view class="order-benefit" wx:if="{{item._benefit}}">{{item._benefit}}</view>
<view class="order-bottom">
<text class="order-amount">实付 <text class="num">{{item._amount}}</text></text>
<view class="order-actions">
<block wx:if="{{item.order_status === 1}}">
<text wx:if="{{item._countdown}}" class="countdown">剩余 {{item._countdown}}</text>
<view wx:if="{{item._canCancel}}" class="action-btn outline" data-no="{{item.order_no}}" bindtap="onCancel">取消订单</view>
<view class="action-btn primary" data-no="{{item.order_no}}" bindtap="onPay">去支付</view>
</block>
<block wx:elif="{{item.order_status === 2}}">
<view class="action-btn outline" data-no="{{item.order_no}}" bindtap="onRefund">申请退款</view>
</block>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
+122
View File
@@ -0,0 +1,122 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.filter-scroll {
background: #fff;
white-space: nowrap;
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.filter-item {
flex-shrink: 0;
padding: 0 28rpx;
height: 56rpx;
line-height: 56rpx;
border-radius: 28rpx;
font-size: 24rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.filter-item.active {
background: #007AFF;
color: #fff;
}
.list {
padding: 24rpx;
}
.order-card {
margin-bottom: 20rpx;
padding: 28rpx;
}
.order-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.order-no {
font-size: 24rpx;
color: #8E8E93;
}
.order-body {
margin-top: 20rpx;
}
.order-goods {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.order-benefit {
font-size: 24rpx;
color: #8E8E93;
margin-top: 6rpx;
}
.order-bottom {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
}
.order-amount {
font-size: 26rpx;
color: #3C3C43;
}
.order-amount .num {
font-size: 32rpx;
font-weight: 600;
color: #000;
}
.order-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.countdown {
font-size: 24rpx;
color: #8E8E93;
}
.action-btn {
height: 56rpx;
padding: 0 28rpx;
border-radius: 28rpx;
font-size: 24rpx;
display: flex;
align-items: center;
}
.action-btn.outline {
border: 1rpx solid #C7C7CC;
color: #3C3C43;
}
.action-btn.primary {
background: #007AFF;
color: #fff;
}
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
@@ -0,0 +1,145 @@
import { request } from '../../utils/request'
import { OutfitItem, ClothesItem } from '../../types/models'
import { OutfitSource, IsPublic } from '../../utils/config'
Page({
data: {
uuid: '',
isOwner: true,
outfit: {} as OutfitItem,
clothes: [] as ClothesItem[],
isLiked: false,
likeCount: 0,
isCollected: false,
loading: true,
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
const isOwner = query.owner !== '0'
this.setData({ uuid, isOwner })
if (uuid) this.fetchDetail()
},
async fetchDetail() {
try {
const outfit = await request<OutfitItem>({ url: `/outfit/${this.data.uuid}` })
this.setData({ outfit })
const clothes = await Promise.all(
outfit.items.map(async (it) => {
try {
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
} catch (e) {
return { clothes_uuid: it.clothes_uuid } as ClothesItem
}
})
)
this.setData({ clothes })
const like = await request<{ is_liked: boolean; like_count: number }>({
url: `/like/${this.data.uuid}`,
showError: false,
})
this.setData({ isLiked: like.is_liked, likeCount: like.like_count, loading: false })
// 收藏状态:调用后端 /collect/:targetUUID 初始化,失败静默保持未收藏
request<{ is_collected: boolean; collect_count: number }>({
url: `/collect/${this.data.uuid}`,
showError: false,
})
.then((collect) => this.setData({ isCollected: collect.is_collected }))
.catch(() => {})
} catch (e) {
this.setData({ loading: false })
}
},
onLike() {
const action = this.data.isLiked ? 2 : 1
request({ url: '/like', method: 'POST', data: { target_uuid: this.data.uuid, action } })
.then(() => {
this.setData({
isLiked: action === 1,
likeCount: this.data.likeCount + (action === 1 ? 1 : -1),
})
})
.catch(() => {})
},
onCollect() {
const action = this.data.isCollected ? 2 : 1
request({ url: '/collect', method: 'POST', data: { target_uuid: this.data.uuid, action } })
.then(() => this.setData({ isCollected: action === 1 }))
.catch(() => {})
},
onTogglePublic() {
const isPublic = this.data.outfit.is_public === IsPublic.PUBLIC
if (isPublic) {
this.doPublic(IsPublic.PRIVATE)
} else {
wx.showModal({
title: '公开到社区',
content: '公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度',
confirmText: '确认公开',
success: (res) => {
if (res.confirm) this.doPublic(IsPublic.PUBLIC)
},
})
}
},
doPublic(isPublic: number) {
request({ url: `/outfit/${this.data.uuid}/public`, method: 'PUT', data: { is_public: isPublic } })
.then(() => {
this.setData({ 'outfit.is_public': isPublic })
wx.showToast({ title: isPublic === IsPublic.PUBLIC ? '已公开' : '已取消公开', icon: 'none' })
})
.catch(() => {})
},
onMore() {
wx.showActionSheet({
itemList: ['编辑穿搭', '删除穿搭'],
success: (res) => {
if (res.tapIndex === 0) this.onEdit()
else this.onDelete()
},
})
},
onEdit() {
wx.navigateTo({ url: `/pages/outfit-edit/outfit-edit?uuid=${this.data.uuid}` })
},
onDelete() {
wx.showModal({
title: '确认删除这套穿搭?',
content: '删除后不可恢复,但不会影响衣橱内的单品。',
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
})
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
wx.navigateTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${uuid}` })
},
onShareAppMessage() {
return {
title: this.data.outfit.outfit_name || '我的穿搭',
path: `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=0`,
}
},
})
@@ -0,0 +1,79 @@
<view class="page detail-page">
<navigation-bar title="{{outfit.outfit_name || '穿搭详情'}}">
<view wx:if="{{isOwner}}" slot="right" class="nav-more" bindtap="onMore">
<view class="icon icon-ellipsis"></view>
</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<!-- 拼图封面 -->
<view class="cover">
<view
class="cover-item cover-{{index}}"
wx:for="{{clothes}}"
wx:key="clothes_uuid"
wx:if="{{index < 5}}"
>
<image wx:if="{{item.processed_image}}" class="cover-img" src="{{item.processed_image}}" mode="aspectFit" />
<view wx:else class="icon icon-hanger cover-placeholder"></view>
</view>
<view wx:if="{{!clothes.length}}" class="cover-empty">
<view class="icon icon-hanger"></view>
</view>
</view>
<!-- 信息卡片 -->
<view class="card info-card">
<view class="title-row">
<text class="title">{{outfit.outfit_name}}</text>
</view>
<view class="tag-row">
<text wx:if="{{outfit.scene}}" class="tag tag-gray">{{outfit.scene}}</text>
<text class="tag {{outfit.source === 2 ? 'tag-indigo' : 'tag-gray'}}">{{outfit.source === 2 ? 'AI 推荐' : '自建'}}</text>
<text class="tag {{outfit.is_public === 2 ? 'tag-blue' : 'tag-gray'}}">{{outfit.is_public === 2 ? '已公开' : '仅自己可见'}}</text>
</view>
<view class="desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
<!-- 包含衣物 -->
<view class="items-title">包含衣物</view>
<scroll-view scroll-x class="items-scroll" show-scrollbar="{{false}}">
<view class="items-row">
<view
class="item-chip"
wx:for="{{clothes}}"
wx:key="clothes_uuid"
data-uuid="{{item.clothes_uuid}}"
bindtap="onItemTap"
>
<image wx:if="{{item.processed_image}}" class="item-img" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="item-img item-img-empty"><view class="icon icon-hanger"></view></view>
<text class="item-name">{{item.name || '未命名'}}</text>
</view>
</view>
</scroll-view>
</view>
<view class="footer-placeholder"></view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="footer">
<view class="footer-left">
<view class="fb-btn" bindtap="onLike">
<view class="icon icon-heart {{isLiked ? 'liked' : ''}}"></view>
<text class="{{isLiked ? 'liked' : ''}}">{{likeCount}}</text>
</view>
<view class="fb-btn" wx:if="{{!isOwner}}" bindtap="onCollect">
<view class="icon {{isCollected ? 'icon-star-fill collected' : 'icon-star'}}"></view>
</view>
</view>
<view class="footer-right" wx:if="{{isOwner}}">
<view class="fb-public {{outfit.is_public === 2 ? 'public' : ''}}" bindtap="onTogglePublic">
{{outfit.is_public === 2 ? '取消公开' : '公开到社区'}}
</view>
<button class="fb-share" open-type="share">
<view class="icon icon-share"></view>
</button>
</view>
</view>
</view>
@@ -0,0 +1,241 @@
.detail-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.nav-more {
padding: 0 8rpx;
color: #000;
}
/* 封面 */
.cover {
margin: 24rpx;
height: 480rpx;
background: #fff;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.cover-item {
position: absolute;
width: 200rpx;
height: 260rpx;
background: #F8F8FA;
border-radius: 20rpx;
border: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cover-0 { left: 30rpx; top: 80rpx; transform: rotate(-6deg); }
.cover-1 { left: 120rpx; top: 140rpx; transform: rotate(2deg); }
.cover-2 { left: 220rpx; top: 70rpx; transform: rotate(5deg); }
.cover-3 { left: 320rpx; top: 150rpx; transform: rotate(-2deg); }
.cover-4 { left: 420rpx; top: 90rpx; transform: rotate(7deg); }
.cover-img {
width: 100%;
height: 100%;
}
.cover-placeholder {
width: 80rpx;
height: 80rpx;
color: #C7C7CC;
}
.cover-empty {
color: #C7C7CC;
}
.cover-empty .icon {
width: 120rpx;
height: 120rpx;
}
/* 信息卡 */
.info-card {
margin: 0 24rpx 24rpx;
}
.title {
font-size: 40rpx;
font-weight: 700;
color: #000;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.desc {
font-size: 28rpx;
color: #3C3C43;
margin-top: 20rpx;
line-height: 1.6;
}
.items-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
margin-top: 32rpx;
}
.items-scroll {
margin-top: 20rpx;
white-space: nowrap;
}
.items-row {
display: inline-flex;
}
.item-chip {
display: flex;
flex-direction: column;
align-items: center;
width: 140rpx;
margin-right: 20rpx;
}
.item-img {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background: #F2F2F7;
}
.item-img-empty {
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.item-img-empty .icon {
width: 56rpx;
height: 56rpx;
}
.item-name {
font-size: 20rpx;
color: #8E8E93;
margin-top: 8rpx;
max-width: 140rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.footer-placeholder {
height: 40rpx;
}
/* 底部操作栏 */
.footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-top: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 32rpx;
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
z-index: 50;
}
.footer-left {
display: flex;
align-items: center;
gap: 40rpx;
}
.fb-btn {
display: flex;
align-items: center;
font-size: 26rpx;
color: #8E8E93;
}
.fb-btn .icon {
width: 48rpx;
height: 48rpx;
margin-right: 8rpx;
}
.liked {
color: #FF2D55;
}
.collected {
color: #007AFF;
}
.footer-right {
display: flex;
align-items: center;
gap: 20rpx;
}
.fb-public {
height: 76rpx;
padding: 0 40rpx;
border-radius: 38rpx;
background: #007AFF;
color: #fff;
font-size: 28rpx;
font-weight: 500;
display: flex;
align-items: center;
}
.fb-public.public {
background: #fff;
border: 1rpx solid #007AFF;
color: #007AFF;
}
.fb-share {
width: 76rpx;
height: 76rpx;
padding: 0;
margin: 0;
background: #fff;
border: 1rpx solid #E5E5EA;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #007AFF;
line-height: 1;
}
.fb-share::after {
border: none;
}
.fb-share .icon {
width: 40rpx;
height: 40rpx;
}
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card"
}
}
@@ -0,0 +1,179 @@
import { request } from '../../utils/request'
import { OutfitItem, ClothesItem, PagedResult } from '../../types/models'
const CATEGORIES = [
{ name: '全部', value: '' },
{ name: '上装', value: '上装' },
{ name: '下装', value: '下装' },
{ name: '连衣裙', value: '连衣裙' },
{ name: '外套', value: '外套' },
{ name: '鞋', value: '鞋' },
{ name: '配饰', value: '配饰' },
]
Page({
data: {
uuid: '',
isEdit: false,
view: 'form' as 'form' | 'select',
outfitName: '',
outfitDesc: '',
scene: '',
selectedUuids: [] as string[],
selectedItems: [] as ClothesItem[],
categories: CATEGORIES,
activeCategory: 0,
allClothes: [] as ClothesItem[],
selectLoading: true,
},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
if (uuid) {
this.setData({ uuid, isEdit: true })
this.fetchOutfit(uuid)
}
},
async fetchOutfit(uuid: string) {
try {
const outfit = await request<OutfitItem>({ url: `/outfit/${uuid}` })
const uuids = outfit.items.map((i) => i.clothes_uuid)
const clothes = await Promise.all(
uuids.map(async (id) => {
try {
return await request<ClothesItem>({ url: `/clothes/${id}`, showError: false })
} catch (e) {
return { clothes_uuid: id } as ClothesItem
}
})
)
this.setData({
outfitName: outfit.outfit_name,
outfitDesc: outfit.outfit_desc,
scene: outfit.scene,
selectedUuids: uuids,
selectedItems: clothes,
})
} catch (e) {}
},
onNameInput(e: WechatMiniprogram.Input) {
this.setData({ outfitName: e.detail.value })
},
onDescInput(e: WechatMiniprogram.Input) {
this.setData({ outfitDesc: e.detail.value })
},
onSceneInput(e: WechatMiniprogram.Input) {
this.setData({ scene: e.detail.value })
},
openSelect() {
this.setData({ view: 'select', selectLoading: true })
this.fetchAllClothes()
},
backToForm() {
this.setData({ view: 'form' })
},
fetchAllClothes() {
const cat = CATEGORIES[this.data.activeCategory].value
const params: Record<string, any> = { page: 1, page_size: 50 }
if (cat) params.category_main = cat
request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
...it,
_selected: this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0,
}))
this.setData({ allClothes: list, selectLoading: false })
})
.catch(() => this.setData({ selectLoading: false }))
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeCategory) return
this.setData({ activeCategory: index, selectLoading: true })
this.fetchAllClothes()
},
onToggleSelect(e: WechatMiniprogram.CustomEvent) {
const item = e.detail.item as ClothesItem
const uuids = this.data.selectedUuids
const idx = uuids.indexOf(item.clothes_uuid)
if (idx >= 0) {
this.syncSelect(uuids.filter((u) => u !== item.clothes_uuid))
} else {
if (uuids.length >= 10) {
wx.showToast({ title: '最多选择 10 件衣物', icon: 'none' })
return
}
this.syncSelect([...uuids, item.clothes_uuid])
}
},
syncSelect(uuids: string[]) {
this.setData({
selectedUuids: uuids,
allClothes: this.data.allClothes.map((it) => ({
...it,
_selected: uuids.indexOf(it.clothes_uuid) >= 0,
})),
})
},
confirmSelect() {
const selectedItems = this.data.allClothes.filter(
(it) => this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0
)
this.setData({ view: 'form', selectedItems })
},
onSave() {
const name = this.data.outfitName.trim()
if (!name) {
wx.showToast({ title: '请填写穿搭名称', icon: 'none' })
return
}
if (!this.data.selectedUuids.length) {
wx.showToast({ title: '至少选择 1 件衣物', icon: 'none' })
return
}
const body = {
outfit_name: name,
outfit_desc: this.data.outfitDesc,
scene: this.data.scene,
items: this.data.selectedUuids.map((u) => ({ clothes_uuid: u })),
}
const req = this.data.isEdit
? request({ url: `/outfit/${this.data.uuid}`, method: 'PUT', data: body })
: request({ url: '/outfit', method: 'POST', data: body })
req
.then(() => {
wx.showToast({ title: '已保存', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
onDelete() {
wx.showModal({
title: '确认删除这套穿搭?',
content: '删除后不可恢复,但不会影响衣橱内的单品。',
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(() => wx.navigateBack(), 500)
})
.catch(() => {})
},
})
},
})
@@ -0,0 +1,114 @@
<view class="page edit-page">
<navigation-bar title="{{view === 'select' ? '选择衣橱单品' : (isEdit ? '编辑穿搭' : '创建穿搭')}}">
<view wx:if="{{view === 'select'}}" slot="right" class="nav-cancel" bindtap="backToForm">取消</view>
</navigation-bar>
<!-- 表单视图 -->
<scroll-view wx:if="{{view === 'form'}}" scroll-y class="scroll-body">
<!-- 拼图预览 -->
<view class="card preview">
<view wx:if="{{selectedItems.length}}" class="preview-items">
<view
class="preview-item preview-{{index}}"
wx:for="{{selectedItems}}"
wx:key="clothes_uuid"
wx:if="{{index < 5}}"
>
<image wx:if="{{item.processed_image}}" class="preview-img" src="{{item.processed_image}}" mode="aspectFit" />
<view wx:else class="icon icon-hanger preview-placeholder"></view>
</view>
<view wx:if="{{selectedItems.length > 5}}" class="preview-more">+{{selectedItems.length - 5}}</view>
</view>
<view wx:else class="preview-empty">
<view class="icon icon-hanger"></view>
<text class="pe-title">还没有选择衣物</text>
<text class="pe-desc">点击下方「选择衣物」添加你的单品</text>
</view>
</view>
<!-- 表单 -->
<view class="card form-card">
<view class="field">
<view class="field-label">
<text>穿搭名称 <text class="req">*</text></text>
<text class="count">{{outfitName.length}}/32</text>
</view>
<input class="input" value="{{outfitName}}" bindinput="onNameInput" placeholder="给这套穿搭起个名字" maxlength="32" />
</view>
<view class="field">
<view class="field-label">
<text>穿搭描述</text>
<text class="count">{{outfitDesc.length}}/500</text>
</view>
<textarea class="textarea" value="{{outfitDesc}}" bindinput="onDescInput" placeholder="简单描述这套穿搭的灵感或亮点" maxlength="500" />
</view>
<view class="field">
<view class="field-label">
<text>适合场景</text>
<text class="count">{{scene.length}}/100</text>
</view>
<input class="input" value="{{scene}}" bindinput="onSceneInput" placeholder="例如:通勤、约会、旅行" maxlength="100" />
</view>
</view>
<!-- 选择衣物入口 -->
<view class="card select-entry" bindtap="openSelect">
<view class="entry-left">
<view class="entry-icon"><view class="icon icon-shirt"></view></view>
<view>
<view class="entry-title">选择衣橱单品</view>
<view class="entry-desc">{{selectedUuids.length ? '点击调整当前穿搭包含的衣物' : '选择 1~10 件衣物合成画报'}}</view>
</view>
</view>
<view class="entry-right">
<text class="entry-count {{selectedUuids.length ? 'on' : ''}}">{{selectedUuids.length ? '已选 ' + selectedUuids.length + ' 件' : '未选择'}}</text>
<view class="icon icon-chevron-right"></view>
</view>
</view>
<!-- 底部按钮 -->
<view class="footer">
<view wx:if="{{isEdit}}" class="btn-danger-text delete-link" bindtap="onDelete">删除穿搭</view>
<view
class="btn btn-primary {{(!outfitName || !selectedUuids.length) ? 'disabled' : ''}}"
bindtap="onSave"
>{{isEdit ? '保存修改' : '保存'}}</view>
</view>
</scroll-view>
<!-- 多选视图 -->
<view wx:else class="select-view">
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
<view class="cat-row">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
<scroll-view scroll-y class="select-body">
<view wx:if="{{selectLoading}}" class="grid">
<view class="grid-item" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
<view class="skeleton sk-card"></view>
</view>
</view>
<view wx:else class="grid">
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
</view>
</view>
</scroll-view>
<view class="select-footer">
<view>
<view class="sf-count">已选 <text class="t-primary">{{selectedUuids.length}}</text> 件</view>
<view class="sf-desc">最多可选择 10 件单品</view>
</view>
<view class="sf-confirm btn btn-primary" bindtap="confirmSelect">确定</view>
</view>
</view>
</view>
@@ -0,0 +1,296 @@
.edit-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.scroll-body {
flex: 1;
height: 0;
}
.nav-cancel {
font-size: 30rpx;
color: #007AFF;
padding: 0 8rpx;
}
/* 预览区 */
.preview {
margin: 24rpx;
height: 320rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.preview-empty {
display: flex;
flex-direction: column;
align-items: center;
color: #C7C7CC;
}
.preview-empty .icon {
width: 96rpx;
height: 96rpx;
}
.pe-title {
font-size: 26rpx;
color: #8E8E93;
margin-top: 16rpx;
}
.pe-desc {
font-size: 22rpx;
color: #C7C7CC;
margin-top: 8rpx;
}
.preview-items {
width: 100%;
height: 100%;
position: relative;
}
.preview-item {
position: absolute;
width: 140rpx;
height: 180rpx;
background: #F8F8FA;
border-radius: 16rpx;
border: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.preview-0 { left: 30rpx; top: 60rpx; transform: rotate(-5deg); }
.preview-1 { left: 120rpx; top: 100rpx; transform: rotate(3deg); }
.preview-2 { left: 210rpx; top: 50rpx; transform: rotate(6deg); }
.preview-3 { left: 300rpx; top: 110rpx; transform: rotate(-3deg); }
.preview-4 { left: 390rpx; top: 70rpx; transform: rotate(8deg); }
.preview-img {
width: 100%;
height: 100%;
}
.preview-placeholder {
width: 56rpx;
height: 56rpx;
color: #C7C7CC;
}
.preview-more {
position: absolute;
right: 24rpx;
top: 24rpx;
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #007AFF;
color: #fff;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* 表单 */
.form-card {
margin: 0 24rpx 24rpx;
}
.field {
margin-bottom: 32rpx;
}
.field:last-child {
margin-bottom: 0;
}
.field-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
font-weight: 600;
color: #1C1C1E;
margin-bottom: 16rpx;
}
.req {
color: #FF3B30;
}
.count {
font-size: 22rpx;
color: #8E8E93;
font-weight: 400;
}
/* 选择衣物入口 */
.select-entry {
margin: 0 24rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.entry-left {
display: flex;
align-items: center;
}
.entry-icon {
width: 72rpx;
height: 72rpx;
border-radius: 24rpx;
background: rgba(0, 122, 255, 0.1);
color: #007AFF;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.entry-icon .icon {
width: 40rpx;
height: 40rpx;
}
.entry-title {
font-size: 28rpx;
font-weight: 600;
color: #1C1C1E;
}
.entry-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 4rpx;
}
.entry-right {
display: flex;
align-items: center;
color: #8E8E93;
}
.entry-count {
font-size: 26rpx;
margin-right: 8rpx;
}
.entry-count.on {
color: #007AFF;
}
.entry-right .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
}
/* 底部按钮 */
.footer {
padding: 16rpx 24rpx;
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
}
.delete-link {
text-align: center;
padding: 16rpx 0 24rpx;
}
/* 多选视图 */
.select-view {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
flex-shrink: 0;
}
.cat-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.cat-item {
flex-shrink: 0;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.cat-item.active {
background: #007AFF;
color: #fff;
}
.select-body {
flex: 1;
height: 0;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
}
.sk-card {
height: 360rpx;
border-radius: 32rpx;
}
.select-footer {
flex-shrink: 0;
background: #fff;
border-top: 1rpx solid #E5E5EA;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 32rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
}
.sf-count {
font-size: 30rpx;
font-weight: 600;
color: #1C1C1E;
}
.sf-desc {
font-size: 22rpx;
color: #8E8E93;
margin-top: 4rpx;
}
.sf-confirm {
width: 200rpx;
height: 80rpx;
font-size: 28rpx;
}
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
}
}
+107
View File
@@ -0,0 +1,107 @@
import { request } from '../../utils/request'
import { PagedResult, OutfitItem } from '../../types/models'
import { OutfitSource, IsPublic } from '../../utils/config'
Page({
data: {
activeTab: 0,
list: [] as OutfitItem[],
loading: true,
refreshing: false,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.syncTabBar()
const openCollect = wx.getStorageSync('zt_open_collect')
if (openCollect) {
wx.removeStorageSync('zt_open_collect')
if (this.data.activeTab !== 1) {
this.setData({ activeTab: 1, list: [], loading: true })
}
}
this.refresh()
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) tabBar.setData({ selected: 1 })
},
onTabChange(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeTab) return
this.setData({ activeTab: index, list: [], loading: true })
this.refresh()
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
},
onRefresh() {
this.setData({ refreshing: true })
this.fetchList(1).finally(() => this.setData({ refreshing: false }))
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number): Promise<void> {
const isCollect = this.data.activeTab === 1
const url = isCollect ? '/collect' : '/outfit'
return request<PagedResult<OutfitItem>>({ url, data: { page, page_size: this.data.pageSize } })
.then(async (res) => {
const items = await this.decorateList(res.list)
const merged = page === 1 ? items : this.data.list.concat(items)
this.setData({
list: merged,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => this.setData({ loading: false }))
},
decorateList(list: OutfitItem[]): Promise<OutfitItem[]> {
return Promise.all(
list.map(async (it) => {
let likeCount = 0
try {
const like = await request<{ is_liked: boolean; like_count: number }>({
url: `/like/${it.outfit_uuid}`,
showError: false,
})
likeCount = like.like_count
} catch (e) {}
return { ...it, _likeCount: likeCount, _status: this.getStatusText(it) }
})
)
},
getStatusText(it: OutfitItem): { text: string; type: string } {
if (it.source === OutfitSource.AI) return { text: 'AI 推荐', type: 'indigo' }
if (it.is_public === IsPublic.PUBLIC) return { text: '已公开', type: 'blue' }
return { text: '仅自己可见', type: 'gray' }
},
onCreate() {
wx.navigateTo({ url: '/pages/outfit-edit/outfit-edit' })
},
onAiRecommend() {
wx.navigateTo({ url: '/pages/ai-recommend/ai-recommend' })
},
onOutfitTap(e: WechatMiniprogram.TouchEvent) {
const uuid = e.currentTarget.dataset.uuid as string
const owner = this.data.activeTab === 0 ? '1' : '0'
wx.navigateTo({ url: `/pages/outfit-detail/outfit-detail?uuid=${uuid}&owner=${owner}` })
},
})
+84
View File
@@ -0,0 +1,84 @@
<view class="page page-flex">
<navigation-bar title="穿搭" back="{{false}}" />
<scroll-view
scroll-y
class="scroll-body"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
>
<!-- 分段控件 -->
<view class="topbar">
<view class="segment">
<view class="segment-item {{activeTab === 0 ? 'active' : ''}}" data-index="0" bindtap="onTabChange">我的穿搭</view>
<view class="segment-item {{activeTab === 1 ? 'active' : ''}}" data-index="1" bindtap="onTabChange">已收藏</view>
</view>
</view>
<!-- 入口按钮 -->
<view class="entry-row">
<view class="entry-btn entry-outline" bindtap="onCreate">+ 创建穿搭</view>
<view class="entry-btn entry-primary" bindtap="onAiRecommend">AI 推荐</view>
</view>
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="list">
<view class="skeleton sk-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
</view>
<!-- 空态 -->
<empty-state
wx:elif="{{!list.length}}"
icon="icon-shirt"
title="{{activeTab === 0 ? '还没有穿搭' : '还没有收藏的穿搭'}}"
desc="{{activeTab === 0 ? '从衣橱挑几件,搭一套吧' : ''}}"
buttonText="{{activeTab === 0 ? '创建穿搭' : ''}}"
bind:action="onCreate"
/>
<!-- 卡片流 -->
<view wx:else class="list">
<view
class="outfit-card card"
wx:for="{{list}}"
wx:key="outfit_uuid"
data-uuid="{{item.outfit_uuid}}"
bindtap="onOutfitTap"
>
<view class="cover">
<view
class="cover-item cover-{{index}}"
wx:for="{{item.items}}"
wx:key="clothes_uuid"
wx:if="{{index < 5}}"
>
<image wx:if="{{item.processed_image}}" class="cover-img" src="{{item.processed_image}}" mode="aspectFit" />
<view wx:else class="icon icon-hanger cover-ph"></view>
</view>
<view wx:if="{{!item.items.length}}" class="icon icon-hanger"></view>
<text class="cover-count">{{item.items.length}} 件</text>
</view>
<view class="card-body">
<view class="title-row">
<text class="title">{{item.outfit_name}}</text>
<text class="tag tag-{{item._status.type}}">{{item._status.text}}</text>
</view>
<view class="desc" wx:if="{{item.outfit_desc}}">{{item.outfit_desc}}</view>
<view class="meta-row">
<text class="tag tag-gray" wx:if="{{item.scene}}">{{item.scene}}</text>
<text class="meta">{{item.items.length}} 件衣物</text>
<view class="spacer"></view>
<view class="meta-icon">
<view class="icon icon-heart heart"></view>
<text>{{item._likeCount || 0}}</text>
</view>
</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
</scroll-view>
</view>
+208
View File
@@ -0,0 +1,208 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.topbar {
background: #fff;
padding: 20rpx 32rpx;
}
.segment {
display: flex;
background: #F2F2F7;
border-radius: 20rpx;
padding: 6rpx;
}
.segment-item {
flex: 1;
height: 64rpx;
line-height: 64rpx;
text-align: center;
font-size: 28rpx;
color: #3C3C43;
border-radius: 16rpx;
}
.segment-item.active {
background: #fff;
color: #007AFF;
font-weight: 600;
}
.entry-row {
display: flex;
gap: 20rpx;
padding: 24rpx 32rpx;
}
.entry-btn {
flex: 1;
height: 72rpx;
border-radius: 36rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 500;
}
.entry-outline {
border: 1rpx solid #007AFF;
color: #007AFF;
background: #fff;
}
.entry-primary {
background: #007AFF;
color: #fff;
}
.list {
padding: 0 32rpx;
}
.sk-card {
height: 360rpx;
border-radius: 32rpx;
margin-bottom: 24rpx;
}
.outfit-card {
margin-bottom: 24rpx;
padding: 24rpx;
}
.cover {
height: 280rpx;
background: #F8F8FA;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
position: relative;
overflow: hidden;
}
.cover .icon {
width: 96rpx;
height: 96rpx;
}
.cover-item {
position: absolute;
width: 150rpx;
height: 190rpx;
background: #fff;
border: 1rpx solid #E5E5EA;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.cover-0 { left: 16rpx; top: 40rpx; transform: rotate(-6deg); }
.cover-1 { left: 140rpx; top: 82rpx; transform: rotate(3deg); }
.cover-2 { left: 268rpx; top: 32rpx; transform: rotate(5deg); }
.cover-3 { left: 392rpx; top: 84rpx; transform: rotate(-3deg); }
.cover-4 { left: 480rpx; top: 40rpx; transform: rotate(6deg); }
.cover-img {
width: 100%;
height: 100%;
}
.cover-ph {
width: 72rpx;
height: 72rpx;
color: #C7C7CC;
}
.cover-count {
position: absolute;
top: 20rpx;
right: 20rpx;
font-size: 22rpx;
color: #007AFF;
background: rgba(0, 122, 255, 0.1);
padding: 4rpx 20rpx;
border-radius: 20rpx;
}
.card-body {
margin-top: 20rpx;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 32rpx;
font-weight: 600;
color: #000;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16rpx;
}
.desc {
font-size: 26rpx;
color: #8E8E93;
margin-top: 12rpx;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
.meta-row {
display: flex;
align-items: center;
margin-top: 16rpx;
}
.meta {
font-size: 22rpx;
color: #8E8E93;
margin-left: 12rpx;
}
.spacer {
flex: 1;
}
.meta-icon {
display: flex;
align-items: center;
font-size: 22rpx;
color: #8E8E93;
}
.heart {
width: 36rpx;
height: 36rpx;
color: #C7C7CC;
margin-right: 8rpx;
}
.no-more {
text-align: center;
font-size: 24rpx;
color: #C7C7CC;
padding: 24rpx 0 40rpx;
}
+5
View File
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
+172
View File
@@ -0,0 +1,172 @@
import { request, uploadFile } from '../../utils/request'
import { UserInfo, QuotaItem } from '../../types/models'
import { QuotaType, STORAGE_KEYS } from '../../utils/config'
import { formatDate } from '../../utils/format'
function getZodiac(birthDay: number): string {
if (!birthDay) return ''
const d = new Date(birthDay * 1000)
const m = d.getMonth() + 1
const day = d.getDate()
const arr = [
[120, '摩羯座'], [218, '水瓶座'], [320, '双鱼座'], [419, '白羊座'],
[520, '金牛座'], [621, '双子座'], [722, '巨蟹座'], [822, '狮子座'],
[922, '处女座'], [1023, '天秤座'], [1122, '天蝎座'], [1221, '射手座'],
]
const val = m * 100 + day
for (let i = 0; i < arr.length; i++) {
if (val <= (arr[i][0] as number)) return arr[i][1] as string
}
return '摩羯座'
}
Page({
data: {
userInfo: {} as UserInfo,
uploadRemain: 0,
zodiac: '',
editVisible: false,
avatarUploading: false,
avatarChanged: false,
editForm: {
username: '',
sex: 0,
birth_day: 0,
avatar: '',
},
birthDateStr: '',
today: '',
},
onLoad() {
const d = new Date()
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`)
this.setData({ today: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` })
},
onShow() {
this.syncTabBar()
this.fetchUser()
this.fetchQuota()
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) {
tabBar.setData({ selected: 4 })
if (tabBar.refreshUnread) tabBar.refreshUnread()
}
},
fetchUser() {
request<UserInfo>({ url: '/user/info', showError: false })
.then((user) => {
wx.setStorageSync(STORAGE_KEYS.USER_INFO, user)
this.setData({ userInfo: user, zodiac: getZodiac(user.birth_day) })
})
.catch(() => {})
},
fetchQuota() {
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
.then((res) => {
const upload = res.quotas.find((q) => q.quota_type === QuotaType.UPLOAD)
if (upload) this.setData({ uploadRemain: upload.remain })
})
.catch(() => {})
},
onEdit() {
const u = this.data.userInfo
this.setData({
editVisible: true,
avatarChanged: false,
editForm: {
username: u.username || '',
sex: u.sex || 0,
birth_day: u.birth_day || 0,
avatar: u.avatar || '',
},
birthDateStr: u.birth_day ? formatDate(u.birth_day) : '',
})
},
onCloseEdit() {
this.setData({ editVisible: false })
},
noop() {},
onChooseAvatar(e: WechatMiniprogram.CustomEvent) {
this.setData({ 'editForm.avatar': e.detail.avatarUrl, avatarChanged: true })
},
onNameInput(e: WechatMiniprogram.Input) {
this.setData({ 'editForm.username': e.detail.value })
},
onSexSelect() {
wx.showActionSheet({
itemList: ['男', '女'],
success: (res) => this.setData({ 'editForm.sex': res.tapIndex + 1 }),
})
},
onBirthChange(e: WechatMiniprogram.CustomEvent) {
const dateStr = e.detail.value as string
const ts = Math.floor(new Date(dateStr.replace(/-/g, '/')).getTime() / 1000)
this.setData({ 'editForm.birth_day': ts, birthDateStr: dateStr })
},
onSaveProfile() {
if (this.data.avatarUploading) return
const f = this.data.editForm
const avatar = f.avatar || ''
const saveProfile = () => {
const body: Record<string, any> = {}
if (f.username) body.username = f.username
if (f.sex) body.sex = f.sex
if (f.birth_day) body.birth_day = f.birth_day
request({ url: '/user/profile', method: 'PUT', data: body })
.then(() => {
wx.showToast({ title: '已保存', icon: 'success' })
this.setData({ editVisible: false, avatarUploading: false })
this.fetchUser()
})
.catch(() => this.setData({ avatarUploading: false }))
}
if (this.data.avatarChanged && avatar) {
this.setData({ avatarUploading: true })
uploadFile<{ avatar: string }>(avatar, 'file', '/user/avatar/upload')
.then(() => saveProfile())
.catch(() => this.setData({ avatarUploading: false }))
} else {
saveProfile()
}
},
onTapMall() {
wx.navigateTo({ url: '/pages/mall/mall' })
},
onTapOrder() {
wx.navigateTo({ url: '/pages/order/order' })
},
onTapNotification() {
wx.navigateTo({ url: '/pages/notification/notification' })
},
onTapCollect() {
wx.setStorageSync('zt_open_collect', 1)
wx.switchTab({ url: '/pages/outfit/outfit' })
},
onTapFlow() {
wx.navigateTo({ url: '/pages/mall/mall?tab=flow' })
},
onTapService() {
wx.showToast({ title: '客服与反馈敬请期待', icon: 'none' })
},
onTapAbout() {
wx.showToast({ title: '纸条AI衣橱 v1.0.0', icon: 'none' })
},
})
+125
View File
@@ -0,0 +1,125 @@
<view class="page page-flex">
<navigation-bar title="我的" back="{{false}}" />
<scroll-view scroll-y class="scroll-body">
<!-- 额度提示条 -->
<view wx:if="{{uploadRemain > 0 && uploadRemain <= 2}}" class="quota-banner" bindtap="onTapMall">
你的上传额度仅剩 {{uploadRemain}} 次,点击购买额度包 >
</view>
<!-- 个人信息 -->
<view class="card user-card">
<view class="avatar">
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user avatar-placeholder"></view>
</view>
<view class="user-info">
<view class="user-name">
<text>{{userInfo.username || '微信用户'}}</text>
<text wx:if="{{userInfo.sex === 1}}" class="sex-tag">男</text>
<text wx:elif="{{userInfo.sex === 2}}" class="sex-tag">女</text>
</view>
<view class="user-tags">
<text wx:if="{{userInfo.vip_type !== 1}}" class="tag tag-blue">月度会员</text>
<text wx:if="{{zodiac}}" class="tag tag-gray">{{zodiac}}</text>
<text wx:else class="tag tag-gray" bindtap="onEdit">设置生日</text>
</view>
</view>
<view class="edit-link" bindtap="onEdit">编辑资料<view class="icon icon-chevron-right"></view></view>
</view>
<!-- 功能列表 1 -->
<view class="card menu-card">
<view class="menu-row hairline" bindtap="onTapMall">
<view class="icon icon-gauge menu-icon"></view>
<text class="menu-label">我的额度</text>
<text class="menu-value">上传额度剩 {{uploadRemain}}</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onTapOrder">
<view class="icon icon-doc menu-icon"></view>
<text class="menu-label">我的订单</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onTapNotification">
<view class="icon icon-bell menu-icon"></view>
<text class="menu-label">通知</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onTapCollect">
<view class="icon icon-star menu-icon"></view>
<text class="menu-label">我的收藏</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row" bindtap="onTapFlow">
<view class="icon icon-doc menu-icon"></view>
<text class="menu-label">额度明细</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
</view>
<!-- 功能列表 2 -->
<view class="card menu-card">
<view class="menu-row hairline" bindtap="onEdit">
<view class="icon icon-pencil menu-icon"></view>
<text class="menu-label">编辑资料</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onEdit">
<view class="icon icon-calendar menu-icon"></view>
<text class="menu-label">设置生日</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row hairline" bindtap="onTapService">
<view class="icon icon-headphones menu-icon"></view>
<text class="menu-label">客服与反馈</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
<view class="menu-row" bindtap="onTapAbout">
<view class="icon icon-info menu-icon"></view>
<text class="menu-label">关于纸条AI衣橱</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
</view>
<view class="version">v1.0.0</view>
</scroll-view>
<!-- 编辑资料弹窗 -->
<view wx:if="{{editVisible}}" class="mask" bindtap="onCloseEdit">
<view class="sheet" catchtap="noop">
<view class="sheet-grabber"></view>
<view class="sheet-title">编辑资料</view>
<view class="avatar-wrap">
<image wx:if="{{editForm.avatar}}" class="avatar-img" src="{{editForm.avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user avatar-placeholder"></view>
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
</view>
<view class="avatar-tip">点击更换头像</view>
<view class="form-field">
<text class="form-label">昵称</text>
<input class="input" type="nickname" value="{{editForm.username}}" bindinput="onNameInput" maxlength="32" placeholder="请输入昵称" />
</view>
<view class="form-field">
<text class="form-label">性别</text>
<view class="form-value" bindtap="onSexSelect">
<text class="{{editForm.sex ? '' : 'placeholder'}}">{{editForm.sex === 1 ? '男' : editForm.sex === 2 ? '女' : '请选择'}}</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
</view>
<view class="form-field">
<text class="form-label">生日</text>
<picker mode="date" value="{{birthDateStr}}" end="{{today}}" bindchange="onBirthChange">
<view class="form-value">
<text class="{{birthDateStr ? '' : 'placeholder'}}">{{birthDateStr || '请选择生日'}}</text>
<view class="icon icon-chevron-right menu-arrow"></view>
</view>
</picker>
</view>
<view class="btn btn-primary sheet-btn {{avatarUploading ? 'btn-disabled' : ''}}" bindtap="onSaveProfile">{{avatarUploading ? '上传中…' : '保存'}}</view>
</view>
</view>
</view>
+266
View File
@@ -0,0 +1,266 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.quota-banner {
margin: 24rpx 24rpx 0;
background: #FFF3E6;
color: #FF9500;
font-size: 24rpx;
padding: 20rpx 24rpx;
border-radius: 16rpx;
}
.user-card {
margin: 24rpx;
display: flex;
align-items: center;
}
.avatar {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
background: #F2F2F7;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 72rpx;
height: 72rpx;
}
.user-info {
flex: 1;
margin-left: 24rpx;
min-width: 0;
}
.user-name {
display: flex;
align-items: center;
font-size: 36rpx;
font-weight: 700;
color: #000;
}
.sex-tag {
font-size: 20rpx;
color: #007AFF;
background: rgba(0, 122, 255, 0.1);
border-radius: 8rpx;
padding: 2rpx 12rpx;
margin-left: 12rpx;
font-weight: 400;
}
.user-tags {
display: flex;
gap: 12rpx;
margin-top: 12rpx;
}
.edit-link {
display: flex;
align-items: center;
font-size: 26rpx;
color: #8E8E93;
}
.edit-link .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
}
.menu-card {
margin: 0 24rpx 24rpx;
padding: 0 32rpx;
}
.menu-row {
display: flex;
align-items: center;
height: 100rpx;
}
.menu-icon {
width: 44rpx;
height: 44rpx;
color: #007AFF;
margin-right: 24rpx;
}
.menu-label {
flex: 1;
font-size: 28rpx;
color: #000;
}
.menu-value {
font-size: 26rpx;
color: #8E8E93;
}
.menu-arrow {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
margin-left: 12rpx;
}
.version {
text-align: center;
font-size: 22rpx;
color: #C7C7CC;
padding: 24rpx 0 48rpx;
}
/* 编辑弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
}
.sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
padding: 20rpx 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom) + 120rpx);
max-height: 85vh;
overflow-y: auto;
}
.sheet-grabber {
width: 72rpx;
height: 10rpx;
border-radius: 5rpx;
background: #E5E5EA;
margin: 0 auto 24rpx;
}
.sheet-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
text-align: center;
margin-bottom: 24rpx;
}
.avatar-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
margin: 0 auto;
border-radius: 50%;
background: #F2F2F7;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-sizing: border-box;
color: #C7C7CC;
}
.avatar-wrap .avatar-img {
width: 100%;
height: 100%;
}
.avatar-wrap .avatar-placeholder {
width: 88rpx;
height: 88rpx;
}
.avatar-btn {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
border: 0;
background: transparent;
opacity: 0;
}
.avatar-btn::after {
border: none;
}
.avatar-tip {
text-align: center;
font-size: 22rpx;
color: #8E8E93;
margin: 16rpx 0 24rpx;
}
.form-field {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #F2F2F7;
}
.form-label {
width: 140rpx;
font-size: 28rpx;
color: #000;
flex-shrink: 0;
}
.form-field .input {
flex: 1;
background: #fff;
padding: 0;
}
.form-value {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #000;
}
.placeholder {
color: #C7C7CC;
}
.sheet-btn {
margin-top: 32rpx;
width: 100%;
}
.btn-disabled {
opacity: 0.5;
pointer-events: none;
}
+5
View File
@@ -0,0 +1,5 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
}
}
+135
View File
@@ -0,0 +1,135 @@
import { request, uploadFile } from '../../utils/request'
import { ProcessStatus } from '../../utils/config'
Page({
data: {
state: 'idle' as 'idle' | 'processing' | 'failed',
imagePath: '',
clothesUuid: '',
failReason: '',
step: 0,
},
onChoose() {
wx.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: (res) => {
const sourceType: Array<'camera' | 'album'> = res.tapIndex === 0 ? ['camera'] : ['album']
this.chooseImage(sourceType)
},
})
},
chooseImage(sourceType: Array<'camera' | 'album'>) {
wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType,
success: (res) => {
const file = res.tempFiles[0]
this.startUpload(file.tempFilePath)
},
})
},
startUpload(filePath: string) {
this.setData({ state: 'processing', imagePath: filePath, step: 0, failReason: '' })
uploadFile<{ clothes_uuid: string; image_process_status: number }>(filePath)
.then((res) => {
this.setData({ clothesUuid: res.clothes_uuid })
this.askNextAction()
})
.catch((e: any) => {
this.setData({ state: 'idle' })
if (e && e.code === 80000) this.showQuotaModal()
})
},
askNextAction() {
// 后台继续轮询,仅用于更新步骤进度(不再自动跳详情)
this.pollStatus(this.data.clothesUuid)
// 立即弹窗让用户选择下一步,避免干等
wx.showModal({
title: '衣物已上传',
content: 'AI 正在识别中,可在衣橱查看进度',
confirmText: '继续上传',
cancelText: '回到衣橱',
success: (res) => {
this.clearTimer()
if (res.confirm) {
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' })
} else {
wx.navigateBack({
delta: 1,
fail: () => wx.switchTab({ url: '/pages/wardrobe/wardrobe' }),
})
}
},
})
},
pollStatus(uuid: string) {
let count = 0
;(this as any).pollTimer = setInterval(() => {
count++
request<{ image_process_status: number; fail_reason: string }>({
url: `/clothes/${uuid}/status`,
showError: false,
})
.then((res) => {
const status = res.image_process_status
if (status === ProcessStatus.MATTING) {
this.setData({ step: 1 })
} else if (status === ProcessStatus.CLASSIFYING) {
this.setData({ step: 2 })
} else if (status === ProcessStatus.SUCCESS) {
// 处理成功不再自动跳转,由用户自行从衣橱进入详情
this.clearTimer()
} else if (status === ProcessStatus.FAILED) {
this.clearTimer()
this.setData({ state: 'failed', failReason: res.fail_reason || '识别失败' })
}
})
.catch(() => {})
if (count >= 60) {
this.clearTimer()
this.setData({ state: 'failed', failReason: '处理超时,请重试' })
}
}, 1500)
},
clearTimer() {
const t = (this as any).pollTimer
if (t) {
clearInterval(t)
;(this as any).pollTimer = null
}
},
onUnload() {
this.clearTimer()
},
showQuotaModal() {
wx.showModal({
title: '上传额度不足',
content: '当前剩余 0 次,购买额度包后可继续上传',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/mall/mall' })
},
})
},
onRetry() {
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', failReason: '' })
},
onManualEdit() {
if (!this.data.clothesUuid) return
wx.redirectTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${this.data.clothesUuid}` })
},
})
+48
View File
@@ -0,0 +1,48 @@
<view class="page upload-page">
<navigation-bar title="上传衣物" />
<!-- 初始态 -->
<view wx:if="{{state === 'idle'}}" class="idle">
<view class="upload-zone" bindtap="onChoose">
<view class="icon icon-camera upload-camera"></view>
<view class="upload-title">拍照或从相册选择</view>
<view class="upload-desc">支持 JPG / PNG,单张不超过 20MB</view>
<view class="upload-desc">上传消耗 1 次上传额度</view>
</view>
<view class="upload-tip">AI 将自动抠图并识别 15 项属性</view>
</view>
<!-- 处理中 -->
<view wx:elif="{{state === 'processing'}}" class="processing">
<image class="proc-img" src="{{imagePath}}" mode="aspectFit" />
<view class="steps">
<view class="step">
<view class="step-dot {{step >= 0 ? 'active' : ''}}"></view>
<text class="{{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 >= 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 >= 2 ? 'active-text' : ''}}">智能分类</text>
</view>
</view>
<view class="proc-text">AI 正在识别你的衣物…</view>
<view class="proc-desc">通常需要 10 秒左右,请稍候</view>
</view>
<!-- 失败态 -->
<view wx:else class="failed">
<view class="fail-icon">!</view>
<view class="fail-title">识别失败:{{failReason}}</view>
<view class="fail-desc">本次上传额度已自动退回</view>
<view class="fail-btns">
<view class="btn btn-primary" bindtap="onRetry">重新上传</view>
<view class="btn btn-outline" bindtap="onManualEdit">手动填写信息</view>
</view>
</view>
</view>
+156
View File
@@ -0,0 +1,156 @@
.upload-page {
padding: 32rpx;
box-sizing: border-box;
}
/* 初始态 */
.upload-zone {
margin-top: 40rpx;
height: 520rpx;
border: 3rpx dashed #C7C7CC;
border-radius: 32rpx;
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-camera {
width: 100rpx;
height: 100rpx;
color: #007AFF;
margin-bottom: 24rpx;
}
.upload-title {
font-size: 30rpx;
color: #000;
font-weight: 500;
}
.upload-desc {
font-size: 22rpx;
color: #8E8E93;
margin-top: 12rpx;
}
.upload-tip {
text-align: center;
font-size: 24rpx;
color: #8E8E93;
margin-top: 24rpx;
}
/* 处理中 */
.processing {
margin-top: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.proc-img {
width: 480rpx;
height: 480rpx;
border-radius: 32rpx;
background: #F2F2F7;
}
.steps {
display: flex;
align-items: center;
margin-top: 56rpx;
}
.step {
display: flex;
flex-direction: column;
align-items: center;
font-size: 22rpx;
color: #C7C7CC;
}
.step-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background: #E5E5EA;
margin-bottom: 12rpx;
}
.step-dot.active {
background: #007AFF;
}
.active-text {
color: #007AFF;
}
.step-line {
width: 100rpx;
height: 2rpx;
background: #E5E5EA;
margin: 0 16rpx 34rpx;
}
.step-line.active {
background: #007AFF;
}
.proc-text {
margin-top: 56rpx;
font-size: 30rpx;
font-weight: 500;
color: #000;
}
.proc-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
}
/* 失败态 */
.failed {
margin-top: 120rpx;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 40rpx;
}
.fail-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-title {
margin-top: 40rpx;
font-size: 32rpx;
font-weight: 600;
color: #000;
text-align: center;
}
.fail-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 16rpx;
}
.fail-btns {
width: 100%;
margin-top: 56rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
}
+7
View File
@@ -0,0 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card",
"empty-state": "/components/empty-state/empty-state"
}
}
+107
View File
@@ -0,0 +1,107 @@
import { request } from '../../utils/request'
import { PagedResult, ClothesItem, QuotaItem } from '../../types/models'
import { QuotaType } from '../../utils/config'
const CATEGORIES = [
{ name: '全部', value: '' },
{ name: '上装', value: '上装' },
{ name: '下装', value: '下装' },
{ name: '连衣裙', value: '连衣裙' },
{ name: '外套', value: '外套' },
{ name: '鞋', value: '鞋' },
{ name: '配饰', value: '配饰' },
]
Page({
data: {
categories: CATEGORIES,
activeCategory: 0,
list: [] as ClothesItem[],
total: 0,
uploadRemain: 0,
loading: true,
refreshing: false,
page: 1,
pageSize: 20,
hasMore: true,
},
onShow() {
this.syncTabBar()
this.refresh()
},
syncTabBar() {
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
if (tabBar) tabBar.setData({ selected: 0 })
},
refresh() {
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
this.fetchList(1)
this.fetchQuota()
},
onRefresh() {
this.setData({ refreshing: true })
Promise.all([this.fetchList(1), this.fetchQuota()]).finally(() => {
this.setData({ refreshing: false })
})
},
onReachBottom() {
if (!this.data.hasMore || this.data.loading) return
this.fetchList(this.data.page + 1)
},
fetchList(page: number): Promise<void> {
const cat = CATEGORIES[this.data.activeCategory].value
const params: Record<string, any> = { page, page_size: this.data.pageSize }
if (cat) params.category_main = cat
return request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
.then((res) => {
const list = res.list.map((it) => ({
...it,
_statusText: !it.category_main && !it.processed_image ? 'AI 处理中' : '',
_statusType: 'blue',
}))
const merged = page === 1 ? list : this.data.list.concat(list)
this.setData({
list: merged,
total: res.total,
hasMore: merged.length < res.total,
loading: false,
page,
})
})
.catch(() => {
this.setData({ loading: false })
})
},
fetchQuota(): Promise<void> {
return request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
.then((res) => {
const upload = res.quotas.find((q) => q.quota_type === QuotaType.UPLOAD)
if (upload) this.setData({ uploadRemain: upload.remain })
})
.catch(() => {})
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
const index = Number(e.currentTarget.dataset.index)
if (index === this.data.activeCategory) return
this.setData({ activeCategory: index, list: [], loading: true })
this.refresh()
},
onClothesTap(e: WechatMiniprogram.CustomEvent) {
const item = e.detail.item as ClothesItem
wx.navigateTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${item.clothes_uuid}` })
},
onUpload() {
wx.navigateTo({ url: '/pages/upload/upload' })
},
})
+64
View File
@@ -0,0 +1,64 @@
<view class="page page-flex">
<navigation-bar title="我的衣橱" back="{{false}}" />
<scroll-view
scroll-y
class="scroll-body"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
>
<!-- 分类筛选 -->
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
<view class="cat-row">
<view
wx:for="{{categories}}"
wx:key="name"
class="cat-item {{activeCategory === index ? 'active' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
<!-- 统计 -->
<view class="stat">共 {{total}} 件 · 上传额度剩余 {{uploadRemain}} 次</view>
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="grid">
<view class="grid-item" wx:for="{{[1, 2, 3, 4, 5, 6]}}" wx:key="*this">
<view class="skeleton sk-card"></view>
<view class="skeleton sk-line"></view>
</view>
</view>
<!-- 空态 -->
<empty-state
wx:elif="{{!list.length}}"
icon="icon-hanger"
title="衣橱还空着"
desc="拍照上传第一件衣物,AI 自动抠图并识别属性"
buttonText="上传衣物"
bind:action="onUpload"
/>
<!-- 衣物网格 -->
<view wx:else class="grid">
<view class="grid-item" wx:for="{{list}}" wx:key="clothes_uuid">
<clothes-card
item="{{item}}"
statusText="{{item._statusText}}"
statusType="{{item._statusType}}"
bind:select="onClothesTap"
/>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
</scroll-view>
<view class="fab" bindtap="onUpload">
<view class="icon icon-plus"></view>
</view>
</view>
+74
View File
@@ -0,0 +1,74 @@
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
flex: 1;
height: 0;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
}
.cat-row {
display: inline-flex;
padding: 20rpx 24rpx;
}
.cat-item {
flex-shrink: 0;
padding: 0 32rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
font-size: 26rpx;
color: #3C3C43;
background: #F2F2F7;
margin-right: 16rpx;
}
.cat-item.active {
background: #007AFF;
color: #fff;
}
.stat {
padding: 8rpx 32rpx 0;
font-size: 22rpx;
color: #8E8E93;
}
.grid {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
}
.sk-card {
height: 320rpx;
border-radius: 32rpx;
}
.sk-line {
height: 40rpx;
margin-top: 16rpx;
border-radius: 12rpx;
}
.no-more {
text-align: center;
font-size: 24rpx;
color: #C7C7CC;
padding: 24rpx 0 40rpx;
}