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
+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;
}