first commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -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}` })
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
@@ -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}` })
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user