first commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/login/login",
|
||||
"pages/wardrobe/wardrobe",
|
||||
"pages/outfit/outfit",
|
||||
"pages/horoscope/horoscope",
|
||||
"pages/notification/notification",
|
||||
"pages/profile/profile",
|
||||
"pages/upload/upload",
|
||||
"pages/clothes-detail/clothes-detail",
|
||||
"pages/outfit-detail/outfit-detail",
|
||||
"pages/outfit-edit/outfit-edit",
|
||||
"pages/ai-recommend/ai-recommend",
|
||||
"pages/notification-detail/notification-detail",
|
||||
"pages/closet-share/closet-share",
|
||||
"pages/closet-share-create/closet-share-create",
|
||||
"pages/closet-share-detail/closet-share-detail",
|
||||
"pages/mall/mall",
|
||||
"pages/order/order"
|
||||
],
|
||||
"tabBar": {
|
||||
"custom": true,
|
||||
"color": "#8E8E93",
|
||||
"selectedColor": "#007AFF",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"list": [
|
||||
{ "pagePath": "pages/wardrobe/wardrobe", "text": "衣橱" },
|
||||
{ "pagePath": "pages/outfit/outfit", "text": "穿搭" },
|
||||
{ "pagePath": "pages/horoscope/horoscope", "text": "运势" },
|
||||
{ "pagePath": "pages/closet-share/closet-share", "text": "分享" },
|
||||
{ "pagePath": "pages/profile/profile", "text": "我的" }
|
||||
]
|
||||
},
|
||||
"window": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationStyle": "custom",
|
||||
"backgroundColor": "#F2F2F7"
|
||||
},
|
||||
"style": "v2",
|
||||
"sitemapLocation": "sitemap.json",
|
||||
"lazyCodeLoading": "requiredComponents"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// app.ts
|
||||
import * as auth from './utils/auth'
|
||||
import { setAuthExpiredHandler } from './utils/request'
|
||||
|
||||
App<IAppOption>({
|
||||
globalData: {
|
||||
userInfo: undefined,
|
||||
},
|
||||
|
||||
onLaunch() {
|
||||
// 登录失效 / 账号禁用时统一清登录态并跳转登录页
|
||||
setAuthExpiredHandler(() => {
|
||||
auth.clearTokens()
|
||||
auth.clearUserInfo()
|
||||
wx.reLaunch({ url: '/pages/login/login' })
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
/**app.wxss —— 全局设计令牌与通用样式**/
|
||||
@import "styles/icons.wxss";
|
||||
|
||||
page {
|
||||
--color-primary: #007AFF;
|
||||
--color-primary-aux: #5856D6;
|
||||
--color-pink: #FF2D55;
|
||||
--color-bg: #F2F2F7;
|
||||
--color-card: #FFFFFF;
|
||||
--color-hairline: #E5E5EA;
|
||||
--color-text-main: #000000;
|
||||
--color-text-secondary: #8E8E93;
|
||||
--color-text-tertiary: #C7C7CC;
|
||||
--color-green: #34C759;
|
||||
--color-orange: #FF9500;
|
||||
--color-red: #FF3B30;
|
||||
--color-yellow: #FFCC00;
|
||||
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-main);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 30rpx;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ===== 布局 ===== */
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ===== 卡片 ===== */
|
||||
.card {
|
||||
background: var(--color-card);
|
||||
border-radius: 32rpx;
|
||||
padding: 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ===== 发丝分隔线 ===== */
|
||||
.hairline {
|
||||
border-bottom: 1rpx solid var(--color-hairline);
|
||||
}
|
||||
.hairline-t {
|
||||
border-top: 1rpx solid var(--color-hairline);
|
||||
}
|
||||
|
||||
/* ===== 文本 ===== */
|
||||
.t-main { color: var(--color-text-main); }
|
||||
.t-sub { color: var(--color-text-secondary); }
|
||||
.t-weak { color: var(--color-text-tertiary); }
|
||||
.t-primary { color: var(--color-primary); }
|
||||
.t-danger { color: var(--color-red); }
|
||||
.t-green { color: var(--color-green); }
|
||||
.t-orange { color: var(--color-orange); }
|
||||
|
||||
.fs-title { font-size: 68rpx; font-weight: 700; }
|
||||
.fs-h1 { font-size: 40rpx; font-weight: 700; }
|
||||
.fs-h2 { font-size: 34rpx; font-weight: 600; }
|
||||
.fs-body { font-size: 30rpx; }
|
||||
.fs-caption { font-size: 26rpx; }
|
||||
.fs-note { font-size: 22rpx; }
|
||||
|
||||
.num { font-variant-numeric: tabular-nums; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: 24rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary {
|
||||
height: 96rpx;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
.btn-primary.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.btn-primary:active { opacity: 0.7; }
|
||||
|
||||
.btn-secondary {
|
||||
height: 88rpx;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
.btn-secondary:active { opacity: 0.7; }
|
||||
|
||||
.btn-outline {
|
||||
height: 88rpx;
|
||||
border: 1rpx solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
background: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn-outline:active { opacity: 0.7; }
|
||||
|
||||
.btn-danger-text {
|
||||
color: var(--color-red);
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ===== 输入框 ===== */
|
||||
.input {
|
||||
height: 88rpx;
|
||||
background: var(--color-bg);
|
||||
border-radius: 20rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.textarea {
|
||||
background: var(--color-bg);
|
||||
border-radius: 20rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
font-size: 28rpx;
|
||||
width: 100%;
|
||||
min-height: 160rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ===== 胶囊标签 ===== */
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 44rpx;
|
||||
padding: 0 20rpx;
|
||||
border-radius: 22rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.tag-blue { background: rgba(0, 122, 255, 0.1); color: #007AFF; }
|
||||
.tag-gray { background: #F2F2F7; color: #8E8E93; }
|
||||
.tag-green { background: rgba(52, 199, 89, 0.12); color: #34C759; }
|
||||
.tag-orange { background: rgba(255, 149, 0, 0.12); color: #FF9500; }
|
||||
.tag-red { background: rgba(255, 59, 48, 0.12); color: #FF3B30; }
|
||||
.tag-indigo { background: rgba(88, 86, 214, 0.12); color: #5856D6; }
|
||||
|
||||
/* ===== 骨架屏 ===== */
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #F2F2F7 25%, #E5E5EA 37%, #F2F2F7 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: skeleton-loading 1.4s ease infinite;
|
||||
}
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 100% 50%; }
|
||||
100% { background-position: 0 50%; }
|
||||
}
|
||||
|
||||
/* ===== 悬浮按钮 ===== */
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 32rpx;
|
||||
bottom: 180rpx;
|
||||
width: 108rpx;
|
||||
height: 108rpx;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 122, 255, 0.35);
|
||||
z-index: 20;
|
||||
}
|
||||
.fab:active { opacity: 0.8; }
|
||||
|
||||
/* ===== 底部安全区 ===== */
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
|
||||
/* ===== 通用列表行 ===== */
|
||||
.list-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.list-row:active { background: #F2F2F7; }
|
||||
|
||||
/* ===== 1:1 方图占位 ===== */
|
||||
.square {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background: #F2F2F7;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.square::after {
|
||||
content: '';
|
||||
display: block;
|
||||
padding-bottom: 100%;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
Component({
|
||||
properties: {
|
||||
item: { type: Object, value: {} },
|
||||
selectable: { type: Boolean, value: false },
|
||||
selected: { type: Boolean, value: false },
|
||||
statusText: { type: String, value: '' },
|
||||
statusType: { type: String, value: 'gray' },
|
||||
},
|
||||
methods: {
|
||||
onTap() {
|
||||
this.triggerEvent('select', { item: this.data.item })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
<view class="clothes-card {{selected ? 'selected' : ''}}" bindtap="onTap">
|
||||
<view class="clothes-img">
|
||||
<image
|
||||
wx:if="{{item.processed_image}}"
|
||||
class="clothes-img-img"
|
||||
src="{{item.processed_image}}"
|
||||
mode="aspectFill"
|
||||
lazy-load
|
||||
/>
|
||||
<view wx:else class="clothes-placeholder">
|
||||
<view class="icon icon-hanger"></view>
|
||||
</view>
|
||||
<view wx:if="{{statusText}}" class="clothes-status tag tag-{{statusType}}">{{statusText}}</view>
|
||||
</view>
|
||||
<view class="clothes-name">{{item.name || '未命名衣物'}}</view>
|
||||
<view class="clothes-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}{{item.color_main ? ' · ' + item.color_main : ''}}</view>
|
||||
<view wx:if="{{selectable}}" class="clothes-check {{selected ? 'on' : ''}}">
|
||||
<view wx:if="{{selected}}" class="icon icon-check"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,91 @@
|
||||
.clothes-card {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.clothes-card.selected {
|
||||
border-color: #007AFF;
|
||||
}
|
||||
|
||||
.clothes-img {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
background: #F2F2F7;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.clothes-img-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.clothes-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.clothes-placeholder .icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
|
||||
.clothes-status {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
right: 12rpx;
|
||||
}
|
||||
|
||||
.clothes-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #1C1C1E;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clothes-sub {
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 4rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clothes-check {
|
||||
position: absolute;
|
||||
top: 32rpx;
|
||||
right: 32rpx;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #C7C7CC;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clothes-check.on {
|
||||
background: #007AFF;
|
||||
border-color: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.clothes-check .icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
Component({
|
||||
properties: {
|
||||
icon: { type: String, value: 'icon-hanger' },
|
||||
title: { type: String, value: '' },
|
||||
desc: { type: String, value: '' },
|
||||
buttonText: { type: String, value: '' },
|
||||
buttonType: { type: String, value: 'primary' },
|
||||
},
|
||||
methods: {
|
||||
onTap() {
|
||||
this.triggerEvent('action')
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
<view class="empty">
|
||||
<view class="empty-icon">
|
||||
<view class="icon icon-lg {{icon}}"></view>
|
||||
</view>
|
||||
<view class="empty-title">{{title}}</view>
|
||||
<view wx:if="{{desc}}" class="empty-desc">{{desc}}</view>
|
||||
<view
|
||||
wx:if="{{buttonText}}"
|
||||
class="empty-btn btn {{buttonType === 'outline' ? 'btn-outline' : 'btn-primary'}}"
|
||||
bindtap="onTap"
|
||||
>{{buttonText}}</view>
|
||||
</view>
|
||||
@@ -0,0 +1,37 @@
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 48rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background: #E8F2FF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #007AFF;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
margin-top: 40rpx;
|
||||
width: 360rpx;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
Component({
|
||||
options: {
|
||||
multipleSlots: true // 在组件定义时的选项中启用多slot支持
|
||||
},
|
||||
/**
|
||||
* 组件的属性列表
|
||||
*/
|
||||
properties: {
|
||||
extClass: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
background: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
value: ''
|
||||
},
|
||||
back: {
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
value: false
|
||||
},
|
||||
homeButton: {
|
||||
type: Boolean,
|
||||
value: false,
|
||||
},
|
||||
animated: {
|
||||
// 显示隐藏的时候opacity动画效果
|
||||
type: Boolean,
|
||||
value: true
|
||||
},
|
||||
show: {
|
||||
// 显示隐藏导航,隐藏的时候navigation-bar的高度占位还在
|
||||
type: Boolean,
|
||||
value: true,
|
||||
observer: '_showChange'
|
||||
},
|
||||
// back为true的时候,返回的页面深度
|
||||
delta: {
|
||||
type: Number,
|
||||
value: 1
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 组件的初始数据
|
||||
*/
|
||||
data: {
|
||||
displayStyle: ''
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
const rect = wx.getMenuButtonBoundingClientRect()
|
||||
wx.getSystemInfo({
|
||||
success: (res) => {
|
||||
const isAndroid = res.platform === 'android'
|
||||
const isDevtools = res.platform === 'devtools'
|
||||
this.setData({
|
||||
ios: !isAndroid,
|
||||
innerPaddingRight: `padding-right: ${res.windowWidth - rect.left}px`,
|
||||
leftWidth: `width: ${res.windowWidth - rect.left }px`,
|
||||
safeAreaTop: isDevtools || isAndroid ? `height: calc(var(--height) + ${res.safeArea.top}px); padding-top: ${res.safeArea.top}px` : ``
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
/**
|
||||
* 组件的方法列表
|
||||
*/
|
||||
methods: {
|
||||
_showChange(show: boolean) {
|
||||
const animated = this.data.animated
|
||||
let displayStyle = ''
|
||||
if (animated) {
|
||||
displayStyle = `opacity: ${
|
||||
show ? '1' : '0'
|
||||
};transition:opacity 0.5s;`
|
||||
} else {
|
||||
displayStyle = `display: ${show ? '' : 'none'}`
|
||||
}
|
||||
this.setData({
|
||||
displayStyle
|
||||
})
|
||||
},
|
||||
back() {
|
||||
const data = this.data
|
||||
if (data.delta) {
|
||||
wx.navigateBack({
|
||||
delta: data.delta
|
||||
})
|
||||
}
|
||||
this.triggerEvent('back', { delta: data.delta }, {})
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
<view class="weui-navigation-bar {{extClass}}">
|
||||
<view class="weui-navigation-bar__inner {{ios ? 'ios' : 'android'}}" style="color: {{color}}; background: {{background}}; {{displayStyle}}; {{innerPaddingRight}}; {{safeAreaTop}};">
|
||||
|
||||
<!-- 左侧按钮 -->
|
||||
<view class='weui-navigation-bar__left' style="{{leftWidth}};">
|
||||
<block wx:if="{{back || homeButton}}">
|
||||
<!-- 返回上一页 -->
|
||||
<block wx:if="{{back}}">
|
||||
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_goback">
|
||||
<view
|
||||
bindtap="back"
|
||||
class="weui-navigation-bar__btn_goback_wrapper"
|
||||
hover-class="weui-active"
|
||||
hover-stay-time="100"
|
||||
aria-role="button"
|
||||
aria-label="返回"
|
||||
>
|
||||
<view class="weui-navigation-bar__button weui-navigation-bar__btn_goback"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<!-- 返回首页 -->
|
||||
<block wx:if="{{homeButton}}">
|
||||
<view class="weui-navigation-bar__buttons weui-navigation-bar__buttons_home">
|
||||
<view
|
||||
bindtap="home"
|
||||
class="weui-navigation-bar__btn_home_wrapper"
|
||||
hover-class="weui-active"
|
||||
aria-role="button"
|
||||
aria-label="首页"
|
||||
>
|
||||
<view class="weui-navigation-bar__button weui-navigation-bar__btn_home"></view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<slot name="left"></slot>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 标题 -->
|
||||
<view class='weui-navigation-bar__center'>
|
||||
<view wx:if="{{loading}}" class="weui-navigation-bar__loading" aria-role="alert">
|
||||
<view
|
||||
class="weui-loading"
|
||||
aria-role="img"
|
||||
aria-label="加载中"
|
||||
></view>
|
||||
</view>
|
||||
<block wx:if="{{title}}">
|
||||
<text>{{title}}</text>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<slot name="center"></slot>
|
||||
</block>
|
||||
</view>
|
||||
|
||||
<!-- 右侧留空 -->
|
||||
<view class='weui-navigation-bar__right'>
|
||||
<slot name="right"></slot>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,96 @@
|
||||
.weui-navigation-bar {
|
||||
--weui-FG-0:rgba(0,0,0,.9);
|
||||
--height: 44px;
|
||||
--left: 16px;
|
||||
}
|
||||
.weui-navigation-bar .android {
|
||||
--height: 48px;
|
||||
}
|
||||
|
||||
.weui-navigation-bar {
|
||||
overflow: hidden;
|
||||
color: var(--weui-FG-0);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__inner {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: calc(var(--height) + env(safe-area-inset-top));
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: env(safe-area-inset-top);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__left {
|
||||
position: relative;
|
||||
padding-left: var(--left);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback_wrapper {
|
||||
padding: 11px 18px 11px 16px;
|
||||
margin: -11px -18px -11px -16px;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback_wrapper.weui-active {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__btn_goback {
|
||||
font-size: 12px;
|
||||
width: 12px;
|
||||
height: 24px;
|
||||
-webkit-mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
|
||||
mask: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='24' viewBox='0 0 12 24'%3E %3Cpath fill-opacity='.9' fill-rule='evenodd' d='M10 19.438L8.955 20.5l-7.666-7.79a1.02 1.02 0 0 1 0-1.42L8.955 3.5 10 4.563 2.682 12 10 19.438z'/%3E%3C/svg%3E") no-repeat 50% 50%;
|
||||
-webkit-mask-size: cover;
|
||||
mask-size: cover;
|
||||
background-color: var(--weui-FG-0);
|
||||
}
|
||||
|
||||
.weui-navigation-bar__center {
|
||||
font-size: 17px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.weui-navigation-bar__loading {
|
||||
margin-right: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.weui-loading {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
background: transparent url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='UTF-8'%3F%3E%3Csvg width='80px' height='80px' viewBox='0 0 80 80' version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink'%3E%3Ctitle%3Eloading%3C/title%3E%3Cdefs%3E%3ClinearGradient x1='94.0869141%25' y1='0%25' x2='94.0869141%25' y2='90.559082%25' id='linearGradient-1'%3E%3Cstop stop-color='%23606060' stop-opacity='0' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3ClinearGradient x1='100%25' y1='8.67370605%25' x2='100%25' y2='90.6286621%25' id='linearGradient-2'%3E%3Cstop stop-color='%23606060' offset='0%25'%3E%3C/stop%3E%3Cstop stop-color='%23606060' stop-opacity='0.3' offset='100%25'%3E%3C/stop%3E%3C/linearGradient%3E%3C/defs%3E%3Cg stroke='none' stroke-width='1' fill='none' fill-rule='evenodd' opacity='0.9'%3E%3Cg%3E%3Cpath d='M40,0 C62.09139,0 80,17.90861 80,40 C80,62.09139 62.09139,80 40,80 L40,73 C58.2253967,73 73,58.2253967 73,40 C73,21.7746033 58.2253967,7 40,7 L40,0 Z' fill='url(%23linearGradient-1)'%3E%3C/path%3E%3Cpath d='M40,0 L40,7 C21.7746033,7 7,21.7746033 7,40 C7,58.2253967 21.7746033,73 40,73 L40,80 C17.90861,80 0,62.09139 0,40 C0,17.90861 17.90861,0 40,0 Z' fill='url(%23linearGradient-2)'%3E%3C/path%3E%3Ccircle id='Oval' fill='%23606060' cx='40.5' cy='3.5' r='3.5'%3E%3C/circle%3E%3C/g%3E%3C/g%3E%3C/svg%3E%0A") no-repeat;
|
||||
background-size: 100%;
|
||||
margin-left: 0;
|
||||
animation: loading linear infinite 1s;
|
||||
}
|
||||
|
||||
@keyframes loading {
|
||||
from {
|
||||
transform: rotate(0);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { request } from '../utils/request'
|
||||
|
||||
Component({
|
||||
data: {
|
||||
selected: 0,
|
||||
unread: 0,
|
||||
list: [
|
||||
{ pagePath: '/pages/wardrobe/wardrobe', text: '衣橱', icon: 'icon-hanger' },
|
||||
{ pagePath: '/pages/outfit/outfit', text: '穿搭', icon: 'icon-shirt' },
|
||||
{ pagePath: '/pages/horoscope/horoscope', text: '运势', icon: 'icon-sparkles' },
|
||||
{ pagePath: '/pages/closet-share/closet-share', text: '分享', icon: 'icon-share' },
|
||||
{ pagePath: '/pages/profile/profile', text: '我的', icon: 'icon-user' },
|
||||
],
|
||||
},
|
||||
|
||||
lifetimes: {
|
||||
attached() {
|
||||
this.refreshUnread()
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
refreshUnread() {
|
||||
const unread = (wx.getStorageSync('zt_closet_unread') as number) || 0
|
||||
this.setData({ unread })
|
||||
this.fetchUnread()
|
||||
},
|
||||
fetchUnread() {
|
||||
request<{ unread_count: number }>({ url: '/notification/unread', showError: false })
|
||||
.then((res) => {
|
||||
const unread = res.unread_count || 0
|
||||
wx.setStorageSync('zt_closet_unread', unread)
|
||||
this.setData({ unread })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
switchTab(e: WechatMiniprogram.TouchEvent) {
|
||||
const { path, index } = e.currentTarget.dataset as { path: string; index: number }
|
||||
wx.switchTab({ url: path })
|
||||
this.setData({ selected: index })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
wx:for="{{list}}"
|
||||
wx:key="pagePath"
|
||||
class="tab-item {{selected === index ? 'active' : ''}}"
|
||||
data-path="{{item.pagePath}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="switchTab"
|
||||
>
|
||||
<view class="tab-icon-wrap">
|
||||
<view class="icon tab-icon {{item.icon}}"></view>
|
||||
<view wx:if="{{item.icon === 'icon-user' && unread > 0}}" class="tab-badge">{{unread > 99 ? '99+' : unread}}</view>
|
||||
</view>
|
||||
<text class="tab-text">{{item.text}}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,58 @@
|
||||
.tab-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1rpx solid #E5E5EA;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12rpx 0 8rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.tab-icon-wrap {
|
||||
position: relative;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -20rpx;
|
||||
min-width: 28rpx;
|
||||
height: 28rpx;
|
||||
padding: 0 8rpx;
|
||||
border-radius: 14rpx;
|
||||
background: #FF3B30;
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 20rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { AiRecommendResult, ClothesItem } from '../../types/models'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
state: 'scene' as 'scene' | 'generating' | 'result' | 'empty',
|
||||
showSceneSheet: true,
|
||||
sceneType: 1,
|
||||
scene: '',
|
||||
result: null as AiRecommendResult | null,
|
||||
clothes: [] as ClothesItem[],
|
||||
generating: false,
|
||||
},
|
||||
|
||||
noop() {},
|
||||
|
||||
selectDaily() {
|
||||
this.setData({ sceneType: 1 })
|
||||
},
|
||||
|
||||
selectCustom() {
|
||||
this.setData({ sceneType: 2 })
|
||||
},
|
||||
|
||||
onSceneInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ scene: e.detail.value })
|
||||
},
|
||||
|
||||
onSkipScene() {
|
||||
this.setData({ showSceneSheet: false })
|
||||
this.startGenerate(1, '')
|
||||
},
|
||||
|
||||
onStartGenerate() {
|
||||
this.setData({ showSceneSheet: false })
|
||||
this.startGenerate(this.data.sceneType, this.data.scene)
|
||||
},
|
||||
|
||||
startGenerate(sceneType: number, scene: string) {
|
||||
if (this.data.generating) return
|
||||
wx.showModal({
|
||||
title: 'AI 穿搭推荐',
|
||||
content: '本次将消耗 1 次 AI 推荐额度,生成后可保存到我的穿搭',
|
||||
confirmText: '开始生成',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) this.doGenerate(sceneType, scene)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
doGenerate(sceneType: number, scene: string) {
|
||||
if (this.data.generating) return
|
||||
this.setData({ state: 'generating', generating: true })
|
||||
|
||||
const body: Record<string, any> = { scene_type: sceneType }
|
||||
if (sceneType === 2 && scene) body.scene = scene
|
||||
|
||||
request<AiRecommendResult>({ url: '/ai-recommend', method: 'POST', data: body, showError: false })
|
||||
.then(async (res) => {
|
||||
const clothes = await Promise.all(
|
||||
res.outfit_item.map(async (it) => {
|
||||
try {
|
||||
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
|
||||
} catch (e) {
|
||||
return { clothes_uuid: it.clothes_uuid } as ClothesItem
|
||||
}
|
||||
})
|
||||
)
|
||||
this.setData({ result: res, clothes, state: 'result', generating: false })
|
||||
})
|
||||
.catch((e: any) => {
|
||||
this.setData({ generating: false })
|
||||
const code = e && e.code
|
||||
if (code === 90400) {
|
||||
this.setData({ state: 'empty' })
|
||||
} else if (code === 90401) {
|
||||
this.setData({ state: 'scene', showSceneSheet: true })
|
||||
this.showLimitModal()
|
||||
} else if (code === 10003) {
|
||||
wx.showToast({ title: '正在生成中,请稍候', icon: 'none' })
|
||||
this.setData({ state: 'scene', showSceneSheet: true })
|
||||
} else {
|
||||
this.setData({ state: 'scene', showSceneSheet: true })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
showLimitModal() {
|
||||
wx.showModal({
|
||||
title: 'AI 推荐额度不足',
|
||||
content: '购买额度包后可继续生成 AI 穿搭推荐',
|
||||
confirmText: '去购买',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/mall/mall' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onSave() {
|
||||
if (!this.data.result) return
|
||||
request({
|
||||
url: '/ai-recommend/save',
|
||||
method: 'POST',
|
||||
data: { outfit_uuid: this.data.result.outfit_uuid },
|
||||
})
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已保存到我的穿搭', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
wx.redirectTo({
|
||||
url: `/pages/outfit-detail/outfit-detail?uuid=${this.data.result!.outfit_uuid}&owner=1`,
|
||||
})
|
||||
}, 500)
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onReroll() {
|
||||
this.startGenerate(this.data.sceneType, this.data.scene)
|
||||
},
|
||||
|
||||
onGoUpload() {
|
||||
wx.navigateTo({ url: '/pages/upload/upload' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
<view class="page rec-page">
|
||||
<navigation-bar title="AI 穿搭推荐" />
|
||||
|
||||
<!-- 生成中 -->
|
||||
<view wx:if="{{state === 'generating'}}" class="generating">
|
||||
<view class="gen-ani">
|
||||
<view class="icon icon-shirt gen-icon"></view>
|
||||
<view class="gen-ring"></view>
|
||||
</view>
|
||||
<view class="gen-title">AI 正在为你搭配…</view>
|
||||
<view class="gen-desc">结合今日运势与你的衣橱</view>
|
||||
</view>
|
||||
|
||||
<!-- 衣橱为空 -->
|
||||
<empty-state
|
||||
wx:elif="{{state === 'empty'}}"
|
||||
icon="icon-hanger"
|
||||
title="还没有可用衣物"
|
||||
desc="先上传几件处理成功的衣物吧"
|
||||
buttonText="去上传"
|
||||
bind:action="onGoUpload"
|
||||
/>
|
||||
|
||||
<!-- 结果页 -->
|
||||
<scroll-view wx:elif="{{state === 'result'}}" scroll-y class="scroll-body">
|
||||
<view class="card result-cover">
|
||||
<view class="rc-item rc-{{index}}" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 5}}">
|
||||
<image wx:if="{{item.processed_image}}" class="rc-img" src="{{item.processed_image}}" mode="aspectFit" />
|
||||
<view wx:else class="icon icon-hanger rc-placeholder"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card result-info">
|
||||
<view class="ri-title">{{result.outfit_name}}</view>
|
||||
<view class="ri-desc">{{result.outfit_desc}}</view>
|
||||
<view class="tag-row">
|
||||
<text wx:if="{{result.scene}}" class="tag tag-gray">{{result.scene}}</text>
|
||||
<text class="tag tag-indigo">AI 推荐</text>
|
||||
</view>
|
||||
|
||||
<view class="items-title">推荐单品</view>
|
||||
<scroll-view scroll-x class="items-scroll" show-scrollbar="{{false}}">
|
||||
<view class="items-row">
|
||||
<view class="item-chip" wx:for="{{clothes}}" wx:key="clothes_uuid">
|
||||
<image wx:if="{{item.processed_image}}" class="item-img" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="item-img item-img-empty"><view class="icon icon-hanger"></view></view>
|
||||
<text class="item-name">{{item.name || '未命名'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="footer">
|
||||
<view class="btn btn-primary" bindtap="onSave">保存到我的穿搭</view>
|
||||
<view class="btn btn-outline reroll" bindtap="onReroll">换一套</view>
|
||||
<view class="reroll-tip">换一套将再消耗 1 次推荐额度</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 场景选择弹窗 -->
|
||||
<view wx:if="{{showSceneSheet}}" class="mask" bindtap="onSkipScene">
|
||||
<view class="sheet" catchtap="noop">
|
||||
<view class="sheet-grabber"></view>
|
||||
<view class="sheet-title">选择推荐场景</view>
|
||||
|
||||
<view class="scene-card {{sceneType === 1 ? 'active' : ''}}" bindtap="selectDaily">
|
||||
<view class="scene-name">每日推荐</view>
|
||||
<view class="scene-desc">按今日运势与衣橱自动搭配</view>
|
||||
<view wx:if="{{sceneType === 1}}" class="icon icon-check scene-check"></view>
|
||||
</view>
|
||||
|
||||
<view class="scene-card {{sceneType === 2 ? 'active' : ''}}" bindtap="selectCustom">
|
||||
<view class="scene-name">自定义场景</view>
|
||||
<view class="scene-desc">描述你想去的场合</view>
|
||||
<view wx:if="{{sceneType === 2}}" class="icon icon-check scene-check"></view>
|
||||
</view>
|
||||
|
||||
<input
|
||||
wx:if="{{sceneType === 2}}"
|
||||
class="input scene-input"
|
||||
value="{{scene}}"
|
||||
bindinput="onSceneInput"
|
||||
placeholder="例如:海边度假、周一例会、初次约会"
|
||||
/>
|
||||
|
||||
<view class="btn btn-primary sheet-btn" bindtap="onStartGenerate">开始生成(消耗 1 次推荐额度)</view>
|
||||
<view class="sheet-skip" bindtap="onSkipScene">跳过,使用每日推荐</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,281 @@
|
||||
.rec-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* 生成中 */
|
||||
.generating {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 64rpx;
|
||||
}
|
||||
|
||||
.gen-ani {
|
||||
position: relative;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gen-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.gen-ring {
|
||||
position: absolute;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #E5E5EA;
|
||||
border-top-color: #007AFF;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.gen-title {
|
||||
margin-top: 48rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.gen-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
/* 结果封面 */
|
||||
.result-cover {
|
||||
margin: 24rpx;
|
||||
height: 420rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rc-item {
|
||||
position: absolute;
|
||||
width: 180rpx;
|
||||
height: 240rpx;
|
||||
background: #F8F8FA;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid #E5E5EA;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rc-0 { left: 30rpx; top: 70rpx; transform: rotate(-5deg); }
|
||||
.rc-1 { left: 130rpx; top: 130rpx; transform: rotate(3deg); }
|
||||
.rc-2 { left: 240rpx; top: 60rpx; transform: rotate(6deg); }
|
||||
.rc-3 { left: 350rpx; top: 140rpx; transform: rotate(-3deg); }
|
||||
.rc-4 { left: 460rpx; top: 80rpx; transform: rotate(8deg); }
|
||||
|
||||
.rc-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rc-placeholder {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
/* 结果信息 */
|
||||
.result-info {
|
||||
margin: 0 24rpx 24rpx;
|
||||
}
|
||||
|
||||
.ri-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.ri-desc {
|
||||
font-size: 28rpx;
|
||||
color: #3C3C43;
|
||||
margin-top: 16rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.items-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.items-scroll {
|
||||
margin-top: 20rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.items-row {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.item-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 140rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #F2F2F7;
|
||||
}
|
||||
|
||||
.item-img-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.item-img-empty .icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 20rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 8rpx;
|
||||
max-width: 140rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16rpx 24rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.reroll {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.reroll-tip {
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
/* 场景弹窗 */
|
||||
.mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 20rpx 32rpx;
|
||||
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.sheet-grabber {
|
||||
width: 72rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 5rpx;
|
||||
background: #E5E5EA;
|
||||
margin: 0 auto 24rpx;
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.scene-card {
|
||||
position: relative;
|
||||
border: 2rpx solid #E5E5EA;
|
||||
border-radius: 24rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.scene-card.active {
|
||||
border-color: #007AFF;
|
||||
background: rgba(0, 122, 255, 0.04);
|
||||
}
|
||||
|
||||
.scene-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.scene-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.scene-check {
|
||||
position: absolute;
|
||||
top: 28rpx;
|
||||
right: 28rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.scene-input {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.sheet-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sheet-skip {
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 20rpx;
|
||||
padding: 12rpx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"clothes-card": "/components/clothes-card/clothes-card"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { ClothesItem, PagedResult, UserInfo } from '../../types/models'
|
||||
import { STORAGE_KEYS } from '../../utils/config'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ name: '全部', value: '' },
|
||||
{ name: '上装', value: '上装' },
|
||||
{ name: '下装', value: '下装' },
|
||||
{ name: '连衣裙', value: '连衣裙' },
|
||||
{ name: '外套', value: '外套' },
|
||||
{ name: '鞋', value: '鞋' },
|
||||
{ name: '配饰', value: '配饰' },
|
||||
]
|
||||
|
||||
const EXPIRE_OPTIONS = [
|
||||
{ label: '1 天', days: 1 },
|
||||
{ label: '7 天', days: 7 },
|
||||
{ label: '30 天', days: 30 },
|
||||
{ label: '长期有效', days: 0 },
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
view: 'config' as 'config' | 'select' | 'success',
|
||||
shareType: 1,
|
||||
expireDays: 7,
|
||||
expireOptions: EXPIRE_OPTIONS,
|
||||
selectedUuids: [] as string[],
|
||||
categories: CATEGORIES,
|
||||
activeCategory: 0,
|
||||
allClothes: [] as ClothesItem[],
|
||||
selectLoading: true,
|
||||
shareUuid: '',
|
||||
nickname: '微信用户',
|
||||
avatar: '',
|
||||
selectedCount: 0,
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.loadUserInfo()
|
||||
},
|
||||
|
||||
loadUserInfo() {
|
||||
const cached = wx.getStorageSync(STORAGE_KEYS.USER_INFO) as Record<string, any>
|
||||
if (cached) {
|
||||
this.setData({ nickname: cached.username || '微信用户', avatar: cached.avatar || '' })
|
||||
}
|
||||
// 头像为带时间戳的签名 URL,缓存可能过期,实时刷新并回写缓存。
|
||||
request<UserInfo>({ url: '/user/info', showError: false })
|
||||
.then((user) => {
|
||||
wx.setStorageSync(STORAGE_KEYS.USER_INFO, user)
|
||||
this.setData({ nickname: user.username || '微信用户', avatar: user.avatar || '' })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
selectPartial() {
|
||||
this.setData({ shareType: 1 })
|
||||
},
|
||||
selectFull() {
|
||||
this.setData({ shareType: 2 })
|
||||
},
|
||||
selectExpire(e: WechatMiniprogram.TouchEvent) {
|
||||
const days = Number(e.currentTarget.dataset.days)
|
||||
this.setData({ expireDays: days })
|
||||
},
|
||||
|
||||
openSelect() {
|
||||
this.setData({ view: 'select', selectLoading: true })
|
||||
this.fetchAllClothes()
|
||||
},
|
||||
|
||||
fetchAllClothes() {
|
||||
const cat = CATEGORIES[this.data.activeCategory].value
|
||||
const params: Record<string, any> = { page: 1, page_size: 50 }
|
||||
if (cat) params.category_main = cat
|
||||
request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
|
||||
.then((res) => {
|
||||
const list = res.list.map((it) => ({
|
||||
...it,
|
||||
_selected: this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0,
|
||||
}))
|
||||
this.setData({ allClothes: list, selectLoading: false })
|
||||
})
|
||||
.catch(() => this.setData({ selectLoading: false }))
|
||||
},
|
||||
|
||||
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index)
|
||||
if (index === this.data.activeCategory) return
|
||||
this.setData({ activeCategory: index, selectLoading: true })
|
||||
this.fetchAllClothes()
|
||||
},
|
||||
|
||||
onToggleSelect(e: WechatMiniprogram.CustomEvent) {
|
||||
const item = e.detail.item as ClothesItem
|
||||
const uuids = this.data.selectedUuids
|
||||
const idx = uuids.indexOf(item.clothes_uuid)
|
||||
if (idx >= 0) {
|
||||
this.syncSelect(uuids.filter((u) => u !== item.clothes_uuid))
|
||||
} else {
|
||||
this.syncSelect([...uuids, item.clothes_uuid])
|
||||
}
|
||||
},
|
||||
|
||||
syncSelect(uuids: string[]) {
|
||||
this.setData({
|
||||
selectedUuids: uuids,
|
||||
allClothes: this.data.allClothes.map((it) => ({
|
||||
...it,
|
||||
_selected: uuids.indexOf(it.clothes_uuid) >= 0,
|
||||
})),
|
||||
})
|
||||
},
|
||||
|
||||
confirmSelect() {
|
||||
this.setData({ view: 'config', selectedCount: this.data.selectedUuids.length })
|
||||
},
|
||||
|
||||
backToConfig() {
|
||||
this.setData({ view: 'config' })
|
||||
},
|
||||
|
||||
onConfirm() {
|
||||
const body: Record<string, any> = { share_type: this.data.shareType }
|
||||
if (this.data.shareType === 1) {
|
||||
if (!this.data.selectedUuids.length) {
|
||||
wx.showToast({ title: '请选择要分享的衣物', icon: 'none' })
|
||||
return
|
||||
}
|
||||
body.clothes_uuids = this.data.selectedUuids
|
||||
}
|
||||
const days = this.data.expireDays
|
||||
body.expire_time = days > 0 ? Math.floor(Date.now() / 1000) + days * 86400 : 0
|
||||
|
||||
request<{ share_uuid: string }>({ url: '/closet-share', method: 'POST', data: body })
|
||||
.then((res) => this.setData({ shareUuid: res.share_uuid, view: 'success' }))
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onViewShare() {
|
||||
wx.redirectTo({ url: `/pages/closet-share-detail/closet-share-detail?uuid=${this.data.shareUuid}` })
|
||||
},
|
||||
|
||||
onDone() {
|
||||
wx.navigateBack()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
<view class="page create-page">
|
||||
<navigation-bar title="创建分享" />
|
||||
|
||||
<!-- 配置视图 -->
|
||||
<scroll-view wx:if="{{view === 'config'}}" scroll-y class="scroll-body">
|
||||
<view class="section-title">分享方式</view>
|
||||
<view class="type-card card {{shareType === 1 ? 'active' : ''}}" bindtap="selectPartial">
|
||||
<view class="type-name">部分衣物</view>
|
||||
<view class="type-desc">挑选几件衣物分享给好友</view>
|
||||
<view wx:if="{{shareType === 1}}" class="icon icon-check type-check"></view>
|
||||
</view>
|
||||
<view class="type-card card {{shareType === 2 ? 'active' : ''}}" bindtap="selectFull">
|
||||
<view class="type-name">全量快照</view>
|
||||
<view class="type-desc">分享当前衣橱的全部衣物</view>
|
||||
<view wx:if="{{shareType === 2}}" class="icon icon-check type-check"></view>
|
||||
</view>
|
||||
|
||||
<!-- 选择衣物入口(部分衣物) -->
|
||||
<view wx:if="{{shareType === 1}}" class="card select-entry" bindtap="openSelect">
|
||||
<view class="entry-left">
|
||||
<view class="entry-icon"><view class="icon icon-shirt"></view></view>
|
||||
<view class="entry-title">选择衣物</view>
|
||||
</view>
|
||||
<view class="entry-right">
|
||||
<text class="entry-count {{selectedCount ? 'on' : ''}}">{{selectedCount ? '已选 ' + selectedCount + ' 件' : '未选择'}}</text>
|
||||
<view class="icon icon-chevron-right"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">有效期</view>
|
||||
<view class="card expire-card">
|
||||
<view
|
||||
class="expire-item hairline"
|
||||
wx:for="{{expireOptions}}"
|
||||
wx:key="days"
|
||||
data-days="{{item.days}}"
|
||||
bindtap="selectExpire"
|
||||
>
|
||||
<text>{{item.label}}</text>
|
||||
<view wx:if="{{expireDays === item.days}}" class="icon icon-check expire-check"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer">
|
||||
<view class="btn btn-primary" bindtap="onConfirm">确认分享(消耗 1 次衣柜分享额度)</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 选择衣物视图 -->
|
||||
<view wx:elif="{{view === 'select'}}" class="select-view">
|
||||
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
|
||||
<view class="cat-row">
|
||||
<view
|
||||
wx:for="{{categories}}"
|
||||
wx:key="name"
|
||||
class="cat-item {{activeCategory === index ? 'active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="onCategoryTap"
|
||||
>{{item.name}}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<scroll-view scroll-y class="select-body">
|
||||
<view wx:if="{{selectLoading}}" class="grid">
|
||||
<view class="grid-item" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
|
||||
<view class="skeleton sk-card"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="grid">
|
||||
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
|
||||
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="select-footer">
|
||||
<view class="sf-count">已选 <text class="t-primary">{{selectedUuids.length}}</text> 件</view>
|
||||
<view class="sf-confirm btn btn-primary" bindtap="confirmSelect">确定</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 成功预览 -->
|
||||
<view wx:else class="success-view">
|
||||
<view class="success-icon"><view class="icon icon-check"></view></view>
|
||||
<view class="success-title">分享已创建</view>
|
||||
<view class="preview-card">
|
||||
<view class="preview-top">
|
||||
<view class="avatar">
|
||||
<image wx:if="{{avatar}}" class="avatar-img" src="{{avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user avatar-placeholder"></view>
|
||||
</view>
|
||||
<text class="preview-name">{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物</text>
|
||||
</view>
|
||||
<view class="preview-body">
|
||||
<view class="icon icon-hanger"></view>
|
||||
</view>
|
||||
<view class="preview-btn">查看衣橱</view>
|
||||
</view>
|
||||
<view class="success-btns">
|
||||
<view class="btn btn-outline" bindtap="onDone">完成</view>
|
||||
<view class="btn btn-primary" bindtap="onViewShare">查看分享</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,322 @@
|
||||
.create-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
padding: 24rpx 24rpx 16rpx;
|
||||
}
|
||||
|
||||
.type-card {
|
||||
position: relative;
|
||||
margin: 0 24rpx 20rpx;
|
||||
border: 2rpx solid transparent;
|
||||
}
|
||||
|
||||
.type-card.active {
|
||||
border-color: #007AFF;
|
||||
}
|
||||
|
||||
.type-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.type-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.type-check {
|
||||
position: absolute;
|
||||
top: 32rpx;
|
||||
right: 32rpx;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.select-entry {
|
||||
margin: 0 24rpx 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
color: #007AFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.entry-icon .icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1C1C1E;
|
||||
}
|
||||
|
||||
.entry-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.entry-count {
|
||||
font-size: 26rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.entry-count.on {
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.entry-right .icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.expire-card {
|
||||
margin: 0 24rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.expire-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.expire-check {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 24rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* 选择视图 */
|
||||
.select-view {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cat-scroll {
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cat-row {
|
||||
display: inline-flex;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
|
||||
.cat-item {
|
||||
flex-shrink: 0;
|
||||
padding: 0 32rpx;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #3C3C43;
|
||||
background: #F2F2F7;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.cat-item.active {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.select-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 24rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
width: calc(50% - 12rpx);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sk-card {
|
||||
height: 360rpx;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
|
||||
.select-footer {
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-top: 1rpx solid #E5E5EA;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 32rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.sf-count {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1C1C1E;
|
||||
}
|
||||
|
||||
.sf-confirm {
|
||||
width: 200rpx;
|
||||
height: 80rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
/* 成功视图 */
|
||||
.success-view {
|
||||
flex: 1;
|
||||
padding: 48rpx 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: #34C759;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.success-icon .icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
margin-top: 40rpx;
|
||||
padding: 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.preview-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background: #F2F2F7;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.preview-name {
|
||||
font-size: 26rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
height: 320rpx;
|
||||
background: #F8F8FA;
|
||||
border-radius: 24rpx;
|
||||
margin-top: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.preview-body .icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.preview-btn {
|
||||
margin-top: 24rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 24rpx;
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.success-btns {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.success-btns .btn {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { ClosetShareDetail } from '../../types/models'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
uuid: '',
|
||||
detail: null as ClosetShareDetail | null,
|
||||
expireText: '',
|
||||
loading: true,
|
||||
invalid: false,
|
||||
},
|
||||
|
||||
onLoad(query: Record<string, string>) {
|
||||
const uuid = query.uuid || ''
|
||||
this.setData({ uuid })
|
||||
if (uuid) this.fetchDetail()
|
||||
},
|
||||
|
||||
fetchDetail() {
|
||||
request<ClosetShareDetail>({ url: `/closet-share/${this.data.uuid}`, showError: false })
|
||||
.then((detail) => {
|
||||
const expireText =
|
||||
detail.expire_time === 0 ? '长期有效' : `有效期至 ${formatDateTime(detail.expire_time)}`
|
||||
this.setData({ detail, expireText, loading: false })
|
||||
})
|
||||
.catch((e: any) => {
|
||||
this.setData({ loading: false })
|
||||
const code = e && e.code
|
||||
if (code === 90100 || code === 90101 || code === 90102) {
|
||||
this.setData({ invalid: true })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
onCancel() {
|
||||
wx.showModal({
|
||||
title: '取消分享',
|
||||
content: '取消后该分享链接将失效,且不返还衣柜分享额度',
|
||||
confirmText: '取消分享',
|
||||
confirmColor: '#FF3B30',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
request({ url: `/closet-share/${this.data.uuid}/cancel`, method: 'POST' })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已取消', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 500)
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onBackHome() {
|
||||
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
<view class="page detail-page">
|
||||
<navigation-bar title="分享详情" />
|
||||
|
||||
<empty-state
|
||||
wx:if="{{invalid}}"
|
||||
icon="icon-share"
|
||||
title="该分享已过期或已被取消"
|
||||
desc="分享链接已失效"
|
||||
buttonText="返回首页"
|
||||
bind:action="onBackHome"
|
||||
/>
|
||||
|
||||
<scroll-view wx:else scroll-y class="scroll-body">
|
||||
<!-- 分享者信息 -->
|
||||
<view class="card owner-card">
|
||||
<view class="avatar">
|
||||
<image wx:if="{{detail.owner_avatar}}" class="avatar-img" src="{{detail.owner_avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user avatar-placeholder"></view>
|
||||
</view>
|
||||
<view class="owner-info">
|
||||
<view class="owner-name">{{detail.owner_name}} 分享了 {{detail.clothes.length}} 件衣物</view>
|
||||
<view class="owner-expire">{{expireText}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 衣物网格 -->
|
||||
<view class="grid">
|
||||
<view class="grid-item" wx:for="{{detail.clothes}}" wx:key="clothes_uuid">
|
||||
<view class="clothes-card">
|
||||
<image wx:if="{{item.processed_image}}" class="clothes-img" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="clothes-placeholder"><view class="icon icon-hanger"></view></view>
|
||||
<view class="clothes-name">{{item.name || '未命名'}}</view>
|
||||
<view class="clothes-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 属主取消 -->
|
||||
<view wx:if="{{detail.is_owner}}" class="cancel-btn btn-danger-text" bindtap="onCancel">取消分享</view>
|
||||
<view class="footer-space"></view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -0,0 +1,122 @@
|
||||
.detail-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.owner-card {
|
||||
margin: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: #F2F2F7;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.owner-info {
|
||||
margin-left: 24rpx;
|
||||
}
|
||||
|
||||
.owner-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.owner-expire {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 24rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
width: calc(50% - 12rpx);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clothes-card {
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.clothes-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 24rpx;
|
||||
background: #F2F2F7;
|
||||
}
|
||||
|
||||
.clothes-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 24rpx;
|
||||
background: #F2F2F7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.clothes-placeholder .icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
|
||||
.clothes-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #1C1C1E;
|
||||
margin-top: 12rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clothes-sub {
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
text-align: center;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
.footer-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { PagedResult, ClosetShareItem } from '../../types/models'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
tabs: [
|
||||
{ name: '我的分享' },
|
||||
{ name: '分享给我的' },
|
||||
],
|
||||
activeTab: 0, // 0 我的分享 1 分享给我的
|
||||
list: [] as ClosetShareItem[],
|
||||
loading: true,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.syncTabBar()
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
syncTabBar() {
|
||||
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
|
||||
if (tabBar) tabBar.setData({ selected: 3 })
|
||||
},
|
||||
|
||||
onTabTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index)
|
||||
if (index === this.data.activeTab) return
|
||||
this.setData({ activeTab: index, list: [], loading: true })
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
|
||||
this.fetchList(1)
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
if (!this.data.hasMore || this.data.loading) return
|
||||
this.fetchList(this.data.page + 1)
|
||||
},
|
||||
|
||||
fetchList(page: number) {
|
||||
const url = this.data.activeTab === 0 ? '/closet-share' : '/closet-share/received'
|
||||
request<PagedResult<ClosetShareItem>>({ url, data: { page, page_size: this.data.pageSize } })
|
||||
.then((res) => {
|
||||
const list = res.list.map((it) => this.decorate(it))
|
||||
const merged = page === 1 ? list : this.data.list.concat(list)
|
||||
this.setData({
|
||||
list: merged,
|
||||
hasMore: merged.length < res.total,
|
||||
loading: false,
|
||||
page,
|
||||
})
|
||||
})
|
||||
.catch(() => this.setData({ loading: false }))
|
||||
},
|
||||
|
||||
decorate(it: ClosetShareItem) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
let status = it.status
|
||||
if (status === 1 && it.expire_time > 0 && it.expire_time < now) status = 3
|
||||
|
||||
let expireText = '长期有效'
|
||||
if (status === 2) expireText = '已取消'
|
||||
else if (status === 3) expireText = '已过期'
|
||||
else if (status === 4) expireText = '已封禁'
|
||||
else if (it.expire_time > 0) {
|
||||
const days = Math.ceil((it.expire_time - now) / 86400)
|
||||
expireText = days > 0 ? `${days} 天后过期` : '已过期'
|
||||
}
|
||||
|
||||
return {
|
||||
...it,
|
||||
_status: status,
|
||||
_expireText: expireText,
|
||||
_typeText: it.share_type === 1 ? '部分衣物' : '全量衣橱',
|
||||
_invalid: status === 2 || status === 3 || status === 4,
|
||||
}
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
wx.navigateTo({ url: '/pages/closet-share-create/closet-share-create' })
|
||||
},
|
||||
|
||||
onItemTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const uuid = e.currentTarget.dataset.uuid as string
|
||||
wx.navigateTo({ url: `/pages/closet-share-detail/closet-share-detail?uuid=${uuid}` })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="分享" back="{{false}}" />
|
||||
|
||||
<!-- 分段切换 -->
|
||||
<view class="seg-wrap">
|
||||
<view class="seg-control">
|
||||
<view
|
||||
wx:for="{{tabs}}"
|
||||
wx:key="name"
|
||||
class="seg-item {{activeTab === index ? 'active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="onTabTap"
|
||||
>{{item.name}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom">
|
||||
<view wx:if="{{activeTab === 0}}" class="new-btn btn btn-outline" bindtap="onCreate">+ 新建分享</view>
|
||||
|
||||
<empty-state
|
||||
wx:if="{{!loading && !list.length}}"
|
||||
icon="icon-share"
|
||||
title="{{activeTab === 0 ? '还没有分享' : '还没有收到分享'}}"
|
||||
desc="{{activeTab === 0 ? '把衣橱或精选衣物分享给好友' : '好友分享给你的衣橱会出现在这里'}}"
|
||||
buttonText="{{activeTab === 0 ? '新建分享' : ''}}"
|
||||
bind:action="onCreate"
|
||||
/>
|
||||
|
||||
<view class="list">
|
||||
<view
|
||||
class="share-card card {{item._invalid ? 'invalid' : ''}}"
|
||||
wx:for="{{list}}"
|
||||
wx:key="share_uuid"
|
||||
data-uuid="{{item.share_uuid}}"
|
||||
bindtap="onItemTap"
|
||||
>
|
||||
<view class="preview-grid">
|
||||
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
|
||||
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
|
||||
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
|
||||
<view class="pg-cell"><view class="icon icon-hanger"></view></view>
|
||||
</view>
|
||||
<view class="share-info">
|
||||
<view wx:if="{{activeTab === 1}}" class="owner-row">
|
||||
<image wx:if="{{item.owner_avatar}}" class="owner-avatar" src="{{item.owner_avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="owner-avatar owner-avatar-placeholder">
|
||||
<view class="icon icon-user owner-avatar-icon"></view>
|
||||
</view>
|
||||
<text class="owner-name">{{item.owner_name || '好友'}}</text>
|
||||
</view>
|
||||
<view class="share-title">衣柜分享 · {{item.clothes_count}} 件</view>
|
||||
<view class="tag tag-gray share-type">{{item._typeText}}</view>
|
||||
<view class="share-expire">{{item._expireText}}</view>
|
||||
</view>
|
||||
<view wx:if="{{item._invalid}}" class="share-badge">{{item._status === 2 ? '已取消' : '已过期'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -0,0 +1,151 @@
|
||||
.page-flex {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.seg-wrap {
|
||||
background: #fff;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
|
||||
.seg-control {
|
||||
display: flex;
|
||||
background: #F2F2F7;
|
||||
border-radius: 16rpx;
|
||||
padding: 4rpx;
|
||||
}
|
||||
|
||||
.seg-item {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
text-align: center;
|
||||
border-radius: 12rpx;
|
||||
font-size: 28rpx;
|
||||
color: #3C3C43;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.seg-item.active {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.new-btn {
|
||||
margin: 24rpx;
|
||||
width: calc(100% - 48rpx);
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 0 24rpx 24rpx;
|
||||
}
|
||||
|
||||
.share-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.share-card.invalid {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
gap: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pg-cell {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
background: #F2F2F7;
|
||||
border-radius: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.pg-cell .icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.owner-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.owner-avatar {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
background: #F2F2F7;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.owner-avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.owner-avatar-icon {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
}
|
||||
|
||||
.owner-name {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.share-info {
|
||||
flex: 1;
|
||||
margin-left: 24rpx;
|
||||
}
|
||||
|
||||
.share-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.share-type {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.share-expire {
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.share-badge {
|
||||
position: absolute;
|
||||
top: 16rpx;
|
||||
right: 16rpx;
|
||||
font-size: 20rpx;
|
||||
color: #8E8E93;
|
||||
background: #F2F2F7;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { ClothesItem } from '../../types/models'
|
||||
import { ProcessStatus } from '../../utils/config'
|
||||
|
||||
const ATTRS = [
|
||||
{ key: 'category_main', label: '大类', type: 'text' },
|
||||
{ key: 'category_sub', label: '小类', type: 'text' },
|
||||
{ key: 'style', label: '风格', type: 'text' },
|
||||
{ key: 'color_main', label: '主色', type: 'color' },
|
||||
{ key: 'color_sub', label: '副色', type: 'text' },
|
||||
{ key: 'thickness', label: '厚度', type: 'text' },
|
||||
{ key: 'version', label: '版型', type: 'text' },
|
||||
{ key: 'material', label: '材质', type: 'text' },
|
||||
{ key: 'pattern', label: '花纹', type: 'text' },
|
||||
{ key: 'sleeve_type', label: '袖型', type: 'text' },
|
||||
{ key: 'clothes_type', label: '衣长', type: 'text' },
|
||||
{ key: 'neckline_type', label: '领型', type: 'text' },
|
||||
{ key: 'formality', label: '正式程度', type: 'score' },
|
||||
{ key: 'occasions', label: '适用场合', type: 'text' },
|
||||
{ key: 'visual_impact', label: '视觉冲击力', type: 'score' },
|
||||
]
|
||||
|
||||
// 将后端 image_process_status 映射为前端展示步骤:0=排队中 1=AI 抠图 2=智能分类
|
||||
function statusToStep(status: number): number {
|
||||
if (status === ProcessStatus.QUEUED) return 0
|
||||
if (status === ProcessStatus.MATTING) return 1
|
||||
if (status === ProcessStatus.CLASSIFYING) return 2
|
||||
return 2
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
uuid: '',
|
||||
attrs: ATTRS,
|
||||
detail: {} as ClothesItem,
|
||||
formData: {} as ClothesItem,
|
||||
imageStatus: 0,
|
||||
step: 0,
|
||||
failReason: '',
|
||||
loading: true,
|
||||
editMode: false,
|
||||
},
|
||||
|
||||
onLoad(query: Record<string, string>) {
|
||||
const uuid = query.uuid || ''
|
||||
this.setData({ uuid })
|
||||
if (uuid) {
|
||||
this.fetchDetail()
|
||||
this.pollStatus()
|
||||
}
|
||||
},
|
||||
|
||||
fetchDetail(): Promise<void> {
|
||||
return request<ClothesItem>({ url: `/clothes/${this.data.uuid}` })
|
||||
.then((detail) => this.setData({ detail, loading: false }))
|
||||
.catch(() => this.setData({ loading: false }))
|
||||
},
|
||||
|
||||
pollStatus() {
|
||||
let count = 0
|
||||
;(this as any).pollTimer = setInterval(() => {
|
||||
count++
|
||||
request<{ image_process_status: number; fail_reason?: string }>({
|
||||
url: `/clothes/${this.data.uuid}/status`,
|
||||
showError: false,
|
||||
})
|
||||
.then((res) => {
|
||||
const status = res.image_process_status
|
||||
if (status === ProcessStatus.SUCCESS) {
|
||||
// 成功:先拉最新详情,再切换为详情视图,避免空属性闪烁
|
||||
this.clearTimer()
|
||||
this.fetchDetail().then(() => this.setData({ imageStatus: status, step: 2 }))
|
||||
} else if (status === ProcessStatus.FAILED) {
|
||||
this.clearTimer()
|
||||
this.setData({ imageStatus: status, failReason: res.fail_reason || '识别失败' })
|
||||
} else {
|
||||
this.setData({ imageStatus: status, step: statusToStep(status) })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
if (count >= 120) {
|
||||
this.clearTimer()
|
||||
}
|
||||
}, 2000)
|
||||
},
|
||||
|
||||
clearTimer() {
|
||||
const t = (this as any).pollTimer
|
||||
if (t) {
|
||||
clearInterval(t)
|
||||
;(this as any).pollTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this.clearTimer()
|
||||
},
|
||||
|
||||
onToggleEdit() {
|
||||
if (this.data.editMode) {
|
||||
this.setData({ editMode: false })
|
||||
} else {
|
||||
this.setData({ editMode: true, formData: { ...this.data.detail } })
|
||||
}
|
||||
},
|
||||
|
||||
onNameInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ 'formData.name': e.detail.value })
|
||||
},
|
||||
|
||||
onRemarkInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ 'formData.remark': e.detail.value })
|
||||
},
|
||||
|
||||
onAttrTap(e: WechatMiniprogram.TouchEvent) {
|
||||
if (!this.data.editMode) return
|
||||
const key = e.currentTarget.dataset.key as string
|
||||
const attr = ATTRS.find((a) => a.key === key)
|
||||
if (!attr) return
|
||||
const current = (this.data.formData as any)[key] || ''
|
||||
|
||||
if (attr.type === 'score') {
|
||||
wx.showActionSheet({
|
||||
itemList: ['1', '2', '3', '4', '5'],
|
||||
success: (res) => {
|
||||
this.setData({ [`formData.${key}`]: String(res.tapIndex + 1) })
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
wx.showModal({
|
||||
title: `编辑${attr.label}`,
|
||||
editable: true,
|
||||
placeholderText: `请输入${attr.label}`,
|
||||
content: current,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
this.setData({ [`formData.${key}`]: res.content })
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onSave() {
|
||||
const fd = this.data.formData as any
|
||||
const body: Record<string, any> = {}
|
||||
const fields: Array<[string, string]> = [
|
||||
['name', 'name'],
|
||||
['remark', 'remark'],
|
||||
['category_main', 'category_main'],
|
||||
['category_sub', 'category_sub'],
|
||||
['style', 'style'],
|
||||
['color_main', 'color'], // 主色请求字段名是 color
|
||||
['color_sub', 'color_sub'],
|
||||
['thickness', 'thickness'],
|
||||
['version', 'version'],
|
||||
['material', 'material'],
|
||||
['pattern', 'pattern'],
|
||||
['sleeve_type', 'sleeve_type'],
|
||||
['clothes_type', 'clothes_type'],
|
||||
['neckline_type', 'neckline_type'],
|
||||
['formality', 'formality'],
|
||||
['occasions', 'occasions'],
|
||||
['visual_impact', 'visual_impact'],
|
||||
]
|
||||
fields.forEach(([src, dest]) => {
|
||||
if (fd[src]) body[dest] = fd[src]
|
||||
})
|
||||
|
||||
request({ url: `/clothes/${this.data.uuid}`, method: 'PUT', data: body })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已保存', icon: 'success' })
|
||||
this.setData({ editMode: false })
|
||||
this.fetchDetail()
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onDelete() {
|
||||
const canRefund = this.data.imageStatus !== ProcessStatus.FAILED
|
||||
const content = canRefund ? '删除后将退回 1 次上传额度,确认删除?' : '确认删除?'
|
||||
wx.showModal({
|
||||
title: '删除这件衣物',
|
||||
content,
|
||||
confirmText: '确认删除',
|
||||
confirmColor: '#FF3B30',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
request({ url: `/clothes/${this.data.uuid}`, method: 'DELETE' })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 500)
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
<view class="page detail-page">
|
||||
<navigation-bar title="衣物详情">
|
||||
<view
|
||||
wx:if="{{imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
|
||||
slot="right"
|
||||
class="nav-action"
|
||||
bindtap="onToggleEdit"
|
||||
>{{editMode ? '取消' : '编辑'}}</view>
|
||||
</navigation-bar>
|
||||
|
||||
<scroll-view scroll-y class="scroll-body">
|
||||
<!-- 处理中:显示步骤进度 -->
|
||||
<block wx:if="{{imageStatus === 1 || imageStatus === 2 || imageStatus === 3}}">
|
||||
<view class="card processing-card">
|
||||
<view class="proc-icon"><view class="icon icon-hanger"></view></view>
|
||||
<view class="steps">
|
||||
<view class="step">
|
||||
<view class="step-dot {{step >= 0 ? 'active' : ''}}"></view>
|
||||
<text class="step-text {{step >= 0 ? 'active-text' : ''}}">排队中</text>
|
||||
</view>
|
||||
<view class="step-line {{step >= 1 ? 'active' : ''}}"></view>
|
||||
<view class="step">
|
||||
<view class="step-dot {{step >= 1 ? 'active' : ''}}"></view>
|
||||
<text class="step-text {{step >= 1 ? 'active-text' : ''}}">AI 抠图</text>
|
||||
</view>
|
||||
<view class="step-line {{step >= 2 ? 'active' : ''}}"></view>
|
||||
<view class="step">
|
||||
<view class="step-dot {{step >= 2 ? 'active' : ''}}"></view>
|
||||
<text class="step-text {{step >= 2 ? 'active-text' : ''}}">智能分类</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="proc-text">AI 正在识别你的衣物…</view>
|
||||
<view class="proc-desc">通常需要 10 秒左右,请稍候</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 识别失败 -->
|
||||
<block wx:elif="{{imageStatus === 5}}">
|
||||
<view class="img-box failed-box">
|
||||
<view class="fail-big-icon">!</view>
|
||||
</view>
|
||||
<view class="card name-card">
|
||||
<view class="name-text">{{detail.name || '未命名衣物'}}</view>
|
||||
</view>
|
||||
<view class="card fail-card">
|
||||
<view class="fail-title">识别失败:{{failReason || '未知原因'}}</view>
|
||||
<view class="fail-desc">本次上传额度已自动退回,你可以删除或重新上传</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 正常详情 -->
|
||||
<block wx:else>
|
||||
<!-- 大图区 -->
|
||||
<view class="img-box">
|
||||
<image
|
||||
wx:if="{{detail.processed_image}}"
|
||||
class="img"
|
||||
src="{{detail.processed_image}}"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view wx:else class="img-placeholder"><view class="icon icon-hanger"></view></view>
|
||||
</view>
|
||||
|
||||
<!-- 名称 -->
|
||||
<view class="card name-card">
|
||||
<input
|
||||
wx:if="{{editMode}}"
|
||||
class="input"
|
||||
value="{{formData.name}}"
|
||||
bindinput="onNameInput"
|
||||
placeholder="衣物名称"
|
||||
/>
|
||||
<view wx:else class="name-text">{{detail.name || '未命名衣物'}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 属性列表 -->
|
||||
<view class="card attr-card">
|
||||
<view
|
||||
class="attr-row hairline"
|
||||
wx:for="{{attrs}}"
|
||||
wx:key="key"
|
||||
data-key="{{item.key}}"
|
||||
bindtap="onAttrTap"
|
||||
>
|
||||
<view class="attr-label">{{item.label}}</view>
|
||||
<view class="attr-value">
|
||||
<block wx:if="{{item.type === 'score'}}">
|
||||
<view class="score-bar">
|
||||
<view class="score-fill" style="width: {{(detail[item.key] || 0) * 20}}%"></view>
|
||||
</view>
|
||||
<text class="score-num">{{detail[item.key] || 0}}/5</text>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<text class="attr-text">{{detail[item.key] || '—'}}</text>
|
||||
<view
|
||||
wx:if="{{item.key === 'color_main' && detail.color_main}}"
|
||||
class="color-dot"
|
||||
style="background: {{detail.color_main}}"
|
||||
></view>
|
||||
</block>
|
||||
</view>
|
||||
<view wx:if="{{editMode}}" class="icon icon-chevron-right edit-arrow"></view>
|
||||
</view>
|
||||
|
||||
<!-- 备注 -->
|
||||
<view class="remark-row">
|
||||
<textarea
|
||||
wx:if="{{editMode}}"
|
||||
class="textarea"
|
||||
value="{{formData.remark}}"
|
||||
bindinput="onRemarkInput"
|
||||
placeholder="备注"
|
||||
/>
|
||||
<view wx:elif="{{detail.remark}}" class="remark-text">备注:{{detail.remark}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 底部操作:处理中隐藏,其他显示 -->
|
||||
<view
|
||||
wx:if="{{imageStatus !== 1 && imageStatus !== 2 && imageStatus !== 3}}"
|
||||
class="footer"
|
||||
>
|
||||
<block wx:if="{{editMode}}">
|
||||
<view class="btn btn-primary" bindtap="onSave">保存</view>
|
||||
</block>
|
||||
<block wx:else>
|
||||
<view class="btn-danger-text delete-btn" bindtap="onDelete">删除这件衣物</view>
|
||||
</block>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -0,0 +1,257 @@
|
||||
.detail-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.nav-action {
|
||||
font-size: 30rpx;
|
||||
color: #007AFF;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.img-box {
|
||||
margin: 24rpx;
|
||||
height: 640rpx;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.img-placeholder {
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.img-placeholder .icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
.name-card {
|
||||
margin: 0 24rpx 24rpx;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.attr-card {
|
||||
margin: 0 24rpx 24rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.attr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 88rpx;
|
||||
}
|
||||
|
||||
.attr-label {
|
||||
width: 200rpx;
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attr-value {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.attr-text {
|
||||
font-size: 28rpx;
|
||||
color: #3C3C43;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 50%;
|
||||
margin-left: 16rpx;
|
||||
border: 1rpx solid #E5E5EA;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
width: 160rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 4rpx;
|
||||
background: #F2F2F7;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-fill {
|
||||
height: 100%;
|
||||
background: #007AFF;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.score-num {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
margin-left: 16rpx;
|
||||
min-width: 60rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.edit-arrow {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: #C7C7CC;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.remark-row {
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.remark-text {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 16rpx 24rpx 48rpx;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
/* 处理中 */
|
||||
.processing-card {
|
||||
margin: 24rpx;
|
||||
padding: 64rpx 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.proc-icon {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background: #F2F2F7;
|
||||
color: #C7C7CC;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.proc-icon .icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.step-text.active-text {
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: #E5E5EA;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.step-dot.active {
|
||||
background: #007AFF;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
width: 100rpx;
|
||||
height: 2rpx;
|
||||
background: #E5E5EA;
|
||||
margin: 0 16rpx 34rpx;
|
||||
}
|
||||
|
||||
.step-line.active {
|
||||
background: #007AFF;
|
||||
}
|
||||
|
||||
.proc-text {
|
||||
margin-top: 48rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.proc-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
/* 识别失败 */
|
||||
.failed-box {
|
||||
background: #FFF3F3;
|
||||
}
|
||||
|
||||
.fail-big-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: #FF3B30;
|
||||
color: #fff;
|
||||
font-size: 72rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fail-card {
|
||||
margin: 0 24rpx 24rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.fail-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.fail-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { HoroscopeData } from '../../types/models'
|
||||
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
红色: '#FF3B30',
|
||||
橙色: '#FF9500',
|
||||
黄色: '#FFCC00',
|
||||
绿色: '#34C759',
|
||||
蓝色: '#007AFF',
|
||||
紫色: '#5856D6',
|
||||
粉色: '#FF2D55',
|
||||
白色: '#FFFFFF',
|
||||
黑色: '#000000',
|
||||
灰色: '#8E8E93',
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
horoscope: null as HoroscopeData | null,
|
||||
statDate: '',
|
||||
luckyColorHex: '#007AFF',
|
||||
loading: true,
|
||||
noBirth: false,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.syncTabBar()
|
||||
if (!this.data.horoscope) this.fetchHoroscope(0)
|
||||
},
|
||||
|
||||
syncTabBar() {
|
||||
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
|
||||
if (tabBar) tabBar.setData({ selected: 2 })
|
||||
},
|
||||
|
||||
fetchHoroscope(retry: number) {
|
||||
this.setData({ loading: true })
|
||||
request<HoroscopeData>({ url: '/horoscope/today', showError: false })
|
||||
.then((res) => {
|
||||
this.setData({
|
||||
horoscope: res,
|
||||
statDate: this.formatStatDate(res.stat_date),
|
||||
luckyColorHex: COLOR_MAP[res.lucky_color] || '#007AFF',
|
||||
loading: false,
|
||||
noBirth: false,
|
||||
})
|
||||
})
|
||||
.catch((e: any) => {
|
||||
const code = e && e.code
|
||||
const details = (e && e.details) || []
|
||||
const noBirth = details.some((d: string) => d.indexOf('生日') >= 0)
|
||||
if (code === 10000 && noBirth) {
|
||||
this.setData({ noBirth: true, loading: false })
|
||||
} else if (code === 90300 && retry < 3) {
|
||||
wx.showToast({ title: '运势生成中,请稍后重试', icon: 'none' })
|
||||
setTimeout(() => this.fetchHoroscope(retry + 1), 4000)
|
||||
} else {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
formatStatDate(d: number): string {
|
||||
const s = String(d)
|
||||
return `${s.slice(0, 4)}.${s.slice(4, 6)}.${s.slice(6, 8)}`
|
||||
},
|
||||
|
||||
onGoProfile() {
|
||||
wx.switchTab({ url: '/pages/profile/profile' })
|
||||
},
|
||||
|
||||
onGenerate() {
|
||||
wx.navigateTo({ url: '/pages/ai-recommend/ai-recommend' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="每日运势" back="{{false}}">
|
||||
<view slot="right" class="nav-action" bindtap="onGoProfile">去设置生日</view>
|
||||
</navigation-bar>
|
||||
|
||||
<scroll-view scroll-y class="scroll-body">
|
||||
<!-- 骨架屏 -->
|
||||
<view wx:if="{{loading}}" class="sk-wrap">
|
||||
<view class="skeleton sk-big"></view>
|
||||
<view class="skeleton sk-mid"></view>
|
||||
<view class="skeleton sk-mid"></view>
|
||||
</view>
|
||||
|
||||
<!-- 未设置生日 -->
|
||||
<empty-state
|
||||
wx:elif="{{noBirth}}"
|
||||
icon="icon-calendar"
|
||||
title="还没有设置生日"
|
||||
desc="设置生日后即可查看你的星座运势"
|
||||
buttonText="去完善资料"
|
||||
bind:action="onGoProfile"
|
||||
/>
|
||||
|
||||
<!-- 运势内容 -->
|
||||
<view wx:else class="content">
|
||||
<!-- 星座大卡 -->
|
||||
<view class="card horo-card">
|
||||
<view class="horo-top">
|
||||
<view class="zodiac">
|
||||
<view class="icon icon-sparkles zodiac-icon"></view>
|
||||
<text class="zodiac-name">{{horoscope.zodiac}}</text>
|
||||
</view>
|
||||
<view class="horo-date">
|
||||
<text>{{statDate}}</text>
|
||||
<text class="nav-action" bindtap="onGoProfile">去设置生日</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="overall">
|
||||
<text class="overall-label">综合运势</text>
|
||||
<view class="overall-score">
|
||||
<text class="score-num">{{horoscope.overall_score}}.0</text>
|
||||
<view class="stars">
|
||||
<view
|
||||
class="icon star {{index < horoscope.overall_score ? 'icon-star-fill on' : 'icon-star'}}"
|
||||
wx:for="{{[1, 2, 3, 4, 5]}}"
|
||||
wx:key="*this"
|
||||
></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 三项评分 -->
|
||||
<view class="scores">
|
||||
<view class="score-row">
|
||||
<text class="score-label">爱情运势</text>
|
||||
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.love_score * 20}}%"></view></view>
|
||||
<text class="score-value">{{horoscope.love_score}}/5</text>
|
||||
</view>
|
||||
<view class="score-row">
|
||||
<text class="score-label">工作运势</text>
|
||||
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.career_score * 20}}%"></view></view>
|
||||
<text class="score-value">{{horoscope.career_score}}/5</text>
|
||||
</view>
|
||||
<view class="score-row">
|
||||
<text class="score-label">财富运势</text>
|
||||
<view class="score-bar"><view class="score-fill" style="width: {{horoscope.wealth_score * 20}}%"></view></view>
|
||||
<text class="score-value">{{horoscope.wealth_score}}/5</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="daily-text">
|
||||
<text class="daily-label">今日运势</text>
|
||||
<text class="daily-content">{{horoscope.daily_text}}</text>
|
||||
</view>
|
||||
|
||||
<view class="lucky-row">
|
||||
<view class="lucky-card">
|
||||
<view class="lucky-dot" style="background: {{luckyColorHex}}"></view>
|
||||
<text class="lucky-label">幸运色 · {{horoscope.lucky_color}}</text>
|
||||
</view>
|
||||
<view class="lucky-card">
|
||||
<text class="lucky-number">{{horoscope.lucky_number}}</text>
|
||||
<text class="lucky-label">幸运数字</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="gen-btn btn btn-outline" bindtap="onGenerate">生成今日推荐穿搭</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -0,0 +1,212 @@
|
||||
.page-flex {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.nav-action {
|
||||
font-size: 26rpx;
|
||||
color: #007AFF;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.sk-wrap {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.sk-big {
|
||||
height: 560rpx;
|
||||
border-radius: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.sk-mid {
|
||||
height: 200rpx;
|
||||
border-radius: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.horo-card {
|
||||
background: #E8F2FF;
|
||||
}
|
||||
|
||||
.horo-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.zodiac {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.zodiac-icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
color: #007AFF;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.zodiac-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.horo-date {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.overall {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.overall-label {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.overall-score {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.score-num {
|
||||
font-size: 120rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
line-height: 1;
|
||||
margin-right: 32rpx;
|
||||
}
|
||||
|
||||
.stars {
|
||||
display: flex;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.star {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.star.on {
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.scores {
|
||||
margin-top: 32rpx;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.score-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
width: 160rpx;
|
||||
font-size: 26rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
flex: 1;
|
||||
height: 12rpx;
|
||||
background: #F2F2F7;
|
||||
border-radius: 6rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-fill {
|
||||
height: 100%;
|
||||
background: #007AFF;
|
||||
border-radius: 6rpx;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
width: 80rpx;
|
||||
text-align: right;
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.daily-text {
|
||||
margin-top: 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.daily-label {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.daily-content {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #3C3C43;
|
||||
margin-top: 12rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lucky-row {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.lucky-card {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lucky-dot {
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
border-radius: 50%;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.lucky-number {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.lucky-label {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.gen-btn {
|
||||
margin-top: 24rpx;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { request } from '../../utils/request'
|
||||
import * as auth from '../../utils/auth'
|
||||
import { STORAGE_KEYS, QuotaType } from '../../utils/config'
|
||||
import { LoginResult, UserInfo, QuotaItem } from '../../types/models'
|
||||
import { formatDate } from '../../utils/format'
|
||||
|
||||
interface GiftItem {
|
||||
icon: string
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
loading: false,
|
||||
userInfo: {} as UserInfo,
|
||||
giftVisible: false,
|
||||
giftItems: [] as GiftItem[],
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
if (auth.isLoggedIn()) {
|
||||
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
|
||||
}
|
||||
},
|
||||
|
||||
onLogin() {
|
||||
if (this.data.loading) return
|
||||
this.setData({ loading: true })
|
||||
|
||||
wx.login({
|
||||
success: (res) => {
|
||||
if (!res.code) {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
request<LoginResult>({
|
||||
url: '/user/wxlogin',
|
||||
method: 'POST',
|
||||
data: { code: res.code },
|
||||
auth: false,
|
||||
})
|
||||
.then((data) => {
|
||||
auth.setTokens({
|
||||
accessToken: data.access_token,
|
||||
refreshToken: data.refresh_token,
|
||||
expireTime: data.expire_time,
|
||||
})
|
||||
wx.setStorageSync(STORAGE_KEYS.USER_INFO, data.user_info)
|
||||
this.setData({ userInfo: data.user_info })
|
||||
if (data.user_info.is_new_user) {
|
||||
this.showWelcomeGift(data.user_info)
|
||||
} else if (!data.user_info.birth_day) {
|
||||
this.showBirthGuide()
|
||||
} else {
|
||||
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.setData({ loading: false })
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
this.setData({ loading: false })
|
||||
wx.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
showWelcomeGift(userInfo: UserInfo) {
|
||||
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
|
||||
.then((res) => this.setData({ giftItems: this.buildGift(userInfo, res.quotas), giftVisible: true }))
|
||||
.catch(() => this.setData({ giftItems: this.buildGift(userInfo, []), giftVisible: true }))
|
||||
},
|
||||
|
||||
buildGift(userInfo: UserInfo, quotas: QuotaItem[]): GiftItem[] {
|
||||
const total = (t: number) => {
|
||||
const q = quotas.find((it) => it.quota_type === t)
|
||||
return q ? q.total : 0
|
||||
}
|
||||
const items: GiftItem[] = []
|
||||
if (userInfo.vip_type === 4 && userInfo.vip_expire) {
|
||||
items.push({ icon: 'icon-crown', name: '体验会员', value: `有效期至 ${formatDate(userInfo.vip_expire)}` })
|
||||
}
|
||||
items.push({ icon: 'icon-camera', name: '上传额度', value: `${total(QuotaType.UPLOAD)} 次 · 永久` })
|
||||
items.push({ icon: 'icon-sparkles', name: 'AI 推荐额度', value: `${total(QuotaType.AI)} 次/天` })
|
||||
items.push({ icon: 'icon-shirt', name: '搭配分享额度', value: `${total(QuotaType.OUTFIT_SHARE)} 次/天` })
|
||||
items.push({ icon: 'icon-hanger', name: '衣柜分享额度', value: `${total(QuotaType.CLOSET_SHARE)} 次/天` })
|
||||
return items
|
||||
},
|
||||
|
||||
onGiftConfirm() {
|
||||
this.setData({ giftVisible: false })
|
||||
if (!this.data.userInfo.birth_day) {
|
||||
this.showBirthGuide()
|
||||
} else {
|
||||
wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
|
||||
}
|
||||
},
|
||||
|
||||
showBirthGuide() {
|
||||
wx.showModal({
|
||||
title: '完善个人信息',
|
||||
content: '设置生日后即可查看今日星座运势',
|
||||
confirmText: '去完善',
|
||||
cancelText: '稍后再说',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.switchTab({ url: '/pages/profile/profile' })
|
||||
else wx.switchTab({ url: '/pages/wardrobe/wardrobe' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
openAgreement() {
|
||||
wx.showToast({ title: '协议内容敬请期待', icon: 'none' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
<view class="login-page">
|
||||
<!-- 顶部插画区 -->
|
||||
<view class="hero">
|
||||
<view class="icon hero-icon hero-1 icon-hanger"></view>
|
||||
<view class="icon hero-icon hero-2 icon-shirt"></view>
|
||||
<view class="icon hero-icon hero-3 icon-sparkles"></view>
|
||||
<view class="icon hero-icon hero-4 icon-camera"></view>
|
||||
<view class="icon hero-icon hero-5 icon-star"></view>
|
||||
</view>
|
||||
|
||||
<!-- Logo 与产品名 -->
|
||||
<view class="brand">
|
||||
<view class="brand-logo">
|
||||
<view class="icon icon-hanger"></view>
|
||||
</view>
|
||||
<view class="brand-name">纸条AI衣橱</view>
|
||||
<view class="brand-slogan">把你的衣橱,交给 AI 打理</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部登录区 -->
|
||||
<view class="login-footer">
|
||||
<view class="btn btn-primary login-btn {{loading ? 'disabled' : ''}}" bindtap="onLogin">
|
||||
<view class="wechat-icon"></view>
|
||||
<text>{{loading ? '登录中…' : '微信一键登录'}}</text>
|
||||
</view>
|
||||
<view class="agreement">
|
||||
<text>登录即代表同意</text>
|
||||
<text class="link" bindtap="openAgreement">《用户协议》</text>
|
||||
<text>和</text>
|
||||
<text class="link" bindtap="openAgreement">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 新手礼包弹窗 -->
|
||||
<view wx:if="{{giftVisible}}" class="gift-mask">
|
||||
<view class="gift-card">
|
||||
<view class="gift-badge"><view class="icon icon-sparkles"></view></view>
|
||||
<view class="gift-title">欢迎加入纸条AI衣橱</view>
|
||||
<view class="gift-sub">新人礼包已到账,请查收</view>
|
||||
<view class="gift-list">
|
||||
<view class="gift-item" wx:for="{{giftItems}}" wx:key="name">
|
||||
<view class="gift-item-icon icon {{item.icon}}"></view>
|
||||
<text class="gift-item-name">{{item.name}}</text>
|
||||
<text class="gift-item-val">{{item.value}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="btn btn-primary gift-btn" bindtap="onGiftConfirm">开心收下</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,194 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 顶部插画区 */
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 480rpx;
|
||||
background: #E8F2FF;
|
||||
border-radius: 0 0 48rpx 48rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-icon {
|
||||
position: absolute;
|
||||
color: #007AFF;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.hero-1 { width: 140rpx; height: 140rpx; left: 60rpx; top: 120rpx; transform: rotate(-12deg); }
|
||||
.hero-2 { width: 120rpx; height: 120rpx; right: 80rpx; top: 80rpx; transform: rotate(10deg); color: #5856D6; }
|
||||
.hero-3 { width: 100rpx; height: 100rpx; left: 160rpx; top: 280rpx; transform: rotate(8deg); }
|
||||
.hero-4 { width: 110rpx; height: 110rpx; right: 170rpx; top: 260rpx; transform: rotate(-6deg); color: #5856D6; }
|
||||
.hero-5 { width: 70rpx; height: 70rpx; right: 60rpx; top: 340rpx; transform: rotate(15deg); }
|
||||
|
||||
/* 品牌区 */
|
||||
.brand {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 64rpx;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 32rpx;
|
||||
box-shadow: 0 16rpx 40rpx rgba(0, 122, 255, 0.25);
|
||||
}
|
||||
|
||||
.brand-logo .icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.brand-slogan {
|
||||
font-size: 28rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
/* 底部 */
|
||||
.login-footer {
|
||||
padding: 0 64rpx;
|
||||
padding-bottom: calc(64rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wechat-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
margin-right: 12rpx;
|
||||
background-color: #fff;
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M9.5 3C5.36 3 2 5.69 2 9c0 1.87 1.03 3.54 2.62 4.65L4 16.5l3.27-1.72c.73.2 1.47.3 2.23.3h.5A6.2 6.2 0 0 1 9 12c0-3.3 3.36-6 7.5-6h.5A5.7 5.7 0 0 0 9.5 3z'/%3E%3Cpath d='M22 12c0-2.76-2.69-5-6-5s-6 2.24-6 5 2.69 5 6 5c.72 0 1.42-.1 2.06-.28L20.5 18l-.42-2.6c1.17-.88 1.92-2.06 1.92-3.4z'/%3E%3C/svg%3E") no-repeat center / 100% 100%;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M9.5 3C5.36 3 2 5.69 2 9c0 1.87 1.03 3.54 2.62 4.65L4 16.5l3.27-1.72c.73.2 1.47.3 2.23.3h.5A6.2 6.2 0 0 1 9 12c0-3.3 3.36-6 7.5-6h.5A5.7 5.7 0 0 0 9.5 3z'/%3E%3Cpath d='M22 12c0-2.76-2.69-5-6-5s-6 2.24-6 5 2.69 5 6 5c.72 0 1.42-.1 2.06-.28L20.5 18l-.42-2.6c1.17-.88 1.92-2.06 1.92-3.4z'/%3E%3C/svg%3E") no-repeat center / 100% 100%;
|
||||
}
|
||||
|
||||
.agreement {
|
||||
margin-top: 24rpx;
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
/* 新手礼包弹窗 */
|
||||
.gift-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 300;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.gift-card {
|
||||
width: 560rpx;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
padding: 48rpx 40rpx 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.gift-badge {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FF9500, #FF2D55);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.gift-badge .icon {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
}
|
||||
|
||||
.gift-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.gift-sub {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 8rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.gift-list {
|
||||
width: 100%;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.gift-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 22rpx 0;
|
||||
border-bottom: 1rpx solid #F2F2F7;
|
||||
}
|
||||
|
||||
.gift-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gift-item-icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
color: #007AFF;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gift-item-name {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.gift-item-val {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.gift-btn {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { QuotaItem, UserInfo, GoodsItem, QuotaFlowItem, PagedResult, VirtualPayResult, OrderItem } from '../../types/models'
|
||||
import { QUOTA_NAMES, GoodsType, GOODS_TYPE_NAMES } from '../../utils/config'
|
||||
import { fenToYuan, formatDate, formatDateTime } from '../../utils/format'
|
||||
|
||||
const GOODS_ICONS: Record<number, string> = {
|
||||
[GoodsType.MEMBER]: 'icon-crown',
|
||||
[GoodsType.UPLOAD]: 'icon-camera',
|
||||
[GoodsType.AI]: 'icon-sparkles',
|
||||
[GoodsType.CLOSET_SHARE]: 'icon-hanger',
|
||||
[GoodsType.OUTFIT_SHARE]: 'icon-shirt',
|
||||
}
|
||||
|
||||
const GOODS_FILTERS = [
|
||||
{ name: '全部', value: 0 },
|
||||
{ name: '会员包', value: 1 },
|
||||
{ name: '上传包', value: 2 },
|
||||
{ name: 'AI 推荐包', value: 3 },
|
||||
{ name: '衣柜分享包', value: 4 },
|
||||
{ name: '搭配分享包', value: 5 },
|
||||
]
|
||||
|
||||
type PayPlatform = 'android' | 'ios' | 'harmony' | 'windows'
|
||||
|
||||
// 归一化平台:后端仅接受 android/ios/harmony/windows,devtools/mac 等调试环境默认按 android 处理
|
||||
function normalizePlatform(): PayPlatform {
|
||||
let p = 'android'
|
||||
try {
|
||||
p = wx.getSystemInfoSync().platform
|
||||
} catch (e) {
|
||||
// 获取失败时回退 android,避免请求体缺失导致参数校验失败
|
||||
}
|
||||
if (p === 'ios' || p === 'harmony' || p === 'windows') return p
|
||||
return 'android'
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
view: 'mall' as 'mall' | 'flow',
|
||||
userInfo: {} as UserInfo,
|
||||
isVip: false,
|
||||
vipExpireText: '',
|
||||
quotas: [] as QuotaItem[],
|
||||
goodsFilters: GOODS_FILTERS,
|
||||
activeFilter: 0,
|
||||
goods: [] as GoodsItem[],
|
||||
flowList: [] as QuotaFlowItem[],
|
||||
goodsIcons: GOODS_ICONS,
|
||||
orderVisible: false,
|
||||
orderGoods: null as GoodsItem | null,
|
||||
loading: true,
|
||||
},
|
||||
|
||||
onLoad(query: Record<string, string>) {
|
||||
if (query.tab === 'flow') this.setData({ view: 'flow' })
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.fetchUser()
|
||||
this.fetchQuota()
|
||||
this.fetchGoods()
|
||||
if (this.data.view === 'flow') this.fetchFlow()
|
||||
},
|
||||
|
||||
fetchUser() {
|
||||
request<UserInfo>({ url: '/user/info', showError: false })
|
||||
.then((user) => {
|
||||
const isVip = user.vip_type !== 1
|
||||
this.setData({
|
||||
userInfo: user,
|
||||
isVip,
|
||||
vipExpireText: isVip && user.vip_expire ? `有效期至 ${formatDate(user.vip_expire)}` : '',
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
fetchQuota() {
|
||||
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
|
||||
.then((res) => {
|
||||
const quotas = res.quotas.map((q) => ({
|
||||
...q,
|
||||
_name: QUOTA_NAMES[q.quota_type] || '额度',
|
||||
_percent: q.total > 0 ? Math.round((q.used / q.total) * 100) : 0,
|
||||
}))
|
||||
this.setData({ quotas })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
fetchGoods() {
|
||||
const params: Record<string, any> = { page: 1, page_size: 50 }
|
||||
const filter = this.data.activeFilter
|
||||
if (filter > 0) params.goods_type = filter
|
||||
request<PagedResult<GoodsItem>>({ url: '/goods', data: params, auth: false })
|
||||
.then((res) => {
|
||||
const goods = res.list.map((g) => ({
|
||||
...g,
|
||||
_icon: GOODS_ICONS[g.goods_type] || 'icon-crown',
|
||||
_typeName: GOODS_TYPE_NAMES[g.goods_type] || '',
|
||||
_priceText: fenToYuan(g.discount_price),
|
||||
_originText: fenToYuan(g.price),
|
||||
}))
|
||||
this.setData({ goods, loading: false })
|
||||
})
|
||||
.catch(() => this.setData({ loading: false }))
|
||||
},
|
||||
|
||||
fetchFlow() {
|
||||
request<PagedResult<QuotaFlowItem>>({ url: '/quota/flow', data: { page: 1, page_size: 50 } })
|
||||
.then((res) => {
|
||||
const list = res.list.map((it) => ({
|
||||
...it,
|
||||
_time: formatDateTime(it.created_at),
|
||||
_sign: it.change_num > 0 ? `+${it.change_num}` : `${it.change_num}`,
|
||||
_name: QUOTA_NAMES[it.quota_type] || '额度',
|
||||
}))
|
||||
this.setData({ flowList: list })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onGoodsFilter(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index)
|
||||
if (index === this.data.activeFilter) return
|
||||
this.setData({ activeFilter: index, loading: true })
|
||||
this.fetchGoods()
|
||||
},
|
||||
|
||||
onTapMember() {
|
||||
this.setData({ activeFilter: 1, loading: true })
|
||||
this.fetchGoods()
|
||||
},
|
||||
|
||||
onSwitchMall() {
|
||||
this.setData({ view: 'mall' })
|
||||
},
|
||||
onSwitchFlow() {
|
||||
this.setData({ view: 'flow' })
|
||||
this.fetchFlow()
|
||||
},
|
||||
|
||||
onBuy(e: WechatMiniprogram.TouchEvent) {
|
||||
const goods = e.currentTarget.dataset.goods as GoodsItem
|
||||
this.setData({ orderGoods: goods, orderVisible: true })
|
||||
},
|
||||
|
||||
onCloseOrder() {
|
||||
this.setData({ orderVisible: false })
|
||||
},
|
||||
noop() {},
|
||||
|
||||
confirmPay() {
|
||||
if (!this.data.orderGoods) return
|
||||
request<VirtualPayResult>({
|
||||
url: '/order/virtual',
|
||||
method: 'POST',
|
||||
data: {
|
||||
goods_code: this.data.orderGoods.goods_code,
|
||||
platform: normalizePlatform(),
|
||||
},
|
||||
})
|
||||
.then((res) => {
|
||||
this.setData({ orderVisible: false })
|
||||
this.doPayment(res)
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
doPayment(res: VirtualPayResult) {
|
||||
wx.requestVirtualPayment({
|
||||
signData: res.sign_data,
|
||||
paySig: res.pay_sig,
|
||||
signature: res.signature,
|
||||
mode: 'short_series_goods',
|
||||
success: () => {
|
||||
// 拉起成功仅代表进入支付页,最终到账以后端发货推送为准,轮询订单确认
|
||||
this.pollOrder(res.order_no)
|
||||
},
|
||||
fail: () => {},
|
||||
})
|
||||
},
|
||||
|
||||
pollOrder(orderNo: string) {
|
||||
let count = 0
|
||||
const timer = setInterval(() => {
|
||||
count++
|
||||
request<PagedResult<OrderItem>>({ url: '/order', data: { page: 1, page_size: 50 }, showError: false })
|
||||
.then((res) => {
|
||||
const order = res.list.find((it) => it.order_no === orderNo)
|
||||
if (order && order.order_status === 2) {
|
||||
clearInterval(timer)
|
||||
wx.showToast({ title: '支付成功', icon: 'success' })
|
||||
this.fetchQuota()
|
||||
this.fetchUser()
|
||||
} else if (order && (order.order_status === 3 || order.order_status === 4 || order.order_status === 5)) {
|
||||
clearInterval(timer)
|
||||
const msg = order.order_status === 5 ? '订单已退款' : '订单未支付成功'
|
||||
wx.showToast({ title: msg, icon: 'none' })
|
||||
} else if (count >= 10) {
|
||||
this.finishPayTimeout(timer)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (count >= 10) this.finishPayTimeout(timer)
|
||||
})
|
||||
}, 1500)
|
||||
},
|
||||
|
||||
finishPayTimeout(timer: ReturnType<typeof setInterval>) {
|
||||
clearInterval(timer)
|
||||
wx.showToast({ title: '到账可能有延迟,请稍后查看', icon: 'none' })
|
||||
this.fetchQuota()
|
||||
this.fetchUser()
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
<view class="page mall-page">
|
||||
<navigation-bar title="额度与会员" />
|
||||
|
||||
<!-- 商城视图 -->
|
||||
<scroll-view wx:if="{{view === 'mall'}}" scroll-y class="scroll-body">
|
||||
<!-- 会员卡 -->
|
||||
<view class="card member-card">
|
||||
<view class="member-left">
|
||||
<view class="icon icon-crown member-icon"></view>
|
||||
<view>
|
||||
<view class="member-name">{{isVip ? '月度会员' : '开通会员'}}</view>
|
||||
<view class="member-desc">{{isVip ? vipExpireText : '开通会员,解锁更多 AI 与上传权益'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="member-btn" bindtap="onTapMember">{{isVip ? '立即续费' : '立即开通'}}</view>
|
||||
</view>
|
||||
|
||||
<!-- 额度网格 -->
|
||||
<view class="quota-grid">
|
||||
<view class="quota-cell" wx:for="{{quotas}}" wx:key="quota_type">
|
||||
<view class="quota-name">{{item._name}}</view>
|
||||
<view class="quota-num">{{item.remain}}<text class="quota-total">/ {{item.total}}</text></view>
|
||||
<view class="quota-bar">
|
||||
<view class="quota-fill {{item._percent >= 80 ? 'warn' : ''}}" style="width: {{item._percent}}%"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 额度明细入口 -->
|
||||
<view class="card flow-entry" bindtap="onSwitchFlow">
|
||||
<text>额度明细</text>
|
||||
<view class="icon icon-chevron-right"></view>
|
||||
</view>
|
||||
|
||||
<!-- 商品筛选 -->
|
||||
<scroll-view scroll-x class="goods-filter" show-scrollbar="{{false}}">
|
||||
<view class="filter-row">
|
||||
<view
|
||||
wx:for="{{goodsFilters}}"
|
||||
wx:key="name"
|
||||
class="filter-item {{activeFilter === index ? 'active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="onGoodsFilter"
|
||||
>{{item.name}}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 商品列表 -->
|
||||
<view class="goods-list">
|
||||
<view class="goods-card card" wx:for="{{goods}}" wx:key="goods_code">
|
||||
<view class="icon {{item._icon}} goods-icon"></view>
|
||||
<view class="goods-info">
|
||||
<view class="goods-name">{{item.goods_name}}</view>
|
||||
<view class="goods-desc">{{item.description}}</view>
|
||||
</view>
|
||||
<view class="goods-right">
|
||||
<view class="goods-price num">{{item._priceText}}</view>
|
||||
<view class="goods-origin num" wx:if="{{item.price > item.discount_price}}">{{item._originText}}</view>
|
||||
<view class="buy-btn" data-goods="{{item}}" bindtap="onBuy">购买</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="footer-space"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 额度明细视图 -->
|
||||
<scroll-view wx:else scroll-y class="scroll-body">
|
||||
<view class="flow-head" bindtap="onSwitchMall">← 返回商城</view>
|
||||
<view class="flow-list">
|
||||
<view class="flow-row card" wx:for="{{flowList}}" wx:key="quota_flow_uuid">
|
||||
<view class="flow-left">
|
||||
<view class="flow-reason">{{item.reason || item._name}}</view>
|
||||
<view class="flow-time">{{item._time}}</view>
|
||||
</view>
|
||||
<view class="flow-right">
|
||||
<view class="flow-num {{item.change_num > 0 ? 'plus' : ''}}">{{item._sign}}</view>
|
||||
<view class="flow-after">剩余 {{item.new_value}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<empty-state wx:if="{{!flowList.length}}" icon="icon-doc" title="暂无额度明细" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- 订单确认弹窗 -->
|
||||
<view wx:if="{{orderVisible}}" class="mask" bindtap="onCloseOrder">
|
||||
<view class="sheet" catchtap="noop">
|
||||
<view class="sheet-grabber"></view>
|
||||
<view class="order-goods">{{orderGoods.goods_name}}</view>
|
||||
<view class="order-desc">{{orderGoods.description}}</view>
|
||||
<view class="order-amount">实付 <text class="order-price num">{{orderGoods._priceText}}</text></view>
|
||||
<view class="btn btn-primary" bindtap="confirmPay">立即支付</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,330 @@
|
||||
.mall-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* 会员卡 */
|
||||
.member-card {
|
||||
margin: 24rpx;
|
||||
background: #E8F2FF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.member-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.member-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
color: #007AFF;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.member-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.member-btn {
|
||||
height: 64rpx;
|
||||
padding: 0 32rpx;
|
||||
border: 1rpx solid #007AFF;
|
||||
border-radius: 32rpx;
|
||||
color: #007AFF;
|
||||
font-size: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 额度网格 */
|
||||
.quota-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 0 24rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.quota-cell {
|
||||
width: calc(50% - 10rpx);
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.quota-name {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.quota-num {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.quota-total {
|
||||
font-size: 24rpx;
|
||||
color: #C7C7CC;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.quota-bar {
|
||||
height: 10rpx;
|
||||
background: #F2F2F7;
|
||||
border-radius: 5rpx;
|
||||
margin-top: 16rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quota-fill {
|
||||
height: 100%;
|
||||
background: #007AFF;
|
||||
border-radius: 5rpx;
|
||||
}
|
||||
|
||||
.quota-fill.warn {
|
||||
background: #FF9500;
|
||||
}
|
||||
|
||||
.flow-entry {
|
||||
margin: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.flow-entry .icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
/* 商品筛选 */
|
||||
.goods-filter {
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: inline-flex;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28rpx;
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
border-radius: 28rpx;
|
||||
font-size: 24rpx;
|
||||
color: #3C3C43;
|
||||
background: #F2F2F7;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 商品列表 */
|
||||
.goods-list {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.goods-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.goods-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
color: #007AFF;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.goods-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.goods-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.goods-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.goods-price {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.goods-origin {
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
margin-top: 12rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 32rpx;
|
||||
border-radius: 28rpx;
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-space {
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
/* 额度明细视图 */
|
||||
.flow-head {
|
||||
padding: 24rpx;
|
||||
font-size: 26rpx;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.flow-list {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.flow-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.flow-reason {
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.flow-time {
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.flow-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.flow-num {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.flow-num.plus {
|
||||
color: #34C759;
|
||||
}
|
||||
|
||||
.flow-after {
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
/* 订单弹窗 */
|
||||
.mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 20rpx 32rpx;
|
||||
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.sheet-grabber {
|
||||
width: 72rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 5rpx;
|
||||
background: #E5E5EA;
|
||||
margin: 0 auto 24rpx;
|
||||
}
|
||||
|
||||
.order-goods {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.order-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
text-align: center;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
margin: 32rpx 0;
|
||||
}
|
||||
|
||||
.order-price {
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: #FF9500;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { NotificationItem } from '../../types/models'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
uuid: '',
|
||||
item: {} as NotificationItem,
|
||||
time: '',
|
||||
},
|
||||
|
||||
onLoad(query: Record<string, string>) {
|
||||
const uuid = query.uuid || ''
|
||||
this.setData({ uuid })
|
||||
if (uuid) {
|
||||
this.fetchDetail()
|
||||
request({ url: `/notification/${uuid}/read`, method: 'POST', showError: false }).catch(() => {})
|
||||
}
|
||||
},
|
||||
|
||||
fetchDetail() {
|
||||
request<NotificationItem>({ url: `/notification/${this.data.uuid}` })
|
||||
.then((item) => this.setData({ item, time: formatDateTime(item.created_at) }))
|
||||
.catch(() => {})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
<view class="page detail-page">
|
||||
<navigation-bar title="通知详情" />
|
||||
|
||||
<view class="card detail-card">
|
||||
<view class="icon-row">
|
||||
<view class="n-icon {{item.msg_type === 1 ? 'like' : item.msg_type === 2 ? 'order' : 'sys'}}">
|
||||
<view class="icon {{item.msg_type === 1 ? 'icon-heart' : item.msg_type === 2 ? 'icon-doc' : 'icon-bell'}}"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-title">{{item.title}}</view>
|
||||
<view class="detail-time">{{time}}</view>
|
||||
<view class="detail-content">{{item.content}}</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,65 @@
|
||||
.detail-page {
|
||||
padding: 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
margin-top: 24rpx;
|
||||
padding: 48rpx 40rpx;
|
||||
}
|
||||
|
||||
.icon-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.n-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.n-icon.like {
|
||||
background: #FFE8EC;
|
||||
color: #FF3B30;
|
||||
}
|
||||
|
||||
.n-icon.order {
|
||||
background: #E8F2FF;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.n-icon.sys {
|
||||
background: #F2F2F7;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.n-icon .icon {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.detail-time {
|
||||
font-size: 24rpx;
|
||||
color: #C7C7CC;
|
||||
text-align: center;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
font-size: 28rpx;
|
||||
color: #3C3C43;
|
||||
margin-top: 32rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { PagedResult, NotificationItem } from '../../types/models'
|
||||
import { formatRelativeTime } from '../../utils/format'
|
||||
|
||||
const FILTERS = [
|
||||
{ name: '全部', value: 0 },
|
||||
{ name: '点赞', value: 1 },
|
||||
{ name: '订单', value: 2 },
|
||||
{ name: '系统', value: 3 },
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
filters: FILTERS,
|
||||
activeFilter: 0,
|
||||
list: [] as NotificationItem[],
|
||||
loading: true,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.refresh()
|
||||
this.fetchUnread()
|
||||
},
|
||||
|
||||
fetchUnread() {
|
||||
request<{ unread_count: number }>({ url: '/notification/unread', showError: false })
|
||||
.then((res) => {
|
||||
wx.setStorageSync('zt_closet_unread', res.unread_count)
|
||||
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
|
||||
if (tabBar && tabBar.refreshUnread) tabBar.refreshUnread()
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
|
||||
this.fetchList(1)
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
if (!this.data.hasMore || this.data.loading) return
|
||||
this.fetchList(this.data.page + 1)
|
||||
},
|
||||
|
||||
fetchList(page: number) {
|
||||
const params: Record<string, any> = { page, page_size: this.data.pageSize }
|
||||
const filter = this.data.activeFilter
|
||||
if (filter > 0) params.msg_type = filter
|
||||
|
||||
request<PagedResult<NotificationItem>>({ url: '/notification', data: params })
|
||||
.then((res) => {
|
||||
const list = res.list.map((it) => ({ ...it, _time: formatRelativeTime(it.created_at) }))
|
||||
const merged = page === 1 ? list : this.data.list.concat(list)
|
||||
this.setData({
|
||||
list: merged,
|
||||
hasMore: merged.length < res.total,
|
||||
loading: false,
|
||||
page,
|
||||
})
|
||||
})
|
||||
.catch(() => this.setData({ loading: false }))
|
||||
},
|
||||
|
||||
onFilterTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index)
|
||||
if (index === this.data.activeFilter) return
|
||||
this.setData({ activeFilter: index, list: [], loading: true })
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
onItemTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const uuid = e.currentTarget.dataset.uuid as string
|
||||
wx.navigateTo({ url: `/pages/notification-detail/notification-detail?uuid=${uuid}` })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="通知" />
|
||||
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="scroll-body"
|
||||
bindscrolltolower="onReachBottom"
|
||||
>
|
||||
<!-- 筛选 -->
|
||||
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
|
||||
<view class="filter-row">
|
||||
<view
|
||||
wx:for="{{filters}}"
|
||||
wx:key="name"
|
||||
class="filter-item {{activeFilter === index ? 'active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="onFilterTap"
|
||||
>{{item.name}}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<empty-state
|
||||
wx:if="{{!loading && !list.length}}"
|
||||
icon="icon-bell"
|
||||
title="还没有消息"
|
||||
desc="穿搭被点赞、订单动态都会出现在这里"
|
||||
/>
|
||||
|
||||
<!-- 列表 -->
|
||||
<view class="list">
|
||||
<view
|
||||
class="n-card card {{item.is_read === 1 ? 'unread' : ''}}"
|
||||
wx:for="{{list}}"
|
||||
wx:key="notification_uuid"
|
||||
data-uuid="{{item.notification_uuid}}"
|
||||
bindtap="onItemTap"
|
||||
>
|
||||
<view class="n-icon {{item.msg_type === 1 ? 'like' : item.msg_type === 2 ? 'order' : 'sys'}}">
|
||||
<view class="icon {{item.msg_type === 1 ? 'icon-heart' : item.msg_type === 2 ? 'icon-doc' : 'icon-bell'}}"></view>
|
||||
</view>
|
||||
<view class="n-body">
|
||||
<view class="n-title-row">
|
||||
<view wx:if="{{item.is_read === 1}}" class="n-dot"></view>
|
||||
<text class="n-title {{item.is_read === 2 ? 'read' : ''}}">{{item.title}}</text>
|
||||
</view>
|
||||
<view class="n-content">{{item.content}}</view>
|
||||
<view class="n-time">{{item._time}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -0,0 +1,126 @@
|
||||
.page-flex {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: inline-flex;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
flex-shrink: 0;
|
||||
padding: 0 32rpx;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #3C3C43;
|
||||
background: #F2F2F7;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.n-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.n-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.n-icon.like {
|
||||
background: #FFE8EC;
|
||||
color: #FF3B30;
|
||||
}
|
||||
|
||||
.n-icon.order {
|
||||
background: #E8F2FF;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.n-icon.sys {
|
||||
background: #F2F2F7;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.n-icon .icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
|
||||
.n-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.n-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.n-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #007AFF;
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.n-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.n-title.read {
|
||||
color: #8E8E93;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.n-content {
|
||||
font-size: 26rpx;
|
||||
color: #3C3C43;
|
||||
margin-top: 8rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.n-time {
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { PagedResult, OrderItem } from '../../types/models'
|
||||
import { fenToYuan, formatDateTime, formatCountdown } from '../../utils/format'
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ name: '全部', value: 0 },
|
||||
{ name: '待支付', value: 1 },
|
||||
{ name: '已支付', value: 2 },
|
||||
{ name: '已取消', value: 3 },
|
||||
{ name: '已过期', value: 4 },
|
||||
{ name: '已退款', value: 5 },
|
||||
]
|
||||
|
||||
const STATUS_TEXT: Record<number, string> = { 1: '待支付', 2: '已支付', 3: '已取消', 4: '已过期', 5: '已退款' }
|
||||
const STATUS_TYPE: Record<number, string> = { 1: 'orange', 2: 'green', 3: 'gray', 4: 'gray', 5: 'blue' }
|
||||
|
||||
Page({
|
||||
data: {
|
||||
filters: STATUS_FILTERS,
|
||||
activeFilter: 0,
|
||||
list: [] as OrderItem[],
|
||||
loading: true,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
|
||||
this.fetchList(1)
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
if (!this.data.hasMore || this.data.loading) return
|
||||
this.fetchList(this.data.page + 1)
|
||||
},
|
||||
|
||||
fetchList(page: number) {
|
||||
const params: Record<string, any> = { page, page_size: this.data.pageSize }
|
||||
const filter = this.data.activeFilter
|
||||
if (filter > 0) params.status = filter
|
||||
|
||||
request<PagedResult<OrderItem>>({ url: '/order', data: params })
|
||||
.then((res) => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const list = res.list.map((it) => ({
|
||||
...it,
|
||||
_statusText: STATUS_TEXT[it.order_status] || '',
|
||||
_statusType: STATUS_TYPE[it.order_status] || 'gray',
|
||||
_amount: fenToYuan(it.pay_amount),
|
||||
_time: formatDateTime(it.created_at),
|
||||
_orderTail: `…${it.order_no.slice(-4)}`,
|
||||
_countdown: it.order_status === 1 && it.expire_time > now ? formatCountdown(it.expire_time - now) : '',
|
||||
_benefit: it.member_days > 0 ? `${it.member_days} 天会员` : '',
|
||||
_canCancel: it.order_status === 1 && it.pay_channel !== 2,
|
||||
_payChannelText: it.pay_channel === 2 ? '虚拟支付' : '',
|
||||
}))
|
||||
const merged = page === 1 ? list : this.data.list.concat(list)
|
||||
this.setData({
|
||||
list: merged,
|
||||
hasMore: merged.length < res.total,
|
||||
loading: false,
|
||||
page,
|
||||
})
|
||||
})
|
||||
.catch(() => this.setData({ loading: false }))
|
||||
},
|
||||
|
||||
onFilterTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index)
|
||||
if (index === this.data.activeFilter) return
|
||||
this.setData({ activeFilter: index, list: [], loading: true })
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
onCancel(e: WechatMiniprogram.TouchEvent) {
|
||||
const orderNo = e.currentTarget.dataset.no as string
|
||||
wx.showModal({
|
||||
title: '取消订单',
|
||||
content: '确认取消该订单?',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
request({ url: `/order/${orderNo}/cancel`, method: 'POST' })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已取消', icon: 'success' })
|
||||
this.refresh()
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onPay() {
|
||||
wx.showToast({ title: '请返回商城重新下单支付', icon: 'none' })
|
||||
},
|
||||
|
||||
onRefund(e: WechatMiniprogram.TouchEvent) {
|
||||
const orderNo = e.currentTarget.dataset.no as string
|
||||
wx.showModal({
|
||||
title: '申请退款',
|
||||
editable: true,
|
||||
placeholderText: '请填写退款原因',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
const reason = (res.content || '').trim()
|
||||
if (!reason) {
|
||||
wx.showToast({ title: '请填写退款原因', icon: 'none' })
|
||||
return
|
||||
}
|
||||
request({ url: '/order/refund', method: 'POST', data: { order_no: orderNo, reason } })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '退款申请已提交,等待审核', icon: 'none' })
|
||||
this.refresh()
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onGoMall() {
|
||||
wx.navigateTo({ url: '/pages/mall/mall' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="我的订单" />
|
||||
|
||||
<scroll-view scroll-y class="scroll-body" bindscrolltolower="onReachBottom">
|
||||
<!-- 筛选 -->
|
||||
<scroll-view scroll-x class="filter-scroll" show-scrollbar="{{false}}">
|
||||
<view class="filter-row">
|
||||
<view
|
||||
wx:for="{{filters}}"
|
||||
wx:key="name"
|
||||
class="filter-item {{activeFilter === index ? 'active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="onFilterTap"
|
||||
>{{item.name}}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<empty-state
|
||||
wx:if="{{!loading && !list.length}}"
|
||||
icon="icon-doc"
|
||||
title="还没有订单"
|
||||
desc=""
|
||||
buttonText="去看看额度包"
|
||||
bind:action="onGoMall"
|
||||
/>
|
||||
|
||||
<!-- 订单列表 -->
|
||||
<view class="list">
|
||||
<view class="order-card card" wx:for="{{list}}" wx:key="order_no">
|
||||
<view class="order-top">
|
||||
<text class="order-no">订单 {{item._orderTail}}</text>
|
||||
<text wx:if="{{item._payChannelText}}" class="tag tag-gray">{{item._payChannelText}}</text>
|
||||
<text class="tag tag-{{item._statusType}}">{{item._statusText}}</text>
|
||||
</view>
|
||||
<view class="order-body">
|
||||
<view class="order-goods">{{item.goods_name}}</view>
|
||||
<view class="order-benefit" wx:if="{{item._benefit}}">{{item._benefit}}</view>
|
||||
<view class="order-bottom">
|
||||
<text class="order-amount">实付 <text class="num">{{item._amount}}</text></text>
|
||||
<view class="order-actions">
|
||||
<block wx:if="{{item.order_status === 1}}">
|
||||
<text wx:if="{{item._countdown}}" class="countdown">剩余 {{item._countdown}}</text>
|
||||
<view wx:if="{{item._canCancel}}" class="action-btn outline" data-no="{{item.order_no}}" bindtap="onCancel">取消订单</view>
|
||||
<view class="action-btn primary" data-no="{{item.order_no}}" bindtap="onPay">去支付</view>
|
||||
</block>
|
||||
<block wx:elif="{{item.order_status === 2}}">
|
||||
<view class="action-btn outline" data-no="{{item.order_no}}" bindtap="onRefund">申请退款</view>
|
||||
</block>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -0,0 +1,122 @@
|
||||
.page-flex {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: inline-flex;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
flex-shrink: 0;
|
||||
padding: 0 28rpx;
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
border-radius: 28rpx;
|
||||
font-size: 24rpx;
|
||||
color: #3C3C43;
|
||||
background: #F2F2F7;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.filter-item.active {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
margin-bottom: 20rpx;
|
||||
padding: 28rpx;
|
||||
}
|
||||
|
||||
.order-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.order-body {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.order-goods {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.order-benefit {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.order-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
font-size: 26rpx;
|
||||
color: #3C3C43;
|
||||
}
|
||||
|
||||
.order-amount .num {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
height: 56rpx;
|
||||
padding: 0 28rpx;
|
||||
border-radius: 28rpx;
|
||||
font-size: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-btn.outline {
|
||||
border: 1rpx solid #C7C7CC;
|
||||
color: #3C3C43;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { OutfitItem, ClothesItem } from '../../types/models'
|
||||
import { OutfitSource, IsPublic } from '../../utils/config'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
uuid: '',
|
||||
isOwner: true,
|
||||
outfit: {} as OutfitItem,
|
||||
clothes: [] as ClothesItem[],
|
||||
isLiked: false,
|
||||
likeCount: 0,
|
||||
isCollected: false,
|
||||
loading: true,
|
||||
},
|
||||
|
||||
onLoad(query: Record<string, string>) {
|
||||
const uuid = query.uuid || ''
|
||||
const isOwner = query.owner !== '0'
|
||||
this.setData({ uuid, isOwner })
|
||||
if (uuid) this.fetchDetail()
|
||||
},
|
||||
|
||||
async fetchDetail() {
|
||||
try {
|
||||
const outfit = await request<OutfitItem>({ url: `/outfit/${this.data.uuid}` })
|
||||
this.setData({ outfit })
|
||||
|
||||
const clothes = await Promise.all(
|
||||
outfit.items.map(async (it) => {
|
||||
try {
|
||||
return await request<ClothesItem>({ url: `/clothes/${it.clothes_uuid}`, showError: false })
|
||||
} catch (e) {
|
||||
return { clothes_uuid: it.clothes_uuid } as ClothesItem
|
||||
}
|
||||
})
|
||||
)
|
||||
this.setData({ clothes })
|
||||
|
||||
const like = await request<{ is_liked: boolean; like_count: number }>({
|
||||
url: `/like/${this.data.uuid}`,
|
||||
showError: false,
|
||||
})
|
||||
this.setData({ isLiked: like.is_liked, likeCount: like.like_count, loading: false })
|
||||
|
||||
// 收藏状态:调用后端 /collect/:targetUUID 初始化,失败静默保持未收藏
|
||||
request<{ is_collected: boolean; collect_count: number }>({
|
||||
url: `/collect/${this.data.uuid}`,
|
||||
showError: false,
|
||||
})
|
||||
.then((collect) => this.setData({ isCollected: collect.is_collected }))
|
||||
.catch(() => {})
|
||||
} catch (e) {
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
onLike() {
|
||||
const action = this.data.isLiked ? 2 : 1
|
||||
request({ url: '/like', method: 'POST', data: { target_uuid: this.data.uuid, action } })
|
||||
.then(() => {
|
||||
this.setData({
|
||||
isLiked: action === 1,
|
||||
likeCount: this.data.likeCount + (action === 1 ? 1 : -1),
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onCollect() {
|
||||
const action = this.data.isCollected ? 2 : 1
|
||||
request({ url: '/collect', method: 'POST', data: { target_uuid: this.data.uuid, action } })
|
||||
.then(() => this.setData({ isCollected: action === 1 }))
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onTogglePublic() {
|
||||
const isPublic = this.data.outfit.is_public === IsPublic.PUBLIC
|
||||
if (isPublic) {
|
||||
this.doPublic(IsPublic.PRIVATE)
|
||||
} else {
|
||||
wx.showModal({
|
||||
title: '公开到社区',
|
||||
content: '公开后将展示在社区供他人点赞收藏,并消耗 1 次社区分享额度',
|
||||
confirmText: '确认公开',
|
||||
success: (res) => {
|
||||
if (res.confirm) this.doPublic(IsPublic.PUBLIC)
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
doPublic(isPublic: number) {
|
||||
request({ url: `/outfit/${this.data.uuid}/public`, method: 'PUT', data: { is_public: isPublic } })
|
||||
.then(() => {
|
||||
this.setData({ 'outfit.is_public': isPublic })
|
||||
wx.showToast({ title: isPublic === IsPublic.PUBLIC ? '已公开' : '已取消公开', icon: 'none' })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onMore() {
|
||||
wx.showActionSheet({
|
||||
itemList: ['编辑穿搭', '删除穿搭'],
|
||||
success: (res) => {
|
||||
if (res.tapIndex === 0) this.onEdit()
|
||||
else this.onDelete()
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onEdit() {
|
||||
wx.navigateTo({ url: `/pages/outfit-edit/outfit-edit?uuid=${this.data.uuid}` })
|
||||
},
|
||||
|
||||
onDelete() {
|
||||
wx.showModal({
|
||||
title: '确认删除这套穿搭?',
|
||||
content: '删除后不可恢复,但不会影响衣橱内的单品。',
|
||||
confirmText: '确认删除',
|
||||
confirmColor: '#FF3B30',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 500)
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onItemTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const uuid = e.currentTarget.dataset.uuid as string
|
||||
wx.navigateTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${uuid}` })
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
return {
|
||||
title: this.data.outfit.outfit_name || '我的穿搭',
|
||||
path: `/pages/outfit-detail/outfit-detail?uuid=${this.data.uuid}&owner=0`,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
<view class="page detail-page">
|
||||
<navigation-bar title="{{outfit.outfit_name || '穿搭详情'}}">
|
||||
<view wx:if="{{isOwner}}" slot="right" class="nav-more" bindtap="onMore">
|
||||
<view class="icon icon-ellipsis"></view>
|
||||
</view>
|
||||
</navigation-bar>
|
||||
|
||||
<scroll-view scroll-y class="scroll-body">
|
||||
<!-- 拼图封面 -->
|
||||
<view class="cover">
|
||||
<view
|
||||
class="cover-item cover-{{index}}"
|
||||
wx:for="{{clothes}}"
|
||||
wx:key="clothes_uuid"
|
||||
wx:if="{{index < 5}}"
|
||||
>
|
||||
<image wx:if="{{item.processed_image}}" class="cover-img" src="{{item.processed_image}}" mode="aspectFit" />
|
||||
<view wx:else class="icon icon-hanger cover-placeholder"></view>
|
||||
</view>
|
||||
<view wx:if="{{!clothes.length}}" class="cover-empty">
|
||||
<view class="icon icon-hanger"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 信息卡片 -->
|
||||
<view class="card info-card">
|
||||
<view class="title-row">
|
||||
<text class="title">{{outfit.outfit_name}}</text>
|
||||
</view>
|
||||
<view class="tag-row">
|
||||
<text wx:if="{{outfit.scene}}" class="tag tag-gray">{{outfit.scene}}</text>
|
||||
<text class="tag {{outfit.source === 2 ? 'tag-indigo' : 'tag-gray'}}">{{outfit.source === 2 ? 'AI 推荐' : '自建'}}</text>
|
||||
<text class="tag {{outfit.is_public === 2 ? 'tag-blue' : 'tag-gray'}}">{{outfit.is_public === 2 ? '已公开' : '仅自己可见'}}</text>
|
||||
</view>
|
||||
<view class="desc" wx:if="{{outfit.outfit_desc}}">{{outfit.outfit_desc}}</view>
|
||||
|
||||
<!-- 包含衣物 -->
|
||||
<view class="items-title">包含衣物</view>
|
||||
<scroll-view scroll-x class="items-scroll" show-scrollbar="{{false}}">
|
||||
<view class="items-row">
|
||||
<view
|
||||
class="item-chip"
|
||||
wx:for="{{clothes}}"
|
||||
wx:key="clothes_uuid"
|
||||
data-uuid="{{item.clothes_uuid}}"
|
||||
bindtap="onItemTap"
|
||||
>
|
||||
<image wx:if="{{item.processed_image}}" class="item-img" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="item-img item-img-empty"><view class="icon icon-hanger"></view></view>
|
||||
<text class="item-name">{{item.name || '未命名'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="footer-placeholder"></view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<view class="footer">
|
||||
<view class="footer-left">
|
||||
<view class="fb-btn" bindtap="onLike">
|
||||
<view class="icon icon-heart {{isLiked ? 'liked' : ''}}"></view>
|
||||
<text class="{{isLiked ? 'liked' : ''}}">{{likeCount}}</text>
|
||||
</view>
|
||||
<view class="fb-btn" wx:if="{{!isOwner}}" bindtap="onCollect">
|
||||
<view class="icon {{isCollected ? 'icon-star-fill collected' : 'icon-star'}}"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="footer-right" wx:if="{{isOwner}}">
|
||||
<view class="fb-public {{outfit.is_public === 2 ? 'public' : ''}}" bindtap="onTogglePublic">
|
||||
{{outfit.is_public === 2 ? '取消公开' : '公开到社区'}}
|
||||
</view>
|
||||
<button class="fb-share" open-type="share">
|
||||
<view class="icon icon-share"></view>
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,241 @@
|
||||
.detail-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.nav-more {
|
||||
padding: 0 8rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* 封面 */
|
||||
.cover {
|
||||
margin: 24rpx;
|
||||
height: 480rpx;
|
||||
background: #fff;
|
||||
border-radius: 32rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-item {
|
||||
position: absolute;
|
||||
width: 200rpx;
|
||||
height: 260rpx;
|
||||
background: #F8F8FA;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid #E5E5EA;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-0 { left: 30rpx; top: 80rpx; transform: rotate(-6deg); }
|
||||
.cover-1 { left: 120rpx; top: 140rpx; transform: rotate(2deg); }
|
||||
.cover-2 { left: 220rpx; top: 70rpx; transform: rotate(5deg); }
|
||||
.cover-3 { left: 320rpx; top: 150rpx; transform: rotate(-2deg); }
|
||||
.cover-4 { left: 420rpx; top: 90rpx; transform: rotate(7deg); }
|
||||
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.cover-empty {
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.cover-empty .icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
}
|
||||
|
||||
/* 信息卡 */
|
||||
.info-card {
|
||||
margin: 0 24rpx 24rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 28rpx;
|
||||
color: #3C3C43;
|
||||
margin-top: 20rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.items-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.items-scroll {
|
||||
margin-top: 20rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.items-row {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.item-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 140rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.item-img {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #F2F2F7;
|
||||
}
|
||||
|
||||
.item-img-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.item-img-empty .icon {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-size: 20rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 8rpx;
|
||||
max-width: 140rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer-placeholder {
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
/* 底部操作栏 */
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1rpx solid #E5E5EA;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 32rpx;
|
||||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.footer-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 40rpx;
|
||||
}
|
||||
|
||||
.fb-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.fb-btn .icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.liked {
|
||||
color: #FF2D55;
|
||||
}
|
||||
|
||||
.collected {
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.fb-public {
|
||||
height: 76rpx;
|
||||
padding: 0 40rpx;
|
||||
border-radius: 38rpx;
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fb-public.public {
|
||||
background: #fff;
|
||||
border: 1rpx solid #007AFF;
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.fb-share {
|
||||
width: 76rpx;
|
||||
height: 76rpx;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
border: 1rpx solid #E5E5EA;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #007AFF;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fb-share::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.fb-share .icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"clothes-card": "/components/clothes-card/clothes-card"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { OutfitItem, ClothesItem, PagedResult } from '../../types/models'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ name: '全部', value: '' },
|
||||
{ name: '上装', value: '上装' },
|
||||
{ name: '下装', value: '下装' },
|
||||
{ name: '连衣裙', value: '连衣裙' },
|
||||
{ name: '外套', value: '外套' },
|
||||
{ name: '鞋', value: '鞋' },
|
||||
{ name: '配饰', value: '配饰' },
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
uuid: '',
|
||||
isEdit: false,
|
||||
view: 'form' as 'form' | 'select',
|
||||
outfitName: '',
|
||||
outfitDesc: '',
|
||||
scene: '',
|
||||
selectedUuids: [] as string[],
|
||||
selectedItems: [] as ClothesItem[],
|
||||
categories: CATEGORIES,
|
||||
activeCategory: 0,
|
||||
allClothes: [] as ClothesItem[],
|
||||
selectLoading: true,
|
||||
},
|
||||
|
||||
onLoad(query: Record<string, string>) {
|
||||
const uuid = query.uuid || ''
|
||||
if (uuid) {
|
||||
this.setData({ uuid, isEdit: true })
|
||||
this.fetchOutfit(uuid)
|
||||
}
|
||||
},
|
||||
|
||||
async fetchOutfit(uuid: string) {
|
||||
try {
|
||||
const outfit = await request<OutfitItem>({ url: `/outfit/${uuid}` })
|
||||
const uuids = outfit.items.map((i) => i.clothes_uuid)
|
||||
const clothes = await Promise.all(
|
||||
uuids.map(async (id) => {
|
||||
try {
|
||||
return await request<ClothesItem>({ url: `/clothes/${id}`, showError: false })
|
||||
} catch (e) {
|
||||
return { clothes_uuid: id } as ClothesItem
|
||||
}
|
||||
})
|
||||
)
|
||||
this.setData({
|
||||
outfitName: outfit.outfit_name,
|
||||
outfitDesc: outfit.outfit_desc,
|
||||
scene: outfit.scene,
|
||||
selectedUuids: uuids,
|
||||
selectedItems: clothes,
|
||||
})
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
onNameInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ outfitName: e.detail.value })
|
||||
},
|
||||
onDescInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ outfitDesc: e.detail.value })
|
||||
},
|
||||
onSceneInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ scene: e.detail.value })
|
||||
},
|
||||
|
||||
openSelect() {
|
||||
this.setData({ view: 'select', selectLoading: true })
|
||||
this.fetchAllClothes()
|
||||
},
|
||||
|
||||
backToForm() {
|
||||
this.setData({ view: 'form' })
|
||||
},
|
||||
|
||||
fetchAllClothes() {
|
||||
const cat = CATEGORIES[this.data.activeCategory].value
|
||||
const params: Record<string, any> = { page: 1, page_size: 50 }
|
||||
if (cat) params.category_main = cat
|
||||
request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
|
||||
.then((res) => {
|
||||
const list = res.list.map((it) => ({
|
||||
...it,
|
||||
_selected: this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0,
|
||||
}))
|
||||
this.setData({ allClothes: list, selectLoading: false })
|
||||
})
|
||||
.catch(() => this.setData({ selectLoading: false }))
|
||||
},
|
||||
|
||||
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index)
|
||||
if (index === this.data.activeCategory) return
|
||||
this.setData({ activeCategory: index, selectLoading: true })
|
||||
this.fetchAllClothes()
|
||||
},
|
||||
|
||||
onToggleSelect(e: WechatMiniprogram.CustomEvent) {
|
||||
const item = e.detail.item as ClothesItem
|
||||
const uuids = this.data.selectedUuids
|
||||
const idx = uuids.indexOf(item.clothes_uuid)
|
||||
if (idx >= 0) {
|
||||
this.syncSelect(uuids.filter((u) => u !== item.clothes_uuid))
|
||||
} else {
|
||||
if (uuids.length >= 10) {
|
||||
wx.showToast({ title: '最多选择 10 件衣物', icon: 'none' })
|
||||
return
|
||||
}
|
||||
this.syncSelect([...uuids, item.clothes_uuid])
|
||||
}
|
||||
},
|
||||
|
||||
syncSelect(uuids: string[]) {
|
||||
this.setData({
|
||||
selectedUuids: uuids,
|
||||
allClothes: this.data.allClothes.map((it) => ({
|
||||
...it,
|
||||
_selected: uuids.indexOf(it.clothes_uuid) >= 0,
|
||||
})),
|
||||
})
|
||||
},
|
||||
|
||||
confirmSelect() {
|
||||
const selectedItems = this.data.allClothes.filter(
|
||||
(it) => this.data.selectedUuids.indexOf(it.clothes_uuid) >= 0
|
||||
)
|
||||
this.setData({ view: 'form', selectedItems })
|
||||
},
|
||||
|
||||
onSave() {
|
||||
const name = this.data.outfitName.trim()
|
||||
if (!name) {
|
||||
wx.showToast({ title: '请填写穿搭名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!this.data.selectedUuids.length) {
|
||||
wx.showToast({ title: '至少选择 1 件衣物', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const body = {
|
||||
outfit_name: name,
|
||||
outfit_desc: this.data.outfitDesc,
|
||||
scene: this.data.scene,
|
||||
items: this.data.selectedUuids.map((u) => ({ clothes_uuid: u })),
|
||||
}
|
||||
const req = this.data.isEdit
|
||||
? request({ url: `/outfit/${this.data.uuid}`, method: 'PUT', data: body })
|
||||
: request({ url: '/outfit', method: 'POST', data: body })
|
||||
req
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已保存', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 500)
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onDelete() {
|
||||
wx.showModal({
|
||||
title: '确认删除这套穿搭?',
|
||||
content: '删除后不可恢复,但不会影响衣橱内的单品。',
|
||||
confirmText: '确认删除',
|
||||
confirmColor: '#FF3B30',
|
||||
success: (res) => {
|
||||
if (!res.confirm) return
|
||||
request({ url: `/outfit/${this.data.uuid}`, method: 'DELETE' })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已删除', icon: 'success' })
|
||||
setTimeout(() => wx.navigateBack(), 500)
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,114 @@
|
||||
<view class="page edit-page">
|
||||
<navigation-bar title="{{view === 'select' ? '选择衣橱单品' : (isEdit ? '编辑穿搭' : '创建穿搭')}}">
|
||||
<view wx:if="{{view === 'select'}}" slot="right" class="nav-cancel" bindtap="backToForm">取消</view>
|
||||
</navigation-bar>
|
||||
|
||||
<!-- 表单视图 -->
|
||||
<scroll-view wx:if="{{view === 'form'}}" scroll-y class="scroll-body">
|
||||
<!-- 拼图预览 -->
|
||||
<view class="card preview">
|
||||
<view wx:if="{{selectedItems.length}}" class="preview-items">
|
||||
<view
|
||||
class="preview-item preview-{{index}}"
|
||||
wx:for="{{selectedItems}}"
|
||||
wx:key="clothes_uuid"
|
||||
wx:if="{{index < 5}}"
|
||||
>
|
||||
<image wx:if="{{item.processed_image}}" class="preview-img" src="{{item.processed_image}}" mode="aspectFit" />
|
||||
<view wx:else class="icon icon-hanger preview-placeholder"></view>
|
||||
</view>
|
||||
<view wx:if="{{selectedItems.length > 5}}" class="preview-more">+{{selectedItems.length - 5}}</view>
|
||||
</view>
|
||||
<view wx:else class="preview-empty">
|
||||
<view class="icon icon-hanger"></view>
|
||||
<text class="pe-title">还没有选择衣物</text>
|
||||
<text class="pe-desc">点击下方「选择衣物」添加你的单品</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 表单 -->
|
||||
<view class="card form-card">
|
||||
<view class="field">
|
||||
<view class="field-label">
|
||||
<text>穿搭名称 <text class="req">*</text></text>
|
||||
<text class="count">{{outfitName.length}}/32</text>
|
||||
</view>
|
||||
<input class="input" value="{{outfitName}}" bindinput="onNameInput" placeholder="给这套穿搭起个名字" maxlength="32" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<view class="field-label">
|
||||
<text>穿搭描述</text>
|
||||
<text class="count">{{outfitDesc.length}}/500</text>
|
||||
</view>
|
||||
<textarea class="textarea" value="{{outfitDesc}}" bindinput="onDescInput" placeholder="简单描述这套穿搭的灵感或亮点" maxlength="500" />
|
||||
</view>
|
||||
<view class="field">
|
||||
<view class="field-label">
|
||||
<text>适合场景</text>
|
||||
<text class="count">{{scene.length}}/100</text>
|
||||
</view>
|
||||
<input class="input" value="{{scene}}" bindinput="onSceneInput" placeholder="例如:通勤、约会、旅行" maxlength="100" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 选择衣物入口 -->
|
||||
<view class="card select-entry" bindtap="openSelect">
|
||||
<view class="entry-left">
|
||||
<view class="entry-icon"><view class="icon icon-shirt"></view></view>
|
||||
<view>
|
||||
<view class="entry-title">选择衣橱单品</view>
|
||||
<view class="entry-desc">{{selectedUuids.length ? '点击调整当前穿搭包含的衣物' : '选择 1~10 件衣物合成画报'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="entry-right">
|
||||
<text class="entry-count {{selectedUuids.length ? 'on' : ''}}">{{selectedUuids.length ? '已选 ' + selectedUuids.length + ' 件' : '未选择'}}</text>
|
||||
<view class="icon icon-chevron-right"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<view class="footer">
|
||||
<view wx:if="{{isEdit}}" class="btn-danger-text delete-link" bindtap="onDelete">删除穿搭</view>
|
||||
<view
|
||||
class="btn btn-primary {{(!outfitName || !selectedUuids.length) ? 'disabled' : ''}}"
|
||||
bindtap="onSave"
|
||||
>{{isEdit ? '保存修改' : '保存'}}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 多选视图 -->
|
||||
<view wx:else class="select-view">
|
||||
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
|
||||
<view class="cat-row">
|
||||
<view
|
||||
wx:for="{{categories}}"
|
||||
wx:key="name"
|
||||
class="cat-item {{activeCategory === index ? 'active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="onCategoryTap"
|
||||
>{{item.name}}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view scroll-y class="select-body">
|
||||
<view wx:if="{{selectLoading}}" class="grid">
|
||||
<view class="grid-item" wx:for="{{[1, 2, 3, 4]}}" wx:key="*this">
|
||||
<view class="skeleton sk-card"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view wx:else class="grid">
|
||||
<view class="grid-item" wx:for="{{allClothes}}" wx:key="clothes_uuid">
|
||||
<clothes-card item="{{item}}" selectable selected="{{item._selected}}" bind:select="onToggleSelect" />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="select-footer">
|
||||
<view>
|
||||
<view class="sf-count">已选 <text class="t-primary">{{selectedUuids.length}}</text> 件</view>
|
||||
<view class="sf-desc">最多可选择 10 件单品</view>
|
||||
</view>
|
||||
<view class="sf-confirm btn btn-primary" bindtap="confirmSelect">确定</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,296 @@
|
||||
.edit-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.nav-cancel {
|
||||
font-size: 30rpx;
|
||||
color: #007AFF;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
/* 预览区 */
|
||||
.preview {
|
||||
margin: 24rpx;
|
||||
height: 320rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.preview-empty .icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.pe-title {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.pe-desc {
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.preview-items {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
position: absolute;
|
||||
width: 140rpx;
|
||||
height: 180rpx;
|
||||
background: #F8F8FA;
|
||||
border-radius: 16rpx;
|
||||
border: 1rpx solid #E5E5EA;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-0 { left: 30rpx; top: 60rpx; transform: rotate(-5deg); }
|
||||
.preview-1 { left: 120rpx; top: 100rpx; transform: rotate(3deg); }
|
||||
.preview-2 { left: 210rpx; top: 50rpx; transform: rotate(6deg); }
|
||||
.preview-3 { left: 300rpx; top: 110rpx; transform: rotate(-3deg); }
|
||||
.preview-4 { left: 390rpx; top: 70rpx; transform: rotate(8deg); }
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.preview-more {
|
||||
position: absolute;
|
||||
right: 24rpx;
|
||||
top: 24rpx;
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
border-radius: 50%;
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.form-card {
|
||||
margin: 0 24rpx 24rpx;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #1C1C1E;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.req {
|
||||
color: #FF3B30;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 选择衣物入口 */
|
||||
.select-entry {
|
||||
margin: 0 24rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.entry-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
color: #007AFF;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.entry-icon .icon {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1C1C1E;
|
||||
}
|
||||
|
||||
.entry-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.entry-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.entry-count {
|
||||
font-size: 26rpx;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.entry-count.on {
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.entry-right .icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
/* 底部按钮 */
|
||||
.footer {
|
||||
padding: 16rpx 24rpx;
|
||||
padding-bottom: calc(40rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.delete-link {
|
||||
text-align: center;
|
||||
padding: 16rpx 0 24rpx;
|
||||
}
|
||||
|
||||
/* 多选视图 */
|
||||
.select-view {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cat-scroll {
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cat-row {
|
||||
display: inline-flex;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
|
||||
.cat-item {
|
||||
flex-shrink: 0;
|
||||
padding: 0 32rpx;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #3C3C43;
|
||||
background: #F2F2F7;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.cat-item.active {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.select-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 24rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
width: calc(50% - 12rpx);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sk-card {
|
||||
height: 360rpx;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
|
||||
.select-footer {
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-top: 1rpx solid #E5E5EA;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20rpx 32rpx;
|
||||
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.sf-count {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1C1C1E;
|
||||
}
|
||||
|
||||
.sf-desc {
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.sf-confirm {
|
||||
width: 200rpx;
|
||||
height: 80rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { PagedResult, OutfitItem } from '../../types/models'
|
||||
import { OutfitSource, IsPublic } from '../../utils/config'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
activeTab: 0,
|
||||
list: [] as OutfitItem[],
|
||||
loading: true,
|
||||
refreshing: false,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.syncTabBar()
|
||||
const openCollect = wx.getStorageSync('zt_open_collect')
|
||||
if (openCollect) {
|
||||
wx.removeStorageSync('zt_open_collect')
|
||||
if (this.data.activeTab !== 1) {
|
||||
this.setData({ activeTab: 1, list: [], loading: true })
|
||||
}
|
||||
}
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
syncTabBar() {
|
||||
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
|
||||
if (tabBar) tabBar.setData({ selected: 1 })
|
||||
},
|
||||
|
||||
onTabChange(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index)
|
||||
if (index === this.data.activeTab) return
|
||||
this.setData({ activeTab: index, list: [], loading: true })
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
|
||||
this.fetchList(1)
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.setData({ refreshing: true })
|
||||
this.fetchList(1).finally(() => this.setData({ refreshing: false }))
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
if (!this.data.hasMore || this.data.loading) return
|
||||
this.fetchList(this.data.page + 1)
|
||||
},
|
||||
|
||||
fetchList(page: number): Promise<void> {
|
||||
const isCollect = this.data.activeTab === 1
|
||||
const url = isCollect ? '/collect' : '/outfit'
|
||||
return request<PagedResult<OutfitItem>>({ url, data: { page, page_size: this.data.pageSize } })
|
||||
.then(async (res) => {
|
||||
const items = await this.decorateList(res.list)
|
||||
const merged = page === 1 ? items : this.data.list.concat(items)
|
||||
this.setData({
|
||||
list: merged,
|
||||
hasMore: merged.length < res.total,
|
||||
loading: false,
|
||||
page,
|
||||
})
|
||||
})
|
||||
.catch(() => this.setData({ loading: false }))
|
||||
},
|
||||
|
||||
decorateList(list: OutfitItem[]): Promise<OutfitItem[]> {
|
||||
return Promise.all(
|
||||
list.map(async (it) => {
|
||||
let likeCount = 0
|
||||
try {
|
||||
const like = await request<{ is_liked: boolean; like_count: number }>({
|
||||
url: `/like/${it.outfit_uuid}`,
|
||||
showError: false,
|
||||
})
|
||||
likeCount = like.like_count
|
||||
} catch (e) {}
|
||||
return { ...it, _likeCount: likeCount, _status: this.getStatusText(it) }
|
||||
})
|
||||
)
|
||||
},
|
||||
|
||||
getStatusText(it: OutfitItem): { text: string; type: string } {
|
||||
if (it.source === OutfitSource.AI) return { text: 'AI 推荐', type: 'indigo' }
|
||||
if (it.is_public === IsPublic.PUBLIC) return { text: '已公开', type: 'blue' }
|
||||
return { text: '仅自己可见', type: 'gray' }
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
wx.navigateTo({ url: '/pages/outfit-edit/outfit-edit' })
|
||||
},
|
||||
|
||||
onAiRecommend() {
|
||||
wx.navigateTo({ url: '/pages/ai-recommend/ai-recommend' })
|
||||
},
|
||||
|
||||
onOutfitTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const uuid = e.currentTarget.dataset.uuid as string
|
||||
const owner = this.data.activeTab === 0 ? '1' : '0'
|
||||
wx.navigateTo({ url: `/pages/outfit-detail/outfit-detail?uuid=${uuid}&owner=${owner}` })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="穿搭" back="{{false}}" />
|
||||
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="scroll-body"
|
||||
refresher-enabled
|
||||
refresher-triggered="{{refreshing}}"
|
||||
bindrefresherrefresh="onRefresh"
|
||||
bindscrolltolower="onReachBottom"
|
||||
>
|
||||
<!-- 分段控件 -->
|
||||
<view class="topbar">
|
||||
<view class="segment">
|
||||
<view class="segment-item {{activeTab === 0 ? 'active' : ''}}" data-index="0" bindtap="onTabChange">我的穿搭</view>
|
||||
<view class="segment-item {{activeTab === 1 ? 'active' : ''}}" data-index="1" bindtap="onTabChange">已收藏</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 入口按钮 -->
|
||||
<view class="entry-row">
|
||||
<view class="entry-btn entry-outline" bindtap="onCreate">+ 创建穿搭</view>
|
||||
<view class="entry-btn entry-primary" bindtap="onAiRecommend">AI 推荐</view>
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view wx:if="{{loading}}" class="list">
|
||||
<view class="skeleton sk-card" wx:for="{{[1, 2, 3]}}" wx:key="*this"></view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<empty-state
|
||||
wx:elif="{{!list.length}}"
|
||||
icon="icon-shirt"
|
||||
title="{{activeTab === 0 ? '还没有穿搭' : '还没有收藏的穿搭'}}"
|
||||
desc="{{activeTab === 0 ? '从衣橱挑几件,搭一套吧' : ''}}"
|
||||
buttonText="{{activeTab === 0 ? '创建穿搭' : ''}}"
|
||||
bind:action="onCreate"
|
||||
/>
|
||||
|
||||
<!-- 卡片流 -->
|
||||
<view wx:else class="list">
|
||||
<view
|
||||
class="outfit-card card"
|
||||
wx:for="{{list}}"
|
||||
wx:key="outfit_uuid"
|
||||
data-uuid="{{item.outfit_uuid}}"
|
||||
bindtap="onOutfitTap"
|
||||
>
|
||||
<view class="cover">
|
||||
<view
|
||||
class="cover-item cover-{{index}}"
|
||||
wx:for="{{item.items}}"
|
||||
wx:key="clothes_uuid"
|
||||
wx:if="{{index < 5}}"
|
||||
>
|
||||
<image wx:if="{{item.processed_image}}" class="cover-img" src="{{item.processed_image}}" mode="aspectFit" />
|
||||
<view wx:else class="icon icon-hanger cover-ph"></view>
|
||||
</view>
|
||||
<view wx:if="{{!item.items.length}}" class="icon icon-hanger"></view>
|
||||
<text class="cover-count">{{item.items.length}} 件</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<view class="title-row">
|
||||
<text class="title">{{item.outfit_name}}</text>
|
||||
<text class="tag tag-{{item._status.type}}">{{item._status.text}}</text>
|
||||
</view>
|
||||
<view class="desc" wx:if="{{item.outfit_desc}}">{{item.outfit_desc}}</view>
|
||||
<view class="meta-row">
|
||||
<text class="tag tag-gray" wx:if="{{item.scene}}">{{item.scene}}</text>
|
||||
<text class="meta">{{item.items.length}} 件衣物</text>
|
||||
<view class="spacer"></view>
|
||||
<view class="meta-icon">
|
||||
<view class="icon icon-heart heart"></view>
|
||||
<text>{{item._likeCount || 0}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -0,0 +1,208 @@
|
||||
.page-flex {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
background: #fff;
|
||||
padding: 20rpx 32rpx;
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: flex;
|
||||
background: #F2F2F7;
|
||||
border-radius: 20rpx;
|
||||
padding: 6rpx;
|
||||
}
|
||||
|
||||
.segment-item {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #3C3C43;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.segment-item.active {
|
||||
background: #fff;
|
||||
color: #007AFF;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entry-row {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
padding: 24rpx 32rpx;
|
||||
}
|
||||
|
||||
.entry-btn {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.entry-outline {
|
||||
border: 1rpx solid #007AFF;
|
||||
color: #007AFF;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.entry-primary {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.sk-card {
|
||||
height: 360rpx;
|
||||
border-radius: 32rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.outfit-card {
|
||||
margin-bottom: 24rpx;
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.cover {
|
||||
height: 280rpx;
|
||||
background: #F8F8FA;
|
||||
border-radius: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover .icon {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.cover-item {
|
||||
position: absolute;
|
||||
width: 150rpx;
|
||||
height: 190rpx;
|
||||
background: #fff;
|
||||
border: 1rpx solid #E5E5EA;
|
||||
border-radius: 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-0 { left: 16rpx; top: 40rpx; transform: rotate(-6deg); }
|
||||
.cover-1 { left: 140rpx; top: 82rpx; transform: rotate(3deg); }
|
||||
.cover-2 { left: 268rpx; top: 32rpx; transform: rotate(5deg); }
|
||||
.cover-3 { left: 392rpx; top: 84rpx; transform: rotate(-3deg); }
|
||||
.cover-4 { left: 480rpx; top: 40rpx; transform: rotate(6deg); }
|
||||
|
||||
.cover-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cover-ph {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.cover-count {
|
||||
position: absolute;
|
||||
top: 20rpx;
|
||||
right: 20rpx;
|
||||
font-size: 22rpx;
|
||||
color: #007AFF;
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
padding: 4rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 12rpx;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.heart {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
color: #C7C7CC;
|
||||
margin-right: 8rpx;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #C7C7CC;
|
||||
padding: 24rpx 0 40rpx;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { request, uploadFile } from '../../utils/request'
|
||||
import { UserInfo, QuotaItem } from '../../types/models'
|
||||
import { QuotaType, STORAGE_KEYS } from '../../utils/config'
|
||||
import { formatDate } from '../../utils/format'
|
||||
|
||||
function getZodiac(birthDay: number): string {
|
||||
if (!birthDay) return ''
|
||||
const d = new Date(birthDay * 1000)
|
||||
const m = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
const arr = [
|
||||
[120, '摩羯座'], [218, '水瓶座'], [320, '双鱼座'], [419, '白羊座'],
|
||||
[520, '金牛座'], [621, '双子座'], [722, '巨蟹座'], [822, '狮子座'],
|
||||
[922, '处女座'], [1023, '天秤座'], [1122, '天蝎座'], [1221, '射手座'],
|
||||
]
|
||||
const val = m * 100 + day
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (val <= (arr[i][0] as number)) return arr[i][1] as string
|
||||
}
|
||||
return '摩羯座'
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
userInfo: {} as UserInfo,
|
||||
uploadRemain: 0,
|
||||
zodiac: '',
|
||||
editVisible: false,
|
||||
avatarUploading: false,
|
||||
avatarChanged: false,
|
||||
editForm: {
|
||||
username: '',
|
||||
sex: 0,
|
||||
birth_day: 0,
|
||||
avatar: '',
|
||||
},
|
||||
birthDateStr: '',
|
||||
today: '',
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
const d = new Date()
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`)
|
||||
this.setData({ today: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` })
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.syncTabBar()
|
||||
this.fetchUser()
|
||||
this.fetchQuota()
|
||||
},
|
||||
|
||||
syncTabBar() {
|
||||
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
|
||||
if (tabBar) {
|
||||
tabBar.setData({ selected: 4 })
|
||||
if (tabBar.refreshUnread) tabBar.refreshUnread()
|
||||
}
|
||||
},
|
||||
|
||||
fetchUser() {
|
||||
request<UserInfo>({ url: '/user/info', showError: false })
|
||||
.then((user) => {
|
||||
wx.setStorageSync(STORAGE_KEYS.USER_INFO, user)
|
||||
this.setData({ userInfo: user, zodiac: getZodiac(user.birth_day) })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
fetchQuota() {
|
||||
request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
|
||||
.then((res) => {
|
||||
const upload = res.quotas.find((q) => q.quota_type === QuotaType.UPLOAD)
|
||||
if (upload) this.setData({ uploadRemain: upload.remain })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onEdit() {
|
||||
const u = this.data.userInfo
|
||||
this.setData({
|
||||
editVisible: true,
|
||||
avatarChanged: false,
|
||||
editForm: {
|
||||
username: u.username || '',
|
||||
sex: u.sex || 0,
|
||||
birth_day: u.birth_day || 0,
|
||||
avatar: u.avatar || '',
|
||||
},
|
||||
birthDateStr: u.birth_day ? formatDate(u.birth_day) : '',
|
||||
})
|
||||
},
|
||||
|
||||
onCloseEdit() {
|
||||
this.setData({ editVisible: false })
|
||||
},
|
||||
|
||||
noop() {},
|
||||
|
||||
onChooseAvatar(e: WechatMiniprogram.CustomEvent) {
|
||||
this.setData({ 'editForm.avatar': e.detail.avatarUrl, avatarChanged: true })
|
||||
},
|
||||
|
||||
onNameInput(e: WechatMiniprogram.Input) {
|
||||
this.setData({ 'editForm.username': e.detail.value })
|
||||
},
|
||||
|
||||
onSexSelect() {
|
||||
wx.showActionSheet({
|
||||
itemList: ['男', '女'],
|
||||
success: (res) => this.setData({ 'editForm.sex': res.tapIndex + 1 }),
|
||||
})
|
||||
},
|
||||
|
||||
onBirthChange(e: WechatMiniprogram.CustomEvent) {
|
||||
const dateStr = e.detail.value as string
|
||||
const ts = Math.floor(new Date(dateStr.replace(/-/g, '/')).getTime() / 1000)
|
||||
this.setData({ 'editForm.birth_day': ts, birthDateStr: dateStr })
|
||||
},
|
||||
|
||||
onSaveProfile() {
|
||||
if (this.data.avatarUploading) return
|
||||
const f = this.data.editForm
|
||||
const avatar = f.avatar || ''
|
||||
|
||||
const saveProfile = () => {
|
||||
const body: Record<string, any> = {}
|
||||
if (f.username) body.username = f.username
|
||||
if (f.sex) body.sex = f.sex
|
||||
if (f.birth_day) body.birth_day = f.birth_day
|
||||
request({ url: '/user/profile', method: 'PUT', data: body })
|
||||
.then(() => {
|
||||
wx.showToast({ title: '已保存', icon: 'success' })
|
||||
this.setData({ editVisible: false, avatarUploading: false })
|
||||
this.fetchUser()
|
||||
})
|
||||
.catch(() => this.setData({ avatarUploading: false }))
|
||||
}
|
||||
|
||||
if (this.data.avatarChanged && avatar) {
|
||||
this.setData({ avatarUploading: true })
|
||||
uploadFile<{ avatar: string }>(avatar, 'file', '/user/avatar/upload')
|
||||
.then(() => saveProfile())
|
||||
.catch(() => this.setData({ avatarUploading: false }))
|
||||
} else {
|
||||
saveProfile()
|
||||
}
|
||||
},
|
||||
|
||||
onTapMall() {
|
||||
wx.navigateTo({ url: '/pages/mall/mall' })
|
||||
},
|
||||
onTapOrder() {
|
||||
wx.navigateTo({ url: '/pages/order/order' })
|
||||
},
|
||||
onTapNotification() {
|
||||
wx.navigateTo({ url: '/pages/notification/notification' })
|
||||
},
|
||||
onTapCollect() {
|
||||
wx.setStorageSync('zt_open_collect', 1)
|
||||
wx.switchTab({ url: '/pages/outfit/outfit' })
|
||||
},
|
||||
onTapFlow() {
|
||||
wx.navigateTo({ url: '/pages/mall/mall?tab=flow' })
|
||||
},
|
||||
onTapService() {
|
||||
wx.showToast({ title: '客服与反馈敬请期待', icon: 'none' })
|
||||
},
|
||||
onTapAbout() {
|
||||
wx.showToast({ title: '纸条AI衣橱 v1.0.0', icon: 'none' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="我的" back="{{false}}" />
|
||||
|
||||
<scroll-view scroll-y class="scroll-body">
|
||||
<!-- 额度提示条 -->
|
||||
<view wx:if="{{uploadRemain > 0 && uploadRemain <= 2}}" class="quota-banner" bindtap="onTapMall">
|
||||
你的上传额度仅剩 {{uploadRemain}} 次,点击购买额度包 >
|
||||
</view>
|
||||
|
||||
<!-- 个人信息 -->
|
||||
<view class="card user-card">
|
||||
<view class="avatar">
|
||||
<image wx:if="{{userInfo.avatar}}" class="avatar-img" src="{{userInfo.avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user avatar-placeholder"></view>
|
||||
</view>
|
||||
<view class="user-info">
|
||||
<view class="user-name">
|
||||
<text>{{userInfo.username || '微信用户'}}</text>
|
||||
<text wx:if="{{userInfo.sex === 1}}" class="sex-tag">男</text>
|
||||
<text wx:elif="{{userInfo.sex === 2}}" class="sex-tag">女</text>
|
||||
</view>
|
||||
<view class="user-tags">
|
||||
<text wx:if="{{userInfo.vip_type !== 1}}" class="tag tag-blue">月度会员</text>
|
||||
<text wx:if="{{zodiac}}" class="tag tag-gray">{{zodiac}}</text>
|
||||
<text wx:else class="tag tag-gray" bindtap="onEdit">设置生日</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="edit-link" bindtap="onEdit">编辑资料<view class="icon icon-chevron-right"></view></view>
|
||||
</view>
|
||||
|
||||
<!-- 功能列表 1 -->
|
||||
<view class="card menu-card">
|
||||
<view class="menu-row hairline" bindtap="onTapMall">
|
||||
<view class="icon icon-gauge menu-icon"></view>
|
||||
<text class="menu-label">我的额度</text>
|
||||
<text class="menu-value">上传额度剩 {{uploadRemain}}</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
<view class="menu-row hairline" bindtap="onTapOrder">
|
||||
<view class="icon icon-doc menu-icon"></view>
|
||||
<text class="menu-label">我的订单</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
<view class="menu-row hairline" bindtap="onTapNotification">
|
||||
<view class="icon icon-bell menu-icon"></view>
|
||||
<text class="menu-label">通知</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
<view class="menu-row hairline" bindtap="onTapCollect">
|
||||
<view class="icon icon-star menu-icon"></view>
|
||||
<text class="menu-label">我的收藏</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
<view class="menu-row" bindtap="onTapFlow">
|
||||
<view class="icon icon-doc menu-icon"></view>
|
||||
<text class="menu-label">额度明细</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 功能列表 2 -->
|
||||
<view class="card menu-card">
|
||||
<view class="menu-row hairline" bindtap="onEdit">
|
||||
<view class="icon icon-pencil menu-icon"></view>
|
||||
<text class="menu-label">编辑资料</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
<view class="menu-row hairline" bindtap="onEdit">
|
||||
<view class="icon icon-calendar menu-icon"></view>
|
||||
<text class="menu-label">设置生日</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
<view class="menu-row hairline" bindtap="onTapService">
|
||||
<view class="icon icon-headphones menu-icon"></view>
|
||||
<text class="menu-label">客服与反馈</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
<view class="menu-row" bindtap="onTapAbout">
|
||||
<view class="icon icon-info menu-icon"></view>
|
||||
<text class="menu-label">关于纸条AI衣橱</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="version">v1.0.0</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 编辑资料弹窗 -->
|
||||
<view wx:if="{{editVisible}}" class="mask" bindtap="onCloseEdit">
|
||||
<view class="sheet" catchtap="noop">
|
||||
<view class="sheet-grabber"></view>
|
||||
<view class="sheet-title">编辑资料</view>
|
||||
|
||||
<view class="avatar-wrap">
|
||||
<image wx:if="{{editForm.avatar}}" class="avatar-img" src="{{editForm.avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user avatar-placeholder"></view>
|
||||
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
|
||||
</view>
|
||||
<view class="avatar-tip">点击更换头像</view>
|
||||
|
||||
<view class="form-field">
|
||||
<text class="form-label">昵称</text>
|
||||
<input class="input" type="nickname" value="{{editForm.username}}" bindinput="onNameInput" maxlength="32" placeholder="请输入昵称" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text class="form-label">性别</text>
|
||||
<view class="form-value" bindtap="onSexSelect">
|
||||
<text class="{{editForm.sex ? '' : 'placeholder'}}">{{editForm.sex === 1 ? '男' : editForm.sex === 2 ? '女' : '请选择'}}</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text class="form-label">生日</text>
|
||||
<picker mode="date" value="{{birthDateStr}}" end="{{today}}" bindchange="onBirthChange">
|
||||
<view class="form-value">
|
||||
<text class="{{birthDateStr ? '' : 'placeholder'}}">{{birthDateStr || '请选择生日'}}</text>
|
||||
<view class="icon icon-chevron-right menu-arrow"></view>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="btn btn-primary sheet-btn {{avatarUploading ? 'btn-disabled' : ''}}" bindtap="onSaveProfile">{{avatarUploading ? '上传中…' : '保存'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,266 @@
|
||||
.page-flex {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.quota-banner {
|
||||
margin: 24rpx 24rpx 0;
|
||||
background: #FFF3E6;
|
||||
color: #FF9500;
|
||||
font-size: 24rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
border-radius: 16rpx;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
margin: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 128rpx;
|
||||
height: 128rpx;
|
||||
border-radius: 50%;
|
||||
background: #F2F2F7;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #C7C7CC;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
margin-left: 24rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.sex-tag {
|
||||
font-size: 20rpx;
|
||||
color: #007AFF;
|
||||
background: rgba(0, 122, 255, 0.1);
|
||||
border-radius: 8rpx;
|
||||
padding: 2rpx 12rpx;
|
||||
margin-left: 12rpx;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.user-tags {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.edit-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.edit-link .icon {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.menu-card {
|
||||
margin: 0 24rpx 24rpx;
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100rpx;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
color: #007AFF;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.menu-value {
|
||||
font-size: 26rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.menu-arrow {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
color: #C7C7CC;
|
||||
margin-left: 12rpx;
|
||||
}
|
||||
|
||||
.version {
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
padding: 24rpx 0 48rpx;
|
||||
}
|
||||
|
||||
/* 编辑弹窗 */
|
||||
.mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.sheet {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: #fff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 20rpx 32rpx;
|
||||
padding-bottom: calc(32rpx + env(safe-area-inset-bottom) + 120rpx);
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sheet-grabber {
|
||||
width: 72rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 5rpx;
|
||||
background: #E5E5EA;
|
||||
margin: 0 auto 24rpx;
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
margin: 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #F2F2F7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.avatar-wrap .avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-wrap .avatar-placeholder {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.avatar-btn {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.avatar-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.avatar-tip {
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
margin: 16rpx 0 24rpx;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #F2F2F7;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
width: 140rpx;
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-field .input {
|
||||
flex: 1;
|
||||
background: #fff;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 28rpx;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.sheet-btn {
|
||||
margin-top: 32rpx;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { request, uploadFile } from '../../utils/request'
|
||||
import { ProcessStatus } from '../../utils/config'
|
||||
|
||||
Page({
|
||||
data: {
|
||||
state: 'idle' as 'idle' | 'processing' | 'failed',
|
||||
imagePath: '',
|
||||
clothesUuid: '',
|
||||
failReason: '',
|
||||
step: 0,
|
||||
},
|
||||
|
||||
onChoose() {
|
||||
wx.showActionSheet({
|
||||
itemList: ['拍照', '从相册选择'],
|
||||
success: (res) => {
|
||||
const sourceType: Array<'camera' | 'album'> = res.tapIndex === 0 ? ['camera'] : ['album']
|
||||
this.chooseImage(sourceType)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
chooseImage(sourceType: Array<'camera' | 'album'>) {
|
||||
wx.chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sourceType,
|
||||
success: (res) => {
|
||||
const file = res.tempFiles[0]
|
||||
this.startUpload(file.tempFilePath)
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
startUpload(filePath: string) {
|
||||
this.setData({ state: 'processing', imagePath: filePath, step: 0, failReason: '' })
|
||||
|
||||
uploadFile<{ clothes_uuid: string; image_process_status: number }>(filePath)
|
||||
.then((res) => {
|
||||
this.setData({ clothesUuid: res.clothes_uuid })
|
||||
this.askNextAction()
|
||||
})
|
||||
.catch((e: any) => {
|
||||
this.setData({ state: 'idle' })
|
||||
if (e && e.code === 80000) this.showQuotaModal()
|
||||
})
|
||||
},
|
||||
|
||||
askNextAction() {
|
||||
// 后台继续轮询,仅用于更新步骤进度(不再自动跳详情)
|
||||
this.pollStatus(this.data.clothesUuid)
|
||||
// 立即弹窗让用户选择下一步,避免干等
|
||||
wx.showModal({
|
||||
title: '衣物已上传',
|
||||
content: 'AI 正在识别中,可在衣橱查看进度',
|
||||
confirmText: '继续上传',
|
||||
cancelText: '回到衣橱',
|
||||
success: (res) => {
|
||||
this.clearTimer()
|
||||
if (res.confirm) {
|
||||
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', step: 0, failReason: '' })
|
||||
} else {
|
||||
wx.navigateBack({
|
||||
delta: 1,
|
||||
fail: () => wx.switchTab({ url: '/pages/wardrobe/wardrobe' }),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
pollStatus(uuid: string) {
|
||||
let count = 0
|
||||
;(this as any).pollTimer = setInterval(() => {
|
||||
count++
|
||||
request<{ image_process_status: number; fail_reason: string }>({
|
||||
url: `/clothes/${uuid}/status`,
|
||||
showError: false,
|
||||
})
|
||||
.then((res) => {
|
||||
const status = res.image_process_status
|
||||
if (status === ProcessStatus.MATTING) {
|
||||
this.setData({ step: 1 })
|
||||
} else if (status === ProcessStatus.CLASSIFYING) {
|
||||
this.setData({ step: 2 })
|
||||
} else if (status === ProcessStatus.SUCCESS) {
|
||||
// 处理成功不再自动跳转,由用户自行从衣橱进入详情
|
||||
this.clearTimer()
|
||||
} else if (status === ProcessStatus.FAILED) {
|
||||
this.clearTimer()
|
||||
this.setData({ state: 'failed', failReason: res.fail_reason || '识别失败' })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
if (count >= 60) {
|
||||
this.clearTimer()
|
||||
this.setData({ state: 'failed', failReason: '处理超时,请重试' })
|
||||
}
|
||||
}, 1500)
|
||||
},
|
||||
|
||||
clearTimer() {
|
||||
const t = (this as any).pollTimer
|
||||
if (t) {
|
||||
clearInterval(t)
|
||||
;(this as any).pollTimer = null
|
||||
}
|
||||
},
|
||||
|
||||
onUnload() {
|
||||
this.clearTimer()
|
||||
},
|
||||
|
||||
showQuotaModal() {
|
||||
wx.showModal({
|
||||
title: '上传额度不足',
|
||||
content: '当前剩余 0 次,购买额度包后可继续上传',
|
||||
confirmText: '去购买',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/mall/mall' })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
onRetry() {
|
||||
this.setData({ state: 'idle', imagePath: '', clothesUuid: '', failReason: '' })
|
||||
},
|
||||
|
||||
onManualEdit() {
|
||||
if (!this.data.clothesUuid) return
|
||||
wx.redirectTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${this.data.clothesUuid}` })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
<view class="page upload-page">
|
||||
<navigation-bar title="上传衣物" />
|
||||
|
||||
<!-- 初始态 -->
|
||||
<view wx:if="{{state === 'idle'}}" class="idle">
|
||||
<view class="upload-zone" bindtap="onChoose">
|
||||
<view class="icon icon-camera upload-camera"></view>
|
||||
<view class="upload-title">拍照或从相册选择</view>
|
||||
<view class="upload-desc">支持 JPG / PNG,单张不超过 20MB</view>
|
||||
<view class="upload-desc">上传消耗 1 次上传额度</view>
|
||||
</view>
|
||||
<view class="upload-tip">AI 将自动抠图并识别 15 项属性</view>
|
||||
</view>
|
||||
|
||||
<!-- 处理中 -->
|
||||
<view wx:elif="{{state === 'processing'}}" class="processing">
|
||||
<image class="proc-img" src="{{imagePath}}" mode="aspectFit" />
|
||||
<view class="steps">
|
||||
<view class="step">
|
||||
<view class="step-dot {{step >= 0 ? 'active' : ''}}"></view>
|
||||
<text class="{{step >= 0 ? 'active-text' : ''}}">排队中</text>
|
||||
</view>
|
||||
<view class="step-line {{step >= 1 ? 'active' : ''}}"></view>
|
||||
<view class="step">
|
||||
<view class="step-dot {{step >= 1 ? 'active' : ''}}"></view>
|
||||
<text class="{{step >= 1 ? 'active-text' : ''}}">AI 抠图</text>
|
||||
</view>
|
||||
<view class="step-line {{step >= 2 ? 'active' : ''}}"></view>
|
||||
<view class="step">
|
||||
<view class="step-dot {{step >= 2 ? 'active' : ''}}"></view>
|
||||
<text class="{{step >= 2 ? 'active-text' : ''}}">智能分类</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="proc-text">AI 正在识别你的衣物…</view>
|
||||
<view class="proc-desc">通常需要 10 秒左右,请稍候</view>
|
||||
</view>
|
||||
|
||||
<!-- 失败态 -->
|
||||
<view wx:else class="failed">
|
||||
<view class="fail-icon">!</view>
|
||||
<view class="fail-title">识别失败:{{failReason}}</view>
|
||||
<view class="fail-desc">本次上传额度已自动退回</view>
|
||||
<view class="fail-btns">
|
||||
<view class="btn btn-primary" bindtap="onRetry">重新上传</view>
|
||||
<view class="btn btn-outline" bindtap="onManualEdit">手动填写信息</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,156 @@
|
||||
.upload-page {
|
||||
padding: 32rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 初始态 */
|
||||
.upload-zone {
|
||||
margin-top: 40rpx;
|
||||
height: 520rpx;
|
||||
border: 3rpx dashed #C7C7CC;
|
||||
border-radius: 32rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-camera {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
color: #007AFF;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.upload-title {
|
||||
font-size: 30rpx;
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-desc {
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
/* 处理中 */
|
||||
.processing {
|
||||
margin-top: 40rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.proc-img {
|
||||
width: 480rpx;
|
||||
height: 480rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #F2F2F7;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 56rpx;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
color: #C7C7CC;
|
||||
}
|
||||
|
||||
.step-dot {
|
||||
width: 20rpx;
|
||||
height: 20rpx;
|
||||
border-radius: 50%;
|
||||
background: #E5E5EA;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.step-dot.active {
|
||||
background: #007AFF;
|
||||
}
|
||||
|
||||
.active-text {
|
||||
color: #007AFF;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
width: 100rpx;
|
||||
height: 2rpx;
|
||||
background: #E5E5EA;
|
||||
margin: 0 16rpx 34rpx;
|
||||
}
|
||||
|
||||
.step-line.active {
|
||||
background: #007AFF;
|
||||
}
|
||||
|
||||
.proc-text {
|
||||
margin-top: 56rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.proc-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
/* 失败态 */
|
||||
.failed {
|
||||
margin-top: 120rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 40rpx;
|
||||
}
|
||||
|
||||
.fail-icon {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: #FF3B30;
|
||||
color: #fff;
|
||||
font-size: 72rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fail-title {
|
||||
margin-top: 40rpx;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.fail-desc {
|
||||
font-size: 24rpx;
|
||||
color: #8E8E93;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.fail-btns {
|
||||
width: 100%;
|
||||
margin-top: 56rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"clothes-card": "/components/clothes-card/clothes-card",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { request } from '../../utils/request'
|
||||
import { PagedResult, ClothesItem, QuotaItem } from '../../types/models'
|
||||
import { QuotaType } from '../../utils/config'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ name: '全部', value: '' },
|
||||
{ name: '上装', value: '上装' },
|
||||
{ name: '下装', value: '下装' },
|
||||
{ name: '连衣裙', value: '连衣裙' },
|
||||
{ name: '外套', value: '外套' },
|
||||
{ name: '鞋', value: '鞋' },
|
||||
{ name: '配饰', value: '配饰' },
|
||||
]
|
||||
|
||||
Page({
|
||||
data: {
|
||||
categories: CATEGORIES,
|
||||
activeCategory: 0,
|
||||
list: [] as ClothesItem[],
|
||||
total: 0,
|
||||
uploadRemain: 0,
|
||||
loading: true,
|
||||
refreshing: false,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
hasMore: true,
|
||||
},
|
||||
|
||||
onShow() {
|
||||
this.syncTabBar()
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
syncTabBar() {
|
||||
const tabBar = (this as any).getTabBar && (this as any).getTabBar()
|
||||
if (tabBar) tabBar.setData({ selected: 0 })
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.setData({ page: 1, hasMore: true, loading: this.data.list.length === 0 })
|
||||
this.fetchList(1)
|
||||
this.fetchQuota()
|
||||
},
|
||||
|
||||
onRefresh() {
|
||||
this.setData({ refreshing: true })
|
||||
Promise.all([this.fetchList(1), this.fetchQuota()]).finally(() => {
|
||||
this.setData({ refreshing: false })
|
||||
})
|
||||
},
|
||||
|
||||
onReachBottom() {
|
||||
if (!this.data.hasMore || this.data.loading) return
|
||||
this.fetchList(this.data.page + 1)
|
||||
},
|
||||
|
||||
fetchList(page: number): Promise<void> {
|
||||
const cat = CATEGORIES[this.data.activeCategory].value
|
||||
const params: Record<string, any> = { page, page_size: this.data.pageSize }
|
||||
if (cat) params.category_main = cat
|
||||
|
||||
return request<PagedResult<ClothesItem>>({ url: '/clothes', data: params })
|
||||
.then((res) => {
|
||||
const list = res.list.map((it) => ({
|
||||
...it,
|
||||
_statusText: !it.category_main && !it.processed_image ? 'AI 处理中' : '',
|
||||
_statusType: 'blue',
|
||||
}))
|
||||
const merged = page === 1 ? list : this.data.list.concat(list)
|
||||
this.setData({
|
||||
list: merged,
|
||||
total: res.total,
|
||||
hasMore: merged.length < res.total,
|
||||
loading: false,
|
||||
page,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
this.setData({ loading: false })
|
||||
})
|
||||
},
|
||||
|
||||
fetchQuota(): Promise<void> {
|
||||
return request<{ quotas: QuotaItem[] }>({ url: '/quota/info', showError: false })
|
||||
.then((res) => {
|
||||
const upload = res.quotas.find((q) => q.quota_type === QuotaType.UPLOAD)
|
||||
if (upload) this.setData({ uploadRemain: upload.remain })
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
|
||||
const index = Number(e.currentTarget.dataset.index)
|
||||
if (index === this.data.activeCategory) return
|
||||
this.setData({ activeCategory: index, list: [], loading: true })
|
||||
this.refresh()
|
||||
},
|
||||
|
||||
onClothesTap(e: WechatMiniprogram.CustomEvent) {
|
||||
const item = e.detail.item as ClothesItem
|
||||
wx.navigateTo({ url: `/pages/clothes-detail/clothes-detail?uuid=${item.clothes_uuid}` })
|
||||
},
|
||||
|
||||
onUpload() {
|
||||
wx.navigateTo({ url: '/pages/upload/upload' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
<view class="page page-flex">
|
||||
<navigation-bar title="我的衣橱" back="{{false}}" />
|
||||
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="scroll-body"
|
||||
refresher-enabled
|
||||
refresher-triggered="{{refreshing}}"
|
||||
bindrefresherrefresh="onRefresh"
|
||||
bindscrolltolower="onReachBottom"
|
||||
>
|
||||
<!-- 分类筛选 -->
|
||||
<scroll-view scroll-x class="cat-scroll" show-scrollbar="{{false}}">
|
||||
<view class="cat-row">
|
||||
<view
|
||||
wx:for="{{categories}}"
|
||||
wx:key="name"
|
||||
class="cat-item {{activeCategory === index ? 'active' : ''}}"
|
||||
data-index="{{index}}"
|
||||
bindtap="onCategoryTap"
|
||||
>{{item.name}}</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 统计 -->
|
||||
<view class="stat">共 {{total}} 件 · 上传额度剩余 {{uploadRemain}} 次</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<view wx:if="{{loading}}" class="grid">
|
||||
<view class="grid-item" wx:for="{{[1, 2, 3, 4, 5, 6]}}" wx:key="*this">
|
||||
<view class="skeleton sk-card"></view>
|
||||
<view class="skeleton sk-line"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空态 -->
|
||||
<empty-state
|
||||
wx:elif="{{!list.length}}"
|
||||
icon="icon-hanger"
|
||||
title="衣橱还空着"
|
||||
desc="拍照上传第一件衣物,AI 自动抠图并识别属性"
|
||||
buttonText="上传衣物"
|
||||
bind:action="onUpload"
|
||||
/>
|
||||
|
||||
<!-- 衣物网格 -->
|
||||
<view wx:else class="grid">
|
||||
<view class="grid-item" wx:for="{{list}}" wx:key="clothes_uuid">
|
||||
<clothes-card
|
||||
item="{{item}}"
|
||||
statusText="{{item._statusText}}"
|
||||
statusType="{{item._statusType}}"
|
||||
bind:select="onClothesTap"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{list.length && !hasMore}}" class="no-more">没有更多了</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="fab" bindtap="onUpload">
|
||||
<view class="icon icon-plus"></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -0,0 +1,74 @@
|
||||
.page-flex {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scroll-body {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.cat-scroll {
|
||||
background: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cat-row {
|
||||
display: inline-flex;
|
||||
padding: 20rpx 24rpx;
|
||||
}
|
||||
|
||||
.cat-item {
|
||||
flex-shrink: 0;
|
||||
padding: 0 32rpx;
|
||||
height: 60rpx;
|
||||
line-height: 60rpx;
|
||||
border-radius: 30rpx;
|
||||
font-size: 26rpx;
|
||||
color: #3C3C43;
|
||||
background: #F2F2F7;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
.cat-item.active {
|
||||
background: #007AFF;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: 8rpx 32rpx 0;
|
||||
font-size: 22rpx;
|
||||
color: #8E8E93;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 24rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
width: calc(50% - 12rpx);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sk-card {
|
||||
height: 320rpx;
|
||||
border-radius: 32rpx;
|
||||
}
|
||||
|
||||
.sk-line {
|
||||
height: 40rpx;
|
||||
margin-top: 16rpx;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #C7C7CC;
|
||||
padding: 24rpx 0 40rpx;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||
"rules": [{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/** SF Symbols 风格线性图标库(CSS mask + currentColor 单色可换色)**/
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background-color: currentColor;
|
||||
flex-shrink: 0;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
mask-size: 100% 100%;
|
||||
}
|
||||
|
||||
.icon-sm { width: 40rpx; height: 40rpx; }
|
||||
.icon-md { width: 56rpx; height: 56rpx; }
|
||||
.icon-lg { width: 72rpx; height: 72rpx; }
|
||||
|
||||
/* 衣橱 / 衣架 */
|
||||
.icon-hanger {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v3'/%3E%3Cpath d='M12 6c-2.5 0-4.5 2-4.5 4.5L4 20h16l-3.5-9.5C16.5 8 14.5 6 12 6z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3v3'/%3E%3Cpath d='M12 6c-2.5 0-4.5 2-4.5 4.5L4 20h16l-3.5-9.5C16.5 8 14.5 6 12 6z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 穿搭 / T恤 */
|
||||
.icon-shirt {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 3l4 4-2 2-1-1v3l2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6l2-2V8L6 9 4 7l4-4h2a2 2 0 0 0 4 0h2z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 3l4 4-2 2-1-1v3l2 2v6a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-6l2-2V8L6 9 4 7l4-4h2a2 2 0 0 0 4 0h2z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 运势 / 四角星 */
|
||||
.icon-sparkles {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 通知 / 铃铛 */
|
||||
.icon-bell {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9'/%3E%3Cpath d='M13.73 21a2 2 0 0 1-3.46 0'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9'/%3E%3Cpath d='M13.73 21a2 2 0 0 1-3.46 0'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 我的 / 人形 */
|
||||
.icon-user {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 加号 */
|
||||
.icon-plus {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14'/%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 5v14'/%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 右箭头 */
|
||||
.icon-chevron-right {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18l6-6-6-6'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 相机 */
|
||||
.icon-camera {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z'/%3E%3Ccircle cx='12' cy='13' r='4'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z'/%3E%3Ccircle cx='12' cy='13' r='4'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 点赞 / 爱心 */
|
||||
.icon-heart {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 收藏 / 星(线框) */
|
||||
.icon-star {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 收藏 / 星(实心) */
|
||||
.icon-star-fill {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 分享 */
|
||||
.icon-share {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cpath d='M8.59 13.51l6.83 3.98'/%3E%3Cpath d='M15.41 6.51l-6.82 3.98'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cpath d='M8.59 13.51l6.83 3.98'/%3E%3Cpath d='M15.41 6.51l-6.82 3.98'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 删除 / 垃圾桶 */
|
||||
.icon-trash {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6'/%3E%3Cpath d='M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6'/%3E%3Cpath d='M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 编辑 / 铅笔 */
|
||||
.icon-pencil {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 会员 / 皇冠 */
|
||||
.icon-crown {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8l4 4 5-6 5 6 4-4-2 9H5L3 8z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 8l4 4 5-6 5 6 4-4-2 9H5L3 8z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 订单 / 文档 */
|
||||
.icon-doc {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z'/%3E%3Cpath d='M14 2v6h6'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 额度 / 仪表 */
|
||||
.icon-gauge {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0-18 0'/%3E%3Cpath d='M12 12l5-3'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0-18 0'/%3E%3Cpath d='M12 12l5-3'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 对勾 */
|
||||
.icon-check {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 叉 */
|
||||
.icon-close {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6L6 18'/%3E%3Cpath d='M6 6l12 12'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M18 6L6 18'/%3E%3Cpath d='M6 6l12 12'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 更多 / 三点 */
|
||||
.icon-ellipsis {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Ccircle cx='12' cy='5' r='2'/%3E%3Ccircle cx='12' cy='12' r='2'/%3E%3Ccircle cx='12' cy='19' r='2'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 生日 / 日历 */
|
||||
.icon-calendar {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cpath d='M16 2v4'/%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cpath d='M16 2v4'/%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M3 10h18'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 客服 / 耳机 */
|
||||
.icon-headphones {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 18v-6a9 9 0 0 1 18 0v6'/%3E%3Cpath d='M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 18v-6a9 9 0 0 1 18 0v6'/%3E%3Cpath d='M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 关于 / 信息 */
|
||||
.icon-info {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 16v-4'/%3E%3Cpath d='M12 8h.01'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* 设置 / 齿轮 */
|
||||
.icon-setting {
|
||||
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3Cpath d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E");
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3Cpath d='M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'/%3E%3C/svg%3E");
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// 后端实体类型定义(对照 接口文档.md)
|
||||
|
||||
export interface UserInfo {
|
||||
username: string
|
||||
avatar: string
|
||||
sex: number // 0 未知 1 男 2 女
|
||||
birth_day: number // 秒级时间戳,0 未设置
|
||||
phone: string
|
||||
is_new_user: boolean
|
||||
status: number // 1 正常 2 临时禁用 3 永久禁用 4 已注销
|
||||
ban_reason: string
|
||||
ban_start: number
|
||||
ban_expire: number
|
||||
vip_type: number // 1 非会员 2 月度会员 3 年度会员 4 体验会员
|
||||
vip_expire: number
|
||||
}
|
||||
|
||||
export interface ClothesItem {
|
||||
clothes_uuid: string
|
||||
category_main: string
|
||||
category_sub: string
|
||||
style: string
|
||||
color_main: string
|
||||
color_sub: string
|
||||
thickness: string
|
||||
version: string
|
||||
material: string
|
||||
pattern: string
|
||||
sleeve_type: string
|
||||
clothes_type: string
|
||||
neckline_type: string
|
||||
formality: string
|
||||
occasions: string
|
||||
visual_impact: string
|
||||
name: string
|
||||
remark: string
|
||||
processed_image: string
|
||||
}
|
||||
|
||||
export interface OutfitItem {
|
||||
outfit_uuid: string
|
||||
outfit_name: string
|
||||
outfit_desc: string
|
||||
scene: string
|
||||
source: number // 1 用户创建 2 AI 推荐
|
||||
is_public: number // 1 私有 2 公开
|
||||
audit_status: number // 1 待审核 2 通过 3 不通过
|
||||
items: Array<{ clothes_uuid: string; processed_image: string }>
|
||||
}
|
||||
|
||||
export interface GoodsItem {
|
||||
id: number
|
||||
goods_code: string
|
||||
goods_name: string
|
||||
goods_type: number // 1 会员 2 上传 3 AI 4 衣柜分享 5 搭配分享
|
||||
price: number // 分
|
||||
discount_price: number // 分
|
||||
member_days: number
|
||||
upload_quota: number
|
||||
ai_recommend_quota: number
|
||||
closet_share_quota: number
|
||||
outfit_share_quota: number
|
||||
valid_days: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
order_no: string
|
||||
goods_name: string
|
||||
unit_price: number // 分
|
||||
pay_amount: number // 分
|
||||
pay_channel: number // 1 微信支付 2 虚拟支付
|
||||
order_status: number // 1 待支付 2 已支付 3 已取消 4 已过期 5 已退款
|
||||
member_days: number
|
||||
expire_time: number // 秒
|
||||
pay_time: number
|
||||
refund_time: number
|
||||
refund_amount: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface NotificationItem {
|
||||
notification_uuid: string
|
||||
msg_type: number // 1 点赞 2 订单 3 系统
|
||||
title: string
|
||||
content: string
|
||||
is_read: number // 1 未读 2 已读
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface QuotaItem {
|
||||
quota_type: number
|
||||
total: number
|
||||
used: number
|
||||
remain: number
|
||||
}
|
||||
|
||||
export interface QuotaFlowItem {
|
||||
quota_flow_uuid: string
|
||||
quota_type: number
|
||||
change_target: number
|
||||
change_type: number // 1 购买 2 活动 3 上传消耗 4 删除回退 5 人工 6 退款回收
|
||||
change_num: number // 正增负减
|
||||
new_value: number
|
||||
reason: string
|
||||
order_no: string
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export interface PayParams {
|
||||
timeStamp: string
|
||||
nonceStr: string
|
||||
package: string
|
||||
signType: string
|
||||
paySign: string
|
||||
}
|
||||
|
||||
// 虚拟支付下单响应(小程序虚拟支付 /order/virtual 返回)
|
||||
export interface VirtualPayResult {
|
||||
order_no: string
|
||||
pay_amount: number // 分
|
||||
expire_time: number // 秒
|
||||
sign_data: string // 参与签名的 JSON 字符串(原样透传给 wx.requestVirtualPayment)
|
||||
pay_sig: string // 支付签名
|
||||
signature: string // 用户态签名
|
||||
}
|
||||
|
||||
export interface HoroscopeData {
|
||||
stat_date: number
|
||||
zodiac: string
|
||||
overall_score: number
|
||||
love_score: number
|
||||
career_score: number
|
||||
wealth_score: number
|
||||
daily_text: string
|
||||
lucky_color: string
|
||||
lucky_number: string
|
||||
}
|
||||
|
||||
export interface ClosetShareItem {
|
||||
share_uuid: string
|
||||
share_type: number
|
||||
clothes_count: number
|
||||
expire_time: number
|
||||
status: number
|
||||
owner_name?: string
|
||||
owner_avatar?: string
|
||||
}
|
||||
|
||||
export interface ClosetShareDetail {
|
||||
share_uuid: string
|
||||
owner_name: string
|
||||
owner_avatar: string
|
||||
clothes: ClothesItem[]
|
||||
expire_time: number
|
||||
status: number
|
||||
is_owner: boolean
|
||||
}
|
||||
|
||||
export interface AiRecommendResult {
|
||||
outfit_uuid: string
|
||||
outfit_name: string
|
||||
outfit_desc: string
|
||||
scene: string
|
||||
source: number
|
||||
status: number
|
||||
outfit_item: Array<{ clothes_uuid: string }>
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expire_time: number
|
||||
user_info: UserInfo
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
list: T[]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { STORAGE_KEYS } from './config'
|
||||
|
||||
export interface TokenData {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expireTime: number
|
||||
}
|
||||
|
||||
export function getAccessToken(): string {
|
||||
return wx.getStorageSync(STORAGE_KEYS.ACCESS_TOKEN) || ''
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string {
|
||||
return wx.getStorageSync(STORAGE_KEYS.REFRESH_TOKEN) || ''
|
||||
}
|
||||
|
||||
export function getExpireTime(): number {
|
||||
return wx.getStorageSync(STORAGE_KEYS.EXPIRE_TIME) || 0
|
||||
}
|
||||
|
||||
export function setTokens(data: TokenData): void {
|
||||
wx.setStorageSync(STORAGE_KEYS.ACCESS_TOKEN, data.accessToken)
|
||||
wx.setStorageSync(STORAGE_KEYS.REFRESH_TOKEN, data.refreshToken)
|
||||
wx.setStorageSync(STORAGE_KEYS.EXPIRE_TIME, data.expireTime)
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
wx.removeStorageSync(STORAGE_KEYS.ACCESS_TOKEN)
|
||||
wx.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
|
||||
wx.removeStorageSync(STORAGE_KEYS.EXPIRE_TIME)
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
return !!getAccessToken()
|
||||
}
|
||||
|
||||
export function clearUserInfo(): void {
|
||||
wx.removeStorageSync(STORAGE_KEYS.USER_INFO)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// 全局配置与常量
|
||||
|
||||
// 后端服务域名(不含 /api/v1 前缀、不含结尾斜杠)
|
||||
export const BASE_URL = 'https://zt-closet-backend.lzcwy.cn'
|
||||
|
||||
// API 统一前缀
|
||||
export const API_PREFIX = '/api/v1'
|
||||
|
||||
// 本地存储 key
|
||||
export const STORAGE_KEYS = {
|
||||
ACCESS_TOKEN: 'zt_closet_access_token',
|
||||
REFRESH_TOKEN: 'zt_closet_refresh_token',
|
||||
EXPIRE_TIME: 'zt_closet_expire_time',
|
||||
USER_INFO: 'zt_closet_user_info',
|
||||
}
|
||||
|
||||
// 衣物图片处理状态
|
||||
export enum ProcessStatus {
|
||||
MATTING = 1, // 抠图中
|
||||
CLASSIFYING = 2, // 分类中
|
||||
QUEUED = 3, // 排队中
|
||||
SUCCESS = 4, // 成功
|
||||
FAILED = 5, // 失败
|
||||
}
|
||||
|
||||
// 订单状态
|
||||
export enum OrderStatus {
|
||||
PENDING = 1, // 待支付
|
||||
PAID = 2, // 已支付
|
||||
CANCELLED = 3, // 已取消
|
||||
EXPIRED = 4, // 已过期
|
||||
REFUNDED = 5, // 已退款
|
||||
}
|
||||
|
||||
// 额度类型
|
||||
export enum QuotaType {
|
||||
UPLOAD = 1, // 上传
|
||||
AI = 2, // AI 推荐
|
||||
OUTFIT_SHARE = 3, // 社区分享(搭配分享)
|
||||
CLOSET_SHARE = 4, // 衣柜分享
|
||||
}
|
||||
|
||||
// 通知类型
|
||||
export enum MsgType {
|
||||
LIKE = 1, // 点赞通知
|
||||
ORDER = 2, // 订单通知
|
||||
SYSTEM = 3, // 系统通知
|
||||
}
|
||||
|
||||
// 穿搭来源
|
||||
export enum OutfitSource {
|
||||
USER = 1, // 用户创建
|
||||
AI = 2, // AI 推荐
|
||||
}
|
||||
|
||||
// 公开状态
|
||||
export enum IsPublic {
|
||||
PRIVATE = 1, // 不公开
|
||||
PUBLIC = 2, // 公开
|
||||
}
|
||||
|
||||
// 穿搭审核状态
|
||||
export enum AuditStatus {
|
||||
PENDING = 1, // 待审核
|
||||
PASS = 2, // 审核通过
|
||||
REJECT = 3, // 审核不通过
|
||||
}
|
||||
|
||||
// 衣柜分享状态
|
||||
export enum ShareStatus {
|
||||
NORMAL = 1, // 正常
|
||||
CANCELLED = 2, // 已取消
|
||||
EXPIRED = 3, // 已过期
|
||||
BANNED = 4, // 已封禁
|
||||
}
|
||||
|
||||
// 分享类型
|
||||
export enum ShareType {
|
||||
PARTIAL = 1, // 部分衣物
|
||||
FULL = 2, // 全量快照
|
||||
}
|
||||
|
||||
// 商品类型
|
||||
export enum GoodsType {
|
||||
MEMBER = 1, // 会员包
|
||||
UPLOAD = 2, // 上传包
|
||||
AI = 3, // AI 推荐包
|
||||
CLOSET_SHARE = 4, // 衣柜分享包
|
||||
OUTFIT_SHARE = 5, // 搭配分享包
|
||||
}
|
||||
|
||||
// 额度名称映射
|
||||
export const QUOTA_NAMES: Record<number, string> = {
|
||||
[QuotaType.UPLOAD]: '上传额度',
|
||||
[QuotaType.AI]: 'AI 推荐额度',
|
||||
[QuotaType.OUTFIT_SHARE]: '社区分享额度',
|
||||
[QuotaType.CLOSET_SHARE]: '衣柜分享额度',
|
||||
}
|
||||
|
||||
// 商品类型名称映射
|
||||
export const GOODS_TYPE_NAMES: Record<number, string> = {
|
||||
[GoodsType.MEMBER]: '会员',
|
||||
[GoodsType.UPLOAD]: '上传',
|
||||
[GoodsType.AI]: 'AI 推荐',
|
||||
[GoodsType.CLOSET_SHARE]: '衣柜分享',
|
||||
[GoodsType.OUTFIT_SHARE]: '搭配分享',
|
||||
}
|
||||
|
||||
// 错误码统一文案(兜底,优先展示服务端 details[0])
|
||||
export const ERROR_MESSAGES: Record<number, string> = {
|
||||
80000: '额度不足',
|
||||
80001: '额度记录不存在',
|
||||
90401: 'AI 推荐次数已达上限',
|
||||
90400: 'AI 推荐失败',
|
||||
90300: '运势生成中,请稍后重试',
|
||||
10003: '操作过于频繁,请稍后重试',
|
||||
11000: '请先登录',
|
||||
11001: '登录状态已失效',
|
||||
11002: '登录状态已过期',
|
||||
11004: '请先登录',
|
||||
12000: '无权限操作',
|
||||
21001: '账号已被禁用',
|
||||
22000: '用户不存在',
|
||||
30000: '上传文件为空',
|
||||
30001: '不支持的文件类型',
|
||||
30002: '文件大小超过限制',
|
||||
50000: '衣物不存在',
|
||||
50001: '该衣物已被搭配使用,无法删除',
|
||||
50005: '无权操作该衣物',
|
||||
40000: '穿搭不存在',
|
||||
40004: '无权操作该穿搭',
|
||||
60000: '商品不存在',
|
||||
60001: '商品已下架',
|
||||
70000: '订单不存在',
|
||||
70001: '订单状态不正确',
|
||||
70002: '订单已支付',
|
||||
70005: '虚拟支付未开通',
|
||||
70006: '商品尚未发布为虚拟支付道具',
|
||||
70007: '虚拟支付订单不支持取消',
|
||||
70008: 'iOS 虚拟支付最低 1 元',
|
||||
70009: '订单真实性校验失败',
|
||||
71001: '当前状态不允许退款',
|
||||
71004: 'iOS 订单请通过 App Store 申请退款',
|
||||
79003: '创建虚拟支付订单失败',
|
||||
90100: '分享不存在',
|
||||
90101: '分享已过期',
|
||||
90102: '分享已取消',
|
||||
90200: '目标内容不存在',
|
||||
19000: '服务繁忙,请稍后重试',
|
||||
19001: '服务繁忙,请稍后重试',
|
||||
19002: '服务繁忙,请稍后重试',
|
||||
19003: '服务繁忙,请稍后重试',
|
||||
19999: '服务繁忙,请稍后重试',
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 金额 / 时间格式化工具
|
||||
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`
|
||||
}
|
||||
|
||||
// 金额(分)转「¥xx.xx」字符串
|
||||
export function fenToYuan(fen: number | null | undefined): string {
|
||||
if (fen === null || fen === undefined || isNaN(fen)) return '¥0.00'
|
||||
return `¥${(fen / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
// 秒级时间戳转 YYYY-MM-DD
|
||||
export function formatDate(seconds: number | null | undefined): string {
|
||||
if (!seconds) return ''
|
||||
const d = new Date(seconds * 1000)
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||
}
|
||||
|
||||
// 秒级时间戳转 YYYY-MM-DD HH:mm
|
||||
export function formatDateTime(seconds: number | null | undefined): string {
|
||||
if (!seconds) return ''
|
||||
const d = new Date(seconds * 1000)
|
||||
return `${formatDate(seconds)} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// 相对时间:刚刚 / x分钟前 / x小时前 / 今天 / YYYY-MM-DD
|
||||
export function formatRelativeTime(seconds: number | null | undefined): string {
|
||||
if (!seconds) return ''
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const diff = now - seconds
|
||||
if (diff < 0) return formatDate(seconds)
|
||||
if (diff < 60) return '刚刚'
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
|
||||
|
||||
const d = new Date(seconds * 1000)
|
||||
const today = new Date()
|
||||
const isToday =
|
||||
d.getFullYear() === today.getFullYear() &&
|
||||
d.getMonth() === today.getMonth() &&
|
||||
d.getDate() === today.getDate()
|
||||
|
||||
if (diff < 86400) {
|
||||
return isToday ? '今天' : `${Math.floor(diff / 3600)} 小时前`
|
||||
}
|
||||
return formatDate(seconds)
|
||||
}
|
||||
|
||||
// 支付倒计时(秒)转 mm:ss
|
||||
export function formatCountdown(totalSeconds: number): string {
|
||||
if (totalSeconds <= 0) return '00:00'
|
||||
const m = Math.floor(totalSeconds / 60)
|
||||
const s = Math.floor(totalSeconds % 60)
|
||||
return `${pad(m)}:${pad(s)}`
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { BASE_URL, API_PREFIX, ERROR_MESSAGES } from './config'
|
||||
import * as auth from './auth'
|
||||
|
||||
// 业务错误对象(携带 code,页面可按 code 定制处理)
|
||||
export class ApiError extends Error {
|
||||
code: number
|
||||
details?: string[]
|
||||
constructor(code: number, msg: string, details?: string[]) {
|
||||
super(msg)
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
url: string
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
|
||||
data?: any
|
||||
header?: Record<string, any>
|
||||
auth?: boolean // 默认 true,需要鉴权
|
||||
showError?: boolean // 默认 true,业务错误自动 toast
|
||||
}
|
||||
|
||||
// 登录失效 / 账号禁用后的跳转处理(由 app.ts 注入,避免循环依赖)
|
||||
let onAuthExpired: (() => void) | null = null
|
||||
export function setAuthExpiredHandler(handler: () => void): void {
|
||||
onAuthExpired = handler
|
||||
}
|
||||
|
||||
// 单例刷新锁,避免并发请求同时刷新
|
||||
let refreshingPromise: Promise<boolean> | null = null
|
||||
|
||||
function doRefresh(): Promise<boolean> {
|
||||
if (!refreshingPromise) {
|
||||
refreshingPromise = new Promise<boolean>((resolve) => {
|
||||
const refreshToken = auth.getRefreshToken()
|
||||
const accessToken = auth.getAccessToken()
|
||||
if (!refreshToken || !accessToken) {
|
||||
resolve(false)
|
||||
return
|
||||
}
|
||||
wx.request({
|
||||
url: `${BASE_URL}${API_PREFIX}/user/refresh-token`,
|
||||
method: 'POST',
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
data: { refresh_token: refreshToken, access_token: accessToken },
|
||||
success: (res) => {
|
||||
const body = res.data as { code: number; data?: LoginTokenResponse }
|
||||
if (body && body.code === 0 && body.data) {
|
||||
auth.setTokens({
|
||||
accessToken: body.data.access_token,
|
||||
refreshToken: body.data.refresh_token,
|
||||
expireTime: body.data.expire_time,
|
||||
})
|
||||
resolve(true)
|
||||
} else {
|
||||
resolve(false)
|
||||
}
|
||||
},
|
||||
fail: () => resolve(false),
|
||||
})
|
||||
}).finally(() => {
|
||||
refreshingPromise = null
|
||||
})
|
||||
}
|
||||
return refreshingPromise
|
||||
}
|
||||
|
||||
interface LoginTokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
expire_time: number
|
||||
}
|
||||
|
||||
function showToast(title: string): void {
|
||||
wx.showToast({ title, icon: 'none', duration: 2000 })
|
||||
}
|
||||
|
||||
function redirectToLogin(): void {
|
||||
if (onAuthExpired) {
|
||||
onAuthExpired()
|
||||
} else {
|
||||
wx.reLaunch({ url: '/pages/login/login' })
|
||||
}
|
||||
}
|
||||
|
||||
export function request<T = any>(options: RequestOptions): Promise<T> {
|
||||
const {
|
||||
url,
|
||||
method = 'GET',
|
||||
data,
|
||||
header = {},
|
||||
auth: needAuth = true,
|
||||
showError = true,
|
||||
} = options
|
||||
|
||||
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const doCall = (): void => {
|
||||
const finalHeader: Record<string, any> = {
|
||||
'Content-Type': 'application/json',
|
||||
...header,
|
||||
}
|
||||
if (needAuth) {
|
||||
const token = auth.getAccessToken()
|
||||
if (token) finalHeader['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
wx.request({
|
||||
url: fullUrl,
|
||||
method,
|
||||
data,
|
||||
header: finalHeader,
|
||||
success: (res) => {
|
||||
const body = res.data as { code: number; msg: string; data?: T; details?: string[] }
|
||||
|
||||
// 网络层异常:响应体无 code
|
||||
if (!body || typeof body.code !== 'number') {
|
||||
if (showError) showToast('网络异常,请稍后重试')
|
||||
reject(new Error('network error'))
|
||||
return
|
||||
}
|
||||
|
||||
const code = body.code
|
||||
|
||||
// 成功
|
||||
if (code === 0) {
|
||||
resolve(body.data as T)
|
||||
return
|
||||
}
|
||||
|
||||
// Token 失效:尝试刷新后重试一次
|
||||
if (
|
||||
needAuth &&
|
||||
(code === 11001 || code === 11002 || code === 11004 || res.statusCode === 401)
|
||||
) {
|
||||
doRefresh().then((ok) => {
|
||||
if (ok) {
|
||||
doCall()
|
||||
} else {
|
||||
auth.clearTokens()
|
||||
redirectToLogin()
|
||||
if (showError) showToast('登录已过期,请重新登录')
|
||||
reject(new ApiError(code, body.msg || '登录已过期'))
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 账号禁用(HTTP 200,需单独处理)
|
||||
if (code === 21001) {
|
||||
auth.clearTokens()
|
||||
redirectToLogin()
|
||||
if (showError) showToast(ERROR_MESSAGES[21001] || '账号已被禁用')
|
||||
reject(new ApiError(code, body.msg || '账号已被禁用'))
|
||||
return
|
||||
}
|
||||
|
||||
// 其余业务错误
|
||||
if (showError) {
|
||||
const msg =
|
||||
(body.details && body.details[0]) || ERROR_MESSAGES[code] || body.msg || '请求失败'
|
||||
showToast(msg)
|
||||
}
|
||||
reject(new ApiError(code, body.msg || '请求失败', body.details))
|
||||
},
|
||||
fail: () => {
|
||||
if (showError) showToast('网络异常,请检查网络后重试')
|
||||
reject(new Error('request fail'))
|
||||
},
|
||||
})
|
||||
}
|
||||
doCall()
|
||||
})
|
||||
}
|
||||
|
||||
// 文件上传(multipart,字段名 file)
|
||||
export function uploadFile<T = any>(filePath: string, name = 'file', url = '/clothes/upload'): Promise<T> {
|
||||
const token = auth.getAccessToken()
|
||||
const header: Record<string, any> = {}
|
||||
if (token) header['Authorization'] = `Bearer ${token}`
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: url.startsWith('http') ? url : `${BASE_URL}${API_PREFIX}${url}`,
|
||||
filePath,
|
||||
name,
|
||||
header,
|
||||
success: (res) => {
|
||||
let body: { code: number; msg: string; data?: T; details?: string[] } | null = null
|
||||
try {
|
||||
body = JSON.parse(res.data)
|
||||
} catch (e) {
|
||||
body = null
|
||||
}
|
||||
if (body && typeof body.code === 'number') {
|
||||
if (body.code === 0) {
|
||||
resolve(body.data as T)
|
||||
} else {
|
||||
const msg =
|
||||
(body.details && body.details[0]) || ERROR_MESSAGES[body.code] || body.msg || '上传失败'
|
||||
showToast(msg)
|
||||
reject(new ApiError(body.code, body.msg || '上传失败', body.details))
|
||||
}
|
||||
} else {
|
||||
showToast('上传失败,请稍后重试')
|
||||
reject(new Error('upload fail'))
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
showToast('上传失败,请检查网络后重试')
|
||||
reject(new Error('upload fail'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const formatTime = (date: Date) => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hour = date.getHours()
|
||||
const minute = date.getMinutes()
|
||||
const second = date.getSeconds()
|
||||
|
||||
return (
|
||||
[year, month, day].map(formatNumber).join('/') +
|
||||
' ' +
|
||||
[hour, minute, second].map(formatNumber).join(':')
|
||||
)
|
||||
}
|
||||
|
||||
const formatNumber = (n: number) => {
|
||||
const s = n.toString()
|
||||
return s[1] ? s : '0' + s
|
||||
}
|
||||
Reference in New Issue
Block a user