feat: ui update

This commit is contained in:
2026-09-15 16:01:59 +08:00
parent 1a11816839
commit 26c392d612
89 changed files with 10377 additions and 4861 deletions
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
+46 -25
View File
@@ -1,5 +1,6 @@
import { request } from '../../utils/request'
import { AiRecommendResult, ClothesItem } from '../../types/models'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -14,8 +15,24 @@ Page({
result: null as AiRecommendResult | null,
clothes: [] as ClothesItem[],
generating: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
confirmVisible: false,
confirmButtons: [
{ text: '取消', value: '' },
{ text: '开始生成', value: 'ok', type: 'main' },
],
limitVisible: false,
limitButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
noop() {},
selectDaily() {
@@ -42,16 +59,20 @@ Page({
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)
},
fail: (e) => logger.debug(LOG, '取消生成确认, err=', e && e.errMsg),
})
// 待生成的场景参数暂存到实例,确认后再发起
;(this as any).pendingScene = { sceneType, scene }
this.setData({ confirmVisible: true })
},
onConfirmCancel() {
this.setData({ confirmVisible: false })
},
onConfirmGenerate(e: WechatMiniprogram.CustomEvent) {
this.setData({ confirmVisible: false })
if (e.detail.value !== 'ok') return
const pending = (this as any).pendingScene || { sceneType: 1, scene: '' }
this.doGenerate(pending.sceneType, pending.scene)
},
doGenerate(sceneType: number, scene: string) {
@@ -82,7 +103,8 @@ Page({
logger.warn(LOG, 'AI 推荐生成失败, code=', code)
if (code === 90400) {
this.setData({ state: 'empty' })
} else if (code === 90401) {
} else if (code === 90401 || code === 80000) {
// 90401=AI 推荐次数上限;80000=额度不足兜底,统一弹窗引导购买
this.setData({ state: 'scene', showSceneSheet: true })
this.showLimitModal()
} else if (code === 10003) {
@@ -95,20 +117,19 @@ Page({
},
showLimitModal() {
wx.showModal({
title: 'AI 推荐额度不足',
content: '购买额度包后可继续生成 AI 穿搭推荐',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
wx.navigateTo({
url: '/pages/mall/mall',
fail: (e) => logger.warn(LOG, 'navigateTo 商城失败, err=', e && e.errMsg),
})
}
},
fail: (e) => logger.warn(LOG, '额度弹窗失败, err=', e && e.errMsg),
this.setData({ limitVisible: true })
},
onLimitCancel() {
this.setData({ limitVisible: false })
},
onLimitConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ limitVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
+123 -60
View File
@@ -1,14 +1,25 @@
<view class="page rec-page">
<navigation-bar title="AI 穿搭推荐" />
<wxs module="cl">
function cls(n) {
if (n >= 4) return ''
if (n === 3) return 'collage--3'
if (n === 2) return 'collage--2'
return 'collage--1'
}
module.exports = { cls: cls }
</wxs>
<!-- 生成中 -->
<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 class="page page-flex">
<navigation-bar title="AI 穿搭推荐" scrolled="{{navScrolled}}" />
<!-- 生成中:呼吸感细描边圆环 + 衬线文案 -->
<view wx:if="{{state === 'generating'}}" class="ai-gen">
<view class="ai-ring">
<view class="ai-ring-core">
<view class="icon icon-sparkles"></view>
</view>
</view>
<view class="gen-title">AI 正在为你搭配…</view>
<view class="gen-desc">结合今日运势与你的衣橱</view>
<view class="ai-gen-t">AI 正在为你搭配…</view>
<view class="ai-gen-d">结合今日运势与你的衣橱</view>
</view>
<!-- 衣橱为空 -->
@@ -22,69 +33,121 @@
/>
<!-- 结果页 -->
<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>
<scroll-view wx:elif="{{state === 'result'}}" scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">AI Stylist</view>
<view class="display">今日推荐</view>
</view>
<!-- 无缝拼图封面 -->
<view class="ai-cover">
<view class="collage {{cl.cls(clothes.length)}}">
<view class="cg" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{clothes.length > 4}}" class="cg-more">+{{clothes.length - 4}}</view>
<text class="cg-badge num">{{clothes.length}} 件</text>
</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 class="ai-info">
<view class="t-xl">{{result.outfit_name}}</view>
<view class="ai-tags">
<text wx:if="{{result.scene}}" class="pill pill--line">{{result.scene}}</text>
<text class="pill pill--ink">AI 推荐</text>
</view>
<view class="t-bd ai-desc">{{result.outfit_desc}}</view>
</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 class="ai-items">
<view class="grp-hd">
<text class="gh-cn">推荐单品</text>
<text class="gh-en">Items</text>
</view>
<scroll-view scroll-x class="ai-items-scroll" show-scrollbar="{{false}}">
<view class="ai-items-row">
<view class="ai-chip" wx:for="{{clothes}}" wx:key="clothes_uuid">
<view class="ai-thumb">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<text class="ai-chip-n">{{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>
<view class="gap-64"></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 class="dock" wx:if="{{state === 'result'}}">
<view class="ai-reroll-tip">换一套将再消耗 1 次推荐额度</view>
<view class="dock-row">
<view class="btn btn--ghost" bindtap="onReroll">换一套</view>
<view class="btn btn--ink" bindtap="onSave">保存到我的穿搭</view>
</view>
</view>
<!-- 场景选择抽屉 -->
<view wx:if="{{showSceneSheet}}" class="scrim" bindtap="onSkipScene"></view>
<view wx:if="{{showSceneSheet}}" class="sheet-wrap">
<view class="sheet" catchtap="noop">
<view class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">选择推荐场景</text>
<text class="sheet-x" bindtap="onSkipScene">跳过</text>
</view>
<view class="sheet-body">
<view class="scene-card {{sceneType === 1 ? 'is-on' : ''}}" 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 ? 'is-on' : ''}}" 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="例如:海边度假、周一例会、初次约会"
placeholder-class="input-ph"
/>
</view>
<view class="sheet-foot">
<view class="btn btn--ink btn--block" bindtap="onStartGenerate">开始生成(消耗 1 次推荐额度)</view>
</view>
</view>
</view>
<!-- 生成确认 -->
<v2-overlay
visible="{{confirmVisible}}"
type="modal"
title="AI 穿搭推荐"
content="本次将消耗 1 次 AI 推荐额度,生成后可保存到我的穿搭"
buttons="{{confirmButtons}}"
bind:confirm="onConfirmGenerate"
bind:cancel="onConfirmCancel"
/>
<!-- 额度不足 -->
<v2-overlay
visible="{{limitVisible}}"
type="modal"
title="AI 推荐额度不足"
content="购买额度包后可继续生成 AI 穿搭推荐"
buttons="{{limitButtons}}"
bind:confirm="onLimitConfirm"
bind:cancel="onLimitCancel"
/>
</view>
+130 -219
View File
@@ -1,7 +1,9 @@
.rec-page {
/** AI 推荐:呼吸感圆环生成态 + 拼图结果卡 + 悬浮玻璃操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -10,272 +12,181 @@
}
/* 生成中 */
.generating {
.ai-gen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
padding: 0 var(--pad) 120rpx;
}
.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;
.ai-ring {
width: 216rpx;
height: 216rpx;
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;
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
animation: aiBreath 2.4s var(--ease) infinite;
}
.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 {
.ai-ring-core {
width: 152rpx;
height: 152rpx;
border-radius: 50%;
background: var(--paper-2);
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
color: var(--ink-2);
}
.item-img-empty .icon {
.ai-ring-core .icon {
width: 56rpx;
height: 56rpx;
}
.item-name {
font-size: 20rpx;
color: #8E8E93;
margin-top: 8rpx;
max-width: 140rpx;
@keyframes aiBreath {
0%, 100% { transform: scale(.96); opacity: .6; }
50% { transform: scale(1); opacity: 1; }
}
.ai-gen-t {
margin-top: 64rpx;
font-family: var(--serif);
font-size: 42rpx;
font-weight: 500;
letter-spacing: -.005em;
}
.ai-gen-d {
margin-top: 18rpx;
font-size: 25rpx;
color: var(--ink-2);
}
/* 结果 */
.ai-cover {
padding: 40rpx var(--pad) 0;
}
.ai-info {
padding: 48rpx var(--pad) 0;
}
.ai-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 26rpx;
}
.ai-desc {
margin-top: 28rpx;
}
.ai-items {
margin-top: 64rpx;
}
.ai-items-scroll {
white-space: nowrap;
}
.ai-items-row {
display: inline-flex;
gap: 28rpx;
padding: 0 var(--pad);
}
.ai-chip {
display: flex;
flex-direction: column;
width: 160rpx;
}
.ai-thumb {
width: 160rpx;
height: 160rpx;
background: var(--paper-2);
border-radius: var(--r-img);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
}
.ai-thumb .ph {
width: 52%;
height: 52%;
color: var(--art);
}
.ai-thumb .pic {
width: 100%;
height: 100%;
}
.ai-chip-n {
margin-top: 14rpx;
font-size: 22rpx;
color: var(--ink-3);
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 {
.ai-reroll-tip {
padding-bottom: 20rpx;
text-align: center;
font-size: 22rpx;
color: #8E8E93;
margin-top: 16rpx;
color: var(--ink-3);
}
/* 场景弹窗 */
.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;
.dock-row .btn {
flex: 1;
}
/* 场景抽屉 */
.scene-card {
position: relative;
border: 2rpx solid #E5E5EA;
border-radius: 24rpx;
padding: 28rpx;
margin-bottom: 20rpx;
margin: 0 var(--pad) 20rpx;
padding: 32rpx;
border-radius: var(--r-ctrl);
background: var(--fill);
transition: background .18s var(--ease);
}
.scene-card.active {
border-color: #007AFF;
background: rgba(0, 122, 255, 0.04);
.scene-card.is-on {
background: var(--paper);
box-shadow: inset 0 0 0 1rpx var(--ink);
}
.scene-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
font-family: var(--serif);
font-size: 32rpx;
font-weight: 500;
}
.scene-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
margin-top: 10rpx;
font-size: 23rpx;
color: var(--ink-2);
}
.scene-check {
position: absolute;
top: 28rpx;
right: 28rpx;
width: 36rpx;
height: 36rpx;
color: #007AFF;
top: 32rpx;
right: 32rpx;
width: 34rpx;
height: 34rpx;
color: var(--ink);
}
.scene-input {
margin-bottom: 20rpx;
}
.sheet-btn {
width: 100%;
}
.sheet-skip {
text-align: center;
font-size: 26rpx;
color: #8E8E93;
margin-top: 20rpx;
padding: 12rpx;
margin: 0 var(--pad) 20rpx;
width: auto;
display: block;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card"
"clothes-card": "/components/clothes-card/clothes-card",
"v2-overlay": "/components/v2-overlay/index"
}
}
@@ -2,6 +2,7 @@ import { request } from '../../utils/request'
import { ClothesItem, PagedResult, UserInfo } from '../../types/models'
import { STORAGE_KEYS } from '../../utils/config'
import { normalizePaged, hasMorePaged } from '../../utils/paged'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -45,8 +46,21 @@ Page({
nickname: '微信用户',
avatar: '',
selectedCount: 0,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 额度不足引导:v2 玻璃弹窗
quotaVisible: false,
quotaTitle: '衣柜分享额度不足',
quotaText: '购买额度包后可创建衣柜分享',
quotaButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad() {
this.loadUserInfo()
},
@@ -182,6 +196,10 @@ Page({
.catch((e: any) => {
logger.warn(LOG, '分享创建失败, code=', e && e.code)
this.setData({ submitting: false })
// 衣柜分享额度不足:弹窗引导购买
if (e && e.code === 80000) {
this.setData({ quotaVisible: true })
}
})
},
@@ -197,4 +215,17 @@ Page({
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
})
},
onQuotaCancel() {
this.setData({ quotaVisible: false })
},
onQuotaConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ quotaVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
})
@@ -1,102 +1,143 @@
<view class="page create-page">
<navigation-bar title="创建分享" />
<view class="page page-flex">
<navigation-bar title="创建分享" scrolled="{{navScrolled}}" />
<!-- 配置视图 -->
<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>
<scroll-view wx:if="{{view === 'config'}}" scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">New Share</view>
<view class="display">创建分享</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 class="grp-hd csc-grp"><text class="gh-cn">分享方式</text><text class="gh-en">Type</text></view>
<view class="csc-types">
<view class="csc-type {{shareType === 1 ? 'is-on' : ''}}" bindtap="selectPartial">
<view class="csc-type-n">部分衣物</view>
<view class="csc-type-d">挑选几件衣物分享给好友</view>
<view wx:if="{{shareType === 1}}" class="icon icon-check csc-check"></view>
</view>
<view class="csc-type {{shareType === 2 ? 'is-on' : ''}}" bindtap="selectFull">
<view class="csc-type-n">全量快照</view>
<view class="csc-type-d">分享当前衣橱的全部衣物</view>
<view wx:if="{{shareType === 2}}" class="icon icon-check csc-check"></view>
</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 wx:if="{{shareType === 1}}" class="rows csc-entry">
<view class="row" bindtap="openSelect">
<view class="icon row-ic icon-shirt"></view>
<text class="row-lb">选择衣物</text>
<text class="row-vl">{{selectedCount ? '已选 ' + selectedCount + ' 件' : '未选择'}}</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
</view>
<view class="section-title">有效期</view>
<view class="card expire-card">
<view class="grp-hd csc-grp"><text class="gh-cn">有效期</text><text class="gh-en">Expire</text></view>
<view class="rows">
<view
class="expire-item hairline"
class="row"
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>
<text class="row-lb">{{item.label}}</text>
<view wx:if="{{expireDays === item.days}}" class="icon csc-tick icon-check"></view>
</view>
</view>
<view class="footer">
<view class="btn btn-primary" bindtap="onConfirm">确认分享(消耗 1 次衣柜分享额度)</view>
</view>
<view class="gap-64"></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>
<block wx:elif="{{view === 'select'}}">
<view class="segbar">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{categories}}"
wx:key="name"
class="seg-item {{activeCategory === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
</view>
<scroll-view scroll-y class="select-body pb-dock" bindscroll="onNavScroll">
<view wx:if="{{selectLoading}}" class="csc-grid">
<view class="csc-skel" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
<view class="sk sk-img"></view>
<view class="sk sk-ln" style="width: 62%;"></view>
</view>
</view>
<view wx:else class="grid">
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<view wx:else class="csc-grid">
<view class="csc-tile" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
</view>
</view>
<view class="gap-32"></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>
</block>
<!-- 成功预览 -->
<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>
<scroll-view wx:else scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">Ready</view>
<view class="display">分享已创建</view>
</view>
<view class="csc-done-ic">
<view class="icon icon-check"></view>
</view>
<view class="csc-preview">
<view class="csc-preview-hd">
<view class="csc-av">
<image wx:if="{{avatar}}" class="pic" src="{{avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user"></view>
</view>
<text class="preview-name">{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物</text>
<text class="csc-preview-n">{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物</text>
</view>
<view class="preview-body">
<view class="csc-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 class="gap-64"></view>
</scroll-view>
<!-- 悬浮底部操作条 -->
<view class="dock">
<block wx:if="{{view === 'config'}}">
<view class="btn btn--ink btn--block" bindtap="onConfirm">确认分享(消耗 1 次衣柜分享额度)</view>
</block>
<block wx:elif="{{view === 'select'}}">
<view class="dock-row">
<view class="csc-count-wrap">
<view class="csc-count-t num">已选 {{selectedUuids.length}} 件</view>
<view class="csc-count-d">最多可选择 10 件单品</view>
</view>
<view class="btn btn--ink btn--sm csc-confirm" bindtap="confirmSelect">确定</view>
</view>
</block>
<block wx:else>
<view class="dock-row">
<view class="btn btn--ghost" bindtap="onDone">完成</view>
<view class="btn btn--ink" bindtap="onViewShare">查看分享</view>
</view>
</block>
</view>
<!-- 额度不足 -->
<v2-overlay
visible="{{quotaVisible}}"
type="modal"
title="{{quotaTitle}}"
content="{{quotaText}}"
buttons="{{quotaButtons}}"
bind:confirm="onQuotaConfirm"
bind:cancel="onQuotaCancel"
/>
</view>
@@ -1,7 +1,9 @@
.create-page {
/** 创建分享:三视图(配置 / 选择衣物 / 成功预览)**/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,314 +11,196 @@
height: 0;
}
.section-title {
font-size: 26rpx;
color: #8E8E93;
padding: 24rpx 24rpx 16rpx;
.csc-grp {
margin-top: 64rpx;
}
.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;
/* 分享方式:两张可点卡片 */
.csc-types {
padding: 0 var(--pad);
display: flex;
flex-direction: column;
gap: 20rpx;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
.csc-type {
position: relative;
padding: 34rpx 36rpx;
border-radius: var(--r-ctrl);
background: var(--fill);
transition: background .18s var(--ease), box-shadow .18s var(--ease);
}
.csc-type.is-on {
background: var(--paper);
box-shadow: inset 0 0 0 1rpx var(--ink);
}
.csc-type-n {
font-family: var(--serif);
font-size: 32rpx;
font-weight: 500;
}
.csc-type-d {
margin-top: 10rpx;
font-size: 23rpx;
color: var(--ink-2);
}
.csc-check {
position: absolute;
top: 34rpx;
right: 36rpx;
width: 34rpx;
height: 34rpx;
color: var(--ink);
}
.csc-entry {
margin-top: 32rpx;
}
.csc-tick {
width: 30rpx;
height: 30rpx;
color: var(--ink);
}
/* 选择衣物视图 */
.segbar {
position: sticky;
top: 0;
z-index: 20;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
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;
.csc-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 44rpx 26rpx;
padding: 40rpx var(--pad) 0;
}
.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;
.csc-skel {
display: flex;
flex-direction: column;
align-items: center;
gap: 18rpx;
}
.success-icon {
width: 120rpx;
height: 120rpx;
.csc-tile {
display: block;
width: 100%;
}
/* 成功预览 */
.csc-done-ic {
margin: 56rpx auto 0;
width: 104rpx;
height: 104rpx;
border-radius: 50%;
background: #34C759;
color: #fff;
color: var(--ink);
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
display: flex;
align-items: center;
justify-content: center;
}
.success-icon .icon {
width: 64rpx;
height: 64rpx;
.csc-done-ic .icon {
width: 44rpx;
height: 44rpx;
}
.success-title {
font-size: 34rpx;
font-weight: 600;
color: #000;
margin-top: 24rpx;
.csc-preview {
margin: 64rpx var(--pad) 0;
border-top: 1rpx solid var(--hairline);
border-bottom: 1rpx solid var(--hairline);
padding: 40rpx 0;
}
.preview-card {
width: 100%;
background: #fff;
border-radius: 32rpx;
margin-top: 40rpx;
padding: 32rpx;
box-sizing: border-box;
}
.preview-top {
.csc-preview-hd {
display: flex;
align-items: center;
gap: 20rpx;
}
.avatar {
width: 64rpx;
height: 64rpx;
.csc-av {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: #F2F2F7;
background: var(--paper-3);
color: var(--ink-3);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
margin-right: 16rpx;
flex-shrink: 0;
}
.avatar-img {
.csc-av .pic {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 40rpx;
height: 40rpx;
.csc-av .icon {
width: 30rpx;
height: 30rpx;
}
.preview-name {
font-size: 26rpx;
color: #000;
.csc-preview-n {
font-size: 27rpx;
color: var(--ink-2);
}
.preview-body {
height: 320rpx;
background: #F8F8FA;
border-radius: 24rpx;
margin-top: 24rpx;
.csc-preview-body {
margin-top: 32rpx;
aspect-ratio: 16 / 9;
background: var(--paper-2);
border-radius: var(--r-img);
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
color: var(--art);
}
.preview-body .icon {
.csc-preview-body .icon {
width: 96rpx;
height: 96rpx;
}
.preview-btn {
margin-top: 24rpx;
/* 底部操作条 */
.csc-count-wrap {
flex: 1;
min-width: 0;
}
.csc-count-t {
font-size: 30rpx;
font-weight: 600;
color: var(--ink);
}
.csc-count-d {
margin-top: 4rpx;
font-size: 22rpx;
color: var(--ink-3);
}
.csc-confirm {
width: 240rpx;
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 {
.dock-row .btn {
flex: 1;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
@@ -2,6 +2,7 @@ import { request, AUTH_EXPIRED_CODES } from '../../utils/request'
import { ClosetShareDetail } from '../../types/models'
import { ShareStatus } from '../../utils/config'
import { formatDateTime } from '../../utils/format'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -17,8 +18,19 @@ Page({
invalidTitle: '该分享已过期或已被取消',
invalidDesc: '分享链接已失效',
needLogin: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 取消分享确认:v2 玻璃弹窗
cancelVisible: false,
cancelButtons: [
{ text: '再想想', value: '' },
{ text: '取消分享', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
logger.debug(LOG, 'onLoad, uuid=', uuid)
@@ -99,29 +111,29 @@ Page({
},
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(() => {
logger.info(LOG, '取消分享成功')
wx.showToast({ title: '已取消', icon: 'success' })
;(this as any).backTimer = setTimeout(
() =>
wx.navigateBack({
fail: (e: any) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '取消分享失败, code=', e && e.code))
},
fail: (e: any) => logger.warn(LOG, '取消分享弹窗失败, err=', e && e.errMsg),
})
this.setData({ cancelVisible: true })
},
onCancelCancel() {
this.setData({ cancelVisible: false })
},
onCancelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ cancelVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/closet-share/${this.data.uuid}/cancel`, method: 'POST' })
.then(() => {
logger.info(LOG, '取消分享成功')
wx.showToast({ title: '已取消', icon: 'success' })
;(this as any).backTimer = setTimeout(
() =>
wx.navigateBack({
fail: (err: any) => logger.warn(LOG, 'navigateBack 失败, err=', err && err.errMsg),
}),
500,
)
})
.catch((err: any) => logger.warn(LOG, '取消分享失败, code=', err && err.code))
},
onBackHome() {
@@ -1,5 +1,5 @@
<view class="page detail-page">
<navigation-bar title="分享详情" />
<view class="page page-flex">
<navigation-bar title="分享详情" scrolled="{{navScrolled}}" />
<empty-state
wx:if="{{invalid && needLogin}}"
@@ -19,36 +19,53 @@
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>
<scroll-view wx:else scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<!-- 分享者行 -->
<view class="head">
<view class="eyebrow">Shared Closet</view>
<view class="display">{{detail.owner_name}} 的衣橱</view>
</view>
<view class="csd-owner">
<view class="csd-av">
<image wx:if="{{detail.owner_avatar}}" class="pic" src="{{detail.owner_avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user"></view>
</view>
<view class="owner-info">
<view class="owner-name">{{detail.owner_name}} 分享了 {{detail.clothes.length}} 件衣物</view>
<view class="owner-expire">{{expireText}}</view>
<view class="csd-owner-body">
<view class="csd-owner-n">{{detail.owner_name}} 分享了 {{detail.clothes.length}} 件衣物</view>
<view class="csd-owner-e num">{{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 class="csd-grid">
<view class="csd-tile" wx:for="{{detail.clothes}}" wx:key="clothes_uuid">
<view class="tile-img">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" lazy-load />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view class="tile-name">{{item.name || '未命名'}}</view>
<view class="tile-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}</view>
</view>
</view>
<!-- 转发出口 -->
<button class="share-btn" open-type="share">分享给好友</button>
<!-- 属主取消 -->
<view wx:if="{{detail.is_owner}}" class="cancel-btn btn-danger-text" bindtap="onCancel">取消分享</view>
<view class="footer-space"></view>
<view class="gap-64"></view>
</scroll-view>
<!-- 悬浮玻璃操作条 -->
<view class="dock" wx:if="{{!invalid}}">
<view wx:if="{{detail.is_owner}}" class="btn--text csd-cancel" bindtap="onCancel">取消分享</view>
<button class="btn btn--ink btn--block" open-type="share">分享给好友</button>
</view>
<!-- 取消分享确认 -->
<v2-overlay
visible="{{cancelVisible}}"
type="modal"
title="取消分享"
content="取消后该分享链接将失效,且不返还衣柜分享额度"
buttons="{{cancelButtons}}"
bind:confirm="onCancelConfirm"
bind:cancel="onCancelCancel"
/>
</view>
@@ -1,7 +1,9 @@
.detail-page {
/** 衣柜分享详情:分享者行 + 只读网格 + 悬浮玻璃操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,129 +11,65 @@
height: 0;
}
.owner-card {
margin: 24rpx;
.csd-owner {
display: flex;
align-items: center;
gap: 24rpx;
padding: 40rpx var(--pad) 0;
}
.avatar {
width: 96rpx;
height: 96rpx;
.csd-av {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #F2F2F7;
background: var(--paper-3);
color: var(--ink-3);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
.csd-av .pic {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 56rpx;
height: 56rpx;
.csd-av .icon {
width: 36rpx;
height: 36rpx;
}
.owner-info {
margin-left: 24rpx;
.csd-owner-body {
min-width: 0;
}
.owner-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
.csd-owner-n {
font-size: 27rpx;
color: var(--ink);
}
.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 {
.csd-owner-e {
margin-top: 6rpx;
font-size: 22rpx;
color: #8E8E93;
margin-top: 4rpx;
color: var(--ink-3);
}
.share-btn {
margin: 32rpx 24rpx 0;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
font-weight: 500;
color: var(--color-primary, #000);
background: #fff;
border-radius: 24rpx;
.csd-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 44rpx 26rpx;
padding: 56rpx var(--pad) 0;
}
.share-btn::after {
border: none;
.csd-tile {
display: block;
width: 100%;
}
.cancel-btn {
.csd-cancel {
display: block;
text-align: center;
padding: 32rpx 0;
}
.footer-space {
height: 40rpx;
padding: 8rpx 0 28rpx;
}
+30 -18
View File
@@ -2,8 +2,9 @@ import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { PagedResult, ClosetShareItem, OutfitShareItem } from '../../types/models'
import { ShareStatus, ShareType } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { normalizePaged } from '../../utils/paged'
import { setTabBarSelected } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -37,8 +38,13 @@ Page({
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
@@ -82,30 +88,36 @@ Page({
return this.fetchMine(page)
},
// 我的分享:仅衣柜分享
// 我的分享:衣柜分享 + 穿搭分享并行拉取后按时间倒序合并
fetchMine(page: number) {
// 请求序号:切 tab 时旧响应可能后到,序号不匹配直接丢弃
const seq = ((this as any).reqSeq || 0) + 1
;(this as any).reqSeq = seq
if (page > 1) this.setData({ loadingMore: true })
request<PagedResult<ClosetShareItem>>({
url: '/closet-share',
data: { page, page_size: this.data.pageSize },
})
.then((res) => {
Promise.all([
request<PagedResult<ClosetShareItem>>({
url: '/closet-share',
data: { page, page_size: this.data.pageSize },
}),
request<PagedResult<OutfitShareItem>>({
url: '/outfit-share',
data: { page, page_size: this.data.pageSize },
}),
])
.then(([closetRes, outfitRes]) => {
if ((this as any).reqSeq !== seq) return
const { list: raw, total } = normalizePaged<ClosetShareItem>(res)
logger.debug(LOG, '我的分享加载成功, page=', page, '条数=', raw && raw.length)
const list = raw.map((it) => this.decorateCloset(it))
const merged = mergePaged(this.data.list, list, page === 1)
this.setData({
list: merged,
hasMore: hasMorePaged(merged, total, this.data.pageSize),
loading: false,
loadingMore: false,
page,
})
const closetList = normalizePaged<ClosetShareItem>(closetRes).list.map((it) => this.decorateCloset(it))
const outfitList = normalizePaged<OutfitShareItem>(outfitRes).list.map((it) => this.decorateOutfit(it))
logger.debug(LOG, '我的分享加载成功, page=', page, '衣柜=', closetList.length, '穿搭=', outfitList.length)
const prev = page === 1 ? [] : this.data.list
const merged = prev
.concat(closetList, outfitList)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
// 任一侧仍返回满页则认为还有更多
const hasMore =
closetList.length >= this.data.pageSize || outfitList.length >= this.data.pageSize
this.setData({ list: merged, hasMore, loading: false, loadingMore: false, page })
})
.catch((e: any) => {
if ((this as any).reqSeq !== seq) return
@@ -1,67 +1,79 @@
<view class="page page-flex">
<navigation-bar title="分享" back="{{false}}" />
<navigation-bar title="分享" back="{{false}}" scrolled="{{navScrolled}}">
<view slot="right" class="nav-action nav-action--ink" bindtap="onCreate">新建</view>
</navigation-bar>
<!-- 分段切换 -->
<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>
<scroll-view scroll-y class="scroll-body pb-tab" bindscrolltolower="onReachBottom" bindscroll="onNavScroll">
<!-- 大标题页头 -->
<view class="head">
<view class="eyebrow">Sharing</view>
<view class="display">分享</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>
<!-- 下划线式文字分段 -->
<view class="segbar">
<view class="seg">
<view
wx:for="{{tabs}}"
wx:key="name"
class="seg-item {{activeTab === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onTabTap"
>{{item.name}}</view>
</view>
</view>
<empty-state
wx:if="{{!loading && !list.length}}"
icon="icon-share"
title="{{activeTab === 0 ? '还没有分享' : '还没有收到分享'}}"
desc="{{activeTab === 0 ? '把衣橱或精选衣物分享给好友' : '好友分享给你的衣橱或穿搭会出现在这里'}}"
desc="{{activeTab === 0 ? '把衣橱、精选衣物或穿搭分享给好友' : '好友分享给你的衣橱或穿搭会出现在这里'}}"
buttonText="{{activeTab === 0 ? '新建分享' : ''}}"
bind:action="onCreate"
/>
<view class="list">
<!-- 横向条目卡:左缩略图 + 右标题/有效期 -->
<view class="cs-list">
<view
class="share-card card {{item._invalid ? 'invalid' : ''}}"
class="cs-card {{item._invalid ? 'is-dead' : ''}}"
wx:for="{{list}}"
wx:key="share_uuid"
data-uuid="{{item.share_uuid}}"
data-kind="{{item.kind}}"
bindtap="onItemTap"
>
<!-- 衣柜分享:四宫格占位 -->
<view class="preview-grid" wx:if="{{item.kind === 'closet'}}">
<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 class="cs-thumb" wx:if="{{item.kind === 'closet'}}">
<view class="c"><view class="icon icon-hanger"></view></view>
<view class="c"><view class="icon icon-hanger"></view></view>
<view class="c"><view class="icon icon-hanger"></view></view>
<view class="c"><view class="icon icon-hanger"></view></view>
</view>
<!-- 穿搭分享:封面 -->
<view class="preview-cover" wx:else>
<image wx:if="{{item.cover}}" class="preview-cover-img" src="{{item.cover}}" mode="aspectFill" />
<view wx:else class="icon icon-hanger preview-cover-ph"></view>
<view class="cs-thumb is-photo" wx:else>
<image wx:if="{{item.cover}}" class="pic" src="{{item.cover}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></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 class="cs-body">
<view class="cs-owner" wx:if="{{activeTab === 1}}">
<image wx:if="{{item.owner_avatar}}" class="oav" src="{{item.owner_avatar}}" mode="aspectFill" />
<view wx:else class="oav">
<view class="icon icon-user"></view>
</view>
<text class="owner-name">{{item.owner_name || '好友'}}</text>
<text class="onm">{{item.owner_name || '好友'}}</text>
</view>
<view class="cs-t">{{item.title}}</view>
<view class="cs-tags">
<text class="pill">{{item.typeText}}</text>
<text wx:if="{{item._invalid}}" class="pill pill--line">{{item.expireText}}</text>
<text wx:else class="cs-exp">{{item.expireText}}</text>
</view>
<view class="share-title">{{item.title}}</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>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
</view>
+86 -109
View File
@@ -1,3 +1,4 @@
/** 分享列表:下划线分段 + 横向条目卡(失效项降透明) **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,166 +11,142 @@
height: 0;
}
.seg-wrap {
background: #fff;
padding: 20rpx 24rpx;
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.seg-control {
.cs-list {
padding: 28rpx var(--pad) 0;
display: flex;
background: #F2F2F7;
border-radius: 16rpx;
padding: 4rpx;
flex-direction: column;
}
.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;
.cs-card {
display: flex;
align-items: center;
margin-bottom: 20rpx;
padding: 24rpx;
gap: 26rpx;
padding: 32rpx 0;
border-bottom: 1rpx solid var(--hairline);
width: 100%;
align-items: flex-start;
transition: opacity .2s var(--ease), transform .2s var(--ease);
}
.share-card.invalid {
opacity: 0.5;
.cs-card:active {
transform: scale(.985);
}
.preview-grid {
display: flex;
flex-wrap: wrap;
width: 160rpx;
height: 160rpx;
gap: 8rpx;
.cs-card.is-dead {
opacity: .38;
}
.cs-thumb {
width: 144rpx;
height: 144rpx;
flex-shrink: 0;
border-radius: var(--r-img);
background: var(--paper-2);
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 2rpx;
}
.pg-cell {
width: 76rpx;
height: 76rpx;
background: #F2F2F7;
border-radius: 12rpx;
.cs-thumb .c {
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
background: var(--paper-2);
min-width: 0;
min-height: 0;
overflow: hidden;
}
.pg-cell .icon {
.cs-thumb .c .icon {
width: 40rpx;
height: 40rpx;
flex-shrink: 0;
color: var(--art);
}
/* 穿搭分享封面 */
.preview-cover {
width: 160rpx;
height: 160rpx;
border-radius: 16rpx;
background: #F2F2F7;
.cs-thumb.is-photo {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
color: #C7C7CC;
}
.preview-cover-img {
.cs-thumb.is-photo .ph {
width: 52%;
height: 52%;
color: var(--art);
}
.cs-thumb .pic {
width: 100%;
height: 100%;
}
.preview-cover-ph {
width: 64rpx;
height: 64rpx;
.cs-body {
flex: 1;
min-width: 0;
}
.owner-row {
.cs-owner {
display: flex;
align-items: center;
margin-bottom: 10rpx;
gap: 12rpx;
margin-bottom: 14rpx;
}
.owner-avatar {
width: 40rpx;
height: 40rpx;
.cs-owner .oav {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
margin-right: 12rpx;
flex-shrink: 0;
background: #F2F2F7;
overflow: hidden;
}
.owner-avatar-placeholder {
background: var(--paper-3);
color: var(--ink-3);
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
overflow: hidden;
}
.owner-avatar-icon {
width: 24rpx;
height: 24rpx;
.cs-owner .oav .icon {
width: 20rpx;
height: 20rpx;
}
.owner-name {
font-size: 24rpx;
color: #8E8E93;
.cs-owner .onm {
font-size: 23rpx;
color: var(--ink-2);
}
.share-info {
flex: 1;
margin-left: 24rpx;
}
.share-title {
.cs-t {
font-family: var(--serif);
font-size: 30rpx;
font-weight: 600;
color: #000;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-type {
margin-top: 12rpx;
.cs-tags {
display: flex;
align-items: center;
gap: 14rpx;
margin-top: 18rpx;
flex-wrap: wrap;
}
.share-expire {
.cs-exp {
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;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
@@ -1,5 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
@@ -1,6 +1,7 @@
import { request } from '../../utils/request'
import { ClothesItem } from '../../types/models'
import { ProcessStatus } from '../../utils/config'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -122,8 +123,23 @@ Page({
sheetOptions: [] as string[],
sheetValue: '',
sheetValues: [] as string[],
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 删除确认:v2 玻璃弹窗
delVisible: false,
delText: '',
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
// 抽屉内容点击不穿透到遮罩
noop() {},
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
const shareUUID = query.share_uuid || ''
@@ -155,7 +171,7 @@ Page({
: `/clothes/${this.data.uuid}`
return request<ClothesItem>({ url })
.then((detail) => {
logger.debug(LOG, '详情加载成功, 处理状态=', detail && detail.image_process_status)
logger.debug(LOG, '详情加载成功, name=', detail && detail.name)
this.setData({ detail, loading: false })
})
.catch((e: any) => {
@@ -320,31 +336,34 @@ Page({
.catch((e: any) => logger.warn(LOG, '衣物信息保存失败, code=', e && e.code))
},
// 删除入口:打开 v2 玻璃确认弹窗
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(() => {
logger.info(LOG, '衣物删除成功')
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(
() =>
wx.navigateBack({
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '衣物删除失败, code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '删除弹窗失败, err=', e && e.errMsg),
this.setData({
delVisible: true,
delText: canRefund ? '删除后将退回 1 次上传额度,确认删除?' : '确认删除?',
})
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/clothes/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
logger.info(LOG, '衣物删除成功')
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(
() =>
wx.navigateBack({
fail: (e2) => logger.warn(LOG, 'navigateBack 失败, err=', e2 && e2.errMsg),
}),
500,
)
})
.catch((e2: any) => logger.warn(LOG, '衣物删除失败, code=', e2 && e2.code))
},
})
@@ -40,17 +40,21 @@ function tags(v) {
module.exports = { hex: hex, tags: tags }
</wxs>
<view class="page detail-page">
<navigation-bar title="衣物详情">
<view class="page page-flex">
<navigation-bar title="衣物详情" scrolled="{{navScrolled}}">
<view
wx:if="{{!readonly && imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
slot="right"
class="nav-action"
class="nav-action nav-action--ink"
bindtap="onToggleEdit"
>{{editMode ? '取消' : '编辑'}}</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<scroll-view
scroll-y
class="scroll-body {{editMode ? 'pb-dock' : 'pb-safe'}}"
bindscroll="onNavScroll"
>
<!-- 缺少衣物标识 -->
<empty-state
wx:if="{{notFound}}"
@@ -61,229 +65,254 @@ module.exports = { hex: hex, tags: tags }
bind:action="onBackWardrobe"
/>
<!-- 处理中:显示步骤进度 -->
<!-- 处理中:三步发丝线进度轨 -->
<block wx:elif="{{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 class="head">
<view class="eyebrow">Processing</view>
<view class="display">AI 正在识别你的衣物…</view>
<view class="cd-note">通常需要 10 秒左右,请稍候</view>
</view>
<view class="up-preview">
<view class="ph icon icon-hanger"></view>
</view>
<view class="up-steps">
<view class="up-step {{step > 0 ? 'is-done' : ''}} {{step === 0 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">排队中</text>
</view>
<view class="up-step {{step > 1 ? 'is-done' : ''}} {{step === 1 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">AI 抠图</text>
</view>
<view class="up-step {{step === 2 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">智能分类</text>
</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 class="up-preview">
<view class="up-bang">!</view>
</view>
<view class="card name-card">
<view class="name-text">{{detail.name || '未命名衣物'}}</view>
<view class="head">
<view class="display">识别失败</view>
<view class="cd-note">{{failReason || '未知原因'}} · 本次上传额度已自动退回,你可以删除或重新上传</view>
</view>
<view class="card fail-card">
<view class="fail-title">识别失败:{{failReason || '未知原因'}}</view>
<view class="fail-desc">本次上传额度已自动退回,你可以删除或重新上传</view>
<view class="up-duo">
<view class="btn btn--ghost" bindtap="onDelete">删除</view>
<view class="btn btn--ink" bindtap="onBackWardrobe">重新上传</view>
</view>
</block>
<!-- 正常详情 -->
<block wx:else>
<!-- 大图区 -->
<view class="img-box">
<!-- 大图:买手店目录式线稿/实拍 -->
<view class="cd-photo">
<image
wx:if="{{detail.processed_image}}"
class="img"
class="pic"
src="{{detail.processed_image}}"
mode="aspectFit"
/>
<view wx:else class="img-placeholder"><view class="icon icon-hanger"></view></view>
<view wx:else class="ph icon icon-hanger"></view>
</view>
<!-- 名称 -->
<view class="card name-card">
<view class="cd-name">
<input
wx:if="{{editMode}}"
class="input"
class="cd-input"
value="{{formData.name}}"
bindinput="onNameInput"
placeholder="衣物名称"
placeholder-class="input-ph"
/>
<view wx:else class="name-text">{{detail.name || '未命名衣物'}}</view>
<view wx:else class="t-lg">{{detail.name || '未命名衣物'}}</view>
</view>
<!-- 属性分组(基础属性 / 版型与材质 / 智能量化评估) -->
<view class="card attr-card" wx:for="{{attrGroups}}" wx:key="title" wx:for-item="group">
<view class="group-title">{{group.title}}</view>
<view
class="attr-row hairline {{editMode ? 'edit' : ''}}"
wx:for="{{group.attrs}}"
wx:key="key"
data-key="{{item.key}}"
bindtap="onAttrTap"
>
<view class="attr-label">{{item.label}}</view>
<view class="attr-value">
<!-- 评分:5 段圆条 + 分值 -->
<block wx:if="{{item.type === 'score'}}">
<view class="score-dots">
<!-- 属性规格表 -->
<view class="cd-group" wx:for="{{attrGroups}}" wx:key="title" wx:for-item="group">
<view class="grp-hd">
<text class="gh-cn">{{group.title}}</text>
</view>
<view class="rows">
<view
class="spec {{editMode ? 'is-edit' : ''}}"
wx:for="{{group.attrs}}"
wx:key="key"
data-key="{{item.key}}"
bindtap="onAttrTap"
>
<text class="spec-k">{{item.label}}</text>
<view class="spec-v">
<!-- 量化评分:细描边方点 -->
<block wx:if="{{item.type === 'score'}}">
<view class="dots">
<view
class="d {{dotIdx < ((editMode ? formData[item.key] : detail[item.key]) || 0) ? 'on' : ''}}"
wx:for="{{[1, 2, 3, 4, 5]}}"
wx:for-item="dot"
wx:for-index="dotIdx"
wx:key="*this"
></view>
</view>
<text class="cd-score num">{{(editMode ? formData[item.key] : detail[item.key]) || 0}}/5</text>
</block>
<!-- 适用场合:无底色描边胶囊 -->
<block wx:elif="{{item.type === 'multi'}}">
<view class="cd-tags" wx:if="{{fmt.tags(editMode ? formData.occasions : detail.occasions).length}}">
<text
class="pill pill--line"
wx:for="{{fmt.tags(editMode ? formData.occasions : detail.occasions)}}"
wx:for-item="scene"
wx:key="*this"
>{{scene}}</text>
</view>
<text wx:else class="c-3">—</text>
</block>
<!-- 普通文本 / 主色 -->
<block wx:else>
<text class="spec-t">{{(editMode ? formData[item.key] : detail[item.key]) || '—'}}</text>
<view
class="score-dot {{dotIdx < ((editMode ? formData[item.key] : detail[item.key]) || 0) ? 'on' : ''}} {{item.key === 'visual_impact' ? 'violet' : ''}}"
wx:for="{{[1, 2, 3, 4, 5]}}"
wx:for-item="dot"
wx:for-index="dotIdx"
wx:key="*this"
wx:if="{{item.key === 'color_main' && fmt.hex(editMode ? formData.color_main : detail.color_main)}}"
class="dot"
style="background: {{fmt.hex(editMode ? formData.color_main : detail.color_main)}};"
></view>
</view>
<text class="score-num">{{(editMode ? formData[item.key] : detail[item.key]) || 0}}/5</text>
</block>
<!-- 适用场合:胶囊标签 -->
<block wx:elif="{{item.type === 'multi'}}">
<view
class="scene-list"
wx:if="{{fmt.tags(editMode ? formData.occasions : detail.occasions).length}}"
>
<text
class="tag tag-blue scene-tag"
wx:for="{{fmt.tags(editMode ? formData.occasions : detail.occasions)}}"
wx:for-item="scene"
wx:key="*this"
>{{scene}}</text>
</view>
<text wx:else class="attr-text weak">—</text>
</block>
<!-- 普通文本 / 主色 -->
<block wx:else>
<text class="attr-text">{{(editMode ? formData[item.key] : detail[item.key]) || '—'}}</text>
<view
wx:if="{{item.key === 'color_main' && fmt.hex(editMode ? formData.color_main : detail.color_main)}}"
class="color-dot"
style="background: {{fmt.hex(editMode ? formData.color_main : detail.color_main)}}"
></view>
</block>
</block>
</view>
<view wx:if="{{editMode}}" class="icon icon-chevron-right cd-arrow"></view>
</view>
<view wx:if="{{editMode}}" class="icon icon-chevron-right edit-arrow"></view>
</view>
</view>
<!-- 备注 -->
<view class="card remark-card">
<view class="group-title">备注信息</view>
<view class="remark-body">
<view class="cd-group">
<view class="grp-hd">
<text class="gh-cn">备注信息</text>
<text class="gh-en">Note</text>
</view>
<view class="cd-remark">
<textarea
wx:if="{{editMode}}"
class="textarea"
value="{{formData.remark}}"
bindinput="onRemarkInput"
placeholder="添加备注…"
placeholder-class="input-ph"
/>
<view wx:elif="{{detail.remark}}" class="remark-text">{{detail.remark}}</view>
<view wx:else class="remark-text weak">暂无备注</view>
<view wx:elif="{{detail.remark}}" class="t-bd">{{detail.remark}}</view>
<view wx:else class="c-3">暂无备注</view>
</view>
</view>
</block>
<!-- 底部操作:处理中隐藏,其他显示 -->
<view
wx:if="{{!readonly && !notFound && 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 class="sheet-mask" wx:if="{{sheetVisible}}" bindtap="onSheetClose"></view>
<view class="sheet" wx:if="{{sheetVisible}}">
<view class="sheet-head">
<text class="sheet-title">{{sheetLabel}}</text>
<view class="sheet-done" bindtap="onSheetConfirm">完成</view>
</view>
<scroll-view scroll-y class="sheet-body">
<!-- 评分:大号点选圆点 -->
<block wx:if="{{sheetType === 'score'}}">
<view class="sheet-score">
<view
class="sheet-score-dot {{index < (sheetValue * 1) ? 'on' : ''}}"
wx:for="{{[1, 2, 3, 4, 5]}}"
wx:key="*this"
data-value="{{item}}"
bindtap="onSheetScoreTap"
>{{item}}</view>
</view>
</block>
<!-- 多选(适用场合) -->
<block wx:elif="{{sheetType === 'multi'}}">
<view class="sheet-tags">
<view
class="sheet-tag {{sheetValues.indexOf(item) >= 0 ? 'on' : ''}}"
wx:for="{{sheetOptions}}"
wx:key="*this"
data-value="{{item}}"
bindtap="onSheetValueTap"
>{{item}}</view>
</view>
</block>
<!-- 主色:色板 + 自定义 -->
<block wx:elif="{{sheetType === 'color'}}">
<view class="sheet-colors">
<view
class="sheet-color {{sheetValue === item.name || sheetValue === item.value ? 'on' : ''}}"
wx:for="{{colorPresets}}"
wx:key="name"
data-name="{{item.name}}"
bindtap="onSheetColorTap"
>
<view class="sheet-color-dot" style="background: {{item.value}}"></view>
</view>
</view>
<input
class="input sheet-input"
value="{{sheetValue}}"
bindinput="onSheetInput"
placeholder="也可输入色值,如 #FFFFFF"
/>
</block>
<!-- 文本:快捷选项 + 自定义输入 -->
<block wx:else>
<view class="sheet-tags" wx:if="{{sheetOptions.length}}">
<view
class="sheet-tag {{sheetValue === item ? 'on' : ''}}"
wx:for="{{sheetOptions}}"
wx:key="*this"
data-value="{{item}}"
bindtap="onSheetOptionTap"
>{{item}}</view>
</view>
<input
class="input sheet-input"
value="{{sheetValue}}"
bindinput="onSheetInput"
placeholder="请输入{{sheetLabel}}"
/>
</block>
</scroll-view>
<view class="safe-bottom"></view>
<!-- 底部悬浮操作条 -->
<view
wx:if="{{!readonly && !notFound && imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3 && imageStatus !== 5}}"
class="dock"
>
<block wx:if="{{editMode}}">
<view class="btn btn--ink btn--block" bindtap="onSave">保存</view>
</block>
<block wx:else>
<view class="dock-row">
<view class="btn--text" bindtap="onDelete">删除这件衣物</view>
</view>
</block>
</view>
<!-- 底部属性选择面板 -->
<view wx:if="{{sheetVisible}}" class="scrim" bindtap="onSheetClose"></view>
<view wx:if="{{sheetVisible}}" class="sheet-wrap">
<view class="sheet" catchtap="noop">
<view class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">{{sheetLabel}}</text>
<text class="sheet-x" bindtap="onSheetConfirm">完成</text>
</view>
<scroll-view scroll-y class="sheet-body">
<!-- 评分:大号点选圆点 -->
<block wx:if="{{sheetType === 'score'}}">
<view class="sheet-score">
<view
class="sheet-score-dot {{index < (sheetValue * 1) ? 'on' : ''}}"
wx:for="{{[1, 2, 3, 4, 5]}}"
wx:key="*this"
data-value="{{item}}"
bindtap="onSheetScoreTap"
>{{item}}</view>
</view>
</block>
<!-- 多选(适用场合) -->
<block wx:elif="{{sheetType === 'multi'}}">
<view class="sheet-tags">
<view
class="sheet-tag {{sheetValues.indexOf(item) >= 0 ? 'on' : ''}}"
wx:for="{{sheetOptions}}"
wx:key="*this"
data-value="{{item}}"
bindtap="onSheetValueTap"
>{{item}}</view>
</view>
</block>
<!-- 主色:色板 + 自定义 -->
<block wx:elif="{{sheetType === 'color'}}">
<view class="sheet-colors">
<view
class="sheet-color {{sheetValue === item.name || sheetValue === item.value ? 'on' : ''}}"
wx:for="{{colorPresets}}"
wx:key="name"
data-name="{{item.name}}"
bindtap="onSheetColorTap"
>
<view class="sheet-color-dot" style="background: {{item.value}};"></view>
</view>
</view>
<input
class="input sheet-input"
value="{{sheetValue}}"
bindinput="onSheetInput"
placeholder="也可输入色值,如 #FFFFFF"
placeholder-class="input-ph"
/>
</block>
<!-- 文本:快捷选项 + 自定义输入 -->
<block wx:else>
<view class="sheet-tags" wx:if="{{sheetOptions.length}}">
<view
class="sheet-tag {{sheetValue === item ? 'on' : ''}}"
wx:for="{{sheetOptions}}"
wx:key="*this"
data-value="{{item}}"
bindtap="onSheetOptionTap"
>{{item}}</view>
</view>
<input
class="input sheet-input"
value="{{sheetValue}}"
bindinput="onSheetInput"
placeholder="请输入{{sheetLabel}}"
placeholder-class="input-ph"
/>
</block>
</scroll-view>
</view>
</view>
<!-- 删除确认:v2 玻璃弹窗 -->
<v2-overlay
visible="{{delVisible}}"
type="modal"
title="删除这件衣物"
content="{{delText}}"
buttons="{{delButtons}}"
bind:confirm="onDelConfirm"
bind:cancel="onDelCancel"
/>
</view>
@@ -1,7 +1,9 @@
.detail-page {
/** 衣物详情:买手店目录式大图 + 规格表 + 悬浮底部操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,474 +11,159 @@
height: 0;
}
.nav-action {
font-size: 30rpx;
color: #007AFF;
padding: 0 8rpx;
.cd-note {
margin-top: 20rpx;
font-size: 25rpx;
color: var(--ink-2);
line-height: 1.7;
}
.img-box {
margin: 24rpx;
height: 640rpx;
background: #fff;
border-radius: 32rpx;
.cd-photo {
margin: 32rpx var(--pad) 0;
aspect-ratio: 4 / 5;
background: var(--paper-2);
border-radius: var(--r-img);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.img {
.cd-photo .ph {
width: 46%;
height: 46%;
color: var(--art);
}
.cd-photo .pic {
width: 100%;
height: 100%;
}
.img-placeholder {
color: #C7C7CC;
.cd-name {
padding: 48rpx var(--pad) 0;
}
.img-placeholder .icon {
width: 120rpx;
height: 120rpx;
}
.name-card {
margin: 0 24rpx 24rpx;
}
.name-text {
.cd-input {
font-family: var(--serif);
font-size: 40rpx;
font-weight: 700;
color: #000;
width: 100%;
padding-bottom: 12rpx;
border-bottom: 1rpx solid var(--hairline-2);
}
/* ===== 属性分组卡片 ===== */
.card.attr-card {
margin: 0 24rpx 24rpx;
padding: 0;
overflow: hidden;
.cd-group {
margin-top: 64rpx;
}
.group-title {
padding: 14rpx 32rpx;
font-size: 24rpx;
font-weight: 600;
color: #8E8E93;
background: rgba(242, 242, 247, 0.6);
border-bottom: 1rpx solid #E5E5EA;
.cd-group .grp-hd {
padding-bottom: 22rpx;
}
.attr-row {
display: flex;
align-items: center;
min-height: 92rpx;
padding: 0 32rpx;
box-sizing: border-box;
.spec.is-edit:active {
background: var(--fill);
}
.attr-row:last-child {
border-bottom: none;
.spec-t {
font-size: 29rpx;
}
.attr-row.edit:active {
background: #F2F2F7;
}
.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;
min-width: 0;
}
.attr-text {
font-size: 28rpx;
color: #3C3C43;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attr-text.weak {
color: #C7C7CC;
}
.color-dot {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
.cd-score {
margin-left: 16rpx;
border: 1rpx solid #E5E5EA;
flex-shrink: 0;
font-size: 25rpx;
color: var(--ink-3);
}
/* ===== 评分:5 段圆条 ===== */
.score-dots {
display: flex;
align-items: center;
}
.score-dot {
width: 28rpx;
height: 12rpx;
border-radius: 6rpx;
background: #E5E5EA;
margin-left: 8rpx;
}
.score-dot:first-child {
margin-left: 0;
}
.score-dot.on {
background: #007AFF;
}
.score-dot.violet.on {
background: #5856D6;
}
.score-num {
font-size: 26rpx;
color: #8E8E93;
margin-left: 16rpx;
min-width: 60rpx;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* ===== 适用场合标签 ===== */
.scene-list {
display: flex;
align-items: center;
justify-content: flex-end;
.cd-tags {
display: inline-flex;
flex-wrap: wrap;
gap: 12rpx;
justify-content: flex-end;
}
.scene-tag {
margin-left: 8rpx;
}
.scene-tag:first-child {
margin-left: 0;
}
.edit-arrow {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
.cd-arrow {
width: 26rpx;
height: 26rpx;
color: var(--ink-4);
margin-left: 12rpx;
flex-shrink: 0;
}
/* ===== 备注卡片 ===== */
.card.remark-card {
margin: 0 24rpx 24rpx;
padding: 0;
overflow: hidden;
.cd-remark {
padding: 0 var(--pad);
}
.remark-body {
padding: 24rpx 32rpx;
}
.remark-text {
font-size: 28rpx;
color: #3C3C43;
line-height: 1.6;
}
.remark-text.weak {
color: #C7C7CC;
}
/* ===== 底部属性选择面板 ===== */
.sheet-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99;
animation: mask-in 0.2s ease;
}
.sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
background: #fff;
border-radius: 32rpx 32rpx 0 0;
max-height: 72vh;
display: flex;
flex-direction: column;
animation: sheet-up 0.25s ease;
}
.sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 28rpx 32rpx 12rpx;
}
.sheet-title {
font-size: 32rpx;
font-weight: 600;
color: #000;
}
.sheet-done {
font-size: 30rpx;
font-weight: 600;
color: #007AFF;
padding: 8rpx 0 8rpx 32rpx;
}
.sheet-done:active {
opacity: 0.6;
}
.sheet-body {
padding: 12rpx 32rpx 32rpx;
max-height: 56vh;
box-sizing: border-box;
}
/* 选项胶囊 */
.sheet-tags {
display: flex;
flex-wrap: wrap;
}
.sheet-tag {
padding: 16rpx 30rpx;
margin: 0 16rpx 16rpx 0;
border-radius: 18rpx;
background: #F2F2F7;
color: #3C3C43;
font-size: 28rpx;
line-height: 1;
}
.sheet-tag.on {
background: #007AFF;
color: #fff;
font-weight: 500;
}
.sheet-tag:active {
opacity: 0.8;
}
.sheet-input {
margin-top: 12rpx;
}
/* 色板 */
.sheet-colors {
display: flex;
flex-wrap: wrap;
}
.sheet-color {
width: 96rpx;
height: 96rpx;
margin: 0 20rpx 20rpx 0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 4rpx solid transparent;
box-sizing: border-box;
}
.sheet-color.on {
border-color: #007AFF;
}
.sheet-color-dot {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
border: 1rpx solid #E5E5EA;
}
/* 评分点选 */
/* ===== 属性选择面板 ===== */
.sheet-score {
display: flex;
justify-content: space-between;
padding: 20rpx 0 8rpx;
gap: 20rpx;
padding: 32rpx var(--pad);
}
.sheet-score-dot {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: #F2F2F7;
color: #8E8E93;
font-size: 34rpx;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 108rpx;
line-height: 108rpx;
text-align: center;
border-radius: var(--r-ctrl);
background: var(--fill);
font-size: 32rpx;
color: var(--ink-2);
transition: background .18s var(--ease), color .18s var(--ease);
}
.sheet-score-dot.on {
background: #007AFF;
background: var(--ink);
color: #fff;
}
.sheet-score-dot:active {
opacity: 0.8;
}
@keyframes sheet-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
@keyframes mask-in {
from { opacity: 0; }
to { opacity: 1; }
}
.footer {
padding: 16rpx 24rpx 48rpx;
}
.delete-btn {
text-align: center;
padding: 24rpx 0;
}
/* 处理中 */
.processing-card {
margin: 24rpx;
padding: 64rpx 32rpx;
.sheet-tags {
display: flex;
flex-direction: column;
align-items: center;
flex-wrap: wrap;
gap: 16rpx;
padding: 32rpx var(--pad);
}
.proc-icon {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: #F2F2F7;
color: #C7C7CC;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 48rpx;
.sheet-tag {
padding: 16rpx 32rpx;
border-radius: var(--r-pill);
background: var(--fill);
font-size: 27rpx;
color: var(--ink-2);
transition: background .18s var(--ease), color .18s var(--ease);
}
.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;
.sheet-tag.on {
background: var(--ink);
color: #fff;
font-size: 72rpx;
font-weight: 700;
}
.sheet-colors {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 24rpx;
padding: 32rpx var(--pad) 8rpx;
}
.fail-card {
margin: 0 24rpx 24rpx;
padding: 24rpx 32rpx;
.sheet-color {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
overflow: hidden;
box-shadow: inset 0 0 0 1rpx rgba(0, 0, 0, .12);
}
.fail-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
.sheet-color.on {
box-shadow: 0 0 0 4rpx var(--ink);
}
.fail-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 12rpx;
line-height: 1.5;
.sheet-color-dot {
width: 100%;
height: 100%;
border-radius: 50%;
}
.sheet-input {
margin: 24rpx var(--pad);
width: auto;
display: block;
}
+9 -2
View File
@@ -2,6 +2,7 @@ import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { HoroscopeData } from '../../types/models'
import { setTabBarSelected } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
import { STORAGE_KEYS } from '../../utils/config'
@@ -32,13 +33,19 @@ Page({
data: {
horoscope: null as HoroscopeData | null,
statDate: '',
luckyColorHex: '#007AFF',
// 幸运色是唯一允许出现彩色的业务数据;取不到色值时退回中性灰
luckyColorHex: '#AEAEB2',
loading: true,
noBirth: false,
error: false,
errorText: '',
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
@@ -72,7 +79,7 @@ Page({
this.setData({
horoscope: res,
statDate: this.formatStatDate(res.stat_date),
luckyColorHex: COLOR_MAP[res.lucky_color] || '#007AFF',
luckyColorHex: COLOR_MAP[res.lucky_color] || '#AEAEB2',
loading: false,
noBirth: false,
error: false,
+99 -58
View File
@@ -1,14 +1,23 @@
<view class="page page-flex">
<navigation-bar title="每日运势" back="{{false}}">
<navigation-bar title="每日运势" back="{{false}}" scrolled="{{navScrolled}}">
<view slot="right" class="nav-action" bindtap="onGoProfile">去设置生日</view>
</navigation-bar>
<scroll-view scroll-y class="scroll-body">
<scroll-view scroll-y class="scroll-body pb-tab" bindscroll="onNavScroll">
<!-- 大标题页头 -->
<view class="head">
<view class="eyebrow">Daily Fortune</view>
<view class="display">每日运势</view>
</view>
<!-- 骨架屏 -->
<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 wx:if="{{loading}}" class="hs-skel">
<view class="sk sk-ln" style="width: 38%; height: 28rpx;"></view>
<view class="sk hs-skel-num"></view>
<view class="sk sk-ln" style="width: 100%;"></view>
<view class="sk sk-ln" style="width: 100%;"></view>
<view class="sk sk-ln" style="width: 92%;"></view>
<view class="sk sk-ln" style="width: 84%;"></view>
</view>
<!-- 未设置生日 -->
@@ -31,72 +40,104 @@
bind:action="onRetry"
/>
<!-- 运势内容 -->
<view wx:elif="{{horoscope}}" 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>
<!-- 运势内容:杂志内页排版 -->
<block wx:elif="{{horoscope}}">
<view class="hs-hero">
<view class="hs-zodiac">
<view class="icon icon-sparkles"></view>
<text class="t-xl">{{horoscope.zodiac}}</text>
</view>
<text class="hs-date num">{{statDate}}</text>
</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 class="hs-score">
<view class="hs-score-main">
<view class="eyebrow">综合运势</view>
<view class="hs-num num">{{horoscope.overall_score}}.0</view>
</view>
<view class="hs-stars">
<view
wx:for="{{[1, 2, 3, 4, 5]}}"
wx:key="*this"
class="icon {{index < horoscope.overall_score ? 'icon-star-fill' : 'icon-star'}}"
></view>
</view>
</view>
<!-- 三项评分:横向发丝线刻度尺 -->
<view class="hs-scales">
<view class="hs-scale">
<view class="hs-st">
<text class="k">爱情运势</text>
<text class="v num">{{horoscope.love_score}}/5</text>
</view>
<view class="scale">
<view class="scale-ticks">
<view class="tk" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this"></view>
</view>
<view class="scale-line"></view>
<view class="scale-fill" style="width: {{horoscope.love_score * 20}}%;"></view>
<view class="scale-pip" style="left: {{horoscope.love_score * 20}}%;"></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 class="hs-scale">
<view class="hs-st">
<text class="k">工作运势</text>
<text class="v num">{{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.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 class="scale">
<view class="scale-ticks">
<view class="tk" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this"></view>
</view>
<view class="scale-line"></view>
<view class="scale-fill" style="width: {{horoscope.career_score * 20}}%;"></view>
<view class="scale-pip" style="left: {{horoscope.career_score * 20}}%;"></view>
</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 class="hs-scale">
<view class="hs-st">
<text class="k">财富运势</text>
<text class="v num">{{horoscope.wealth_score}}/5</text>
</view>
<view class="lucky-card">
<text class="lucky-number">{{horoscope.lucky_number}}</text>
<text class="lucky-label">幸运数字</text>
<view class="scale">
<view class="scale-ticks">
<view class="tk" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this"></view>
</view>
<view class="scale-line"></view>
<view class="scale-fill" style="width: {{horoscope.wealth_score * 20}}%;"></view>
<view class="scale-pip" style="left: {{horoscope.wealth_score * 20}}%;"></view>
</view>
</view>
</view>
<view class="gen-btn btn btn-outline" bindtap="onGenerate">生成今日推荐穿搭</view>
</view>
<!-- 今日运势:衬线引文排版 -->
<view class="hs-quote">
<view class="eyebrow">Today</view>
<view class="t-quote">{{horoscope.daily_text}}</view>
</view>
<!-- 幸运色 / 幸运数字:两栏发丝线小格 -->
<view class="hs-lucky">
<view class="hs-lucky-cell">
<view class="lk">幸运色</view>
<view class="lv">
<view class="dot" style="background: {{luckyColorHex}};"></view>
<text>{{horoscope.lucky_color}}</text>
</view>
</view>
<view class="hs-lucky-cell">
<view class="lk">幸运数字</view>
<view class="lv num">{{horoscope.lucky_number}}</view>
</view>
</view>
<view class="hs-foot">
<view class="btn btn--ghost btn--block" bindtap="onGenerate">生成今日推荐穿搭</view>
</view>
</block>
<view class="gap-64"></view>
</scroll-view>
</view>
+127 -164
View File
@@ -1,3 +1,4 @@
/** 运势:杂志内页 —— 巨大衬线星座名 / 超大数字 / 发丝线刻度尺 / 衬线引文 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,203 +11,165 @@
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 {
.hs-skel {
padding: 60rpx var(--pad) 0;
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 24rpx;
color: #8E8E93;
gap: 44rpx;
}
.overall {
margin-top: 40rpx;
.hs-skel-num {
width: 56%;
height: 128rpx;
border-radius: 12rpx;
}
.overall-label {
font-size: 26rpx;
color: #8E8E93;
.hs-hero {
padding: 44rpx var(--pad) 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 24rpx;
}
.overall-score {
.hs-zodiac {
display: flex;
align-items: center;
margin-top: 12rpx;
gap: 18rpx;
}
.score-num {
font-size: 120rpx;
font-weight: 700;
color: #000;
line-height: 1;
margin-right: 32rpx;
}
.stars {
display: flex;
gap: 8rpx;
}
.star {
.hs-zodiac .icon {
width: 40rpx;
height: 40rpx;
color: #C7C7CC;
color: var(--ink-2);
flex-shrink: 0;
}
.star.on {
color: #007AFF;
.hs-date {
font-size: 22rpx;
color: var(--ink-3);
letter-spacing: .08em;
}
.scores {
margin-top: 32rpx;
background: #fff;
border-radius: 24rpx;
padding: 24rpx;
}
.score-row {
.hs-score {
padding: 56rpx var(--pad) 0;
display: flex;
align-items: center;
padding: 16rpx 0;
align-items: flex-end;
gap: 40rpx;
}
.score-label {
width: 160rpx;
font-size: 26rpx;
color: #000;
.hs-score-main {
min-width: 0;
}
.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 {
/* 单项评分块(行内纵向排布,由 .hs-scales 的 gap 控制间距) */
.hs-scale {
display: block;
}
.hs-num {
margin-top: 24rpx;
font-family: var(--serif);
font-size: 152rpx;
font-weight: 500;
line-height: .84;
letter-spacing: -.035em;
}
.hs-stars {
display: flex;
gap: 8rpx;
padding-bottom: 18rpx;
}
.hs-stars .icon {
width: 30rpx;
height: 30rpx;
color: var(--ink);
}
.hs-scales {
padding: 64rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 38rpx;
}
.hs-st {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 14rpx;
}
.hs-st .k {
font-size: 26rpx;
color: var(--ink-2);
}
.hs-st .v {
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .04em;
}
.hs-quote {
padding: 64rpx var(--pad) 0;
}
.hs-quote .t-quote {
margin-top: 26rpx;
}
.hs-lucky {
display: flex;
margin: 64rpx var(--pad) 0;
border-top: 1rpx solid var(--hairline);
border-bottom: 1rpx solid var(--hairline);
}
.hs-lucky-cell {
flex: 1;
padding: 32rpx 0 34rpx;
text-align: center;
position: relative;
}
.hs-lucky-cell + .hs-lucky-cell::before {
content: '';
position: absolute;
left: 0;
top: 32rpx;
bottom: 34rpx;
width: 1rpx;
background: var(--hairline);
}
.hs-lucky .lk {
font-size: 19rpx;
font-weight: 600;
color: #000;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--ink-3);
}
.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;
.hs-lucky .lv {
margin-top: 20rpx;
font-family: var(--serif);
font-size: 38rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 14rpx;
}
.lucky-dot {
width: 24rpx;
height: 24rpx;
.hs-lucky .lv .dot {
width: 22rpx;
height: 22rpx;
border-radius: 50%;
margin-right: 16rpx;
box-shadow: inset 0 0 0 1rpx rgba(0, 0, 0, .14);
}
.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%;
.hs-foot {
padding: 56rpx var(--pad) 0;
}
+3 -1
View File
@@ -1,3 +1,5 @@
{
"usingComponents": {}
"usingComponents": {
"v2-overlay": "/components/v2-overlay/index"
}
}
+28 -21
View File
@@ -21,6 +21,12 @@ Page({
userInfo: {} as UserInfo,
giftVisible: false,
giftItems: [] as GiftItem[],
// 生日引导:v2 玻璃弹窗
birthVisible: false,
birthButtons: [
{ text: '稍后再说', value: '' },
{ text: '去完善', value: 'ok', type: 'main' },
],
},
onLoad(query: Record<string, string>) {
@@ -180,28 +186,29 @@ Page({
},
showBirthGuide() {
wx.showModal({
title: '完善个人信息',
content: '设置生日后即可查看今日星座运势',
confirmText: '去完善',
cancelText: '稍后再说',
success: (res) => {
if (res.confirm) {
// switchTab 不支持 query 传参,用 storage 标记让「我的」页 onShow 自动弹出编辑弹窗
wx.setStorageSync(STORAGE_KEYS.OPEN_PROFILE_EDIT, 1)
wx.switchTab({
url: '/pages/profile/profile',
fail: (e) => {
logger.warn(LOG, 'switchTab 个人资料失败, err=', e && e.errMsg)
// 跳转失败时清除标记,避免下次进入「我的」页意外弹窗
wx.removeStorageSync(STORAGE_KEYS.OPEN_PROFILE_EDIT)
},
})
} else {
this.goAfterLogin()
}
this.setData({ birthVisible: true })
},
onBirthCancel() {
this.setData({ birthVisible: false })
this.goAfterLogin()
},
onBirthConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ birthVisible: false })
if (e.detail.value !== 'ok') {
this.goAfterLogin()
return
}
// switchTab 不支持 query 传参,用 storage 标记让「我的」页 onShow 自动弹出编辑弹窗
wx.setStorageSync(STORAGE_KEYS.OPEN_PROFILE_EDIT, 1)
wx.switchTab({
url: '/pages/profile/profile',
fail: (err) => {
logger.warn(LOG, 'switchTab 个人资料失败, err=', err && err.errMsg)
// 跳转失败时清除标记,避免下次进入「我的」页意外弹窗
wx.removeStorageSync(STORAGE_KEYS.OPEN_PROFILE_EDIT)
},
fail: (e) => logger.warn(LOG, '生日引导弹窗失败, err=', e && e.errMsg),
})
},
+45 -27
View File
@@ -1,29 +1,29 @@
<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 class="lg-hero">
<view class="icon lg-ic lg-ic1 icon-hanger"></view>
<view class="icon lg-ic lg-ic2 icon-shirt"></view>
<view class="icon lg-ic lg-ic3 icon-sparkles"></view>
<view class="icon lg-ic lg-ic4 icon-camera"></view>
<view class="icon lg-ic lg-ic5 icon-star"></view>
</view>
<!-- Logo 与产品名 -->
<view class="brand">
<view class="brand-logo">
<!-- 品牌区 -->
<view class="lg-top">
<view class="lg-logo">
<view class="icon icon-hanger"></view>
</view>
<view class="brand-name">纸条AI衣橱</view>
<view class="brand-slogan">把你的衣橱,交给 AI 打理</view>
<view class="lg-name">纸条AI衣橱</view>
<view class="lg-slogan">把你的衣橱,交给 AI 打理</view>
</view>
<!-- 底部登录区 -->
<view class="login-footer">
<view class="btn btn-primary login-btn {{loading ? 'disabled' : ''}}" bindtap="onLogin">
<view class="lg-foot">
<view class="btn btn--ink btn--block {{loading ? 'is-off' : ''}}" bindtap="onLogin">
<view class="wechat-icon"></view>
<text>{{loading ? '登录中…' : '微信一键登录'}}</text>
</view>
<view class="agreement">
<view class="lg-terms">
<text>登录即代表同意</text>
<text class="link" bindtap="openAgreement">《用户协议》</text>
<text>和</text>
@@ -31,20 +31,38 @@
</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 wx:if="{{giftVisible}}" class="scrim"></view>
<view wx:if="{{giftVisible}}" class="modal-wrap">
<view class="modal">
<view class="modal-body">
<view class="lg-gift-ic">
<view class="icon icon-sparkles"></view>
</view>
<view class="modal-title">欢迎加入纸条AI衣橱</view>
<view class="modal-text">新人礼包已到账,请查收</view>
<view class="lg-gift-list">
<view class="lg-gift-row" wx:for="{{giftItems}}" wx:key="name">
<view class="icon {{item.icon}}"></view>
<text class="gn">{{item.name}}</text>
<text class="gv num">{{item.value}}</text>
</view>
</view>
</view>
<view class="btn btn-primary gift-btn" bindtap="onGiftConfirm">开心收下</view>
<view class="modal-acts">
<view class="modal-act modal-act--main" bindtap="onGiftConfirm">开心收下</view>
</view>
</view>
</view>
<!-- 生日引导 -->
<v2-overlay
visible="{{birthVisible}}"
type="modal"
title="完善个人信息"
content="设置生日后即可查看今日星座运势"
buttons="{{birthButtons}}"
bind:confirm="onBirthConfirm"
bind:cancel="onBirthCancel"
/>
</view>
+86 -130
View File
@@ -1,194 +1,150 @@
/** 登录:全屏纯白 + 居中线稿 Logo + 衬线品牌名 + 底部实心黑胶囊 **/
.login-page {
min-height: 100vh;
background: #fff;
background: var(--paper);
display: flex;
flex-direction: column;
box-sizing: border-box;
}
/* 顶部插画区 */
.hero {
/* 装饰线稿 */
.lg-hero {
position: relative;
height: 480rpx;
background: #E8F2FF;
border-radius: 0 0 48rpx 48rpx;
overflow: hidden;
width: 100%;
height: 340rpx;
flex-shrink: 0;
}
.hero-icon {
.lg-ic {
position: absolute;
color: #007AFF;
opacity: 0.85;
color: var(--ink-4);
}
.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); }
.lg-ic1 { left: 5%; top: 12%; width: 60rpx; height: 60rpx; transform: rotate(-13deg); opacity: .52; }
.lg-ic2 { left: 27%; top: 62%; width: 42rpx; height: 42rpx; transform: rotate(9deg); opacity: .34; }
.lg-ic3 { right: 7%; top: 16%; width: 52rpx; height: 52rpx; transform: rotate(15deg); opacity: .46; }
.lg-ic4 { right: 24%; bottom: 4%; width: 40rpx; height: 40rpx; transform: rotate(-7deg); opacity: .3; }
.lg-ic5 { left: 13%; bottom: 8%; width: 34rpx; height: 34rpx; opacity: .28; }
/* 品牌区 */
.brand {
.lg-top {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 64rpx;
padding: 0 64rpx 120rpx;
}
.brand-logo {
width: 128rpx;
height: 128rpx;
border-radius: 32rpx;
background: #007AFF;
color: #fff;
.lg-logo {
width: 124rpx;
height: 124rpx;
border-radius: 50%;
margin-bottom: 44rpx;
color: var(--ink);
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
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;
.lg-logo .icon {
width: 54rpx;
height: 54rpx;
}
.brand-name {
font-size: 48rpx;
font-weight: 700;
color: #000;
.lg-name {
font-family: var(--serif);
font-size: 54rpx;
font-weight: 500;
letter-spacing: .08em;
text-indent: .08em;
line-height: 1.2;
}
.brand-slogan {
font-size: 28rpx;
color: #8E8E93;
margin-top: 16rpx;
.lg-slogan {
margin-top: 24rpx;
font-size: 26rpx;
color: var(--ink-2);
letter-spacing: .03em;
}
/* 底部 */
.login-footer {
padding: 0 64rpx;
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
}
.login-btn {
width: 100%;
.lg-foot {
padding: 0 56rpx calc(52rpx + env(safe-area-inset-bottom));
}
.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;
.lg-terms {
margin-top: 28rpx;
text-align: center;
font-size: 22rpx;
line-height: 1.75;
color: var(--ink-3);
}
.link {
color: #007AFF;
.lg-terms .link {
color: var(--ink-2);
text-decoration: underline;
text-underline-offset: 6rpx;
text-decoration-thickness: 1rpx;
text-decoration-color: var(--ink-4);
}
/* 新手礼包弹窗 */
.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;
/* 新手礼包 */
.lg-gift-ic {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: linear-gradient(135deg, #FF9500, #FF2D55);
color: #fff;
margin: 0 auto 32rpx;
color: var(--ink);
box-shadow: inset 0 0 0 1rpx var(--hairline-2);
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 {
.lg-gift-ic .icon {
width: 40rpx;
height: 40rpx;
color: #007AFF;
margin-right: 20rpx;
}
.lg-gift-list {
margin-top: 32rpx;
text-align: left;
}
.lg-gift-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid var(--hairline);
}
.lg-gift-row:last-child {
border-bottom: 0;
}
.lg-gift-row .icon {
width: 32rpx;
height: 32rpx;
color: var(--ink-2);
flex-shrink: 0;
}
.gift-item-name {
.lg-gift-row .gn {
flex: 1;
font-size: 28rpx;
color: #000;
}
.gift-item-val {
font-size: 26rpx;
color: #8E8E93;
}
.gift-btn {
width: 100%;
.lg-gift-row .gv {
font-size: 24rpx;
color: var(--ink-3);
}
+6
View File
@@ -3,6 +3,7 @@ import { QuotaItem, UserInfo, GoodsItem, QuotaFlowItem, PagedResult, VirtualPayR
import { QUOTA_NAMES, GoodsType, GOODS_TYPE_NAMES } from '../../utils/config'
import { fenToYuan, formatDate, formatDateTime } from '../../utils/format'
import { normalizePaged } from '../../utils/paged'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -55,8 +56,13 @@ Page({
orderGoods: null as GoodsItem | null,
submitting: false, // 下单防连点:连点「立即支付」会重复创建虚拟支付订单
loading: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
logger.debug(LOG, 'onLoad, query=', JSON.stringify(query))
if (query.tab === 'flow') this.setData({ view: 'flow' })
+92 -64
View File
@@ -1,95 +1,123 @@
<view class="page mall-page">
<navigation-bar title="额度与会员" />
<view class="page page-flex">
<navigation-bar title="额度与会员" scrolled="{{navScrolled}}" />
<!-- 商城视图 -->
<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>
<scroll-view wx:if="{{view === 'mall'}}" scroll-y class="scroll-body pb-safe" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">Membership</view>
<view class="display">额度与会员</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 class="ml-member">
<view class="ml-member-l">
<view class="icon icon-crown"></view>
<view class="ml-member-body">
<view class="ml-member-n">{{isVip ? '月度会员' : '开通会员'}}</view>
<view class="ml-member-d">{{isVip ? vipExpireText : '开通会员,解锁更多 AI 与上传权益'}}</view>
</view>
</view>
<view class="ml-member-b" bindtap="onTapMember">{{isVip ? '立即续费' : '立即开通'}}</view>
</view>
<!-- 我的额度:四栏发丝线数字条 -->
<view class="grp-hd ml-grp"><text class="gh-cn">我的额度</text><text class="gh-en">Quota</text></view>
<view class="stats">
<view class="stat" wx:for="{{quotas}}" wx:key="quota_type">
<view class="stat-n num">{{item.remain}}<text class="unit">/{{item.total}}</text></view>
<view class="stat-l">{{item._name}}</view>
<view class="track ml-track">
<view class="bar" 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 class="rows ml-flow-entry">
<view class="row" bindtap="onSwitchFlow">
<view class="icon row-ic icon-doc"></view>
<text class="row-lb">额度明细</text>
<view class="icon row-ch icon-chevron-right"></view>
</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="ml-filters">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{goodsFilters}}"
wx:key="name"
class="seg-item {{activeFilter === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onGoodsFilter"
>{{item.name}}</view>
</view>
</scroll-view>
</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 class="rows">
<view class="row ml-goods" wx:for="{{goods}}" wx:key="goods_code">
<view class="icon row-ic {{item._icon}}"></view>
<view class="ml-goods-body">
<view class="ml-goods-n">{{item.goods_name}}</view>
<view class="ml-goods-d">{{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 class="ml-goods-r">
<view class="ml-price num">{{item._priceText}}</view>
<view class="ml-origin num" wx:if="{{item.price > item.discount_price}}">{{item._originText}}</view>
</view>
<view class="btn btn--ink btn--sm ml-buy" data-goods="{{item}}" bindtap="onBuy">购买</view>
</view>
</view>
<view class="footer-space"></view>
<view class="gap-64"></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>
<scroll-view wx:else scroll-y class="scroll-body pb-safe" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">Quota Ledger</view>
<view class="display">额度明细</view>
<view class="ml-back" bindtap="onSwitchMall">← 返回商城</view>
</view>
<view class="ml-flows">
<view class="ml-flow-row" wx:for="{{flowList}}" wx:key="quota_flow_uuid">
<view class="ml-flow-l">
<view class="ml-flow-reason">{{item.reason || item._name}}</view>
<view class="ml-flow-time num">{{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 class="ml-flow-r">
<view class="ml-flow-n num">{{item._sign}}</view>
<view class="ml-flow-after num">剩余 {{item.new_value}}</view>
</view>
</view>
</view>
<empty-state wx:if="{{!flowList.length}}" icon="icon-doc" title="暂无额度明细" />
<view class="gap-64"></view>
</scroll-view>
<!-- 订单确认弹窗 -->
<view wx:if="{{orderVisible}}" class="mask" bindtap="onCloseOrder">
<!-- 订单确认:底部玻璃抽屉 -->
<view wx:if="{{orderVisible}}" class="scrim" bindtap="onCloseOrder"></view>
<view wx:if="{{orderVisible}}" class="sheet-wrap">
<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 {{submitting ? 'disabled' : ''}}" bindtap="confirmPay">{{submitting ? '下单中…' : '立即支付'}}</view>
<view class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">{{orderGoods.goods_name}}</text>
<text class="sheet-x" bindtap="onCloseOrder">取消</text>
</view>
<view class="sheet-body">
<view class="ml-order-d">{{orderGoods.description}}</view>
<view class="ml-order-a">实付 <text class="num">{{orderGoods._priceText}}</text></view>
</view>
<view class="sheet-foot">
<view
class="btn btn--ink btn--block {{submitting ? 'is-off' : ''}}"
bindtap="confirmPay"
>{{submitting ? '下单中…' : '立即支付'}}</view>
</view>
</view>
</view>
</view>
+137 -252
View File
@@ -1,7 +1,9 @@
.mall-page {
/** 额度与会员:唯一深色会员卡 + 四栏额度条 + 商品行 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,322 +11,205 @@
height: 0;
}
/* 会员卡 */
.member-card {
margin: 24rpx;
background: #E8F2FF;
/* 会员卡(全稿唯一深色块) */
.ml-member {
margin: 40rpx var(--pad) 0;
padding: 40rpx 36rpx;
border-radius: var(--r-ctrl);
background: var(--block);
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
gap: 24rpx;
}
.member-left {
.ml-member-l {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 24rpx;
}
.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;
.ml-member-l .icon {
width: 52rpx;
height: 52rpx;
flex-shrink: 0;
opacity: .9;
}
/* 额度网格 */
.quota-grid {
display: flex;
flex-wrap: wrap;
padding: 0 24rpx;
gap: 20rpx;
.ml-member-body {
min-width: 0;
}
.quota-cell {
width: calc(50% - 10rpx);
.ml-member-n {
font-family: var(--serif);
font-size: 36rpx;
font-weight: 500;
letter-spacing: -.005em;
}
.ml-member-d {
margin-top: 10rpx;
font-size: 23rpx;
line-height: 1.5;
color: rgba(255, 255, 255, .66);
}
.ml-member-b {
flex-shrink: 0;
height: 68rpx;
padding: 0 30rpx;
border-radius: var(--r-pill);
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;
color: var(--block);
font-size: 26rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #000;
transition: opacity .18s var(--ease);
}
.flow-entry .icon {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
.ml-member-b:active {
opacity: .7;
}
/* 额度 */
.ml-grp {
margin-top: 64rpx;
}
.ml-track {
margin: 18rpx 24rpx 0;
}
.ml-flow-entry {
margin-top: 32rpx;
}
/* 商品筛选 */
.goods-filter {
background: #fff;
white-space: nowrap;
margin-bottom: 20rpx;
.ml-filters {
margin-top: 64rpx;
border-bottom: 1rpx solid var(--hairline);
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
/* 商品行 */
.ml-goods {
min-height: 152rpx;
padding-top: 20rpx;
padding-bottom: 20rpx;
}
.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 {
.ml-goods-body {
flex: 1;
min-width: 0;
}
.goods-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
.ml-goods-n {
font-size: 29rpx;
color: var(--ink);
}
.goods-desc {
font-size: 24rpx;
color: #8E8E93;
margin-top: 6rpx;
.ml-goods-d {
margin-top: 8rpx;
font-size: 23rpx;
color: var(--ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.goods-right {
display: flex;
flex-direction: column;
align-items: flex-end;
.ml-goods-r {
text-align: right;
flex-shrink: 0;
}
.goods-price {
font-size: 30rpx;
font-weight: 700;
color: #007AFF;
.ml-price {
font-family: var(--serif);
font-size: 34rpx;
color: var(--ink);
}
.goods-origin {
font-size: 22rpx;
color: #C7C7CC;
.ml-origin {
margin-top: 4rpx;
font-size: 21rpx;
color: var(--ink-4);
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;
.ml-buy {
flex-shrink: 0;
margin-left: 20rpx;
}
.footer-space {
height: 40rpx;
}
/* 额度明细视图 */
.flow-head {
padding: 24rpx;
/* 额度明细 */
.ml-back {
margin-top: 20rpx;
font-size: 26rpx;
color: #007AFF;
color: var(--ink-2);
}
.flow-list {
padding: 0 24rpx;
.ml-flows {
padding: 40rpx var(--pad) 0;
}
.flow-row {
.ml-flow-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
padding: 24rpx;
align-items: flex-start;
gap: 24rpx;
padding: 32rpx 0;
border-bottom: 1rpx solid var(--hairline);
}
.flow-reason {
font-size: 28rpx;
color: #000;
.ml-flow-l {
flex: 1;
min-width: 0;
}
.flow-time {
font-size: 22rpx;
color: #C7C7CC;
.ml-flow-reason {
font-size: 29rpx;
color: var(--ink);
}
.ml-flow-time {
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;
color: var(--ink-3);
}
/* 订单弹窗 */
.mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
.ml-flow-r {
text-align: right;
flex-shrink: 0;
}
.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 {
.ml-flow-n {
font-family: var(--serif);
font-size: 34rpx;
font-weight: 600;
color: #000;
text-align: center;
color: var(--ink);
}
.order-desc {
font-size: 24rpx;
color: #8E8E93;
text-align: center;
margin-top: 12rpx;
.ml-flow-after {
margin-top: 6rpx;
font-size: 22rpx;
color: var(--ink-3);
}
.order-amount {
/* 订单抽屉 */
.ml-order-d {
padding: 0 var(--pad);
font-size: 26rpx;
line-height: 1.7;
color: var(--ink-2);
}
.ml-order-a {
margin: 40rpx var(--pad) 32rpx;
font-size: 28rpx;
color: #000;
text-align: center;
margin: 32rpx 0;
color: var(--ink-2);
}
.order-price {
font-size: 44rpx;
font-weight: 700;
color: #FF9500;
.ml-order-a .num {
font-family: var(--serif);
font-size: 48rpx;
color: var(--ink);
}
@@ -2,6 +2,7 @@ import { request } from '../../utils/request'
import { NotificationItem } from '../../types/models'
import { formatDateTime } from '../../utils/format'
import { syncTabBarUnread } from '../../utils/tabbar'
import { navPageScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -12,8 +13,13 @@ Page({
uuid: '',
item: {} as NotificationItem,
time: '',
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// 页面级滚动 → 导航收缩为玻璃小标题
onPageScroll: navPageScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
logger.debug(LOG, 'onLoad, uuid=', uuid)
@@ -1,14 +1,18 @@
<view class="page detail-page">
<navigation-bar title="通知详情" />
<view class="page nd-page">
<navigation-bar title="通知详情" scrolled="{{navScrolled}}" />
<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 class="head">
<view class="eyebrow">{{item.msg_type === 1 ? 'Like' : item.msg_type === 2 ? 'Order' : 'System'}}</view>
<view class="t-xl nd-title">{{item.title}}</view>
<view class="nd-time num">{{time}}</view>
</view>
<view class="nd-body">
<view class="nd-ic">
<view class="icon {{item.msg_type === 1 ? 'icon-heart' : item.msg_type === 2 ? 'icon-doc' : 'icon-bell'}}"></view>
</view>
<view class="t-bd nd-content">{{item.content}}</view>
</view>
<view class="gap-64"></view>
</view>
@@ -1,65 +1,43 @@
.detail-page {
padding: 24rpx;
box-sizing: border-box;
/** 通知详情:衬线标题 + 发丝线正文排版 **/
.nd-page {
padding-bottom: 40rpx;
}
.detail-card {
margin-top: 24rpx;
padding: 48rpx 40rpx;
.nd-title {
margin-top: 22rpx;
}
.icon-row {
display: flex;
justify-content: center;
margin-bottom: 32rpx;
.nd-time {
margin-top: 18rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .04em;
}
.n-icon {
width: 120rpx;
height: 120rpx;
.nd-body {
margin: 56rpx var(--pad) 0;
padding-top: 56rpx;
border-top: 1rpx solid var(--hairline);
}
.nd-ic {
width: 96rpx;
height: 96rpx;
border-radius: 50%;
background: var(--fill);
color: var(--ink-2);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 40rpx;
}
.n-icon.like {
background: #FFE8EC;
color: #FF3B30;
.nd-ic .icon {
width: 44rpx;
height: 44rpx;
}
.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;
.nd-content {
line-height: 1.85;
color: var(--ink);
}
@@ -5,6 +5,7 @@ import { STORAGE_KEYS } from '../../utils/config'
import { formatRelativeTime } from '../../utils/format'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { getTabBarInstance } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -27,8 +28,13 @@ Page({
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
if (!auth.isLoggedIn()) {
@@ -1,53 +1,64 @@
<view class="page page-flex">
<navigation-bar title="通知" />
<navigation-bar title="通知" scrolled="{{navScrolled}}" />
<scroll-view
scroll-y
class="scroll-body"
bindscrolltolower="onReachBottom"
>
<!-- 筛选 -->
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
<view class="filter-row">
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom" bindscroll="onNavScroll">
<!-- 大标题页头 -->
<view class="head">
<view class="eyebrow">Notifications</view>
<view class="display">通知</view>
</view>
<!-- 下划线式筛选 -->
<view class="segbar">
<view class="seg">
<view
wx:for="{{filters}}"
wx:key="name"
class="filter-item {{activeFilter === index ? 'active' : ''}}"
class="seg-item {{activeFilter === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onFilterTap"
>{{item.name}}</view>
</view>
</scroll-view>
</view>
<!-- 骨架屏 -->
<view wx:if="{{loading}}" class="nt-skel">
<view class="nt-skel-item" wx:for="{{[1, 2, 3]}}" wx:key="*this">
<view class="sk sk-ln" style="width: 52%;"></view>
<view class="sk sk-ln" style="width: 86%;"></view>
<view class="sk sk-ln" style="width: 30%;"></view>
</view>
</view>
<!-- 空态 -->
<empty-state
wx:if="{{!loading && !list.length}}"
wx:elif="{{!list.length}}"
icon="icon-bell"
title="还没有消息"
desc="穿搭被点赞、订单动态都会出现在这里"
/>
<!-- 列表 -->
<view class="list">
<!-- 列表:未读左侧黑竖条 + 黑字标题,已读降为 ink-3 -->
<view wx:else class="nt-list">
<view
class="n-card card {{item.is_read === 1 ? 'unread' : ''}}"
class="nt-item {{item.is_read === 1 ? 'is-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="nt-ic">
<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 class="nt-body">
<view class="nt-t">{{item.title}}</view>
<view class="nt-c">{{item.content}}</view>
<view class="nt-time num">{{item._time}}</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-32"></view>
</scroll-view>
</view>
@@ -1,3 +1,4 @@
/** 通知列表:下划线筛选 + 未读黑竖条 + 发丝线分行 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,117 +11,104 @@
height: 0;
}
.filter-scroll {
background: #fff;
white-space: nowrap;
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
.nt-skel {
padding: 40rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 52rpx;
}
.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;
.nt-skel-item {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.filter-item.active {
background: #007AFF;
color: #fff;
.nt-list {
padding: 8rpx var(--pad) 0;
}
.list {
padding: 24rpx;
}
.n-card {
.nt-item {
position: relative;
display: flex;
align-items: flex-start;
padding: 28rpx;
margin-bottom: 20rpx;
gap: 26rpx;
padding: 36rpx 0;
border-bottom: 1rpx solid var(--hairline);
}
.n-icon {
width: 80rpx;
height: 80rpx;
/* 未读:左侧 4rpx 黑竖条 */
.nt-item.is-unread::before {
content: '';
position: absolute;
left: -20rpx;
top: 44rpx;
width: 4rpx;
height: 32rpx;
border-radius: 2rpx;
background: var(--ink);
}
.nt-ic {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: var(--fill);
color: var(--ink-2);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
}
.n-icon.like {
background: #FFE8EC;
color: #FF3B30;
.nt-ic .icon {
width: 36rpx;
height: 36rpx;
}
.n-icon.order {
background: #E8F2FF;
color: #007AFF;
}
.n-icon.sys {
background: #F2F2F7;
color: #8E8E93;
}
.n-icon .icon {
width: 44rpx;
height: 44rpx;
}
.n-body {
.nt-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 {
.nt-t {
font-size: 30rpx;
font-weight: 600;
color: #000;
}
.n-title.read {
color: #8E8E93;
font-weight: 500;
color: var(--ink-3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.n-content {
.nt-item.is-unread .nt-t {
color: var(--ink);
font-weight: 600;
}
.nt-c {
margin-top: 10rpx;
font-size: 26rpx;
color: #3C3C43;
margin-top: 8rpx;
line-height: 1.6;
color: var(--ink-2);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.n-time {
.nt-time {
margin-top: 12rpx;
font-size: 22rpx;
color: #C7C7CC;
margin-top: 8rpx;
color: var(--ink-4);
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
+79 -38
View File
@@ -2,6 +2,7 @@ import { request } from '../../utils/request'
import { PagedResult, OrderItem } from '../../types/models'
import { fenToYuan, formatDateTime, formatCountdown } from '../../utils/format'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -17,7 +18,14 @@ const STATUS_FILTERS = [
]
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' }
// 零彩色状态语义:待支付 = 实心黑;已支付/已退款 = 描边;已取消/已过期 = 降透明
const STATUS_CLS: Record<number, string> = {
1: 'pill--ink',
2: 'pill--line',
3: 'pill--dim',
4: 'pill--dim',
5: 'pill--line pill--dim',
}
Page({
data: {
@@ -29,8 +37,28 @@ Page({
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 取消订单确认
cancelVisible: false,
cancelOrderNo: '',
cancelButtons: [
{ text: '再想想', value: '' },
{ text: '确认取消', value: 'ok', type: 'main' },
],
// 退款申请(抽屉 + 原因输入)
refundVisible: false,
refundOrderNo: '',
refundReason: '',
refundSubmitting: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
// 抽屉内容点击不穿透到遮罩
noop() {},
onShow() {
this.refresh()
this.startCountdown()
@@ -103,7 +131,7 @@ Page({
const list = raw.map((it) => ({
...it,
_statusText: STATUS_TEXT[it.order_status] || '',
_statusType: STATUS_TYPE[it.order_status] || 'gray',
_statusCls: STATUS_CLS[it.order_status] || 'pill--line',
_amount: fenToYuan(it.pay_amount),
_time: formatDateTime(it.created_at),
_orderTail: `…${it.order_no.slice(-4)}`,
@@ -137,21 +165,24 @@ Page({
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(() => {
logger.info(LOG, '取消订单成功, orderNo=', orderNo)
wx.showToast({ title: '已取消', icon: 'success' })
this.refresh()
})
.catch((e: any) => logger.warn(LOG, '取消订单失败, orderNo=', orderNo, 'code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '取消订单弹窗失败, err=', e && e.errMsg),
})
this.setData({ cancelVisible: true, cancelOrderNo: orderNo })
},
onCancelCancel() {
this.setData({ cancelVisible: false })
},
onCancelConfirm(ev: WechatMiniprogram.CustomEvent) {
const orderNo = this.data.cancelOrderNo
this.setData({ cancelVisible: false })
if (ev.detail.value !== 'ok' || !orderNo) return
request({ url: `/order/${orderNo}/cancel`, method: 'POST' })
.then(() => {
logger.info(LOG, '取消订单成功, orderNo=', orderNo)
wx.showToast({ title: '已取消', icon: 'success' })
this.refresh()
})
.catch((err: any) => logger.warn(LOG, '取消订单失败, orderNo=', orderNo, 'code=', err && err.code))
},
onPay() {
@@ -160,27 +191,37 @@ Page({
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(() => {
logger.info(LOG, '退款申请提交成功, orderNo=', orderNo)
wx.showToast({ title: '退款申请已提交,等待审核', icon: 'none' })
this.refresh()
})
.catch((e: any) => logger.warn(LOG, '退款申请失败, orderNo=', orderNo, 'code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '退款弹窗失败, err=', e && e.errMsg),
})
this.setData({ refundVisible: true, refundOrderNo: orderNo, refundReason: '' })
},
onRefundClose() {
this.setData({ refundVisible: false })
},
onRefundInput(e: WechatMiniprogram.Input) {
this.setData({ refundReason: e.detail.value })
},
onRefundSubmit() {
if (this.data.refundSubmitting) return
const orderNo = this.data.refundOrderNo
const reason = (this.data.refundReason || '').trim()
if (!reason) {
wx.showToast({ title: '请填写退款原因', icon: 'none' })
return
}
this.setData({ refundSubmitting: true })
request({ url: '/order/refund', method: 'POST', data: { order_no: orderNo, reason } })
.then(() => {
logger.info(LOG, '退款申请提交成功, orderNo=', orderNo)
wx.showToast({ title: '退款申请已提交,等待审核', icon: 'none' })
this.setData({ refundVisible: false, refundSubmitting: false })
this.refresh()
})
.catch((err: any) => {
logger.warn(LOG, '退款申请失败, orderNo=', orderNo, 'code=', err && err.code)
this.setData({ refundSubmitting: false })
})
},
onGoMall() {
+90 -36
View File
@@ -1,19 +1,26 @@
<view class="page page-flex">
<navigation-bar title="我的订单" />
<navigation-bar title="我的订单" scrolled="{{navScrolled}}" />
<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>
<scroll-view scroll-y class="scroll-body pb-safe" bindscrolltolower="onReachBottom" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">Orders</view>
<view class="display">我的订单</view>
</view>
<!-- 下划线式筛选 -->
<view class="segbar">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{filters}}"
wx:key="name"
class="seg-item {{activeFilter === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onFilterTap"
>{{item.name}}</view>
</view>
</scroll-view>
</view>
<empty-state
wx:if="{{!loading && !list.length}}"
@@ -24,32 +31,79 @@
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 class="od-list">
<view class="od-card" wx:for="{{list}}" wx:key="order_no">
<view class="od-top">
<text class="od-no num">订单 {{item._orderTail}}</text>
<text wx:if="{{item._payChannelText}}" class="pill">{{item._payChannelText}}</text>
<text class="pill {{item._statusCls}}">{{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 class="od-goods">{{item.goods_name}}</view>
<view class="od-benefit" wx:if="{{item._benefit}}">{{item._benefit}}</view>
<view class="od-bottom">
<view class="od-amount">实付 <text class="num">{{item._amount}}</text></view>
<view class="od-actions">
<block wx:if="{{item.order_status === 1}}">
<text wx:if="{{item._countdown}}" class="od-countdown num">剩余 {{item._countdown}}</text>
<view
wx:if="{{item._canCancel}}"
class="btn btn--ghost btn--sm"
data-no="{{item.order_no}}"
bindtap="onCancel"
>取消订单</view>
<view class="btn btn--ink btn--sm" data-no="{{item.order_no}}" bindtap="onPay">去支付</view>
</block>
<block wx:elif="{{item.order_status === 2}}">
<view class="btn btn--ghost btn--sm" data-no="{{item.order_no}}" bindtap="onRefund">申请退款</view>
</block>
</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
<!-- 取消订单确认 -->
<v2-overlay
visible="{{cancelVisible}}"
type="modal"
title="取消订单"
content="确认取消该订单?"
buttons="{{cancelButtons}}"
bind:confirm="onCancelConfirm"
bind:cancel="onCancelCancel"
/>
<!-- 退款申请:底部玻璃抽屉 -->
<view wx:if="{{refundVisible}}" class="scrim" bindtap="onRefundClose"></view>
<view wx:if="{{refundVisible}}" class="sheet-wrap">
<view class="sheet" catchtap="noop">
<view class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">申请退款</text>
<text class="sheet-x" bindtap="onRefundClose">取消</text>
</view>
<view class="sheet-body">
<textarea
class="textarea od-refund-input"
value="{{refundReason}}"
bindinput="onRefundInput"
placeholder="请填写退款原因"
placeholder-class="input-ph"
maxlength="200"
/>
</view>
<view class="sheet-foot">
<view
class="btn btn--ink btn--block {{refundSubmitting ? 'is-off' : ''}}"
bindtap="onRefundSubmit"
>{{refundSubmitting ? '提交中…' : '提交申请'}}</view>
</view>
</view>
</view>
</view>
+58 -83
View File
@@ -1,3 +1,4 @@
/** 订单:下划线筛选 + 描边状态胶囊 + tabular 倒计时 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,113 +11,87 @@
height: 0;
}
.filter-scroll {
background: #fff;
white-space: nowrap;
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.filter-row {
display: inline-flex;
padding: 20rpx 24rpx;
.od-list {
padding: 40rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 40rpx;
}
.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;
.od-card {
padding-bottom: 36rpx;
border-bottom: 1rpx solid var(--hairline);
}
.filter-item.active {
background: #007AFF;
color: #fff;
}
.list {
padding: 24rpx;
}
.order-card {
margin-bottom: 20rpx;
padding: 28rpx;
}
.order-top {
.od-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14rpx;
}
.order-no {
font-size: 24rpx;
color: #8E8E93;
.od-no {
flex: 1;
font-size: 23rpx;
color: var(--ink-3);
}
.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;
.od-goods {
margin-top: 24rpx;
font-family: var(--serif);
font-size: 34rpx;
line-height: 1.35;
}
.order-amount {
font-size: 26rpx;
color: #3C3C43;
.od-benefit {
margin-top: 10rpx;
font-size: 23rpx;
color: var(--ink-2);
}
.order-amount .num {
font-size: 32rpx;
font-weight: 600;
color: #000;
.od-bottom {
margin-top: 28rpx;
display: flex;
align-items: center;
gap: 20rpx;
}
.order-actions {
.od-amount {
flex: 1;
font-size: 24rpx;
color: var(--ink-2);
}
.od-amount .num {
font-family: var(--serif);
font-size: 36rpx;
color: var(--ink);
}
.od-actions {
display: flex;
align-items: center;
gap: 16rpx;
}
.countdown {
font-size: 24rpx;
color: #8E8E93;
.od-countdown {
font-size: 23rpx;
color: var(--ink-3);
font-family: var(--serif);
}
.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;
.od-refund-input {
margin: 0 var(--pad);
width: auto;
display: block;
}
@@ -1,5 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
+292 -95
View File
@@ -1,77 +1,225 @@
import { request } from '../../utils/request'
import { request, AUTH_EXPIRED_CODES } from '../../utils/request'
import { OutfitItem, ClothesItem } from '../../types/models'
import { IsPublic } from '../../utils/config'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'outfit-detail'
// 接收者打开「分享者额度不足」链接后的自动返回倒计时(秒)
const SHARE_FAIL_BACK_SECONDS = 10
Page({
data: {
uuid: '',
isOwner: true,
// 仅显式 owner=1 视为属主:无参数链接(如微信默认分享路径)安全降级为非属主视角
isOwner: false,
outfit: {} as OutfitItem,
clothes: [] as ClothesItem[],
isLiked: false,
likeCount: 0,
isCollected: false,
loading: true,
// 页面状态:normal 正常;quota 接收者命中「分享者额度不足」;invalid 加载失败
pageState: 'normal' as 'normal' | 'quota' | 'invalid',
invalidTitle: '',
invalidDesc: '',
needLogin: false,
countdown: SHARE_FAIL_BACK_SECONDS,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
tipVisible: false,
tipTitle: '',
tipText: '',
tipButtons: [{ text: '我知道了', value: 'ok', type: 'main' }],
pubVisible: false,
pubButtons: [
{ text: '取消', value: '' },
{ text: '确认公开', value: 'ok', type: 'main' },
],
delVisible: false,
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
moreVisible: false,
moreItems: [
{ text: '编辑穿搭', value: 'edit' },
{ text: '删除穿搭', value: 'delete' },
],
// 额度不足引导:v2 玻璃弹窗
quotaVisible: false,
quotaTitle: '',
quotaText: '',
quotaButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
const isOwner = query.owner !== '0'
logger.debug(LOG, 'onLoad, uuid=', uuid, 'isOwner=', isOwner)
const isOwner = query.owner === '1'
logger.debug(LOG, 'onLoad, uuid=', uuid, 'isOwner=', isOwner, 'shareFail=', query.share_fail)
this.setData({ uuid, isOwner })
if (uuid) this.fetchDetail()
else logger.warn(LOG, 'onLoad 缺少 uuid 参数')
// 分享者额度不足时分享出去的链接:不请求后端(也不依赖登录),直接进入提示态
if (query.share_fail === 'quota') {
this.showShareFailQuota()
return
}
if (!uuid) {
logger.warn(LOG, 'onLoad 缺少 uuid 参数')
this.setInvalid('链接不完整', '缺少穿搭标识,无法打开')
return
}
this.fetchDetail()
},
// 接收者命中「分享者额度不足」:弹提醒 + 倒计时后返回「我的」页
showShareFailQuota() {
this.setData({
pageState: 'quota',
loading: false,
countdown: SHARE_FAIL_BACK_SECONDS,
tipVisible: true,
tipTitle: '提示',
tipText: '给您分享的好友额度不足哦',
})
this.startBackCountdown()
},
onTipConfirm() {
this.setData({ tipVisible: false })
},
startBackCountdown() {
this.clearBackTimer()
;(this as any).backTimer = setInterval(() => {
const next = this.data.countdown - 1
if (next > 0) {
this.setData({ countdown: next })
return
}
this.clearBackTimer()
this.onBackProfile()
}, 1000)
},
clearBackTimer() {
const timer = (this as any).backTimer
if (timer) {
clearInterval(timer)
;(this as any).backTimer = null
}
},
onBackProfile() {
logger.info(LOG, '额度不足提示结束,返回「我的」页')
wx.switchTab({
url: '/pages/profile/profile',
fail: (e) => logger.warn(LOG, 'switchTab 我的失败, err=', e && e.errMsg),
})
},
onBackHome() {
wx.switchTab({
url: '/pages/wardrobe/wardrobe',
fail: (e) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e && e.errMsg),
})
},
onGoLogin() {
// 透传视角参数:属主登录失效后重登,仍回到属主视角
const owner = this.data.isOwner ? '1' : '0'
const redirect = `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=${owner}`
const loginUrl = `/pages/login/login?redirect=${encodeURIComponent(redirect)}`
wx.navigateTo({
url: loginUrl,
fail: () => {
wx.redirectTo({
url: loginUrl,
fail: () => wx.switchTab({ url: '/pages/profile/profile' }),
})
},
})
},
setInvalid(title: string, desc: string, needLogin = false) {
this.setData({
loading: false,
pageState: 'invalid',
invalidTitle: title,
invalidDesc: desc,
needLogin,
})
},
async fetchDetail() {
let outfit: OutfitItem
try {
const outfit = await request<OutfitItem>({ url: `/outfit/${this.data.uuid}` })
logger.debug(LOG, '穿搭详情加载成功, 单品数=', outfit.items && outfit.items.length)
this.setData({ outfit })
// 封面与衣物缩略图直接使用穿搭明细里的抠图(processed_image),
// 避免非属主逐件请求 /clothes/:uuid 被拒(50005)导致图片与名称全部丢失。
const baseClothes = (outfit.items || []).map(
(it) => ({ clothes_uuid: it.clothes_uuid, processed_image: it.processed_image }) as ClothesItem,
)
this.setData({ clothes: baseClothes })
// 属主可进一步拉取衣物详情补充名称;非属主无权限,跳过。
if (this.data.isOwner) {
const clothes = await Promise.all(
(outfit.items || []).map(async (it) => {
try {
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
} catch (e: any) {
logger.warn(LOG, '单品详情拉取失败, uuid=', it.clothes_uuid, 'code=', e && e.code)
return { clothes_uuid: it.clothes_uuid, processed_image: it.processed_image } 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((e: any) => logger.warn(LOG, '收藏状态拉取失败(保持未收藏), code=', e && e.code))
outfit = await request<OutfitItem>({ url: `/outfit/${this.data.uuid}`, showError: false })
} catch (e: any) {
logger.warn(LOG, '穿搭详情加载失败, code=', e && e.code)
this.setData({ loading: false })
const code = e && e.code
logger.warn(LOG, '穿搭详情加载失败, code=', code)
if (AUTH_EXPIRED_CODES.indexOf(code) >= 0) {
// 未登录:给「去登录」出口,登录成功后回到本页
this.setInvalid('请先登录', '登录后即可查看该穿搭', true)
} else if (code === 40004 || code === 40000) {
// 他人未公开 / 已删除:不暴露具体原因,统一提示内容不可见
this.setInvalid('内容不可见', '该穿搭可能已被删除或未公开')
} else {
this.setInvalid('加载失败', '请稍后重试')
}
return
}
logger.debug(LOG, '穿搭详情加载成功, 单品数=', outfit.items && outfit.items.length)
this.setData({ outfit, loading: false })
// 封面与衣物缩略图直接使用穿搭明细里的抠图(processed_image),
// 避免非属主逐件请求 /clothes/:uuid 被拒(50005)导致图片与名称全部丢失。
const baseClothes = (outfit.items || []).map(
(it) => ({ clothes_uuid: it.clothes_uuid, processed_image: it.processed_image }) as ClothesItem,
)
this.setData({ clothes: baseClothes })
// 属主可进一步拉取衣物详情补充名称;非属主无权限,跳过。
if (this.data.isOwner) {
const clothes = await Promise.all(
(outfit.items || []).map(async (it) => {
try {
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
} catch (e: any) {
logger.warn(LOG, '单品详情拉取失败, uuid=', it.clothes_uuid, 'code=', e && e.code)
return { clothes_uuid: it.clothes_uuid, processed_image: it.processed_image } as ClothesItem
}
})
)
this.setData({ clothes })
}
// 点赞状态:失败静默保持默认,不影响详情展示
request<{ is_liked: boolean; like_count: number }>({
url: `/like/${this.data.uuid}`,
showError: false,
})
.then((like) => this.setData({ isLiked: like.is_liked, likeCount: like.like_count }))
.catch((e: any) => logger.warn(LOG, '点赞状态拉取失败(保持默认), code=', e && e.code))
// 收藏状态:调用后端 /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((e: any) => logger.warn(LOG, '收藏状态拉取失败(保持未收藏), code=', e && e.code))
},
onLike() {
@@ -99,18 +247,20 @@ Page({
if (isPublic) {
this.doPublic(IsPublic.PRIVATE)
} else {
wx.showModal({
title: '公开到社区',
content: '公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度',
confirmText: '确认公开',
success: (res) => {
if (res.confirm) this.doPublic(IsPublic.PUBLIC)
},
fail: (e) => logger.warn(LOG, '公开弹窗失败, err=', e && e.errMsg),
})
this.setData({ pubVisible: true })
}
},
onPubCancel() {
this.setData({ pubVisible: false })
},
onPubConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ pubVisible: false })
if (e.detail.value !== 'ok') return
this.doPublic(IsPublic.PUBLIC)
},
doPublic(isPublic: number) {
// 后端 OutfitPublicRequest.OutfitUUID 为必填(binding required),请求体需携带
request({
@@ -123,18 +273,32 @@ Page({
this.setData({ 'outfit.is_public': isPublic })
wx.showToast({ title: isPublic === IsPublic.PUBLIC ? '已公开' : '已取消公开', icon: 'none' })
})
.catch((e: any) => logger.warn(LOG, '公开状态变更失败, code=', e && e.code))
.catch((e: any) => {
logger.warn(LOG, '公开状态变更失败, code=', e && e.code)
// 社区分享额度不足(后端已回滚为私有):弹窗引导购买
if (e && e.code === 80000) {
this.setData({
quotaVisible: true,
quotaTitle: '社区分享额度不足',
quotaText: '购买额度包后可公开到社区',
})
}
})
},
onMore() {
wx.showActionSheet({
itemList: ['编辑穿搭', '删除穿搭'],
success: (res) => {
if (res.tapIndex === 0) this.onEdit()
else this.onDelete()
},
fail: (e) => logger.debug(LOG, '取消更多操作, err=', e && e.errMsg),
})
this.setData({ moreVisible: true })
},
onMoreCancel() {
this.setData({ moreVisible: false })
},
onMoreSelect(e: WechatMiniprogram.CustomEvent) {
this.setData({ moreVisible: false })
const value = e.detail.value
if (value === 'edit') this.onEdit()
else if (value === 'delete') this.onDelete()
},
onEdit() {
@@ -145,29 +309,29 @@ Page({
},
onDelete() {
wx.showModal({
title: '确认删除这套穿搭?',
content: '删除后不可恢复,但不会影响衣橱内的单品。',
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
logger.info(LOG, '穿搭删除成功')
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(
() =>
wx.navigateBack({
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '穿搭删除失败, code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '删除弹窗失败, err=', e && e.errMsg),
})
this.setData({ delVisible: true })
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
logger.info(LOG, '穿搭删除成功')
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(
() =>
wx.navigateBack({
fail: (e2) => logger.warn(LOG, 'navigateBack 失败, err=', e2 && e2.errMsg),
}),
500,
)
})
.catch((e2: any) => logger.warn(LOG, '穿搭删除失败, code=', e2 && e2.code))
},
onItemTap(e: WechatMiniprogram.TouchEvent) {
@@ -184,14 +348,21 @@ Page({
})
},
// 分享前先创建穿搭分享记录拿到 share_uuid,链接指向分享详情页;
// 失败(如穿搭未公开)则回退到普通穿搭详情链接,保证分享动作仍可用。
// 分享采用微信官方「同步返回 + promise 字段」异步分享模式(基础库 2.12.0+):
// 同步返回兜底链接(3 秒内未 resolve 时生效),promise resolve 后以结果为准。
// 非属主不创建分享记录(对他人穿搭创建会被拒),直接转发普通详情链接。
onShareAppMessage(): any {
const name = this.data.outfit.outfit_name || '我的穿搭'
return request<{ share_uuid: string }>({
const fallback = `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=0`
if (!this.data.isOwner) {
return { title: name, path: fallback }
}
const promise = request<{ share_uuid: string }>({
url: '/outfit-share',
method: 'POST',
data: { outfit_uuid: this.data.uuid },
showError: false,
})
.then((res) => {
logger.info(LOG, '穿搭分享创建成功, share_uuid=', res.share_uuid)
@@ -201,11 +372,37 @@ Page({
}
})
.catch((e: any) => {
logger.warn(LOG, '穿搭分享创建失败,回退普通分享链接, code=', e && e.code)
return {
title: name,
path: `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=0`,
logger.warn(LOG, '穿搭分享创建失败, code=', e && e.code)
if (e && e.code === 80000) {
// 额度不足:提示去购买,链接指向「额度不足」提示页(接收者侧提示后自动返回)
this.setData({
quotaVisible: true,
quotaTitle: '分享额度不足',
quotaText: '购买额度包后可继续分享',
})
return { title: name, path: `${fallback}&share_fail=quota` }
}
// 其他失败:回退普通详情链接(接收者侧展示通用「内容不可见」空态)
return { title: name, path: fallback }
})
return { title: name, path: fallback, promise }
},
onQuotaCancel() {
this.setData({ quotaVisible: false })
},
onQuotaConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ quotaVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
onUnload() {
this.clearBackTimer()
},
})
@@ -1,79 +1,179 @@
<view class="page detail-page">
<navigation-bar title="{{outfit.outfit_name || '穿搭详情'}}">
<view wx:if="{{isOwner}}" slot="right" class="nav-more" bindtap="onMore">
<wxs module="cl">
function cls(n) {
if (n >= 4) return ''
if (n === 3) return 'collage--3'
if (n === 2) return 'collage--2'
return 'collage--1'
}
module.exports = { cls: cls }
</wxs>
<view class="page page-flex">
<navigation-bar title="{{outfit.outfit_name || '穿搭详情'}}" scrolled="{{navScrolled}}">
<view
wx:if="{{isOwner && pageState === 'normal' && !loading}}"
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>
<!-- 接收者:分享者额度不足(不请求后端,倒计时后返回「我的」) -->
<empty-state
wx:if="{{pageState === 'quota'}}"
icon="icon-user"
title="好友分享额度不足"
desc="给您分享的好友额度不足哦,{{countdown}} 秒后自动返回"
buttonText="立即返回"
bind:action="onBackProfile"
/>
<!-- 信息卡片 -->
<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>
<!-- 未登录:去登录后回本页 -->
<empty-state
wx:elif="{{pageState === 'invalid' && needLogin}}"
icon="icon-user"
title="{{invalidTitle}}"
desc="{{invalidDesc}}"
buttonText="去登录"
bind:action="onGoLogin"
/>
<!-- 包含衣物 -->
<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>
<!-- 其他加载失败:内容不可见 / 加载失败 -->
<empty-state
wx:elif="{{pageState === 'invalid'}}"
icon="icon-shirt"
title="{{invalidTitle}}"
desc="{{invalidDesc}}"
buttonText="返回首页"
bind:action="onBackHome"
/>
<block wx:else>
<scroll-view scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<!-- 无缝拼图封面 -->
<view class="od-cover">
<view class="collage {{cl.cls(clothes.length)}}">
<view class="cg" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{clothes.length > 4}}" class="cg-more">+{{clothes.length - 4}}</view>
<text class="cg-badge num">{{clothes.length}} 件</text>
</view>
</scroll-view>
</view>
</view>
<view class="footer-placeholder"></view>
</scroll-view>
<!-- 信息区 -->
<view class="od-info">
<view class="eyebrow">{{outfit.source === 2 ? 'AI Stylist' : 'My Look'}}</view>
<view class="t-xl od-title">{{outfit.outfit_name}}</view>
<view class="od-tags">
<text wx:if="{{outfit.scene}}" class="pill pill--line">{{outfit.scene}}</text>
<text class="pill {{outfit.source === 2 ? 'pill--ink' : ''}}">{{outfit.source === 2 ? 'AI 推荐' : '自建'}}</text>
<text class="pill {{outfit.is_public === 2 ? 'pill--line' : ''}}">{{outfit.is_public === 2 ? '已公开' : '仅自己可见'}}</text>
</view>
<view class="t-bd od-desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
</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 class="od-items">
<view class="grp-hd">
<text class="gh-cn">包含衣物</text>
<text class="gh-en">Items</text>
</view>
<scroll-view scroll-x class="od-items-scroll" show-scrollbar="{{false}}">
<view class="od-items-row">
<view
class="od-chip"
wx:for="{{clothes}}"
wx:key="clothes_uuid"
data-uuid="{{item.clothes_uuid}}"
bindtap="onItemTap"
>
<view class="od-thumb">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<text class="od-chip-n">{{item.name || '未命名'}}</text>
</view>
</view>
</scroll-view>
</view>
<view class="fb-btn" wx:if="{{!isOwner}}" bindtap="onCollect">
<view class="icon {{isCollected ? 'icon-star-fill collected' : 'icon-star'}}"></view>
<view class="gap-64"></view>
</scroll-view>
<!-- 悬浮玻璃操作条 -->
<view class="dock">
<view class="dock-row">
<view class="dock-ic {{isLiked ? 'is-on' : ''}}" bindtap="onLike">
<view class="icon icon-heart"></view>
<text class="num">{{likeCount}}</text>
</view>
<view class="dock-ic" wx:if="{{!isOwner}}" bindtap="onCollect">
<view class="icon {{isCollected ? 'icon-star-fill' : 'icon-star'}}"></view>
</view>
<view class="spacer"></view>
<block wx:if="{{isOwner}}">
<view class="btn btn--ghost btn--sm" bindtap="onTogglePublic">{{outfit.is_public === 2 ? '取消公开' : '公开到社区'}}</view>
<button class="od-share" open-type="share">
<view class="icon icon-share"></view>
</button>
</block>
</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>
</block>
<!-- 提示(分享者额度不足) -->
<v2-overlay
visible="{{tipVisible}}"
type="modal"
title="{{tipTitle}}"
content="{{tipText}}"
buttons="{{tipButtons}}"
bind:confirm="onTipConfirm"
/>
<!-- 公开到社区确认 -->
<v2-overlay
visible="{{pubVisible}}"
type="modal"
title="公开到社区"
content="公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度"
buttons="{{pubButtons}}"
bind:confirm="onPubConfirm"
bind:cancel="onPubCancel"
/>
<!-- 删除确认 -->
<v2-overlay
visible="{{delVisible}}"
type="modal"
title="确认删除这套穿搭?"
content="删除后不可恢复,但不会影响衣橱内的单品。"
buttons="{{delButtons}}"
bind:confirm="onDelConfirm"
bind:cancel="onDelCancel"
/>
<!-- 更多操作 -->
<v2-overlay
visible="{{moreVisible}}"
type="actionsheet"
items="{{moreItems}}"
bind:select="onMoreSelect"
bind:cancel="onMoreCancel"
/>
<!-- 额度不足 -->
<v2-overlay
visible="{{quotaVisible}}"
type="modal"
title="{{quotaTitle}}"
content="{{quotaText}}"
buttons="{{quotaButtons}}"
bind:confirm="onQuotaConfirm"
bind:cancel="onQuotaCancel"
/>
</view>
@@ -1,7 +1,9 @@
.detail-page {
/** 穿搭详情:无缝拼图封面 + 衬线大标题 + 横滑单品 + 悬浮玻璃操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,233 +11,101 @@
height: 0;
}
.nav-more {
padding: 0 8rpx;
color: #000;
.od-cover {
padding: 32rpx var(--pad) 0;
}
/* 封面 */
.cover {
margin: 24rpx;
height: 480rpx;
background: #fff;
border-radius: 32rpx;
.od-info {
padding: 48rpx var(--pad) 0;
}
.od-title {
margin-top: 22rpx;
}
.od-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 26rpx;
}
.od-desc {
margin-top: 28rpx;
}
.od-items {
margin-top: 64rpx;
}
.od-items-scroll {
white-space: nowrap;
}
.od-items-row {
display: inline-flex;
gap: 28rpx;
padding: 0 var(--pad);
}
.od-chip {
display: flex;
flex-direction: column;
width: 160rpx;
}
.od-thumb {
width: 160rpx;
height: 160rpx;
background: var(--paper-2);
border-radius: var(--r-img);
overflow: hidden;
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;
.od-thumb .ph {
width: 52%;
height: 52%;
color: var(--art);
}
.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 {
.od-thumb .pic {
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;
.od-chip-n {
margin-top: 14rpx;
font-size: 22rpx;
color: var(--ink-3);
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;
/* 分享:原生 button 去默认外观 */
.od-share {
width: 88rpx;
height: 88rpx;
padding: 0;
margin: 0;
background: #fff;
border: 1rpx solid #E5E5EA;
border: 0;
background: var(--fill);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #007AFF;
color: var(--ink);
line-height: 1;
}
.fb-share::after {
.od-share::after {
border: none;
}
.fb-share .icon {
.od-share .icon {
width: 40rpx;
height: 40rpx;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"clothes-card": "/components/clothes-card/clothes-card"
"clothes-card": "/components/clothes-card/clothes-card",
"v2-overlay": "/components/v2-overlay/index"
}
}
+35 -23
View File
@@ -1,6 +1,7 @@
import { request } from '../../utils/request'
import { OutfitItem, ClothesItem, PagedResult } from '../../types/models'
import { normalizePaged, hasMorePaged } from '../../utils/paged'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -36,8 +37,19 @@ Page({
hasMore: true,
page: 1,
saving: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 删除确认:v2 玻璃弹窗
delVisible: false,
delButtons: [
{ text: '取消', value: '' },
{ text: '确认删除', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
logger.debug(LOG, 'onLoad, uuid=', uuid, '编辑态=', !!uuid)
@@ -233,28 +245,28 @@ Page({
},
onDelete() {
wx.showModal({
title: '确认删除这套穿搭?',
content: '删除后不可恢复,但不会影响衣橱内的单品。',
confirmText: '确认删除',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
logger.info(LOG, '穿搭删除成功')
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(
() =>
wx.navigateBack({
fail: (e) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '穿搭删除失败, code=', e && e.code))
},
fail: (e) => logger.warn(LOG, '删除弹窗失败, err=', e && e.errMsg),
})
this.setData({ delVisible: true })
},
onDelCancel() {
this.setData({ delVisible: false })
},
onDelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ delVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
.then(() => {
logger.info(LOG, '穿搭删除成功')
wx.showToast({ title: '已删除', icon: 'success' })
setTimeout(
() =>
wx.navigateBack({
fail: (e2) => logger.warn(LOG, 'navigateBack 失败, err=', e2 && e2.errMsg),
}),
500,
)
})
.catch((e2: any) => logger.warn(LOG, '穿搭删除失败, code=', e2 && e2.code))
},
})
+130 -74
View File
@@ -1,114 +1,170 @@
<view class="page edit-page">
<navigation-bar title="{{view === 'select' ? '选择衣橱单品' : (isEdit ? '编辑穿搭' : '创建穿搭')}}">
<view wx:if="{{view === 'select'}}" slot="right" class="nav-cancel" bindtap="backToForm">取消</view>
<wxs module="cl">
function cls(n) {
if (n >= 4) return ''
if (n === 3) return 'collage--3'
if (n === 2) return 'collage--2'
return 'collage--1'
}
module.exports = { cls: cls }
</wxs>
<view class="page page-flex">
<navigation-bar
title="{{view === 'select' ? '选择衣橱单品' : (isEdit ? '编辑穿搭' : '创建穿搭')}}"
scrolled="{{navScrolled}}"
>
<view wx:if="{{view === 'select'}}" slot="right" class="nav-action" bindtap="backToForm">取消</view>
</navigation-bar>
<!-- 表单视图 -->
<scroll-view wx:if="{{view === 'form'}}" scroll-y class="scroll-body">
<scroll-view wx:if="{{view === 'form'}}" scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<view class="head">
<view class="eyebrow">{{isEdit ? 'Edit Look' : 'New Look'}}</view>
<view class="display">{{isEdit ? '编辑穿搭' : '创建穿搭'}}</view>
</view>
<!-- 拼图预览 -->
<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 class="oe-preview">
<view wx:if="{{selectedItems.length}}" class="collage {{cl.cls(selectedItems.length)}}">
<view class="cg" wx:for="{{selectedItems}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{selectedItems.length > 5}}" class="preview-more">+{{selectedItems.length - 5}}</view>
<view wx:if="{{selectedItems.length > 4}}" class="cg-more">+{{selectedItems.length - 4}}</view>
<text class="cg-badge num">{{selectedItems.length}} 件</text>
</view>
<view wx:else class="preview-empty">
<view wx:else class="oe-empty">
<view class="icon icon-hanger"></view>
<text class="pe-title">还没有选择衣物</text>
<text class="pe-desc">点击下方「选择衣物」添加你的单品</text>
<text class="oe-empty-t">还没有选择衣物</text>
<text class="oe-empty-d">点击下方「选择衣橱单品」添加你的单品</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 class="oe-form">
<view class="oe-field">
<view class="oe-label">
<text>穿搭名称 <text class="oe-req">*</text></text>
<text class="oe-count num">{{outfitName.length}}/32</text>
</view>
<input class="input" value="{{outfitName}}" bindinput="onNameInput" placeholder="给这套穿搭起个名字" maxlength="32" />
<input
class="input"
value="{{outfitName}}"
bindinput="onNameInput"
placeholder="给这套穿搭起个名字"
placeholder-class="input-ph"
maxlength="32"
/>
</view>
<view class="field">
<view class="field-label">
<view class="oe-field">
<view class="oe-label">
<text>穿搭描述</text>
<text class="count">{{outfitDesc.length}}/500</text>
<text class="oe-count num">{{outfitDesc.length}}/500</text>
</view>
<textarea class="textarea" value="{{outfitDesc}}" bindinput="onDescInput" placeholder="简单描述这套穿搭的灵感或亮点" maxlength="500" />
<textarea
class="textarea"
value="{{outfitDesc}}"
bindinput="onDescInput"
placeholder="简单描述这套穿搭的灵感或亮点"
placeholder-class="input-ph"
maxlength="500"
/>
</view>
<view class="field">
<view class="field-label">
<view class="oe-field">
<view class="oe-label">
<text>适合场景</text>
<text class="count">{{scene.length}}/100</text>
<text class="oe-count num">{{scene.length}}/100</text>
</view>
<input class="input" value="{{scene}}" bindinput="onSceneInput" placeholder="例如:通勤、约会、旅行" maxlength="100" />
<input
class="input"
value="{{scene}}"
bindinput="onSceneInput"
placeholder="例如:通勤、约会、旅行"
placeholder-class="input-ph"
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 class="grp-hd oe-grp"><text class="gh-cn">单品</text><text class="gh-en">Items</text></view>
<view class="rows">
<view class="row" bindtap="openSelect">
<view class="icon row-ic icon-shirt"></view>
<view class="row-lb">
<view>选择衣橱单品</view>
<view class="oe-entry-d">{{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>
<text class="row-vl">{{selectedUuids.length ? '已选 ' + selectedUuids.length + ' 件' : '未选择'}}</text>
<view class="icon row-ch 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>
<view class="gap-64"></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>
<block wx:else>
<view class="segbar">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{categories}}"
wx:key="name"
class="seg-item {{activeCategory === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
</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>
<scroll-view scroll-y class="select-body pb-dock" bindscroll="onNavScroll">
<view wx:if="{{selectLoading}}" class="oe-grid">
<view class="oe-skel" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
<view class="sk sk-img"></view>
<view class="sk sk-ln" style="width: 62%;"></view>
</view>
</view>
<view wx:else class="grid">
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<view wx:else class="oe-grid">
<view class="oe-tile" wx:for="{{allClothes}}" wx:key="clothes_uuid">
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
</view>
</view>
<view class="gap-32"></view>
</scroll-view>
</block>
<view class="select-footer">
<view>
<view class="sf-count">已选 <text class="t-primary">{{selectedUuids.length}}</text> 件</view>
<view class="sf-desc">最多可选择 10 件单品</view>
<!-- 悬浮底部操作条 -->
<view class="dock">
<block wx:if="{{view === 'form'}}">
<view wx:if="{{isEdit}}" class="btn--text oe-del" bindtap="onDelete">删除穿搭</view>
<view
class="btn btn--ink btn--block {{(!outfitName || !selectedUuids.length || saving) ? 'is-off' : ''}}"
bindtap="onSave"
>{{saving ? '保存中…' : (isEdit ? '保存修改' : '保存')}}</view>
</block>
<block wx:else>
<view class="dock-row">
<view class="oe-count-wrap">
<view class="oe-count-t num">已选 {{selectedUuids.length}} 件</view>
<view class="oe-count-d">最多可选择 10 件单品</view>
</view>
<view class="btn btn--ink btn--sm oe-confirm" bindtap="confirmSelect">确定</view>
</view>
<view class="sf-confirm btn btn-primary" bindtap="confirmSelect">确定</view>
</view>
</block>
</view>
<!-- 删除确认 -->
<v2-overlay
visible="{{delVisible}}"
type="modal"
title="确认删除这套穿搭?"
content="删除后不可恢复,但不会影响衣橱内的单品。"
buttons="{{delButtons}}"
bind:confirm="onDelConfirm"
bind:cancel="onDelCancel"
/>
</view>
+75 -219
View File
@@ -1,7 +1,9 @@
.edit-page {
/** 穿搭编辑:无缝拼图预览 + 发丝线表单 + 悬浮底部操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,288 +11,142 @@
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;
.oe-preview {
margin: 40rpx var(--pad) 0;
}
.preview-empty {
.oe-empty {
display: flex;
flex-direction: column;
align-items: center;
color: #C7C7CC;
justify-content: center;
aspect-ratio: 1 / 1;
background: var(--paper-2);
border-radius: var(--r-img);
color: var(--ink-4);
}
.preview-empty .icon {
.oe-empty .icon {
width: 96rpx;
height: 96rpx;
}
.pe-title {
font-size: 26rpx;
color: #8E8E93;
margin-top: 16rpx;
.oe-empty-t {
margin-top: 24rpx;
font-family: var(--serif);
font-size: 32rpx;
color: var(--ink-2);
}
.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;
.oe-empty-d {
margin-top: 10rpx;
font-size: 23rpx;
color: var(--ink-3);
}
/* 表单 */
.form-card {
margin: 0 24rpx 24rpx;
.oe-form {
padding: 64rpx var(--pad) 0;
}
.field {
margin-bottom: 32rpx;
.oe-field {
margin-bottom: 40rpx;
}
.field:last-child {
.oe-field:last-child {
margin-bottom: 0;
}
.field-label {
.oe-label {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 26rpx;
font-weight: 600;
color: #1C1C1E;
color: var(--ink-2);
margin-bottom: 16rpx;
}
.req {
color: #FF3B30;
.oe-req {
color: var(--ink);
}
.count {
.oe-count {
font-size: 22rpx;
color: #8E8E93;
font-weight: 400;
color: var(--ink-3);
}
/* 选择衣物入口 */
.select-entry {
margin: 0 24rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
.oe-grp {
margin-top: 64rpx;
}
.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;
.oe-entry-d {
margin-top: 6rpx;
font-size: 23rpx;
color: var(--ink-3);
}
/* 多选视图 */
.select-view {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
.segbar {
position: sticky;
top: 0;
z-index: 20;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
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 {
.oe-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 44rpx 26rpx;
padding: 40rpx var(--pad) 0;
}
.oe-skel {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
flex-direction: column;
gap: 18rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
.oe-tile {
display: block;
width: 100%;
}
.sk-card {
height: 360rpx;
border-radius: 32rpx;
/* 底部操作条 */
.oe-del {
display: block;
text-align: center;
padding: 8rpx 0 28rpx;
}
.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));
.oe-count-wrap {
flex: 1;
min-width: 0;
}
.sf-count {
.oe-count-t {
font-size: 30rpx;
font-weight: 600;
color: #1C1C1E;
color: var(--ink);
}
.sf-desc {
font-size: 22rpx;
color: #8E8E93;
.oe-count-d {
margin-top: 4rpx;
font-size: 22rpx;
color: var(--ink-3);
}
.sf-confirm {
width: 200rpx;
height: 80rpx;
.oe-confirm {
width: 240rpx;
height: 88rpx;
font-size: 28rpx;
}
@@ -1,6 +1,7 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar",
"empty-state": "/components/empty-state/empty-state"
"empty-state": "/components/empty-state/empty-state",
"v2-overlay": "/components/v2-overlay/index"
}
}
@@ -1,6 +1,7 @@
import { request, AUTH_EXPIRED_CODES } from '../../utils/request'
import { OutfitShareDetail, OutfitItem } from '../../types/models'
import { ShareStatus } from '../../utils/config'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -10,14 +11,26 @@ Page({
data: {
uuid: '',
detail: null as OutfitShareDetail | null,
outfit: { items: [] } as OutfitItem,
outfit: { items: [] } as unknown as OutfitItem,
loading: true,
invalid: false,
invalidTitle: '该分享已取消',
invalidDesc: '分享链接已失效',
needLogin: false,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 取消分享确认:v2 玻璃弹窗
cancelVisible: false,
cancelContent: '取消后该分享链接将失效',
cancelButtons: [
{ text: '再想想', value: '' },
{ text: '取消分享', value: 'ok', type: 'main' },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad(query: Record<string, string>) {
const uuid = query.uuid || ''
logger.debug(LOG, 'onLoad, uuid=', uuid)
@@ -93,29 +106,29 @@ Page({
},
onCancel() {
wx.showModal({
title: '取消分享',
content: '取消后该分享链接将失效',
confirmText: '取消分享',
confirmColor: '#FF3B30',
success: (res) => {
if (!res.confirm) return
request({ url: `/outfit-share/${this.data.uuid}/cancel`, method: 'POST' })
.then(() => {
logger.info(LOG, '取消分享成功')
wx.showToast({ title: '已取消', icon: 'success' })
;(this as any).backTimer = setTimeout(
() =>
wx.navigateBack({
fail: (e: any) => logger.warn(LOG, 'navigateBack 失败, err=', e && e.errMsg),
}),
500,
)
})
.catch((e: any) => logger.warn(LOG, '取消分享失败, code=', e && e.code))
},
fail: (e: any) => logger.warn(LOG, '取消分享弹窗失败, err=', e && e.errMsg),
})
this.setData({ cancelVisible: true })
},
onCancelCancel() {
this.setData({ cancelVisible: false })
},
onCancelConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ cancelVisible: false })
if (e.detail.value !== 'ok') return
request({ url: `/outfit-share/${this.data.uuid}/cancel`, method: 'POST' })
.then(() => {
logger.info(LOG, '取消分享成功')
wx.showToast({ title: '已取消', icon: 'success' })
;(this as any).backTimer = setTimeout(
() =>
wx.navigateBack({
fail: (err: any) => logger.warn(LOG, 'navigateBack 失败, err=', err && err.errMsg),
}),
500,
)
})
.catch((err: any) => logger.warn(LOG, '取消分享失败, code=', err && err.code))
},
onBackHome() {
@@ -1,5 +1,15 @@
<view class="page detail-page">
<navigation-bar title="穿搭分享" />
<wxs module="cl">
function cls(n) {
if (n >= 4) return ''
if (n === 3) return 'collage--3'
if (n === 2) return 'collage--2'
return 'collage--1'
}
module.exports = { cls: cls }
</wxs>
<view class="page page-flex">
<navigation-bar title="穿搭分享" scrolled="{{navScrolled}}" />
<empty-state
wx:if="{{invalid && needLogin}}"
@@ -19,69 +29,86 @@
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>
<scroll-view wx:else scroll-y class="scroll-body pb-dock" bindscroll="onNavScroll">
<!-- 分享者行 -->
<view class="head">
<view class="eyebrow">Shared Look</view>
<view class="display">{{outfit.outfit_name}}</view>
</view>
<view class="osd-owner">
<view class="osd-av">
<image wx:if="{{detail.owner_avatar}}" class="pic" src="{{detail.owner_avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user"></view>
</view>
<view class="owner-info">
<view class="owner-name">{{detail.owner_name || '好友'}} 分享了穿搭</view>
<view class="owner-expire">共 {{outfit.items.length}} 件单品</view>
<view class="osd-owner-body">
<view class="osd-owner-n">{{detail.owner_name || '好友'}} 分享了穿搭</view>
<view class="osd-owner-e num">共 {{outfit.items.length}} 件单品</view>
</view>
</view>
<!-- 拼图封面 -->
<view class="cover">
<view
class="cover-item cover-{{index}}"
wx:for="{{outfit.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-placeholder"></view>
</view>
<view wx:if="{{!outfit.items.length}}" class="cover-empty">
<view class="icon icon-hanger"></view>
<!-- 无缝拼图封面 -->
<view class="osd-cover">
<view class="collage {{cl.cls(outfit.items.length)}}">
<view class="cg" wx:for="{{outfit.items}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view wx:if="{{outfit.items.length > 4}}" class="cg-more">+{{outfit.items.length - 4}}</view>
<text class="cg-badge num">{{outfit.items.length}} 件</text>
</view>
</view>
<!-- 信息卡片 -->
<view class="card info-card">
<view class="title-row">
<text class="title">{{outfit.outfit_name}}</text>
<!-- 信息区 -->
<view class="osd-info">
<view class="osd-tags">
<text wx:if="{{outfit.scene}}" class="pill pill--line">{{outfit.scene}}</text>
<text class="pill {{outfit.source === 2 ? 'pill--ink' : ''}}">{{outfit.source === 2 ? 'AI 推荐' : '自建'}}</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>
</view>
<view class="desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
<view class="t-bd osd-desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
</view>
<!-- 包含衣物(点击只读查看) -->
<view class="items-title">包含衣物</view>
<scroll-view scroll-x class="items-scroll" show-scrollbar="{{false}}">
<view class="items-row">
<!-- 包含衣物:横滑(点击只读查看) -->
<view class="osd-items">
<view class="grp-hd">
<text class="gh-cn">包含衣物</text>
<text class="gh-en">Items</text>
</view>
<scroll-view scroll-x class="osd-items-scroll" show-scrollbar="{{false}}">
<view class="osd-items-row">
<view
class="item-chip"
class="osd-chip"
wx:for="{{outfit.items}}"
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>
<view class="osd-thumb">
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- 转发出口 -->
<button class="share-btn" open-type="share">分享给好友</button>
<!-- 属主取消 -->
<view wx:if="{{detail.is_owner}}" class="cancel-btn btn-danger-text" bindtap="onCancel">取消分享</view>
<view class="footer-space"></view>
<view class="gap-64"></view>
</scroll-view>
<!-- 悬浮玻璃操作条 -->
<view class="dock" wx:if="{{!invalid}}">
<view wx:if="{{detail.is_owner}}" class="btn--text osd-cancel" bindtap="onCancel">取消分享</view>
<button class="btn btn--ink btn--block" open-type="share">分享给好友</button>
</view>
<!-- 取消分享确认 -->
<v2-overlay
visible="{{cancelVisible}}"
type="modal"
title="取消分享"
content="{{cancelContent}}"
buttons="{{cancelButtons}}"
bind:confirm="onCancelConfirm"
bind:cancel="onCancelCancel"
/>
</view>
@@ -1,7 +1,9 @@
.detail-page {
/** 穿搭分享详情:分享者行 + 无缝拼图 + 横滑单品 + 悬浮玻璃操作条 **/
.page-flex {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.scroll-body {
@@ -9,193 +11,111 @@
height: 0;
}
/* 分享者信息 */
.owner-card {
margin: 24rpx;
.osd-owner {
display: flex;
align-items: center;
gap: 24rpx;
padding: 40rpx var(--pad) 0;
}
.avatar {
width: 96rpx;
height: 96rpx;
.osd-av {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #F2F2F7;
background: var(--paper-3);
color: var(--ink-3);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
.osd-av .pic {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 56rpx;
height: 56rpx;
.osd-av .icon {
width: 36rpx;
height: 36rpx;
}
.owner-info {
margin-left: 24rpx;
.osd-owner-body {
min-width: 0;
}
.owner-name {
font-size: 30rpx;
font-weight: 600;
color: #000;
.osd-owner-n {
font-size: 27rpx;
color: var(--ink);
}
.owner-expire {
font-size: 24rpx;
color: #8E8E93;
margin-top: 8rpx;
.osd-owner-e {
margin-top: 6rpx;
font-size: 22rpx;
color: var(--ink-3);
}
/* 封面 */
.cover {
margin: 0 24rpx 24rpx;
height: 480rpx;
background: #fff;
border-radius: 32rpx;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
.osd-cover {
padding: 48rpx var(--pad) 0;
}
.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;
.osd-info {
padding: 48rpx var(--pad) 0;
}
.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 {
.osd-tags {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.desc {
font-size: 28rpx;
color: #3C3C43;
margin-top: 20rpx;
line-height: 1.6;
.osd-desc {
margin-top: 26rpx;
}
.items-title {
font-size: 28rpx;
font-weight: 600;
color: #000;
margin-top: 32rpx;
.osd-items {
margin-top: 64rpx;
}
.items-scroll {
margin-top: 20rpx;
.osd-items-scroll {
white-space: nowrap;
}
.items-row {
.osd-items-row {
display: inline-flex;
gap: 24rpx;
padding: 0 var(--pad);
}
.item-chip {
display: flex;
flex-direction: column;
align-items: center;
width: 140rpx;
margin-right: 20rpx;
.osd-chip {
width: 160rpx;
}
.item-img {
width: 120rpx;
height: 120rpx;
border-radius: 20rpx;
background: #F2F2F7;
}
.item-img-empty {
.osd-thumb {
width: 160rpx;
height: 160rpx;
background: var(--paper-2);
border-radius: var(--r-img);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
}
.item-img-empty .icon {
width: 56rpx;
height: 56rpx;
.osd-thumb .ph {
width: 52%;
height: 52%;
color: var(--art);
}
/* 转发出口 */
.share-btn {
margin: 32rpx 24rpx 0;
height: 88rpx;
line-height: 88rpx;
font-size: 30rpx;
font-weight: 500;
color: var(--color-primary, #000);
background: #fff;
border-radius: 24rpx;
.osd-thumb .pic {
width: 100%;
height: 100%;
}
.share-btn::after {
border: none;
}
.cancel-btn {
.osd-cancel {
display: block;
text-align: center;
padding: 32rpx 0;
}
.footer-space {
height: 40rpx;
padding: 8rpx 0 28rpx;
}
+62 -12
View File
@@ -1,26 +1,49 @@
import { request, redirectToLogin } from '../../utils/request'
import * as auth from '../../utils/auth'
import { PagedResult, OutfitItem } from '../../types/models'
import { PagedResult, OutfitItem, CommunityOutfitItem } from '../../types/models'
import { OutfitSource, IsPublic, STORAGE_KEYS } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { setTabBarSelected } from '../../utils/tabbar'
import { formatRelativeTime } from '../../utils/format'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
const LOG = 'outfit'
// 拼图封面单元:无图时前端渲染线稿占位
type CoverCell = { k: string; img: string }
// 列表行:我的穿搭/已收藏来自 OutfitItem;社区来自 CommunityOutfitItem(额外带作者与互动计数)。
// 统一为交叉类型,模板按 activeTab 条件渲染差异字段。
type OutfitRow = OutfitItem & Partial<CommunityOutfitItem> & {
_likeCount?: number
_collectCount?: number
// cls 为胶囊类名(零彩色:pill--ink / pill--line / 空)
_status?: { text: string; cls: string }
_time?: string
_covers?: CoverCell[]
_collageCls?: string
_moreCount?: number
}
Page({
data: {
activeTab: 0,
list: [] as OutfitItem[],
list: [] as OutfitRow[],
loading: true,
refreshing: false,
loadingMore: false,
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
@@ -73,16 +96,16 @@ Page({
},
fetchList(page: number): Promise<void> {
const isCollect = this.data.activeTab === 1
const url = isCollect ? '/collect' : '/outfit'
const tab = this.data.activeTab
const url = tab === 1 ? '/collect' : tab === 2 ? '/outfit/community' : '/outfit'
const seq = ((this as any).reqSeq || 0) + 1
;(this as any).reqSeq = seq
if (page > 1) this.setData({ loadingMore: true })
return request<PagedResult<OutfitItem>>({ url, data: { page, page_size: this.data.pageSize } })
return request<PagedResult<OutfitRow>>({ url, data: { page, page_size: this.data.pageSize } })
.then(async (res) => {
const { list: raw, total } = normalizePaged<OutfitItem>(res)
const { list: raw, total } = normalizePaged<OutfitRow>(res)
logger.debug(LOG, '列表加载成功, url=', url, 'page=', page, '条数=', raw && raw.length)
const items = await this.decorateList(raw)
// decorateList 是异步的,期间可能已切换 tab:序号不匹配则丢弃
@@ -103,7 +126,18 @@ Page({
})
},
decorateList(list: OutfitItem[]): Promise<OutfitItem[]> {
decorateList(list: OutfitRow[]): Promise<OutfitRow[]> {
// 社区分段:作者与互动计数由后端一次性下发,本地仅补相对时间,避免逐条请求点赞接口
if (this.data.activeTab === 2) {
return Promise.resolve(
list.map((it) => ({
...it,
_likeCount: it.like_count || 0,
_collectCount: it.collect_count || 0,
_time: formatRelativeTime(it.created_at),
})),
)
}
return Promise.all(
list.map(async (it) => {
let likeCount = 0
@@ -116,15 +150,31 @@ Page({
} catch (e: any) {
logger.warn(LOG, '点赞数拉取失败, uuid=', it.outfit_uuid, 'code=', e && e.code)
}
return { ...it, _likeCount: likeCount, _status: this.getStatusText(it) }
return { ...it, ...this.decorateCover(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' }
// 无缝拼图封面:取前 4 件,超出用 +N 角标;不足 1 件时补一个线稿占位格
decorateCover(it: OutfitItem): { _covers: CoverCell[]; _collageCls: string; _moreCount: number } {
const items = it.items || []
const covers: CoverCell[] = items.slice(0, 4).map((c) => ({
k: c.clothes_uuid,
img: c.processed_image || '',
}))
if (!covers.length) covers.push({ k: 'empty', img: '' })
return {
_covers: covers,
_collageCls: `collage--${covers.length}`,
_moreCount: Math.max(items.length - 4, 0),
}
},
// 零彩色状态语义:AI 来源 = 黑底白字,已公开 = 描边,仅自己可见 = 浅灰
getStatusText(it: OutfitItem): { text: string; cls: string } {
if (it.source === OutfitSource.AI) return { text: 'AI 推荐', cls: 'pill--ink' }
if (it.is_public === IsPublic.PUBLIC) return { text: '已公开', cls: 'pill--line' }
return { text: '仅自己可见', cls: '' }
},
onCreate() {
+66 -44
View File
@@ -1,84 +1,106 @@
<view class="page page-flex">
<navigation-bar title="穿搭" back="{{false}}" />
<navigation-bar title="穿搭" back="{{false}}" scrolled="{{navScrolled}}" />
<scroll-view
scroll-y
class="scroll-body"
class="scroll-body pb-float"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
bindscroll="onNavScroll"
>
<!-- 分段控件 -->
<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 class="head">
<view class="eyebrow">Styling</view>
<view class="display">穿搭</view>
</view>
<!-- 下划线式文字分段 -->
<view class="segbar">
<view class="seg">
<view class="seg-item {{activeTab === 0 ? 'is-on' : ''}}" data-index="0" bindtap="onTabChange">我的穿搭</view>
<view class="seg-item {{activeTab === 1 ? 'is-on' : ''}}" data-index="1" bindtap="onTabChange">已收藏</view>
<view class="seg-item {{activeTab === 2 ? 'is-on' : ''}}" data-index="2" 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 wx:if="{{loading}}" class="of-skel">
<view class="of-skel-item" wx:for="{{[1, 2]}}" wx:key="*this">
<view class="sk sk-img sk-cover"></view>
<view class="sk sk-ln" style="width: 44%;"></view>
<view class="sk sk-ln" style="width: 72%;"></view>
</view>
</view>
<!-- 空态 -->
<empty-state
wx:elif="{{!list.length}}"
icon="icon-shirt"
title="{{activeTab === 0 ? '还没有穿搭' : '还没有收藏的穿搭'}}"
desc="{{activeTab === 0 ? '从衣橱挑几件,搭一套吧' : ''}}"
title="{{activeTab === 0 ? '还没有穿搭' : activeTab === 1 ? '还没有收藏的穿搭' : '还没有公开的穿搭'}}"
desc="{{activeTab === 0 ? '从衣橱挑几件,搭一套吧' : activeTab === 1 ? '在社区或 AI 推荐中点亮小星星,喜欢的穿搭会出现在这里' : '把喜欢的穿搭公开到社区,让大家看到你的搭配'}}"
buttonText="{{activeTab === 0 ? '创建穿搭' : ''}}"
bind:action="onCreate"
/>
<!-- 卡片流 -->
<view wx:else class="list">
<view wx:else class="of-list">
<view
class="outfit-card card"
class="of-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 class="of-author" wx:if="{{activeTab === 2}}">
<image wx:if="{{item.owner_avatar}}" class="of-av" src="{{item.owner_avatar}}" mode="aspectFill" />
<view wx:else class="of-av">
<view class="icon icon-user"></view>
</view>
<view wx:if="{{!item.items.length}}" class="icon icon-hanger"></view>
<text class="cover-count">{{item.items.length}} 件</text>
<text class="of-aname">{{item.owner_name || '穿搭达人'}}</text>
<text class="of-atime">{{item._time}}</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 class="collage {{item._collageCls}}">
<view class="cg" wx:for="{{item._covers}}" wx:for-item="c" wx:key="k">
<image wx:if="{{c.img}}" class="pic" src="{{c.img}}" mode="aspectFill" lazy-load />
<view wx:else class="ph icon icon-hanger"></view>
</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 wx:if="{{item._moreCount > 0}}" class="cg-more">+{{item._moreCount}}</view>
<text class="cg-badge num">{{item.items.length}} 件</text>
</view>
<view class="of-trow">
<text class="of-title">{{item.outfit_name}}</text>
<text wx:if="{{activeTab !== 2}}" class="pill {{item._status.cls}}">{{item._status.text}}</text>
</view>
<view class="of-desc" wx:if="{{item.outfit_desc}}">{{item.outfit_desc}}</view>
<view class="of-meta">
<text class="pill pill--line" wx:if="{{item.scene}}">{{item.scene}}</text>
<text class="of-m">{{item.items.length}} 件衣物</text>
<view class="spacer"></view>
<view class="of-m">
<view class="icon icon-heart"></view>
<text class="num">{{item._likeCount || 0}}</text>
</view>
<view class="of-m" wx:if="{{activeTab === 2}}">
<view class="icon icon-star"></view>
<text class="num">{{item._collectCount || 0}}</text>
</view>
</view>
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
<!-- 悬浮玻璃双按钮条(社区为浏览场景,不展示管理入口) -->
<view class="float-duo" wx:if="{{activeTab !== 2}}">
<view class="btn btn--glass" bindtap="onCreate">创建穿搭</view>
<view class="btn btn--ink" bindtap="onAiRecommend">AI 推荐</view>
</view>
</view>
+97 -158
View File
@@ -1,3 +1,4 @@
/** 穿搭:下划线分段 + 无缝拼图卡 + 悬浮双按钮条 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,199 +11,137 @@
height: 0;
}
.topbar {
background: #fff;
padding: 20rpx 32rpx;
/* 分段条:吸顶玻璃 */
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.segment {
.of-list {
padding: 40rpx var(--pad) 0;
display: flex;
background: #F2F2F7;
border-radius: 20rpx;
padding: 6rpx;
flex-direction: column;
gap: 56rpx;
}
.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 {
.of-card {
display: block;
width: 100%;
height: 100%;
transition: transform .2s var(--ease);
}
.cover-ph {
width: 72rpx;
height: 72rpx;
color: #C7C7CC;
.of-card:active {
transform: scale(.985);
}
.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;
.of-skel {
padding: 40rpx var(--pad) 0;
display: flex;
flex-direction: column;
gap: 56rpx;
}
.card-body {
margin-top: 20rpx;
.of-skel-item {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.title-row {
.sk-cover {
aspect-ratio: 4 / 3;
}
/* 作者行(仅社区分段) */
.of-author {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
margin-bottom: 20rpx;
}
.title {
font-size: 32rpx;
font-weight: 600;
color: #000;
.of-av {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: var(--paper-3);
color: var(--ink-3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
flex-shrink: 0;
}
.of-av .icon {
width: 26rpx;
height: 26rpx;
}
.of-aname {
font-size: 25rpx;
letter-spacing: .01em;
color: var(--ink);
}
.of-atime {
margin-left: auto;
font-size: 22rpx;
color: var(--ink-3);
}
.of-trow {
display: flex;
align-items: center;
gap: 18rpx;
margin-top: 24rpx;
}
.of-title {
font-family: var(--serif);
font-size: 34rpx;
font-weight: 500;
line-height: 1.35;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 16rpx;
}
.desc {
font-size: 26rpx;
color: #8E8E93;
margin-top: 12rpx;
.of-desc {
margin-top: 14rpx;
font-size: 25rpx;
line-height: 1.62;
color: var(--ink-2);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.meta-row {
.of-meta {
display: flex;
align-items: center;
margin-top: 16rpx;
gap: 22rpx;
margin-top: 24rpx;
}
.meta {
font-size: 22rpx;
color: #8E8E93;
margin-left: 12rpx;
}
.spacer {
flex: 1;
}
.meta-icon {
display: flex;
.of-m {
display: inline-flex;
align-items: center;
gap: 8rpx;
font-size: 22rpx;
color: #8E8E93;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
.heart {
width: 36rpx;
height: 36rpx;
color: #C7C7CC;
margin-right: 8rpx;
}
.no-more {
text-align: center;
font-size: 24rpx;
color: #C7C7CC;
padding: 24rpx 0 40rpx;
.of-m .icon {
width: 26rpx;
height: 26rpx;
}
+2 -1
View File
@@ -1,5 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
"navigation-bar": "/components/navigation-bar/navigation-bar",
"v2-overlay": "/components/v2-overlay/index"
}
}
+25 -5
View File
@@ -4,6 +4,7 @@ import { UserInfo, QuotaItem } from '../../types/models'
import { QuotaType, STORAGE_KEYS } from '../../utils/config'
import { formatDate } from '../../utils/format'
import { setTabBarSelected, setTabBarUnread } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -44,8 +45,21 @@ Page({
},
birthDateStr: '',
today: '',
// 四栏额度条:上传 / AI 推荐 / 社区分享 / 衣柜分享
quotaStats: [] as Array<{ key: number; label: string; remain: number; total: number }>,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// 性别选择:v2 玻璃动作表
sexVisible: false,
sexItems: [
{ text: '男', value: 1 },
{ text: '女', value: 2 },
],
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onLoad() {
this.setToday()
},
@@ -162,11 +176,17 @@ Page({
},
onSexSelect() {
wx.showActionSheet({
itemList: ['男', '女'],
success: (res) => this.setData({ 'editForm.sex': res.tapIndex + 1 }),
fail: (e) => logger.debug(LOG, '取消性别选择, err=', e && e.errMsg),
})
this.setData({ sexVisible: true })
},
onSexPick(e: WechatMiniprogram.CustomEvent) {
const sex = Number(e.detail.value) || 0
logger.debug(LOG, '选择性别, sex=', sex)
this.setData({ 'editForm.sex': sex, sexVisible: false })
},
onSexCancel() {
this.setData({ sexVisible: false })
},
onBirthChange(e: WechatMiniprogram.CustomEvent) {
+150 -101
View File
@@ -1,126 +1,175 @@
<view class="page page-flex">
<navigation-bar title="我的" back="{{false}}" />
<navigation-bar title="我的" back="{{false}}" scrolled="{{navScrolled}}" />
<scroll-view scroll-y class="scroll-body">
<!-- 额度提示条 -->
<view wx:if="{{uploadRemain > 0 && uploadRemain <= 2}}" class="quota-banner" bindtap="onTapMall">
你的上传额度仅剩 {{uploadRemain}} 次,点击购买额度包 >
<scroll-view scroll-y class="scroll-body pb-tab" bindscroll="onNavScroll">
<!-- 页头 -->
<view class="head">
<view class="eyebrow">Profile</view>
</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 class="pf-hero">
<view class="pf-av">
<image wx:if="{{userInfo.avatar}}" class="pf-av-img" src="{{userInfo.avatar}}" mode="aspectFill" />
<view wx:else class="icon icon-user"></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 class="pf-info">
<view class="pf-name">{{userInfo.username || '微信用户'}}</view>
<view class="pf-sub">
<text wx:if="{{userInfo.sex === 1}}" class="pill">男</text>
<text wx:elif="{{userInfo.sex === 2}}" class="pill">女</text>
<text wx:if="{{userInfo.vip_type !== 1}}" class="pill pill--ink">月度会员</text>
<text wx:if="{{zodiac}}" class="pill pill--line">{{zodiac}}</text>
<text wx:else class="pill pill--line" 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 wx:if="{{unread > 0}}" class="menu-badge">{{unread > 99 ? '99+' : unread}}</view>
<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 class="pf-edit" bindtap="onEdit">
编辑资料
<view class="icon icon-chevron-right"></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 wx:if="{{uploadRemain > 0 && uploadRemain <= 2}}" class="pf-warn" bindtap="onTapMall">
<view class="icon icon-info"></view>
<text class="pf-warn-t">你的上传额度仅剩 {{uploadRemain}} 次,点击购买额度包</text>
<view class="icon icon-chevron-right"></view>
</view>
<!-- 我的额度:四栏发丝线数字条 -->
<view class="pf-quota" bindtap="onTapMall">
<view class="pf-quota-hd">
<text class="eyebrow">Quota</text>
<text class="qk">我的额度</text>
<view class="icon icon-chevron-right"></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 class="stats">
<view class="stat" wx:for="{{quotaStats}}" wx:key="key">
<view class="stat-n num">{{item.remain}}<text class="unit">/{{item.total}}</text></view>
<view class="stat-l">{{item.label}}</view>
</view>
</view>
</view>
<view class="version">v1.0.0</view>
<!-- 账户 -->
<view class="grp-hd pf-grp"><text class="gh-cn">账户</text><text class="gh-en">Account</text></view>
<view class="rows">
<view class="row" bindtap="onTapOrder">
<view class="icon row-ic icon-doc"></view>
<text class="row-lb">我的订单</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapNotification">
<view class="icon row-ic icon-bell"></view>
<text class="row-lb">通知</text>
<text wx:if="{{unread > 0}}" class="row-badge">{{unread > 99 ? '99+' : unread}}</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapCollect">
<view class="icon row-ic icon-star"></view>
<text class="row-lb">我的收藏</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapFlow">
<view class="icon row-ic icon-gauge"></view>
<text class="row-lb">额度明细</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
</view>
<!-- 设置 -->
<view class="grp-hd pf-grp"><text class="gh-cn">设置</text><text class="gh-en">Settings</text></view>
<view class="rows">
<view class="row" bindtap="onEdit">
<view class="icon row-ic icon-pencil"></view>
<text class="row-lb">编辑资料</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onEdit">
<view class="icon row-ic icon-calendar"></view>
<text class="row-lb">设置生日</text>
<text class="row-vl">{{birthDateStr || '未设置'}}</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapService">
<view class="icon row-ic icon-headphones"></view>
<text class="row-lb">客服与反馈</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
<view class="row" bindtap="onTapAbout">
<view class="icon row-ic icon-info"></view>
<text class="row-lb">关于纸条AI衣橱</text>
<view class="icon row-ch icon-chevron-right"></view>
</view>
</view>
<view class="pf-ver">v1.0.0</view>
<view class="gap-64"></view>
</scroll-view>
<!-- 编辑资料弹窗 -->
<view wx:if="{{editVisible}}" class="mask" bindtap="onCloseEdit">
<!-- 编辑资料:底部玻璃抽屉 -->
<view wx:if="{{editVisible}}" class="scrim" bindtap="onCloseEdit"></view>
<view wx:if="{{editVisible}}" class="sheet-wrap">
<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 class="sheet-grab"></view>
<view class="sheet-hd">
<text class="sheet-t">编辑资料</text>
<text class="sheet-x" bindtap="onCloseEdit">取消</text>
</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 class="sheet-body">
<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 form-input"
type="nickname"
value="{{editForm.username}}"
bindinput="onNameInput"
maxlength="32"
placeholder="请输入昵称"
placeholder-class="input-ph"
/>
</view>
<view class="form-field" bindtap="onSexSelect">
<text class="form-label">性别</text>
<view class="form-value">
<text class="{{editForm.sex ? '' : 'input-ph'}}">{{editForm.sex === 1 ? '男' : editForm.sex === 2 ? '女' : '请选择'}}</text>
<view class="icon icon-chevron-right form-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 ? '' : 'input-ph'}}">{{birthDateStr || '请选择生日'}}</text>
<view class="icon icon-chevron-right form-arrow"></view>
</view>
</picker>
</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 class="sheet-foot">
<view
class="btn btn--ink btn--block {{avatarUploading || saving ? 'is-off' : ''}}"
bindtap="onSaveProfile"
>{{avatarUploading ? '上传中…' : (saving ? '保存中…' : '保存')}}</view>
</view>
</view>
</view>
<!-- 性别选择:底部动作表 -->
<v2-overlay
visible="{{sexVisible}}"
type="actionsheet"
items="{{sexItems}}"
bind:select="onSexPick"
bind:cancel="onSexCancel"
/>
</view>
+116 -175
View File
@@ -1,3 +1,4 @@
/** 我的:大号头像 + 衬线昵称 + 四栏额度条 + 纯发丝线分组列表 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,198 +11,145 @@
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;
.pf-hero {
display: flex;
align-items: center;
gap: 32rpx;
padding: 48rpx var(--pad) 0;
}
.avatar {
width: 128rpx;
height: 128rpx;
.pf-av {
width: 144rpx;
height: 144rpx;
border-radius: 50%;
background: #F2F2F7;
flex-shrink: 0;
overflow: hidden;
background: var(--paper-3);
color: var(--ink-3);
display: flex;
align-items: center;
justify-content: center;
color: #C7C7CC;
flex-shrink: 0;
}
.avatar-img {
.pf-av .icon {
width: 62rpx;
height: 62rpx;
}
.pf-av-img {
width: 100%;
height: 100%;
}
.avatar-placeholder {
width: 72rpx;
height: 72rpx;
}
.user-info {
flex: 1;
margin-left: 24rpx;
.pf-info {
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;
.pf-name {
font-family: var(--serif);
font-size: 46rpx;
font-weight: 500;
letter-spacing: -.005em;
line-height: 1.2;
}
.menu-badge {
min-width: 32rpx;
height: 32rpx;
padding: 0 10rpx;
border-radius: 16rpx;
background: #FF3B30;
color: #fff;
font-size: 20rpx;
line-height: 32rpx;
text-align: center;
box-sizing: border-box;
.pf-sub {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 20rpx;
}
.pf-edit {
margin-left: auto;
display: flex;
align-items: center;
gap: 2rpx;
font-size: 25rpx;
color: var(--ink-3);
flex-shrink: 0;
margin-left: 12rpx;
}
.menu-arrow {
width: 28rpx;
height: 28rpx;
color: #C7C7CC;
margin-left: 12rpx;
.pf-edit .icon {
width: 24rpx;
height: 24rpx;
}
.version {
/* 额度提醒:浅填充提示条,不依赖彩色 */
.pf-warn {
margin: 44rpx var(--pad) 0;
padding: 26rpx 30rpx;
border-radius: var(--r-ctrl);
background: var(--fill);
display: flex;
align-items: center;
gap: 20rpx;
font-size: 25rpx;
line-height: 1.55;
}
.pf-warn .icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
color: var(--ink-2);
}
.pf-warn-t {
flex: 1;
}
.pf-quota {
display: block;
width: 100%;
margin-top: 48rpx;
}
.pf-quota-hd {
display: flex;
align-items: center;
gap: 16rpx;
padding: 0 var(--pad) 26rpx;
}
.pf-quota-hd .qk {
font-size: 26rpx;
font-weight: 600;
}
.pf-quota-hd .icon {
width: 24rpx;
height: 24rpx;
color: var(--ink-4);
margin-left: auto;
}
.pf-grp {
margin-top: 68rpx;
}
.pf-ver {
padding: 52rpx 0 12rpx;
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;
color: var(--ink-4);
letter-spacing: .06em;
}
/* ===== 编辑资料抽屉 ===== */
.avatar-wrap {
position: relative;
width: 160rpx;
height: 160rpx;
margin: 0 auto;
margin: 16rpx auto 0;
border-radius: 50%;
background: #F2F2F7;
background: var(--paper-3);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
box-sizing: border-box;
color: #C7C7CC;
color: var(--ink-3);
}
.avatar-wrap .avatar-img {
@@ -210,8 +158,8 @@
}
.avatar-wrap .avatar-placeholder {
width: 88rpx;
height: 88rpx;
width: 72rpx;
height: 72rpx;
}
.avatar-btn {
@@ -233,28 +181,30 @@
.avatar-tip {
text-align: center;
font-size: 22rpx;
color: #8E8E93;
color: var(--ink-3);
margin: 16rpx 0 24rpx;
}
.form-field {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #F2F2F7;
padding: 24rpx var(--pad);
border-bottom: 1rpx solid var(--hairline);
}
.form-label {
width: 140rpx;
font-size: 28rpx;
color: #000;
color: var(--ink-2);
flex-shrink: 0;
}
.form-field .input {
.form-input {
flex: 1;
background: #fff;
height: auto;
background: transparent;
padding: 0;
font-size: 30rpx;
}
.form-value {
@@ -262,20 +212,11 @@
display: flex;
align-items: center;
justify-content: space-between;
font-size: 28rpx;
color: #000;
font-size: 30rpx;
}
.placeholder {
color: #C7C7CC;
}
.sheet-btn {
margin-top: 32rpx;
width: 100%;
}
.btn-disabled {
opacity: 0.5;
pointer-events: none;
.form-arrow {
width: 26rpx;
height: 26rpx;
color: var(--ink-4);
}
+2 -1
View File
@@ -1,5 +1,6 @@
{
"usingComponents": {
"navigation-bar": "/components/navigation-bar/navigation-bar"
"navigation-bar": "/components/navigation-bar/navigation-bar",
"v2-overlay": "/components/v2-overlay/index"
}
}
+73 -45
View File
@@ -1,5 +1,6 @@
import { request, uploadFile, NOT_LOGIN_CODE } from '../../utils/request'
import { ProcessStatus } from '../../utils/config'
import { navPageScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -12,17 +13,42 @@ Page({
clothesUuid: '',
failReason: '',
step: 0,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
// v2 玻璃弹层
srcVisible: false,
srcItems: [
{ text: '拍照', value: 'camera' },
{ text: '从相册选择', value: 'album' },
],
doneVisible: false,
doneButtons: [
{ text: '回到衣橱', value: 'back' },
{ text: '继续上传', value: 'again', type: 'main' },
],
quotaVisible: false,
quotaButtons: [
{ text: '取消', value: '' },
{ text: '去购买', value: 'ok', type: 'main' },
],
},
// 页面级滚动 → 导航收缩为玻璃小标题
onPageScroll: navPageScroll,
onChoose() {
wx.showActionSheet({
itemList: ['拍照', '从相册选择'],
success: (res) => {
const sourceType: Array<'camera' | 'album'> = res.tapIndex === 0 ? ['camera'] : ['album']
this.chooseImage(sourceType)
},
fail: (e) => logger.debug(LOG, '取消选择图片, err=', e && e.errMsg),
})
this.setData({ srcVisible: true })
},
onSourceCancel() {
this.setData({ srcVisible: false })
},
onSourceSelect(e: WechatMiniprogram.CustomEvent) {
const value = e.detail.value as string
this.setData({ srcVisible: false })
if (value !== 'camera' && value !== 'album') return
this.chooseImage([value])
},
chooseImage(sourceType: Array<'camera' | 'album'>) {
@@ -66,33 +92,36 @@ Page({
// 后台继续轮询,仅用于更新步骤进度(不再自动跳详情)
this.pollStatus(this.data.clothesUuid)
// 立即弹窗让用户选择下一步,避免干等
wx.showModal({
title: '衣物已上传',
content: 'AI 正在识别中,可在衣橱查看进度',
confirmText: '继续上传',
cancelText: '回到衣橱',
success: (res) => {
this.clearTimer()
if (res.confirm) {
logger.debug(LOG, '选择继续上传,重置状态')
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' })
} else {
wx.navigateBack({
delta: 1,
fail: (e) => {
logger.warn(LOG, 'navigateBack 失败,改跳衣橱, err=', e && e.errMsg)
wx.switchTab({
url: '/pages/wardrobe/wardrobe',
fail: (e2) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e2 && e2.errMsg),
})
},
})
}
this.setData({ doneVisible: true })
},
onDoneCancel() {
// 回到衣橱
this.setData({ doneVisible: false })
this.clearTimer()
wx.navigateBack({
delta: 1,
fail: (e) => {
logger.warn(LOG, 'navigateBack 失败,改跳衣橱, err=', e && e.errMsg)
wx.switchTab({
url: '/pages/wardrobe/wardrobe',
fail: (e2) => logger.warn(LOG, 'switchTab 衣橱失败, err=', e2 && e2.errMsg),
})
},
fail: (e) => logger.warn(LOG, '上传完成弹窗失败, err=', e && e.errMsg),
})
},
onDoneConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ doneVisible: false })
this.clearTimer()
if (e.detail.value === 'again') {
logger.debug(LOG, '选择继续上传,重置状态')
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' })
return
}
this.onDoneCancel()
},
pollStatus(uuid: string) {
let count = 0
;(this as any).pollTimer = setInterval(() => {
@@ -139,20 +168,19 @@ Page({
},
showQuotaModal() {
wx.showModal({
title: '上传额度不足',
content: '当前剩余 0 次,购买额度包后可继续上传',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
wx.navigateTo({
url: '/pages/mall/mall',
fail: (e) => logger.warn(LOG, 'navigateTo 商城失败, err=', e && e.errMsg),
})
}
},
fail: (e) => logger.warn(LOG, '额度弹窗失败, err=', e && e.errMsg),
this.setData({ quotaVisible: true })
},
onQuotaCancel() {
this.setData({ quotaVisible: false })
},
onQuotaConfirm(e: WechatMiniprogram.CustomEvent) {
this.setData({ quotaVisible: false })
if (e.detail.value !== 'ok') return
wx.navigateTo({
url: '/pages/mall/mall',
fail: (err) => logger.warn(LOG, 'navigateTo 商城失败, err=', err && err.errMsg),
})
},
+80 -35
View File
@@ -1,48 +1,93 @@
<view class="page upload-page">
<navigation-bar title="上传衣物" />
<view class="page up-page">
<navigation-bar title="上传衣物" scrolled="{{navScrolled}}" />
<!-- 初始态 -->
<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 wx:if="{{state === 'idle'}}">
<view class="head">
<view class="eyebrow">Upload</view>
<view class="display">上传衣物</view>
</view>
<view class="upload-tip">AI 将自动抠图并识别 15 项属性</view>
<view class="up-drop" hover-class="up-drop-press" bindtap="onChoose">
<view class="icon icon-camera up-drop-ic"></view>
<view class="up-drop-t">拍照或从相册选择</view>
<view class="up-drop-d">支持 JPG / PNG,单张不超过 20MB</view>
<view class="up-drop-d">上传消耗 1 次上传额度</view>
</view>
<view class="up-note">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>
<!-- 处理中:实际图片 + 三段发丝线进度轨(排队中 / AI 抠图 / 智能分类) -->
<view wx:elif="{{state === 'processing'}}">
<view class="head">
<view class="eyebrow">Processing</view>
<view class="display">AI 正在识别你的衣物…</view>
<view class="up-note-left">通常需要 10 秒左右,请稍候</view>
</view>
<view class="up-preview">
<image wx:if="{{imagePath}}" class="pic" src="{{imagePath}}" mode="aspectFit" />
<view wx:else class="ph icon icon-hanger"></view>
</view>
<view class="up-steps">
<view class="up-step {{step > 0 ? 'is-done' : ''}} {{step === 0 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">排队中</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 class="up-step {{step > 1 ? 'is-done' : ''}} {{step === 1 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">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 class="up-step {{step === 2 ? 'is-on' : ''}}">
<view class="up-dot"></view>
<text class="up-lb">智能分类</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 wx:else class="up-failed">
<view class="up-bang">!</view>
<view class="head">
<view class="display">识别失败</view>
<view class="up-note-left">{{failReason || '未知原因'}} · 本次上传额度已自动退回</view>
</view>
<view class="up-duo">
<view class="btn btn--ghost" bindtap="onRetry">重新上传</view>
<view class="btn btn--ink" bindtap="onManualEdit">手动填写信息</view>
</view>
</view>
<!-- 图片来源选择 -->
<v2-overlay
visible="{{srcVisible}}"
type="actionsheet"
items="{{srcItems}}"
bind:select="onSourceSelect"
bind:cancel="onSourceCancel"
/>
<!-- 上传完成 -->
<v2-overlay
visible="{{doneVisible}}"
type="modal"
title="衣物已上传"
content="AI 正在识别中,可在衣橱查看进度"
buttons="{{doneButtons}}"
bind:confirm="onDoneConfirm"
bind:cancel="onDoneCancel"
/>
<!-- 额度不足 -->
<v2-overlay
visible="{{quotaVisible}}"
type="modal"
title="上传额度不足"
content="当前剩余 0 次,购买额度包后可继续上传"
buttons="{{quotaButtons}}"
bind:confirm="onQuotaConfirm"
bind:cancel="onQuotaCancel"
/>
</view>
+43 -131
View File
@@ -1,156 +1,68 @@
.upload-page {
padding: 32rpx;
box-sizing: border-box;
/** 上传衣物:虚线投放框 / 处理中三步进度轨 / 失败双按钮 **/
.up-page {
min-height: 100vh;
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
}
/* 初始态 */
.upload-zone {
margin-top: 40rpx;
height: 520rpx;
border: 3rpx dashed #C7C7CC;
border-radius: 32rpx;
background: #fff;
.up-drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: calc(100% - var(--pad) * 2);
margin: 56rpx var(--pad) 0;
padding: 108rpx 48rpx;
border: 1rpx dashed var(--hairline-2);
border-radius: var(--r-img);
transition: background .2s var(--ease), border-color .2s var(--ease);
}
.upload-camera {
width: 100rpx;
height: 100rpx;
color: #007AFF;
margin-bottom: 24rpx;
.up-drop-press {
background: var(--fill);
border-color: var(--ink-4);
}
.upload-title {
font-size: 30rpx;
color: #000;
.up-drop-ic {
width: 68rpx;
height: 68rpx;
color: var(--ink-3);
margin-bottom: 36rpx;
}
.up-drop-t {
font-family: var(--serif);
font-size: 34rpx;
font-weight: 500;
}
.upload-desc {
font-size: 22rpx;
color: #8E8E93;
margin-top: 12rpx;
.up-drop-d {
margin-top: 18rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .01em;
}
.upload-tip {
.up-note {
margin-top: 44rpx;
text-align: center;
font-size: 24rpx;
color: #8E8E93;
margin-top: 24rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .01em;
}
/* 处理中 */
.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;
.up-note-left {
margin-top: 20rpx;
font-size: 25rpx;
color: var(--ink-2);
line-height: 1.7;
}
/* 失败态 */
.failed {
margin-top: 120rpx;
display: flex;
flex-direction: column;
align-items: center;
padding: 0 40rpx;
.up-failed {
padding-top: 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;
.up-failed .up-bang {
margin-left: var(--pad);
}
+7 -1
View File
@@ -4,6 +4,7 @@ import { PagedResult, ClothesItem, QuotaItem } from '../../types/models'
import { QuotaType } from '../../utils/config'
import { normalizePaged, mergePaged, hasMorePaged } from '../../utils/paged'
import { setTabBarSelected } from '../../utils/tabbar'
import { navScroll } from '../../utils/nav'
import { logger } from '../../utils/logger'
// 日志模块名
@@ -32,8 +33,13 @@ Page({
page: 1,
pageSize: 20,
hasMore: true,
// 滚动收缩:由 utils/nav.ts 的共享 handler 驱动
navScrolled: false,
},
// scroll-view 滚动 → 导航收缩为玻璃小标题
onNavScroll: navScroll,
onShow() {
this.syncTabBar()
// 未登录直接回登录页(幂等跳转),不再发出必然 401 的请求
@@ -93,7 +99,7 @@ Page({
const list = raw.map((it) => ({
...it,
_statusText: !it.category_main && !it.processed_image ? 'AI 处理中' : '',
_statusType: 'blue',
_statusType: 'plain',
}))
const merged = mergePaged(this.data.list, list, page === 1)
this.setData({
+32 -23
View File
@@ -1,35 +1,43 @@
<view class="page page-flex">
<navigation-bar title="我的衣橱" back="{{false}}" />
<navigation-bar title="我的衣橱" back="{{false}}" scrolled="{{navScrolled}}" />
<scroll-view
scroll-y
class="scroll-body"
class="scroll-body pb-tab"
refresher-enabled
refresher-triggered="{{refreshing}}"
bindrefresherrefresh="onRefresh"
bindscrolltolower="onReachBottom"
bindscroll="onNavScroll"
>
<!-- 分类筛选 -->
<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="head">
<view class="eyebrow">Wardrobe</view>
<view class="display">我的衣橱</view>
<view class="wd-stat num">共 {{total}} 件 · 上传额度剩余 {{uploadRemain}} 次</view>
</view>
<!-- 统计 -->
<view class="stat">共 {{total}} 件 · 上传额度剩余 {{uploadRemain}} 次</view>
<!-- 下划线式极简分类条 -->
<view class="segbar">
<scroll-view scroll-x class="seg-scroll" show-scrollbar="{{false}}">
<view class="seg">
<view
wx:for="{{categories}}"
wx:key="name"
class="seg-item {{activeCategory === index ? 'is-on' : ''}}"
data-index="{{index}}"
bindtap="onCategoryTap"
>{{item.name}}</view>
</view>
</scroll-view>
</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 wx:if="{{loading}}" class="wd-grid">
<view class="wd-skel" wx:for="{{[1, 2, 3, 4, 5, 6]}}" wx:key="*this">
<view class="sk sk-img"></view>
<view class="sk sk-ln" style="width: 70%;"></view>
<view class="sk sk-ln" style="width: 46%;"></view>
</view>
</view>
@@ -44,8 +52,8 @@
/>
<!-- 衣物网格 -->
<view wx:else class="grid">
<view class="grid-item" wx:for="{{list}}" wx:key="clothes_uuid">
<view wx:else class="wd-grid">
<view class="wd-tile" wx:for="{{list}}" wx:key="clothes_uuid">
<clothes-card
item="{{item}}"
statusText="{{item._statusText}}"
@@ -55,7 +63,8 @@
</view>
</view>
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
<view wx:if="{{list.length && !hasMore}}" class="foot-note">没有更多了</view>
<view class="gap-64"></view>
</scroll-view>
<view class="fab" bindtap="onUpload">
+30 -50
View File
@@ -1,3 +1,4 @@
/** 衣橱:衬线大标题 + 下划线分类条 + 无容器网格 **/
.page-flex {
height: 100vh;
display: flex;
@@ -10,65 +11,44 @@
height: 0;
}
.cat-scroll {
background: #fff;
white-space: nowrap;
.wd-stat {
margin-top: 22rpx;
font-size: 23rpx;
color: var(--ink-3);
letter-spacing: .02em;
}
.cat-row {
display: inline-flex;
padding: 20rpx 24rpx;
/* 分类条:吸顶玻璃 + 下划线选中态 */
.segbar {
position: sticky;
top: 0;
z-index: 20;
margin-top: 36rpx;
background: var(--glass);
-webkit-backdrop-filter: saturate(180%) blur(20px);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1rpx solid var(--hairline);
}
.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;
.wd-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 44rpx 26rpx;
padding: 40rpx var(--pad) 0;
}
.cat-item.active {
background: #007AFF;
color: #fff;
}
.stat {
padding: 8rpx 32rpx 0;
font-size: 22rpx;
color: #8E8E93;
}
.grid {
.wd-skel {
display: flex;
flex-wrap: wrap;
padding: 24rpx;
gap: 24rpx;
flex-direction: column;
gap: 18rpx;
}
.grid-item {
width: calc(50% - 12rpx);
box-sizing: border-box;
.wd-tile {
display: block;
width: 100%;
transition: transform .2s var(--ease);
}
.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;
.wd-tile:active {
transform: scale(.975);
}