init
@@ -1,12 +1,27 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
pages: [
|
pages: [
|
||||||
'pages/index/index',
|
'pages/index/index',
|
||||||
|
'pages/product/index',
|
||||||
|
'pages/cart/index',
|
||||||
|
'pages/profile/index',
|
||||||
|
'pages/message/index',
|
||||||
|
'pages/login/index',
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'light',
|
backgroundTextStyle: 'light',
|
||||||
navigationBarBackgroundColor: '#fff',
|
navigationBarBackgroundColor: '#ffffff',
|
||||||
navigationBarTitleText: 'WeChat',
|
navigationBarTitleText: '订货采购',
|
||||||
navigationBarTextStyle: 'black',
|
navigationBarTextStyle: 'black',
|
||||||
},
|
},
|
||||||
|
tabBar: {
|
||||||
|
custom: true,
|
||||||
|
list: [
|
||||||
|
{ pagePath: 'pages/index/index', text: '首页' },
|
||||||
|
{ pagePath: 'pages/product/index', text: '商品' },
|
||||||
|
{ pagePath: 'pages/cart/index', text: '购物车' },
|
||||||
|
{ pagePath: 'pages/message/index', text: '消息' },
|
||||||
|
{ pagePath: 'pages/profile/index', text: '我的' },
|
||||||
|
],
|
||||||
|
},
|
||||||
animation: false,
|
animation: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class App extends Component {
|
|||||||
|
|
||||||
// this.props.children 是将要会渲染的页面
|
// this.props.children 是将要会渲染的页面
|
||||||
render () {
|
render () {
|
||||||
|
// @ts-ignore
|
||||||
return this.props.children
|
return this.props.children
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,67 +4,38 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-around;
|
height: 110rpx;
|
||||||
background: rgba(255, 255, 255, 0.85);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
-webkit-backdrop-filter: blur(20px);
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
border-top: 1rpx solid rgba(0, 0, 0, 0.06);
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
box-sizing: border-box;
|
box-sizing: content-box;
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 10px;
|
height: 100%;
|
||||||
flex: 1;
|
color: #969799;
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.tab-icon {
|
|
||||||
font-size: 40px;
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-label {
|
|
||||||
font-size: 20px;
|
|
||||||
color: #969799;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active .tab-label {
|
|
||||||
color: #1989fa;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 中间发布按钮 */
|
.tab-icon{
|
||||||
.tab-publish {
|
width: 36px;
|
||||||
justify-content: flex-start;
|
height: 36px;
|
||||||
padding-top: 0;
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.publish-btn {
|
.tab-label {
|
||||||
width: 88px;
|
font-size: 24rpx;
|
||||||
height: 88px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #1989fa 0%, #07c160 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: 0 8px 24px rgba(25, 137, 250, 0.4);
|
|
||||||
margin-top: -30px;
|
|
||||||
|
|
||||||
.publish-icon {
|
|
||||||
font-size: 44px;
|
|
||||||
color: #fff;
|
|
||||||
font-weight: 300;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.publish-label {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.custom-tab-content {
|
||||||
|
height: 110rpx;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,60 +1,88 @@
|
|||||||
|
import {useEffect, useState} from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { View, Text } from '@tarojs/components'
|
import {View, Text, Image} from '@tarojs/components'
|
||||||
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||||
|
import useCartStore from '@/stores/cart/useCartStore'
|
||||||
|
import IndexImage from '@/static/images/nav/index.png';
|
||||||
|
import IndexActiveImage from '@/static/images/nav/index_active.png';
|
||||||
|
import CartImage from '@/static/images/nav/cart.png';
|
||||||
|
import CartActiveImage from '@/static/images/nav/cart_active.png';
|
||||||
|
import MessageImage from '@/static/images/nav/message.png';
|
||||||
|
import MessageActiveImage from '@/static/images/nav/message_active.png';
|
||||||
|
import ProductImage from '@/static/images/nav/product.png';
|
||||||
|
import ProductActiveImage from '@/static/images/nav/product_active.png';
|
||||||
|
import ProfileImage from '@/static/images/nav/profile.png';
|
||||||
|
import ProfileActiveImage from '@/static/images/nav/profile_active.png';
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
interface CustomTabBarProps {
|
/** tab 配置(与 app.config.ts 的 tabBar.list 保持一致) */
|
||||||
/** 当前激活的 tab key。H5 由页面传入;小程序由 Taro 自动渲染,props 为空 */
|
const TAB_LIST = [
|
||||||
activeKey?: string
|
{
|
||||||
}
|
key: 'index',
|
||||||
|
label: '首页',
|
||||||
|
icon: IndexImage,
|
||||||
|
activeIcon: IndexActiveImage,
|
||||||
|
path: '/pages/index/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'product',
|
||||||
|
label: '商品',
|
||||||
|
icon: ProductImage,
|
||||||
|
activeIcon: ProductActiveImage,
|
||||||
|
path: '/pages/product/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cart',
|
||||||
|
label: '购物车',
|
||||||
|
icon: CartImage,
|
||||||
|
activeIcon: CartActiveImage,
|
||||||
|
path: '/pages/cart/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'message',
|
||||||
|
label: '消息',
|
||||||
|
icon: MessageImage,
|
||||||
|
activeIcon: MessageActiveImage,
|
||||||
|
path: '/pages/message/index'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
label: '我的',
|
||||||
|
icon: ProfileImage,
|
||||||
|
activeIcon: ProfileActiveImage,
|
||||||
|
path: '/pages/profile/index'
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CustomTabBar() {
|
||||||
|
const [activeKey, setActiveKey] = useState('index');
|
||||||
|
// 从当前路由推导激活 tab(Taro 在每个页面重新实例化 tabBar,渲染时取值即可)
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(pages);
|
||||||
|
const route = pages[pages.length - 1]?.route ?? ''
|
||||||
|
setActiveKey(TAB_LIST.find(tab => tab.path.includes(route))?.key ?? 'index')
|
||||||
|
}, [pages]);
|
||||||
|
|
||||||
export default function CustomTabBar({ activeKey }: CustomTabBarProps) {
|
|
||||||
|
|
||||||
/** 切换 Tab */
|
/** 切换 Tab */
|
||||||
const handleTabClick = (tab: string, path: string) => {
|
const handleTabTap = (tab: (typeof TAB_LIST)[number]) => {
|
||||||
if (tab === activeKey) return
|
if (tab.key === activeKey) return
|
||||||
Taro.switchTab({ url: path })
|
Taro.switchTab({ url: tab.path })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<View>
|
||||||
|
<View className='custom-tab-content'></View>
|
||||||
<View className='custom-tab-bar'>
|
<View className='custom-tab-bar'>
|
||||||
<View
|
{TAB_LIST.map(tab => (
|
||||||
className={`tab-item ${activeKey === 'index' ? 'active' : ''}`}
|
<View key={tab.key} onClick={() => handleTabTap(tab)} className='tab-item'>
|
||||||
onClick={() => handleTabClick('index', '/index')}
|
<Image src={activeKey === tab.key ? tab.activeIcon : tab.icon} className='tab-icon' />
|
||||||
>
|
<Text className='tab-label' style={{ color: activeKey === tab.key ? '#d81e06' : '#000' }}>{tab.label}</Text>
|
||||||
<Text className='tab-icon'>首页</Text>
|
|
||||||
<Text className='tab-label'>首页</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`tab-item ${activeKey === 'index' ? 'active' : ''}`}
|
|
||||||
onClick={() => handleTabClick('index', '/index')}
|
|
||||||
>
|
|
||||||
<Text className='tab-icon'>首页</Text>
|
|
||||||
<Text className='tab-label'>商品</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 中间发布按钮 —— 不属于 tabBar list,纯 UI 元素 */}
|
|
||||||
<View className='tab-item tab-publish' onClick={() => handleTabClick('', '')}>
|
|
||||||
<View className='publish-btn'>
|
|
||||||
<Text className='publish-icon'>+</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Text className='tab-label publish-label'>购物车</Text>
|
))}
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`tab-item ${activeKey === 'index' ? 'active' : ''}`}
|
|
||||||
onClick={() => handleTabClick('index', '/index')}
|
|
||||||
>
|
|
||||||
<Text className='tab-icon'>首页</Text>
|
|
||||||
<Text className='tab-label'>消息</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
className={`tab-item ${activeKey === 'index' ? 'active' : ''}`}
|
|
||||||
onClick={() => handleTabClick('index', '/index')}
|
|
||||||
>
|
|
||||||
<Text className='tab-icon'>首页</Text>
|
|
||||||
<Text className='tab-label'>我的</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no,address=no">
|
<meta name="format-detection" content="telephone=no,address=no">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
|
||||||
<title>antmjs</title>
|
<title>订货采购</title>
|
||||||
<script crossorigin="anonymous" src="https://polyfill.alicdn.com/polyfill.min.js?features=es2015%2Ces2016%2Ces2017%2Ces2018%2Ces2019%2Ces2020%2Ces2021%2Ces2022"></script>
|
<script crossorigin="anonymous" src="https://polyfill.alicdn.com/polyfill.min.js?features=es2015%2Ces2016%2Ces2017%2Ces2018%2Ces2019%2Ces2020%2Ces2021%2Ces2022"></script>
|
||||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '首页'
|
navigationBarTitleText: '购物车',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
.cart-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
padding: 20rpx 24rpx 200rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// ===== 头部 =====
|
||||||
|
.cart-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8rpx 8rpx 24rpx;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__clear {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-empty {
|
||||||
|
padding-top: 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-loading {
|
||||||
|
padding-top: 160rpx;
|
||||||
|
text-align: center;
|
||||||
|
color: #c8c9cc;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 购物车项 =====
|
||||||
|
.cart-item {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&--invalid {
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.cart-item__name {
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
width: 150rpx;
|
||||||
|
height: 150rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f2f3f5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__invalid-tag {
|
||||||
|
margin-left: 12rpx;
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #c8c9cc;
|
||||||
|
border: 1rpx solid #d8d9db;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
padding: 2rpx 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spec {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--none {
|
||||||
|
color: #c8c9cc;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
margin-left: 20rpx;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amount {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__delete {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
padding: 8rpx 4rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cart-invalid-hint {
|
||||||
|
padding: 20rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fa5151;
|
||||||
|
background: #fff4f4;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 底部结算栏 =====
|
||||||
|
.cart-footer {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(110rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
border-top: 1rpx solid #ebedf0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amount {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
width: 240rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 下单确认弹层 =====
|
||||||
|
.order-popup {
|
||||||
|
padding: 32rpx 32rpx 24rpx;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
max-height: 480rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-spec {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-qty {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #969799;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-amount {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remark {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remark-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #323233;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remark-input {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120rpx;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 16rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
|
||||||
|
// 金额颜色由内部元素控制
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
width: 280rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +1,282 @@
|
|||||||
import { View } from '@tarojs/components'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { Button } from '@antmjs/vantui'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text, Image, Textarea, ScrollView } from '@tarojs/components'
|
||||||
|
import { Button, Empty, Popup, Stepper } from '@antmjs/vantui'
|
||||||
|
import useCartStore from '@/stores/cart/useCartStore'
|
||||||
|
import { createOrderApi } from '@/services/order'
|
||||||
|
import type { CartItem } from '@/types/cart'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
export default function Index() {
|
export default function CartPage() {
|
||||||
|
const items = useCartStore(s => s.items)
|
||||||
|
const totalQuantity = useCartStore(s => s.totalQuantity)
|
||||||
|
const totalAmount = useCartStore(s => s.totalAmount)
|
||||||
|
const loading = useCartStore(s => s.loading)
|
||||||
|
const fetchCart = useCartStore(s => s.fetchCart)
|
||||||
|
const updateQuantity = useCartStore(s => s.updateQuantity)
|
||||||
|
const removeItem = useCartStore(s => s.removeItem)
|
||||||
|
const clearCart = useCartStore(s => s.clearCart)
|
||||||
|
const clearLocal = useCartStore(s => s.clearLocal)
|
||||||
|
|
||||||
|
/** 本地编辑中的数量(输入即时生效,500ms 防抖后提交服务端) */
|
||||||
|
const [qtyMap, setQtyMap] = useState<Record<number, string>>({})
|
||||||
|
const debounceRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({})
|
||||||
|
|
||||||
|
/** 下单弹层 */
|
||||||
|
const [showOrder, setShowOrder] = useState(false)
|
||||||
|
const [remark, setRemark] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
/** 可购项(status=1) */
|
||||||
|
const purchasable = items.filter(item => item.status === 1)
|
||||||
|
const hasInvalid = items.length > 0 && purchasable.length < items.length
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
fetchCart().catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 同步本地编辑数量:删除不存在的项,保留在编数量 */
|
||||||
|
useEffect(() => {
|
||||||
|
setQtyMap(prev => {
|
||||||
|
const next: Record<number, string> = {}
|
||||||
|
for (const item of items) {
|
||||||
|
if (prev[item.id] !== undefined) {
|
||||||
|
next[item.id] = prev[item.id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
/** 清理防抖定时器 */
|
||||||
|
useEffect(() => {
|
||||||
|
const timers = debounceRef.current
|
||||||
|
return () => {
|
||||||
|
Object.values(timers).forEach(t => clearTimeout(t))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 数量变更:本地即时更新 + 防抖提交 */
|
||||||
|
const handleQtyChange = useCallback(
|
||||||
|
(item: CartItem, val: string | number) => {
|
||||||
|
const value = Number(val)
|
||||||
|
if (!value || value <= 0) return
|
||||||
|
setQtyMap(prev => ({ ...prev, [item.id]: String(value) }))
|
||||||
|
if (debounceRef.current[item.id]) {
|
||||||
|
clearTimeout(debounceRef.current[item.id])
|
||||||
|
}
|
||||||
|
debounceRef.current[item.id] = setTimeout(() => {
|
||||||
|
updateQuantity(item.id, value).catch(() => {
|
||||||
|
// 服务端拒绝(超上限等)→ 重新同步展示
|
||||||
|
fetchCart().catch(() => {})
|
||||||
|
})
|
||||||
|
}, 500)
|
||||||
|
},
|
||||||
|
[updateQuantity, fetchCart],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 删除单项 */
|
||||||
|
const handleRemove = useCallback(
|
||||||
|
(item: CartItem) => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '删除商品',
|
||||||
|
content: `确定删除「${item.name}」吗?`,
|
||||||
|
confirmColor: '#ee0a24',
|
||||||
|
success: res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
removeItem(item.id).catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[removeItem],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 清空购物车 */
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '清空购物车',
|
||||||
|
content: '确定清空购物车吗?',
|
||||||
|
confirmColor: '#ee0a24',
|
||||||
|
success: res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
clearCart().catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [clearCart])
|
||||||
|
|
||||||
|
/** 打开下单弹层 */
|
||||||
|
const handleOrderTap = useCallback(() => {
|
||||||
|
if (!purchasable.length) {
|
||||||
|
Taro.showToast({ title: '没有可购买的商品', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowOrder(true)
|
||||||
|
}, [purchasable.length])
|
||||||
|
|
||||||
|
/** 提交订单(金额一律服务端重算) */
|
||||||
|
const handleSubmitOrder = useCallback(async () => {
|
||||||
|
if (submitting) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await createOrderApi({
|
||||||
|
items: purchasable.map(item => ({
|
||||||
|
product_id: item.product_id,
|
||||||
|
quantity: Number(qtyMap[item.id] ?? item.quantity),
|
||||||
|
})),
|
||||||
|
remark: remark.trim() || undefined,
|
||||||
|
})
|
||||||
|
Taro.showToast({ title: '下单成功', icon: 'success' })
|
||||||
|
setShowOrder(false)
|
||||||
|
setRemark('')
|
||||||
|
// 本地先清空,再以服务端为准同步(服务端可能保留购物车内容)
|
||||||
|
clearLocal()
|
||||||
|
fetchCart().catch(() => {})
|
||||||
|
} catch {
|
||||||
|
// 错误(已下架/未设等级价)已由 request 层 toast
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [submitting, purchasable, qtyMap, remark, clearLocal, fetchCart])
|
||||||
|
|
||||||
|
/** 当前展示的数量 */
|
||||||
|
const displayQty = (item: CartItem) => qtyMap[item.id] ?? item.quantity
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='index'>
|
<View className='cart-page'>
|
||||||
<View><Button type='primary'>Hello world!</Button></View>
|
{/* ========== 头部 ========== */}
|
||||||
<View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/styles/index.less</View>
|
<View className='cart-header'>
|
||||||
|
<Text className='cart-header__title'>购物车</Text>
|
||||||
|
{items.length > 0 && (
|
||||||
|
<Text className='cart-header__clear' onClick={handleClear}>清空</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 列表 ========== */}
|
||||||
|
{items.length === 0 ? (
|
||||||
|
loading ? (
|
||||||
|
<View className='cart-loading'><Text>加载中...</Text></View>
|
||||||
|
) : (
|
||||||
|
<Empty description='购物车还是空的,去逛逛吧' className='cart-empty'>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
size='small'
|
||||||
|
round
|
||||||
|
onClick={() => Taro.switchTab({ url: '/pages/product/index' })}
|
||||||
|
>
|
||||||
|
去选购
|
||||||
|
</Button>
|
||||||
|
</Empty>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
items.map(item => (
|
||||||
|
<View key={item.id} className={`cart-item ${item.status === 0 ? 'cart-item--invalid' : ''}`}>
|
||||||
|
<Image className='cart-item__image' src={item.image} mode='aspectFill' lazyLoad />
|
||||||
|
<View className='cart-item__info'>
|
||||||
|
<View className='cart-item__title-row'>
|
||||||
|
<Text className='cart-item__name'>{item.name}</Text>
|
||||||
|
{item.status === 0 && <Text className='cart-item__invalid-tag'>已失效</Text>}
|
||||||
|
</View>
|
||||||
|
<Text className='cart-item__spec'>{item.spec} / {item.unit}</Text>
|
||||||
|
<View className='cart-item__bottom'>
|
||||||
|
{item.price !== null ? (
|
||||||
|
<Text className='cart-item__price'>¥{item.price}</Text>
|
||||||
|
) : (
|
||||||
|
<Text className='cart-item__price cart-item__price--none'>价格待定</Text>
|
||||||
|
)}
|
||||||
|
{item.status === 1 ? (
|
||||||
|
<Stepper
|
||||||
|
value={displayQty(item)}
|
||||||
|
min={1}
|
||||||
|
max={99999999.99}
|
||||||
|
inputWidth='90rpx'
|
||||||
|
buttonSize='56rpx'
|
||||||
|
onChange={e => handleQtyChange(item, e.detail)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text className='cart-item__delete' onClick={() => handleRemove(item)}>删除</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{item.status === 1 && (
|
||||||
|
<View className='cart-item__actions'>
|
||||||
|
<Text className='cart-item__amount'>¥{item.amount ?? '0.00'}</Text>
|
||||||
|
<Text className='cart-item__delete' onClick={() => handleRemove(item)}>删除</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasInvalid && (
|
||||||
|
<View className='cart-invalid-hint'>
|
||||||
|
<Text>部分商品已下架或未设置您所在等级的价格,不可下单</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 底部结算栏 ========== */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<View className='cart-footer'>
|
||||||
|
<View className='cart-footer__total'>
|
||||||
|
<Text className='cart-footer__label'>合计(可购{totalQuantity}件)</Text>
|
||||||
|
<Text className='cart-footer__amount'>¥{totalAmount}</Text>
|
||||||
|
</View>
|
||||||
|
<Button type='danger' className='cart-footer__submit' onClick={handleOrderTap}>
|
||||||
|
去下单
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 下单确认弹层 ========== */}
|
||||||
|
<Popup
|
||||||
|
show={showOrder}
|
||||||
|
position='bottom'
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
closeOnClickOverlay
|
||||||
|
safeAreaInsetBottom
|
||||||
|
onClose={() => setShowOrder(false)}
|
||||||
|
>
|
||||||
|
<View className='order-popup'>
|
||||||
|
<Text className='order-popup__title'>确认下单</Text>
|
||||||
|
<ScrollView scrollY className='order-popup__list'>
|
||||||
|
{purchasable.map(item => (
|
||||||
|
<View key={item.id} className='order-popup__item'>
|
||||||
|
<View className='order-popup__item-info'>
|
||||||
|
<Text className='order-popup__item-name'>{item.name}</Text>
|
||||||
|
<Text className='order-popup__item-spec'>{item.spec} / {item.unit}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='order-popup__item-right'>
|
||||||
|
<Text className='order-popup__item-qty'>×{displayQty(item)}</Text>
|
||||||
|
<Text className='order-popup__item-amount'>¥{item.amount ?? '0.00'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<View className='order-popup__remark'>
|
||||||
|
<Text className='order-popup__remark-label'>订单备注</Text>
|
||||||
|
<Textarea
|
||||||
|
className='order-popup__remark-input'
|
||||||
|
value={remark}
|
||||||
|
placeholder='选填,最多 255 字'
|
||||||
|
maxlength={255}
|
||||||
|
onInput={e => setRemark(e.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className='order-popup__footer'>
|
||||||
|
<Text className='order-popup__total'>合计 ¥{totalAmount}</Text>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
className='order-popup__submit'
|
||||||
|
loading={submitting}
|
||||||
|
onClick={handleSubmitOrder}
|
||||||
|
>
|
||||||
|
提交订单
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// export default class Index extends Component {
|
|
||||||
|
|
||||||
// componentWillMount () { }
|
|
||||||
|
|
||||||
// componentDidMount () { }
|
|
||||||
|
|
||||||
// componentWillUnmount () { }
|
|
||||||
|
|
||||||
// componentDidShow () { }
|
|
||||||
|
|
||||||
// componentDidHide () { }
|
|
||||||
|
|
||||||
// render () {
|
|
||||||
// return (
|
|
||||||
// <View className='index'>
|
|
||||||
// <View><Button type='primary'>Hello world!</Button></View>
|
|
||||||
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
|
|
||||||
// </View>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '首页'
|
navigationBarTitleText: '首页',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
.home-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
padding: 20rpx 24rpx 140rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// ===== 门店信息卡片 =====
|
||||||
|
.store-card {
|
||||||
|
background: linear-gradient(135deg, #ee0a24, #ff4d4f);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
opacity: 0.85;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__identity {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
|
||||||
|
&--level {
|
||||||
|
background: #ffd21e;
|
||||||
|
color: #7a5b00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 12rpx 30rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #fff;
|
||||||
|
color: #ee0a24;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 汇总卡片 =====
|
||||||
|
.summary-card {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__period {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #323233;
|
||||||
|
|
||||||
|
&--strong {
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 通用区块卡片 =====
|
||||||
|
.section-card {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__more {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
padding: 40rpx 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 分类横向滚动 =====
|
||||||
|
.category-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0 -28rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14rpx 30rpx;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border: 1rpx solid #ebedf0;
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #323233;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 通知 =====
|
||||||
|
.notice-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dot {
|
||||||
|
width: 14rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ee0a24;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +1,236 @@
|
|||||||
import { View } from '@tarojs/components'
|
import { useCallback, useState } from 'react'
|
||||||
import { Button } from '@antmjs/vantui'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
|
import { Grid, GridItem } from '@antmjs/vantui'
|
||||||
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||||
|
import useCartStore from '@/stores/cart/useCartStore'
|
||||||
|
import { getOrderSummaryApi } from '@/services/order'
|
||||||
|
import { getCategoriesApi } from '@/services/product'
|
||||||
|
import { getNoticeListApi, readNoticeApi } from '@/services/notice'
|
||||||
|
import { resolveAvatarUrl } from '@/utils/format'
|
||||||
|
import type { Category } from '@/types/product'
|
||||||
|
import type { SummaryGroup } from '@/types/order'
|
||||||
|
import type { Notice } from '@/types/notice'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
export default function Index() {
|
export default function IndexPage() {
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
const fetchCart = useCartStore(s => s.fetchCart)
|
||||||
|
|
||||||
|
/** 本月订货汇总(取当前月份分组,无则取最近一组) */
|
||||||
|
const [summary, setSummary] = useState<SummaryGroup | null>(null)
|
||||||
|
/** 顶级分类快捷入口 */
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
/** 最新通知 */
|
||||||
|
const [notices, setNotices] = useState<Notice[]>([])
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0)
|
||||||
|
|
||||||
|
const loggedIn = !!token && !!user
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
if (!loggedIn) return
|
||||||
|
// 静默同步购物车角标
|
||||||
|
fetchCart().catch(() => {})
|
||||||
|
loadSummary()
|
||||||
|
loadCategories()
|
||||||
|
loadNotices()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 本月订货汇总 */
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getOrderSummaryApi('month')
|
||||||
|
const groups = res.data.groups
|
||||||
|
if (!groups.length) {
|
||||||
|
setSummary(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const now = new Date()
|
||||||
|
const thisMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
setSummary(groups.find(g => g.period_label === thisMonth) ?? groups[groups.length - 1])
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 商品分类(顶级) */
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCategoriesApi()
|
||||||
|
setCategories(res.data.filter(c => c.parent_id === 0))
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 最新通知 + 未读数 */
|
||||||
|
const loadNotices = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getNoticeListApi({ page: 1, pageSize: 5 })
|
||||||
|
setNotices(res.data.data)
|
||||||
|
setUnreadCount(res.data.unread_count)
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 点击分类 → 商品页(switchTab 无法传参,经本地存储传递) */
|
||||||
|
const handleCategoryTap = useCallback((id: number) => {
|
||||||
|
try {
|
||||||
|
Taro.setStorageSync('product_category_id', id)
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
Taro.switchTab({ url: '/pages/product/index' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 点击通知 → 标记已读 */
|
||||||
|
const handleNoticeTap = useCallback(
|
||||||
|
async (notice: Notice) => {
|
||||||
|
if (notice.is_read === 1) return
|
||||||
|
try {
|
||||||
|
await readNoticeApi(notice.id)
|
||||||
|
setNotices(prev => prev.map(n => (n.id === notice.id ? { ...n, is_read: 1 } : n)))
|
||||||
|
setUnreadCount(count => Math.max(0, count - 1))
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const goLogin = useCallback(() => {
|
||||||
|
Taro.navigateTo({ url: '/pages/login/index' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='index'>
|
<View className='home-page'>
|
||||||
<View><Button type='primary'>Hello world!</Button></View>
|
{/* ========== 门店信息卡片 ========== */}
|
||||||
<View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/styles/index.less</View>
|
<View className='store-card'>
|
||||||
|
{!loggedIn ? (
|
||||||
|
<View className='store-card__row'>
|
||||||
|
<View className='store-card__info'>
|
||||||
|
<Text className='store-card__name'>欢迎使用订货采购系统</Text>
|
||||||
|
<Text className='store-card__desc'>登录后即可查看门店与价格信息</Text>
|
||||||
|
</View>
|
||||||
|
<View className='store-card__btn' onClick={goLogin}>去登录</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className='store-card__row'>
|
||||||
|
{user?.avatar ? (
|
||||||
|
<Image className='store-card__avatar' src={resolveAvatarUrl(user.avatar)} mode='aspectFill' />
|
||||||
|
) : (
|
||||||
|
<View className='store-card__avatar store-card__avatar--text'>{user?.nickname?.[0] || '用'}</View>
|
||||||
|
)}
|
||||||
|
<View className='store-card__info'>
|
||||||
|
<Text className='store-card__name'>{user?.nickname}</Text>
|
||||||
|
{user?.phone ? (
|
||||||
|
<Text className='store-card__desc'>{user.phone}</Text>
|
||||||
|
) : (
|
||||||
|
<Text className='store-card__desc' onClick={goLogin}>未绑定手机号,点击去绑定</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='store-card__identity'>
|
||||||
|
{user?.type === 0 ? (
|
||||||
|
<View className='store-card__btn' onClick={goLogin}>绑定手机号</View>
|
||||||
|
) : user?.store ? (
|
||||||
|
<View className='store-card__tags'>
|
||||||
|
<Text className='store-card__tag'>门店 · {user.store.name}</Text>
|
||||||
|
{user.store.level && <Text className='store-card__tag store-card__tag--level'>{user.store.level.name}</Text>}
|
||||||
|
</View>
|
||||||
|
) : user?.supplier ? (
|
||||||
|
<View className='store-card__tags'>
|
||||||
|
<Text className='store-card__tag'>供应商 · {user.supplier.name}</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='store-card__btn' onClick={goLogin}>绑定身份</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 本月订货汇总 ========== */}
|
||||||
|
<View className='summary-card'>
|
||||||
|
<View className='summary-card__header'>
|
||||||
|
<Text className='summary-card__title'>订货汇总</Text>
|
||||||
|
<Text className='summary-card__period'>{summary?.period_label ?? '—'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='summary-card__body'>
|
||||||
|
<View className='summary-card__item'>
|
||||||
|
<Text className='summary-card__value summary-card__value--strong'>¥{summary?.total_amount ?? '0.00'}</Text>
|
||||||
|
<Text className='summary-card__label'>订货金额</Text>
|
||||||
|
</View>
|
||||||
|
<View className='summary-card__item'>
|
||||||
|
<Text className='summary-card__value'>{summary?.total_quantity ?? '0.00'}</Text>
|
||||||
|
<Text className='summary-card__label'>订货数量</Text>
|
||||||
|
</View>
|
||||||
|
<View className='summary-card__item'>
|
||||||
|
<Text className='summary-card__value'>{summary?.order_count ?? 0}</Text>
|
||||||
|
<Text className='summary-card__label'>订单笔数</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 快捷入口 ========== */}
|
||||||
|
<View className='section-card'>
|
||||||
|
<Grid columnNum={4} iconSize={52} border={false}>
|
||||||
|
<GridItem icon='apps-o' text='商品中心' onClick={() => Taro.switchTab({ url: '/pages/product/index' })} />
|
||||||
|
<GridItem icon='shopping-cart-o' text='购物车' onClick={() => Taro.switchTab({ url: '/pages/cart/index' })} />
|
||||||
|
<GridItem icon='orders-o' text='我的订单' onClick={() => Taro.switchTab({ url: '/pages/profile/index' })} />
|
||||||
|
<GridItem icon='balance-list-o' text='对账单' onClick={() => Taro.switchTab({ url: '/pages/profile/index' })} />
|
||||||
|
</Grid>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 商品分类 ========== */}
|
||||||
|
{categories.length > 0 && (
|
||||||
|
<View className='section-card'>
|
||||||
|
<View className='section-card__header'>
|
||||||
|
<Text className='section-card__title'>商品分类</Text>
|
||||||
|
<Text className='section-card__more' onClick={() => Taro.switchTab({ url: '/pages/product/index' })}>查看全部 ›</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollX items={categories} onTap={handleCategoryTap} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 通知 ========== */}
|
||||||
|
<View className='section-card'>
|
||||||
|
<View className='section-card__header'>
|
||||||
|
<Text className='section-card__title'>通知</Text>
|
||||||
|
{unreadCount > 0 && <Text className='section-card__more'>未读 {unreadCount} 条</Text>}
|
||||||
|
</View>
|
||||||
|
{notices.length === 0 ? (
|
||||||
|
<View className='section-card__empty'>
|
||||||
|
<Text className='section-card__empty-text'>暂无通知</Text>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
notices.map(notice => (
|
||||||
|
<View key={notice.id} className='notice-item' onClick={() => handleNoticeTap(notice)}>
|
||||||
|
{notice.is_read === 0 && <View className='notice-item__dot' />}
|
||||||
|
<View className='notice-item__content'>
|
||||||
|
<Text className='notice-item__title'>{notice.title}</Text>
|
||||||
|
<Text className='notice-item__desc' numberOfLines={1}>{notice.content}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// export default class Index extends Component {
|
/** 横向滚动的分类入口 */
|
||||||
|
function ScrollX({ items, onTap }: { items: Category[]; onTap: (id: number) => void }) {
|
||||||
// componentWillMount () { }
|
return (
|
||||||
|
<ScrollView scrollX className='category-scroll'>
|
||||||
// componentDidMount () { }
|
{items.map(item => (
|
||||||
|
<View key={item.id} className='category-chip' onClick={() => onTap(item.id)}>
|
||||||
// componentWillUnmount () { }
|
<Text className='category-chip__name'>{item.name}</Text>
|
||||||
|
</View>
|
||||||
// componentDidShow () { }
|
))}
|
||||||
|
</ScrollView>
|
||||||
// componentDidHide () { }
|
)
|
||||||
|
}
|
||||||
// render () {
|
|
||||||
// return (
|
|
||||||
// <View className='index'>
|
|
||||||
// <View><Button type='primary'>Hello world!</Button></View>
|
|
||||||
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
|
|
||||||
// </View>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { View, Text, Button } from '@tarojs/components'
|
import { View, Text, Button } from '@tarojs/components'
|
||||||
import CustomNavBar from '@/components/NavBar'
|
import CustomNavBar from '@/components/NavBar'
|
||||||
@@ -6,61 +6,92 @@ import useAuthStore from '@/stores/auth/useAuthStore'
|
|||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
const login = useAuthStore(s => s.login)
|
const login = useAuthStore(s => s.login)
|
||||||
|
const bindPhone = useAuthStore(s => s.bindPhone)
|
||||||
const isLoggedIn = useAuthStore(s => !!s.token && !!s.user)
|
const isLoggedIn = useAuthStore(s => !!s.token && !!s.user)
|
||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [binding, setBinding] = useState(false)
|
||||||
|
/** 登录成功但身份待绑定(type=0)时,引导绑定手机号 */
|
||||||
|
const [needsBind, setNeedsBind] = useState(false)
|
||||||
|
|
||||||
// 已登录则自动返回
|
const isWeb = Taro.getEnv() === Taro.ENV_TYPE.WEB
|
||||||
useEffect(() => {
|
|
||||||
if (isLoggedIn) {
|
/** 返回上一页(无页面栈时回首页) */
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
const pages = Taro.getCurrentPages()
|
||||||
|
if (pages.length > 1) {
|
||||||
Taro.navigateBack()
|
Taro.navigateBack()
|
||||||
|
} else {
|
||||||
|
Taro.switchTab({ url: '/pages/index/index' })
|
||||||
}
|
}
|
||||||
}, [isLoggedIn])
|
}, [])
|
||||||
|
|
||||||
/** 手机号授权登录 */
|
/** 已登录且已绑定身份 → 返回;已登录未绑定 → 引导绑定手机号 */
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoggedIn) return
|
||||||
|
if (user && user.type === 0) {
|
||||||
|
setNeedsBind(true)
|
||||||
|
} else {
|
||||||
|
goBack()
|
||||||
|
}
|
||||||
|
}, [isLoggedIn, user, goBack])
|
||||||
|
|
||||||
|
/** 微信一键登录(wx.login code 换 openid,新用户自动注册) */
|
||||||
|
const handleLogin = useCallback(async () => {
|
||||||
|
if (submitting) return
|
||||||
|
// H5 环境无法获取微信登录凭证
|
||||||
|
if (isWeb) {
|
||||||
|
Taro.showToast({ title: '请在微信小程序中使用微信登录', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const res = await Taro.login()
|
||||||
|
if (!res.code) {
|
||||||
|
Taro.showToast({ title: '获取登录凭证失败', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await login({ code: res.code })
|
||||||
|
// 登录结果(type=0 → needsBind,否则自动返回)由 effect 处理
|
||||||
|
} catch {
|
||||||
|
// 业务/网络错误已由 request 层提示
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}, [login, submitting, isWeb])
|
||||||
|
|
||||||
|
/** 微信手机号授权绑定(自动匹配门店/供应商) */
|
||||||
const handleGetPhoneNumber = useCallback(
|
const handleGetPhoneNumber = useCallback(
|
||||||
async (e: any) => {
|
async (e: any) => {
|
||||||
if (submitting) return
|
if (binding) return
|
||||||
|
|
||||||
const detail = e.detail || {}
|
const detail = e.detail || {}
|
||||||
|
|
||||||
// 用户拒绝授权
|
// 用户拒绝授权
|
||||||
if (detail.errMsg && !detail.errMsg.includes(':ok')) {
|
if (detail.errMsg && !detail.errMsg.includes(':ok')) {
|
||||||
Taro.showToast({ title: '需要授权手机号才能登录', icon: 'none' })
|
Taro.showToast({ title: '需要授权手机号才能绑定', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true)
|
if (!detail.code) {
|
||||||
|
Taro.showToast({ title: '未获取到手机号授权凭证', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBinding(true)
|
||||||
try {
|
try {
|
||||||
// 1. 获取微信登录 code(用于换取 openid / session_key)
|
await bindPhone(detail.code)
|
||||||
const loginRes = await Taro.login()
|
Taro.showToast({ title: '绑定成功', icon: 'success' })
|
||||||
if (!loginRes.code) {
|
// 绑定成功后 type 更新,由 effect 自动返回
|
||||||
Taro.showToast({ title: '获取登录凭证失败', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 调用后端登录接口(仅传 code + phoneCode)
|
|
||||||
await login({
|
|
||||||
code: loginRes.code,
|
|
||||||
// 新版微信 API:动态令牌,后端直接调用微信接口换手机号
|
|
||||||
phoneCode: detail.code,
|
|
||||||
// 旧版微信 API:加密数据,后端用 session_key 解密
|
|
||||||
encryptedData: detail.encryptedData,
|
|
||||||
iv: detail.iv,
|
|
||||||
})
|
|
||||||
|
|
||||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
|
||||||
setTimeout(() => {
|
|
||||||
Taro.navigateBack()
|
|
||||||
}, 1200)
|
|
||||||
} catch {
|
} catch {
|
||||||
Taro.showToast({ title: '登录失败,请重试', icon: 'none' })
|
// 业务/网络错误已由 request 层提示
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setBinding(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[login, submitting],
|
[binding, bindPhone],
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 查看用户协议 */
|
/** 查看用户协议 */
|
||||||
@@ -83,28 +114,39 @@ export default function LoginPage() {
|
|||||||
{/* 品牌区域 */}
|
{/* 品牌区域 */}
|
||||||
<View className='login-brand'>
|
<View className='login-brand'>
|
||||||
<View className='logo-wrapper'>
|
<View className='logo-wrapper'>
|
||||||
<Text className='logo-text'>校</Text>
|
<Text className='logo-text'>订</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className='app-name'>校园通</Text>
|
<Text className='app-name'>订货采购</Text>
|
||||||
<Text className='app-slogan'>校园生活,一触即达</Text>
|
<Text className='app-slogan'>门店订货 · 对账结算 · 一站式采购</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 功能介绍 */}
|
{/* 功能介绍 */}
|
||||||
<View className='login-features'>
|
<View className='login-features'>
|
||||||
<Text className='feature-text'>发布任务 · 二手交易 · 校园资讯</Text>
|
<Text className='feature-text'>在线订货 · 价格透明 · 周期对账</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 登录操作 */}
|
{/* 登录操作 */}
|
||||||
<View className='login-actions'>
|
<View className='login-actions'>
|
||||||
<Button
|
{needsBind ? (
|
||||||
className={`login-btn ${submitting ? 'login-btn--loading' : ''}`}
|
<Button
|
||||||
openType='getPhoneNumber'
|
className={`login-btn ${binding ? 'login-btn--loading' : ''}`}
|
||||||
onGetPhoneNumber={handleGetPhoneNumber}
|
openType='getPhoneNumber'
|
||||||
loading={submitting}
|
onGetPhoneNumber={handleGetPhoneNumber}
|
||||||
disabled={submitting}
|
loading={binding}
|
||||||
>
|
disabled={binding}
|
||||||
{submitting ? '登录中...' : '微信手机号授权登录'}
|
>
|
||||||
</Button>
|
{binding ? '绑定中...' : '微信手机号授权绑定'}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className={`login-btn ${submitting ? 'login-btn--loading' : ''}`}
|
||||||
|
onClick={handleLogin}
|
||||||
|
loading={submitting}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{submitting ? '登录中...' : '微信一键登录'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<View className='login-agreement'>
|
<View className='login-agreement'>
|
||||||
<Text className='agree-text'>登录即代表同意</Text>
|
<Text className='agree-text'>登录即代表同意</Text>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '首页'
|
||||||
|
})
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { View } from '@tarojs/components'
|
||||||
|
import { Button } from '@antmjs/vantui'
|
||||||
|
import './index.less'
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<View className='index'>
|
||||||
|
<View><Button type='primary'>Hello world!</Button></View>
|
||||||
|
<View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/styles/index.less</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// export default class Index extends Component {
|
||||||
|
|
||||||
|
// componentWillMount () { }
|
||||||
|
|
||||||
|
// componentDidMount () { }
|
||||||
|
|
||||||
|
// componentWillUnmount () { }
|
||||||
|
|
||||||
|
// componentDidShow () { }
|
||||||
|
|
||||||
|
// componentDidHide () { }
|
||||||
|
|
||||||
|
// render () {
|
||||||
|
// return (
|
||||||
|
// <View className='index'>
|
||||||
|
// <View><Button type='primary'>Hello world!</Button></View>
|
||||||
|
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
|
||||||
|
// </View>
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '首页'
|
navigationBarTitleText: '商品',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
.product-page {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f7f8fa;
|
||||||
|
|
||||||
|
.product-search {
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 左侧分类 =====
|
||||||
|
.product-categories {
|
||||||
|
width: 180rpx;
|
||||||
|
height: 100%;
|
||||||
|
background: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-item {
|
||||||
|
padding: 28rpx 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: #ee0a24;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 6rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
background: #ee0a24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 右侧商品 =====
|
||||||
|
.product-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16rpx 20rpx 40rpx;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #646566;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #ee0a24;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-empty {
|
||||||
|
padding-top: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-item {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f2f3f5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spec {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__bottom {
|
||||||
|
margin-top: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--none {
|
||||||
|
color: #c8c9cc;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__add {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ee0a24;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__add-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-loading {
|
||||||
|
padding: 24rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 加购弹层 =====
|
||||||
|
.add-popup {
|
||||||
|
padding: 32rpx 32rpx 20rpx;
|
||||||
|
|
||||||
|
&__product {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__image {
|
||||||
|
width: 160rpx;
|
||||||
|
height: 160rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background: #f2f3f5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__spec {
|
||||||
|
margin-top: 10rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__price {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 34rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--none {
|
||||||
|
color: #c8c9cc;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
margin-top: 32rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +1,312 @@
|
|||||||
import { View } from '@tarojs/components'
|
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||||
import { Button } from '@antmjs/vantui'
|
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
||||||
|
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
|
import { Button, Empty, Popup, Search, Stepper } from '@antmjs/vantui'
|
||||||
|
import useCartStore from '@/stores/cart/useCartStore'
|
||||||
|
import { getCategoriesApi, getProductListApi } from '@/services/product'
|
||||||
|
import { getProductCover } from '@/types/product'
|
||||||
|
import type { Category, Product } from '@/types/product'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
export default function Index() {
|
const PAGE_SIZE = 10
|
||||||
|
/** 存储 key:首页点击分类跳转时经本地存储传参(switchTab 无法带参) */
|
||||||
|
const PENDING_CATEGORY_KEY = 'product_category_id'
|
||||||
|
|
||||||
|
export default function ProductPage() {
|
||||||
|
const addItem = useCartStore(s => s.addItem)
|
||||||
|
|
||||||
|
/** 分类树 */
|
||||||
|
const [categories, setCategories] = useState<Category[]>([])
|
||||||
|
/** 选中的顶级分类(null = 全部) */
|
||||||
|
const [activeTop, setActiveTop] = useState<number | null>(null)
|
||||||
|
/** 选中的子分类(null = 顶级分类下全部) */
|
||||||
|
const [activeChild, setActiveChild] = useState<number | null>(null)
|
||||||
|
/** 已提交的搜索词(onSearch 才生效) */
|
||||||
|
const [searchKey, setSearchKey] = useState('')
|
||||||
|
/** 输入框内容 */
|
||||||
|
const [inputKey, setInputKey] = useState('')
|
||||||
|
|
||||||
|
/** 商品列表 */
|
||||||
|
const [products, setProducts] = useState<Product[]>([])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [finished, setFinished] = useState(false)
|
||||||
|
const listLoadingRef = useRef(false)
|
||||||
|
|
||||||
|
/** 加购弹层 */
|
||||||
|
const [showPopup, setShowPopup] = useState(false)
|
||||||
|
const [current, setCurrent] = useState<Product | null>(null)
|
||||||
|
const [qty, setQty] = useState(1)
|
||||||
|
const addingRef = useRef(false)
|
||||||
|
|
||||||
|
/** 选中的顶级分类的子分类 */
|
||||||
|
const childCategories = useMemo(() => {
|
||||||
|
const top = categories.find(c => c.id === activeTop)
|
||||||
|
return top?.children ?? []
|
||||||
|
}, [categories, activeTop])
|
||||||
|
|
||||||
|
/** 当前生效的分类ID(子分类优先) */
|
||||||
|
const effectiveCategoryId = useMemo(
|
||||||
|
() => activeChild ?? activeTop ?? undefined,
|
||||||
|
[activeChild, activeTop],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 拉取商品列表 */
|
||||||
|
const fetchList = useCallback(
|
||||||
|
async (pageNum: number, reset: boolean) => {
|
||||||
|
if (listLoadingRef.current) return
|
||||||
|
listLoadingRef.current = true
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await getProductListApi({
|
||||||
|
category_id: effectiveCategoryId,
|
||||||
|
keyword: searchKey || undefined,
|
||||||
|
page: pageNum,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
})
|
||||||
|
const { data, total: totalCount } = res.data
|
||||||
|
setProducts(prev => (reset ? data : [...prev, ...data]))
|
||||||
|
setPage(pageNum)
|
||||||
|
setFinished(pageNum * PAGE_SIZE >= totalCount)
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
} finally {
|
||||||
|
listLoadingRef.current = false
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[effectiveCategoryId, searchKey],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 分类树(加载完成后处理首页跳转带入的分类) */
|
||||||
|
const loadCategories = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getCategoriesApi()
|
||||||
|
setCategories(res.data)
|
||||||
|
// 处理首页跳转带入的分类(switchTab 无法带参,经本地存储传递)
|
||||||
|
let pending: number | null = null
|
||||||
|
try {
|
||||||
|
pending = Taro.getStorageSync(PENDING_CATEGORY_KEY) || null
|
||||||
|
Taro.removeStorageSync(PENDING_CATEGORY_KEY)
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
if (pending) {
|
||||||
|
const top = res.data.find(c => c.id === pending)
|
||||||
|
const topOfChild = res.data.find(c => c.children.some(ch => ch.id === pending))
|
||||||
|
if (top) {
|
||||||
|
setActiveTop(pending)
|
||||||
|
setActiveChild(null)
|
||||||
|
} else if (topOfChild) {
|
||||||
|
setActiveTop(topOfChild.id)
|
||||||
|
setActiveChild(pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
loadCategories()
|
||||||
|
fetchList(1, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
useReachBottom(() => {
|
||||||
|
if (!finished && !listLoadingRef.current) {
|
||||||
|
fetchList(page + 1, false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 切换顶级分类 */
|
||||||
|
const handleTopTap = useCallback((id: number | null) => {
|
||||||
|
setActiveTop(id)
|
||||||
|
setActiveChild(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 切换子分类 */
|
||||||
|
const handleChildTap = useCallback((id: number | null) => {
|
||||||
|
setActiveChild(id)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 提交搜索 */
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
setSearchKey(inputKey.trim())
|
||||||
|
}, [inputKey])
|
||||||
|
|
||||||
|
/** 清空搜索 */
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setInputKey('')
|
||||||
|
setSearchKey('')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 打开加购弹层 */
|
||||||
|
const handleAddTap = useCallback((product: Product) => {
|
||||||
|
setCurrent(product)
|
||||||
|
setQty(1)
|
||||||
|
setShowPopup(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 确认加购 */
|
||||||
|
const handleConfirmAdd = useCallback(async () => {
|
||||||
|
if (!current || addingRef.current) return
|
||||||
|
addingRef.current = true
|
||||||
|
try {
|
||||||
|
await addItem(current.id, qty)
|
||||||
|
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||||
|
setShowPopup(false)
|
||||||
|
} catch {
|
||||||
|
// 错误(未设等级价/数量上限)已由 request 层 toast
|
||||||
|
} finally {
|
||||||
|
addingRef.current = false
|
||||||
|
}
|
||||||
|
}, [current, qty, addItem])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='index'>
|
<View className='product-page'>
|
||||||
<View><Button type='primary'>Hello world!</Button></View>
|
{/* ========== 搜索 ========== */}
|
||||||
<View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/styles/index.less</View>
|
<View className='product-search'>
|
||||||
|
<Search
|
||||||
|
value={inputKey}
|
||||||
|
placeholder='搜索品名/规格'
|
||||||
|
shape='round'
|
||||||
|
background='#f7f8fa'
|
||||||
|
onChange={e => setInputKey(String(e.detail))}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
onClear={handleClear}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='product-body'>
|
||||||
|
{/* ========== 左侧分类 ========== */}
|
||||||
|
<ScrollView scrollY className='product-categories'>
|
||||||
|
<View
|
||||||
|
className={`category-item ${activeTop === null ? 'active' : ''}`}
|
||||||
|
onClick={() => handleTopTap(null)}
|
||||||
|
>
|
||||||
|
<Text className='category-item__name'>全部</Text>
|
||||||
|
</View>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<View
|
||||||
|
key={cat.id}
|
||||||
|
className={`category-item ${activeTop === cat.id ? 'active' : ''}`}
|
||||||
|
onClick={() => handleTopTap(cat.id)}
|
||||||
|
>
|
||||||
|
<Text className='category-item__name'>{cat.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{/* ========== 右侧商品列表 ========== */}
|
||||||
|
<View className='product-main'>
|
||||||
|
{/* 子分类 chips */}
|
||||||
|
{childCategories.length > 0 && (
|
||||||
|
<ScrollView scrollX className='child-scroll'>
|
||||||
|
<View
|
||||||
|
className={`child-chip ${activeChild === null ? 'active' : ''}`}
|
||||||
|
onClick={() => handleChildTap(null)}
|
||||||
|
>
|
||||||
|
<Text>全部</Text>
|
||||||
|
</View>
|
||||||
|
{childCategories.map(child => (
|
||||||
|
<View
|
||||||
|
key={child.id}
|
||||||
|
className={`child-chip ${activeChild === child.id ? 'active' : ''}`}
|
||||||
|
onClick={() => handleChildTap(child.id)}
|
||||||
|
>
|
||||||
|
<Text>{child.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 商品列表 */}
|
||||||
|
{products.length === 0 && !loading ? (
|
||||||
|
<Empty description='暂无商品' className='product-empty' />
|
||||||
|
) : (
|
||||||
|
products.map(product => (
|
||||||
|
<View key={product.id} className='product-item'>
|
||||||
|
<Image
|
||||||
|
className='product-item__image'
|
||||||
|
src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
|
||||||
|
mode='aspectFill'
|
||||||
|
lazyLoad
|
||||||
|
/>
|
||||||
|
<View className='product-item__info'>
|
||||||
|
<Text className='product-item__name'>{product.name}</Text>
|
||||||
|
<Text className='product-item__spec'>{product.spec} / {product.unit}</Text>
|
||||||
|
<View className='product-item__bottom'>
|
||||||
|
{product.price !== null ? (
|
||||||
|
<Text className='product-item__price'>¥{product.price}</Text>
|
||||||
|
) : (
|
||||||
|
<Text className='product-item__price product-item__price--none'>价格待定</Text>
|
||||||
|
)}
|
||||||
|
<View className='product-item__add' onClick={() => handleAddTap(product)}>
|
||||||
|
<Text className='product-item__add-icon'>+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{loading && <View className='product-loading'><Text>加载中...</Text></View>}
|
||||||
|
{finished && products.length > 0 && (
|
||||||
|
<View className='product-loading'><Text>没有更多了</Text></View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 加购弹层 ========== */}
|
||||||
|
<Popup
|
||||||
|
show={showPopup}
|
||||||
|
position='bottom'
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
closeOnClickOverlay
|
||||||
|
safeAreaInsetBottom
|
||||||
|
onClose={() => setShowPopup(false)}
|
||||||
|
>
|
||||||
|
{current && (
|
||||||
|
<View className='add-popup'>
|
||||||
|
<View className='add-popup__product'>
|
||||||
|
<Image
|
||||||
|
className='add-popup__image'
|
||||||
|
src={getProductCover(current) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
|
||||||
|
mode='aspectFill'
|
||||||
|
/>
|
||||||
|
<View className='add-popup__info'>
|
||||||
|
<Text className='add-popup__name'>{current.name}</Text>
|
||||||
|
<Text className='add-popup__spec'>{current.spec} / {current.unit}</Text>
|
||||||
|
{current.price !== null ? (
|
||||||
|
<Text className='add-popup__price'>¥{current.price}</Text>
|
||||||
|
) : (
|
||||||
|
<Text className='add-popup__price add-popup__price--none'>价格待定</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='add-popup__row'>
|
||||||
|
<Text className='add-popup__label'>购买数量</Text>
|
||||||
|
<Stepper
|
||||||
|
value={qty}
|
||||||
|
min={1}
|
||||||
|
max={99999999.99}
|
||||||
|
onChange={e => setQty(Number(e.detail))}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
block
|
||||||
|
round
|
||||||
|
className='add-popup__submit'
|
||||||
|
onClick={handleConfirmAdd}
|
||||||
|
>
|
||||||
|
加入购物车
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Popup>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// export default class Index extends Component {
|
|
||||||
|
|
||||||
// componentWillMount () { }
|
|
||||||
|
|
||||||
// componentDidMount () { }
|
|
||||||
|
|
||||||
// componentWillUnmount () { }
|
|
||||||
|
|
||||||
// componentDidShow () { }
|
|
||||||
|
|
||||||
// componentDidHide () { }
|
|
||||||
|
|
||||||
// render () {
|
|
||||||
// return (
|
|
||||||
// <View className='index'>
|
|
||||||
// <View><Button type='primary'>Hello world!</Button></View>
|
|
||||||
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
|
|
||||||
// </View>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '首页'
|
navigationBarTitleText: '我的',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
.profile-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f7f8fa;
|
||||||
|
padding: 20rpx 24rpx 160rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
// ===== 用户卡片 =====
|
||||||
|
.profile-card {
|
||||||
|
background: linear-gradient(135deg, #ee0a24, #ff4d4f);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatar {
|
||||||
|
width: 96rpx;
|
||||||
|
height: 96rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&--text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__name {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__btn {
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 12rpx 30rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #fff;
|
||||||
|
color: #ee0a24;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__identity {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__tag {
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 6rpx 18rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
|
||||||
|
&--level {
|
||||||
|
background: #ffd21e;
|
||||||
|
color: #7a5b00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 区块 =====
|
||||||
|
.profile-section {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__action {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
padding: 40rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__loading {
|
||||||
|
padding: 30rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #c8c9cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 订单筛选 chips =====
|
||||||
|
.order-filter-scroll {
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: 0 -28rpx 8rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-filter-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
margin-right: 12rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #f7f8fa;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #646566;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: #ee0a24;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 订单项 =====
|
||||||
|
.order-item {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__date {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amounts {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__qty {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amount {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__remark {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #969799;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__cancel {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
padding: 8rpx 24rpx;
|
||||||
|
border: 1rpx solid #ee0a24;
|
||||||
|
color: #ee0a24;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 对账单项 =====
|
||||||
|
.statement-item {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__no {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__body {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__period {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__amount {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__settle {
|
||||||
|
margin-top: 8rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #969799;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 设置 =====
|
||||||
|
.setting-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 退出登录 =====
|
||||||
|
.profile-logout {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 订单详情弹层 =====
|
||||||
|
.detail-popup {
|
||||||
|
padding: 32rpx 32rpx 24rpx;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__meta {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
color: #ee0a24;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
max-height: 480rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f2f3f5;
|
||||||
|
|
||||||
|
&-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-name {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-spec {
|
||||||
|
margin-top: 6rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #969799;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-amount {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-left: 20rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__footer {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #ee0a24;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 生成对账单弹层 =====
|
||||||
|
.gen-popup {
|
||||||
|
padding: 32rpx 32rpx 24rpx;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
margin-top: 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #323233;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #646566;
|
||||||
|
padding: 12rpx 24rpx;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 回款周期弹层 =====
|
||||||
|
.cycle-popup {
|
||||||
|
padding: 32rpx 32rpx 24rpx;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__desc {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #969799;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__input {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
background: #f7f8fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 20rpx 24rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__submit {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,34 +1,491 @@
|
|||||||
import { View } from '@tarojs/components'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { Button } from '@antmjs/vantui'
|
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
||||||
|
import { View, Text, Image, Input, Picker, ScrollView } from '@tarojs/components'
|
||||||
|
import { Button, Empty, Popup } from '@antmjs/vantui'
|
||||||
|
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||||
|
import { getUserInfoApi } from '@/services/auth'
|
||||||
|
import { cancelOrderApi, getOrderDetailApi, getOrderListApi } from '@/services/order'
|
||||||
|
import { generateStatementApi, getStatementListApi } from '@/services/statement'
|
||||||
|
import { updatePaymentCycleApi } from '@/services/store'
|
||||||
|
import { resolveAvatarUrl } from '@/utils/format'
|
||||||
|
import { ORDER_STATUS_FILTERS, ORDER_STATUS_MAP } from '@/types/order'
|
||||||
|
import { STATEMENT_STATUS_MAP } from '@/types/statement'
|
||||||
|
import type { Order, OrderStatus } from '@/types/order'
|
||||||
|
import type { Statement } from '@/types/statement'
|
||||||
import './index.less'
|
import './index.less'
|
||||||
|
|
||||||
export default function Index() {
|
const ORDER_PAGE_SIZE = 10
|
||||||
|
|
||||||
|
/** 日期 → Y-m-d */
|
||||||
|
function toYMD(d: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfilePage() {
|
||||||
|
const user = useAuthStore(s => s.user)
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
const setUser = useAuthStore(s => s.setUser)
|
||||||
|
const logout = useAuthStore(s => s.logout)
|
||||||
|
|
||||||
|
const loggedIn = !!token && !!user
|
||||||
|
|
||||||
|
// ===== 我的订单 =====
|
||||||
|
const [orderFilter, setOrderFilter] = useState<OrderStatus | null>(null)
|
||||||
|
const [orders, setOrders] = useState<Order[]>([])
|
||||||
|
const [orderPage, setOrderPage] = useState(1)
|
||||||
|
const [orderTotal, setOrderTotal] = useState(0)
|
||||||
|
const [orderLoading, setOrderLoading] = useState(false)
|
||||||
|
const [orderFinished, setOrderFinished] = useState(false)
|
||||||
|
const orderLoadingRef = useRef(false)
|
||||||
|
|
||||||
|
// ===== 订单详情弹层 =====
|
||||||
|
const [showDetail, setShowDetail] = useState(false)
|
||||||
|
const [orderDetail, setOrderDetail] = useState<Order | null>(null)
|
||||||
|
|
||||||
|
// ===== 对账单 =====
|
||||||
|
const [statements, setStatements] = useState<Statement[]>([])
|
||||||
|
|
||||||
|
// ===== 生成对账单弹层 =====
|
||||||
|
const [showGen, setShowGen] = useState(false)
|
||||||
|
const [genStart, setGenStart] = useState(() => toYMD(new Date(Date.now() - 30 * 864e5)))
|
||||||
|
const [genEnd, setGenEnd] = useState(() => toYMD(new Date()))
|
||||||
|
const [genLoading, setGenLoading] = useState(false)
|
||||||
|
|
||||||
|
// ===== 回款周期弹层 =====
|
||||||
|
const [showCycle, setShowCycle] = useState(false)
|
||||||
|
const [cycleInput, setCycleInput] = useState('1')
|
||||||
|
const [cycleLoading, setCycleLoading] = useState(false)
|
||||||
|
|
||||||
|
/** 拉取订单列表(filter 显式传入,避免状态异步导致的旧值) */
|
||||||
|
const loadOrders = useCallback(
|
||||||
|
async (reset: boolean, filter: OrderStatus | null = orderFilter) => {
|
||||||
|
if (!loggedIn) return
|
||||||
|
if (orderLoadingRef.current) return
|
||||||
|
orderLoadingRef.current = true
|
||||||
|
setOrderLoading(true)
|
||||||
|
try {
|
||||||
|
const nextPage = reset ? 1 : orderPage + 1
|
||||||
|
const res = await getOrderListApi({
|
||||||
|
status: filter ?? undefined,
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: ORDER_PAGE_SIZE,
|
||||||
|
})
|
||||||
|
const { data, total } = res.data
|
||||||
|
setOrders(prev => (reset ? data : [...prev, ...data]))
|
||||||
|
setOrderTotal(total)
|
||||||
|
setOrderPage(nextPage)
|
||||||
|
setOrderFinished(nextPage * ORDER_PAGE_SIZE >= total)
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
} finally {
|
||||||
|
orderLoadingRef.current = false
|
||||||
|
setOrderLoading(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loggedIn, orderFilter, orderPage],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 拉取对账单 */
|
||||||
|
const loadStatements = useCallback(async () => {
|
||||||
|
if (!loggedIn) return
|
||||||
|
try {
|
||||||
|
const res = await getStatementListApi({ page: 1, pageSize: 5 })
|
||||||
|
setStatements(res.data.data)
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
}
|
||||||
|
}, [loggedIn])
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
if (!loggedIn) {
|
||||||
|
setOrders([])
|
||||||
|
setStatements([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 刷新用户信息(门店/客户等级可能变化)
|
||||||
|
getUserInfoApi()
|
||||||
|
.then(res => setUser(res.data))
|
||||||
|
.catch(() => {})
|
||||||
|
loadOrders(true)
|
||||||
|
loadStatements()
|
||||||
|
})
|
||||||
|
|
||||||
|
useReachBottom(() => {
|
||||||
|
if (!orderFinished && !orderLoadingRef.current && loggedIn) {
|
||||||
|
loadOrders(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 切换订单状态筛选:立即按新筛选重新加载 */
|
||||||
|
const handleFilterTap = useCallback(
|
||||||
|
(value: OrderStatus | null) => {
|
||||||
|
setOrderFilter(value)
|
||||||
|
setOrderPage(1)
|
||||||
|
setOrderFinished(false)
|
||||||
|
loadOrders(true, value)
|
||||||
|
},
|
||||||
|
[loadOrders],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 查看订单详情 */
|
||||||
|
const handleOrderTap = useCallback(async (order: Order) => {
|
||||||
|
try {
|
||||||
|
const res = await getOrderDetailApi(order.id)
|
||||||
|
setOrderDetail(res.data)
|
||||||
|
setShowDetail(true)
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/** 取消订单(仅待汇总) */
|
||||||
|
const handleCancelOrder = useCallback(
|
||||||
|
(order: Order) => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '取消订单',
|
||||||
|
content: `确定取消订单 ${order.order_no} 吗?`,
|
||||||
|
confirmColor: '#ee0a24',
|
||||||
|
success: res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
cancelOrderApi(order.id)
|
||||||
|
.then(() => {
|
||||||
|
Taro.showToast({ title: '订单已取消', icon: 'success' })
|
||||||
|
loadOrders(true)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[loadOrders],
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 生成对账单 */
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (genStart > genEnd) {
|
||||||
|
Taro.showToast({ title: '结束日期不能早于开始日期', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (genLoading) return
|
||||||
|
setGenLoading(true)
|
||||||
|
try {
|
||||||
|
await generateStatementApi({ period_start: genStart, period_end: genEnd })
|
||||||
|
Taro.showToast({ title: '对账单已生成', icon: 'success' })
|
||||||
|
setShowGen(false)
|
||||||
|
loadStatements()
|
||||||
|
} catch {
|
||||||
|
// 错误(周期内无订单等)已由 request 层 toast
|
||||||
|
} finally {
|
||||||
|
setGenLoading(false)
|
||||||
|
}
|
||||||
|
}, [genStart, genEnd, genLoading, loadStatements])
|
||||||
|
|
||||||
|
/** 保存回款周期 */
|
||||||
|
const handleSaveCycle = useCallback(async () => {
|
||||||
|
const days = Number(cycleInput)
|
||||||
|
if (!Number.isInteger(days) || days < 0) {
|
||||||
|
Taro.showToast({ title: '请输入不小于 0 的整数', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (cycleLoading) return
|
||||||
|
setCycleLoading(true)
|
||||||
|
try {
|
||||||
|
await updatePaymentCycleApi(days)
|
||||||
|
Taro.showToast({ title: '回款周期已更新', icon: 'success' })
|
||||||
|
setShowCycle(false)
|
||||||
|
} catch {
|
||||||
|
// 错误已由 request 层 toast
|
||||||
|
} finally {
|
||||||
|
setCycleLoading(false)
|
||||||
|
}
|
||||||
|
}, [cycleInput, cycleLoading])
|
||||||
|
|
||||||
|
/** 退出登录 */
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '退出登录',
|
||||||
|
content: '确定退出当前账号吗?',
|
||||||
|
confirmColor: '#ee0a24',
|
||||||
|
success: res => {
|
||||||
|
if (res.confirm) {
|
||||||
|
logout()
|
||||||
|
Taro.showToast({ title: '已退出登录', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [logout])
|
||||||
|
|
||||||
|
const goLogin = useCallback(() => {
|
||||||
|
Taro.navigateTo({ url: '/pages/login/index' })
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='index'>
|
<View className='profile-page'>
|
||||||
<View><Button type='primary'>Hello world!</Button></View>
|
{/* ========== 用户卡片 ========== */}
|
||||||
<View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/styles/index.less</View>
|
<View className='profile-card'>
|
||||||
|
{!loggedIn ? (
|
||||||
|
<View className='profile-card__row'>
|
||||||
|
<View className='profile-card__avatar profile-card__avatar--text'>用</View>
|
||||||
|
<View className='profile-card__info'>
|
||||||
|
<Text className='profile-card__name'>未登录</Text>
|
||||||
|
<Text className='profile-card__desc'>登录后查看门店信息与订单</Text>
|
||||||
|
</View>
|
||||||
|
<View className='profile-card__btn' onClick={goLogin}>去登录</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<View className='profile-card__row'>
|
||||||
|
{user?.avatar ? (
|
||||||
|
<Image
|
||||||
|
className='profile-card__avatar'
|
||||||
|
src={resolveAvatarUrl(user.avatar)}
|
||||||
|
mode='aspectFill'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className='profile-card__avatar profile-card__avatar--text'>
|
||||||
|
{user?.nickname?.[0] || '用'}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View className='profile-card__info'>
|
||||||
|
<Text className='profile-card__name'>{user?.nickname}</Text>
|
||||||
|
<Text className='profile-card__desc'>{user?.phone || '未绑定手机号'}</Text>
|
||||||
|
</View>
|
||||||
|
{user?.type === 0 && (
|
||||||
|
<View className='profile-card__btn' onClick={goLogin}>绑定手机号</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className='profile-card__identity'>
|
||||||
|
{user?.store ? (
|
||||||
|
<>
|
||||||
|
<Text className='profile-card__tag'>门店 · {user.store.name}</Text>
|
||||||
|
{user.store.level && (
|
||||||
|
<Text className='profile-card__tag profile-card__tag--level'>{user.store.level.name}</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : user?.supplier ? (
|
||||||
|
<Text className='profile-card__tag'>供应商 · {user.supplier.name}</Text>
|
||||||
|
) : (
|
||||||
|
<Text className='profile-card__tag'>身份待绑定,联系客服或绑定手机号</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{loggedIn && (
|
||||||
|
<>
|
||||||
|
{/* ========== 我的订单 ========== */}
|
||||||
|
<View className='profile-section'>
|
||||||
|
<View className='profile-section__header'>
|
||||||
|
<Text className='profile-section__title'>我的订单</Text>
|
||||||
|
<Text className='profile-section__total'>共 {orderTotal} 单</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView scrollX className='order-filter-scroll'>
|
||||||
|
{ORDER_STATUS_FILTERS.map(item => (
|
||||||
|
<View
|
||||||
|
key={item.label}
|
||||||
|
className={`order-filter-chip ${orderFilter === item.value ? 'active' : ''}`}
|
||||||
|
onClick={() => handleFilterTap(item.value)}
|
||||||
|
>
|
||||||
|
<Text>{item.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
orderLoading ? (
|
||||||
|
<View className='profile-section__loading'><Text>加载中...</Text></View>
|
||||||
|
) : (
|
||||||
|
<Empty description='暂无订单' className='profile-section__empty' />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
orders.map(order => (
|
||||||
|
<View key={order.id} className='order-item' onClick={() => handleOrderTap(order)}>
|
||||||
|
<View className='order-item__header'>
|
||||||
|
<Text className='order-item__no'>{order.order_no}</Text>
|
||||||
|
<Text className='order-item__status'>{ORDER_STATUS_MAP[order.status]}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='order-item__body'>
|
||||||
|
<Text className='order-item__date'>{order.order_date}</Text>
|
||||||
|
<View className='order-item__amounts'>
|
||||||
|
<Text className='order-item__qty'>共 {order.total_quantity} 件</Text>
|
||||||
|
<Text className='order-item__amount'>¥{order.total_amount}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{order.remark && <Text className='order-item__remark'>备注:{order.remark}</Text>}
|
||||||
|
{order.status === 0 && (
|
||||||
|
<View
|
||||||
|
className='order-item__cancel'
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleCancelOrder(order)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
取消订单
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{orderFinished && orders.length > 0 && (
|
||||||
|
<View className='profile-section__loading'><Text>没有更多了</Text></View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 对账单 ========== */}
|
||||||
|
<View className='profile-section'>
|
||||||
|
<View className='profile-section__header'>
|
||||||
|
<Text className='profile-section__title'>对账单</Text>
|
||||||
|
<Text className='profile-section__action' onClick={() => setShowGen(true)}>生成对账单</Text>
|
||||||
|
</View>
|
||||||
|
{statements.length === 0 ? (
|
||||||
|
<View className='profile-section__loading'><Text>暂无对账单</Text></View>
|
||||||
|
) : (
|
||||||
|
statements.map(st => (
|
||||||
|
<View key={st.id} className='statement-item'>
|
||||||
|
<View className='statement-item__header'>
|
||||||
|
<Text className='statement-item__no'>{st.statement_no}</Text>
|
||||||
|
<Text className='statement-item__status'>{STATEMENT_STATUS_MAP[st.status]}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='statement-item__body'>
|
||||||
|
<Text className='statement-item__period'>
|
||||||
|
{st.period_start} ~ {st.period_end}
|
||||||
|
</Text>
|
||||||
|
<Text className='statement-item__amount'>¥{st.total_amount}</Text>
|
||||||
|
</View>
|
||||||
|
{st.settlement_date && (
|
||||||
|
<Text className='statement-item__settle'>应结算:{st.settlement_date}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 设置 ========== */}
|
||||||
|
<View className='profile-section'>
|
||||||
|
<View className='profile-section__header'>
|
||||||
|
<Text className='profile-section__title'>门店设置</Text>
|
||||||
|
</View>
|
||||||
|
<View className='setting-cell' onClick={() => setShowCycle(true)}>
|
||||||
|
<Text className='setting-cell__label'>回款周期(天)</Text>
|
||||||
|
<Text className='setting-cell__value'>设置 ›</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* ========== 退出登录 ========== */}
|
||||||
|
<View className='profile-logout' onClick={handleLogout}>
|
||||||
|
<Text>退出登录</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== 订单详情弹层 ========== */}
|
||||||
|
<Popup
|
||||||
|
show={showDetail}
|
||||||
|
position='bottom'
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
closeOnClickOverlay
|
||||||
|
safeAreaInsetBottom
|
||||||
|
onClose={() => setShowDetail(false)}
|
||||||
|
>
|
||||||
|
{orderDetail && (
|
||||||
|
<View className='detail-popup'>
|
||||||
|
<Text className='detail-popup__title'>{orderDetail.order_no}</Text>
|
||||||
|
<View className='detail-popup__meta'>
|
||||||
|
<Text>{orderDetail.order_date}</Text>
|
||||||
|
<Text className='detail-popup__status'>{ORDER_STATUS_MAP[orderDetail.status]}</Text>
|
||||||
|
</View>
|
||||||
|
<ScrollView scrollY className='detail-popup__list'>
|
||||||
|
{(orderDetail.items ?? []).map(item => (
|
||||||
|
<View key={item.id} className='detail-popup__item'>
|
||||||
|
<View className='detail-popup__item-info'>
|
||||||
|
<Text className='detail-popup__item-name'>{item.product_name}</Text>
|
||||||
|
<Text className='detail-popup__item-spec'>
|
||||||
|
{item.product_spec} ¥{item.price} × {item.quantity}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='detail-popup__item-amount'>¥{item.amount}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
<View className='detail-popup__footer'>
|
||||||
|
<Text className='detail-popup__total'>合计 ¥{orderDetail.total_amount}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Popup>
|
||||||
|
|
||||||
|
{/* ========== 生成对账单弹层 ========== */}
|
||||||
|
<Popup
|
||||||
|
show={showGen}
|
||||||
|
position='bottom'
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
closeOnClickOverlay
|
||||||
|
safeAreaInsetBottom
|
||||||
|
onClose={() => setShowGen(false)}
|
||||||
|
>
|
||||||
|
<View className='gen-popup'>
|
||||||
|
<Text className='gen-popup__title'>生成对账单</Text>
|
||||||
|
<View className='gen-popup__row'>
|
||||||
|
<Text className='gen-popup__label'>开始日期</Text>
|
||||||
|
<Picker mode='date' value={genStart} end={genEnd} onChange={e => setGenStart(e.detail.value)}>
|
||||||
|
<View className='gen-popup__value'>{genStart}</View>
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
<View className='gen-popup__row'>
|
||||||
|
<Text className='gen-popup__label'>结束日期</Text>
|
||||||
|
<Picker mode='date' value={genEnd} start={genStart} onChange={e => setGenEnd(e.detail.value)}>
|
||||||
|
<View className='gen-popup__value'>{genEnd}</View>
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
block
|
||||||
|
round
|
||||||
|
loading={genLoading}
|
||||||
|
className='gen-popup__submit'
|
||||||
|
onClick={handleGenerate}
|
||||||
|
>
|
||||||
|
生成
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
|
|
||||||
|
{/* ========== 回款周期弹层 ========== */}
|
||||||
|
<Popup
|
||||||
|
show={showCycle}
|
||||||
|
position='bottom'
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
closeOnClickOverlay
|
||||||
|
safeAreaInsetBottom
|
||||||
|
onClose={() => setShowCycle(false)}
|
||||||
|
>
|
||||||
|
<View className='cycle-popup'>
|
||||||
|
<Text className='cycle-popup__title'>修改回款周期</Text>
|
||||||
|
<Text className='cycle-popup__desc'>0 = 当天结算;该值影响后续生成对账单的应结算日期</Text>
|
||||||
|
<Input
|
||||||
|
className='cycle-popup__input'
|
||||||
|
type='number'
|
||||||
|
value={cycleInput}
|
||||||
|
placeholder='请输入回款周期天数'
|
||||||
|
onInput={e => setCycleInput(e.detail.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
block
|
||||||
|
round
|
||||||
|
loading={cycleLoading}
|
||||||
|
className='cycle-popup__submit'
|
||||||
|
onClick={handleSaveCycle}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Popup>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// export default class Index extends Component {
|
|
||||||
|
|
||||||
// componentWillMount () { }
|
|
||||||
|
|
||||||
// componentDidMount () { }
|
|
||||||
|
|
||||||
// componentWillUnmount () { }
|
|
||||||
|
|
||||||
// componentDidShow () { }
|
|
||||||
|
|
||||||
// componentDidHide () { }
|
|
||||||
|
|
||||||
// render () {
|
|
||||||
// return (
|
|
||||||
// <View className='index'>
|
|
||||||
// <View><Button type='primary'>Hello world!</Button></View>
|
|
||||||
// <View>上面的按钮的颜色已经通过全局主题重写覆盖了,参见src/style/index.less</View>
|
|
||||||
// </View>
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { get, post } from '@/utils/request'
|
||||||
|
import type { User } from '@/types/user'
|
||||||
|
|
||||||
|
/** 微信登录参数 */
|
||||||
|
export interface WxLoginParams {
|
||||||
|
/** wx.login 的临时凭证 */
|
||||||
|
code: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定手机号参数 */
|
||||||
|
export interface BindPhoneParams {
|
||||||
|
/** wx.getPhoneNumber 授权得到的 code */
|
||||||
|
phoneCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录 / 绑定手机号返回 */
|
||||||
|
export interface AuthResult {
|
||||||
|
token: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 微信登录(自动注册):POST /mini/auth/login */
|
||||||
|
export function wxLoginApi(params: WxLoginParams) {
|
||||||
|
return post<AuthResult>('/mini/auth/login', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定手机号(自动匹配门店/供应商):POST /mini/auth/phone */
|
||||||
|
export function bindPhoneApi(params: BindPhoneParams) {
|
||||||
|
return post<AuthResult>('/mini/auth/phone', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前用户信息(含门店客户等级):GET /mini/auth/info */
|
||||||
|
export function getUserInfoApi() {
|
||||||
|
return get<User>('/mini/auth/info')
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { del, get, post, put } from '@/utils/request'
|
||||||
|
import type { CartData } from '@/types/cart'
|
||||||
|
|
||||||
|
/** 加购 / 改数量返回 */
|
||||||
|
export interface CartMutationResult {
|
||||||
|
id: number
|
||||||
|
quantity: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加购:POST /mini/cart(同商品重复加购自动合并数量) */
|
||||||
|
export function addCartApi(params: { product_id: number; quantity: number }) {
|
||||||
|
return post<CartMutationResult>('/mini/cart', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 购物车列表:GET /mini/cart */
|
||||||
|
export function getCartApi() {
|
||||||
|
return get<CartData>('/mini/cart')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 修改数量:PUT /mini/cart/{id} */
|
||||||
|
export function updateCartItemApi(id: number, quantity: number) {
|
||||||
|
return put<CartMutationResult>(`/mini/cart/${id}`, { quantity })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除单项:DELETE /mini/cart/{id} */
|
||||||
|
export function deleteCartItemApi(id: number) {
|
||||||
|
return del(`/mini/cart/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空购物车:DELETE /mini/cart */
|
||||||
|
export function clearCartApi() {
|
||||||
|
return del('/mini/cart')
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { get, put } from '@/utils/request'
|
||||||
|
import type { NoticeData } from '@/types/notice'
|
||||||
|
|
||||||
|
/** 通知列表(本人通知 + 全员广播):GET /mini/notice */
|
||||||
|
export function getNoticeListApi(params: { page?: number; pageSize?: number } = {}) {
|
||||||
|
return get<NoticeData>('/mini/notice', { data: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记已读:PUT /mini/notice/{id}/read */
|
||||||
|
export function readNoticeApi(id: number) {
|
||||||
|
return put(`/mini/notice/${id}/read`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { get, post, put } from '@/utils/request'
|
||||||
|
import type { PaginatedData } from '@/types/api'
|
||||||
|
import type { Order, OrderCreateResult, OrderSummary } from '@/types/order'
|
||||||
|
|
||||||
|
/** 下单明细行(金额一律服务端重算,前端不传金额) */
|
||||||
|
export interface OrderItemParam {
|
||||||
|
product_id: number
|
||||||
|
quantity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下单参数 */
|
||||||
|
export interface CreateOrderParams {
|
||||||
|
items: OrderItemParam[]
|
||||||
|
/** 订单备注(≤255 字符) */
|
||||||
|
remark?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下单:POST /mini/order */
|
||||||
|
export function createOrderApi(params: CreateOrderParams) {
|
||||||
|
return post<OrderCreateResult>('/mini/order', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 历史订单列表(强制本店隔离):GET /mini/order */
|
||||||
|
export function getOrderListApi(
|
||||||
|
params: { status?: number; page?: number; pageSize?: number } = {},
|
||||||
|
) {
|
||||||
|
return get<PaginatedData<Order>>('/mini/order', { data: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 周期汇总:GET /mini/order/summary */
|
||||||
|
export function getOrderSummaryApi(period: 'day' | 'week' | 'month' = 'month') {
|
||||||
|
return get<OrderSummary>('/mini/order/summary', { data: { period } })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单详情(校验本店归属):GET /mini/order/{id} */
|
||||||
|
export function getOrderDetailApi(id: number) {
|
||||||
|
return get<Order>(`/mini/order/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消订单(仅待汇总可取消):PUT /mini/order/{id}/cancel */
|
||||||
|
export function cancelOrderApi(id: number) {
|
||||||
|
return put(`/mini/order/${id}/cancel`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { get } from '@/utils/request'
|
||||||
|
import type { PaginatedData } from '@/types/api'
|
||||||
|
import type { Category, Product } from '@/types/product'
|
||||||
|
|
||||||
|
/** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */
|
||||||
|
export function getCategoriesApi() {
|
||||||
|
return get<Category[]>('/mini/product/categories')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品列表参数 */
|
||||||
|
export interface ProductListParams {
|
||||||
|
/** 分类ID过滤 */
|
||||||
|
category_id?: number
|
||||||
|
/** 搜索品名/规格(模糊) */
|
||||||
|
keyword?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品列表(当前门店等级实际价):GET /mini/product/list */
|
||||||
|
export function getProductListApi(params: ProductListParams = {}) {
|
||||||
|
return get<PaginatedData<Product>>('/mini/product/list', { data: params })
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { get, post } from '@/utils/request'
|
||||||
|
import type { PaginatedData } from '@/types/api'
|
||||||
|
import type { Statement, StatementDetail } from '@/types/statement'
|
||||||
|
|
||||||
|
/** 对账单列表(仅本店):GET /mini/statement */
|
||||||
|
export function getStatementListApi(params: { page?: number; pageSize?: number } = {}) {
|
||||||
|
return get<PaginatedData<Statement>>('/mini/statement', { data: params })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成对账单:POST /mini/statement/generate */
|
||||||
|
export function generateStatementApi(params: { period_start: string; period_end: string }) {
|
||||||
|
return post<{
|
||||||
|
id: number
|
||||||
|
statement_no: string
|
||||||
|
total_amount: string
|
||||||
|
settlement_date: string | null
|
||||||
|
}>('/mini/statement/generate', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对账单详情(校验归属):GET /mini/statement/{id} */
|
||||||
|
export function getStatementDetailApi(id: number) {
|
||||||
|
return get<StatementDetail>(`/mini/statement/${id}`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { put } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 修改回款周期(≥0,无上限;0 = 当天结算):PUT /mini/store/paymentCycle */
|
||||||
|
export function updatePaymentCycleApi(payment_cycle_days: number) {
|
||||||
|
return put<{ payment_cycle_days: number }>('/mini/store/paymentCycle', {
|
||||||
|
payment_cycle_days,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { wxLoginApi } from '@/services/auth'
|
import { bindPhoneApi, wxLoginApi } from '@/services/auth'
|
||||||
import type { WxLoginParams } from '@/services/auth'
|
import type { WxLoginParams } from '@/services/auth'
|
||||||
import type { User } from '@/types/user'
|
import type { User } from '@/types/user'
|
||||||
|
|
||||||
@@ -31,6 +31,8 @@ interface AuthState {
|
|||||||
token: string | null
|
token: string | null
|
||||||
loading: boolean
|
loading: boolean
|
||||||
login: (params: WxLoginParams) => Promise<void>
|
login: (params: WxLoginParams) => Promise<void>
|
||||||
|
/** 微信手机号授权码绑定手机号(自动匹配门店/供应商) */
|
||||||
|
bindPhone: (phoneCode: string) => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
/** 更新用户信息(用于编辑资料后同步 store) */
|
/** 更新用户信息(用于编辑资料后同步 store) */
|
||||||
setUser: (user: User) => void
|
setUser: (user: User) => void
|
||||||
@@ -58,6 +60,19 @@ const useAuthStore = create<AuthState>((set) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 绑定手机号:POST /mini/auth/phone(登录后 type=0 待绑定时调用) */
|
||||||
|
bindPhone: async (phoneCode: string) => {
|
||||||
|
const res = await bindPhoneApi({ phoneCode })
|
||||||
|
const { token, user } = res.data
|
||||||
|
set({ user, token })
|
||||||
|
try {
|
||||||
|
Taro.setStorageSync(STORAGE_KEYS.TOKEN, token)
|
||||||
|
Taro.setStorageSync(STORAGE_KEYS.USER, JSON.stringify(user))
|
||||||
|
} catch {
|
||||||
|
// storage 写入失败不阻塞绑定流程
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/** 退出登录 */
|
/** 退出登录 */
|
||||||
logout: () => {
|
logout: () => {
|
||||||
set({ user: null, token: null })
|
set({ user: null, token: null })
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import {
|
||||||
|
addCartApi,
|
||||||
|
clearCartApi,
|
||||||
|
deleteCartItemApi,
|
||||||
|
getCartApi,
|
||||||
|
updateCartItemApi,
|
||||||
|
} from '@/services/cart'
|
||||||
|
import type { CartItem } from '@/types/cart'
|
||||||
|
|
||||||
|
/** 存储 key */
|
||||||
|
const STORAGE_KEY = 'cart_data'
|
||||||
|
|
||||||
|
/** 持久化的购物车快照(服务端为准,此处仅作展示缓存) */
|
||||||
|
interface StoredCart {
|
||||||
|
items: CartItem[]
|
||||||
|
totalCount: number
|
||||||
|
totalQuantity: string
|
||||||
|
totalAmount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从本地存储恢复购物车快照 */
|
||||||
|
function loadFromStorage(): StoredCart | null {
|
||||||
|
try {
|
||||||
|
const stored = Taro.getStorageSync(STORAGE_KEY)
|
||||||
|
if (stored && Array.isArray(stored.items)) {
|
||||||
|
return stored as StoredCart
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CartState {
|
||||||
|
items: CartItem[]
|
||||||
|
/** 总项数 */
|
||||||
|
totalCount: number
|
||||||
|
/** 可购项总数量(服务端字符串金额/数量) */
|
||||||
|
totalQuantity: string
|
||||||
|
/** 可购项总金额(服务端重算) */
|
||||||
|
totalAmount: string
|
||||||
|
/** 是否已从服务端同步过(避免每次展示都闪烁 loading) */
|
||||||
|
loaded: boolean
|
||||||
|
loading: boolean
|
||||||
|
/** 拉取购物车(以服务端为准,金额一律服务端重算) */
|
||||||
|
fetchCart: () => Promise<void>
|
||||||
|
/** 加购 */
|
||||||
|
addItem: (productId: number, quantity: number) => Promise<void>
|
||||||
|
/** 修改数量 */
|
||||||
|
updateQuantity: (id: number, quantity: number) => Promise<void>
|
||||||
|
/** 删除单项 */
|
||||||
|
removeItem: (id: number) => Promise<void>
|
||||||
|
/** 清空购物车 */
|
||||||
|
clearCart: () => Promise<void>
|
||||||
|
/** 下单成功后本地清空(不请求接口) */
|
||||||
|
clearLocal: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 空的购物车快照 */
|
||||||
|
const EMPTY_SNAPSHOT = {
|
||||||
|
items: [] as CartItem[],
|
||||||
|
totalCount: 0,
|
||||||
|
totalQuantity: '0.00',
|
||||||
|
totalAmount: '0.00',
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCartStore = create<CartState>((set, get) => {
|
||||||
|
const cached = loadFromStorage()
|
||||||
|
|
||||||
|
/** 持久化购物车快照 */
|
||||||
|
const persist = (
|
||||||
|
snapshot: Pick<CartState, 'items' | 'totalCount' | 'totalQuantity' | 'totalAmount'>,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
Taro.setStorageSync(STORAGE_KEY, snapshot)
|
||||||
|
} catch {
|
||||||
|
// storage 写入失败不阻塞
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...EMPTY_SNAPSHOT,
|
||||||
|
items: cached?.items ?? [],
|
||||||
|
totalCount: cached?.totalCount ?? 0,
|
||||||
|
totalQuantity: cached?.totalQuantity ?? '0.00',
|
||||||
|
totalAmount: cached?.totalAmount ?? '0.00',
|
||||||
|
loaded: false,
|
||||||
|
loading: false,
|
||||||
|
|
||||||
|
/** 拉取购物车(服务端为准) */
|
||||||
|
fetchCart: async () => {
|
||||||
|
if (get().loading) return
|
||||||
|
set({ loading: true })
|
||||||
|
try {
|
||||||
|
const res = await getCartApi()
|
||||||
|
const { items, total_count, total_quantity, total_amount } = res.data
|
||||||
|
const next = {
|
||||||
|
items,
|
||||||
|
totalCount: total_count,
|
||||||
|
totalQuantity: total_quantity,
|
||||||
|
totalAmount: total_amount,
|
||||||
|
}
|
||||||
|
set({ ...next, loaded: true })
|
||||||
|
persist(next)
|
||||||
|
} finally {
|
||||||
|
set({ loading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 加购:服务端校验上架与等级价,成功后重新同步 */
|
||||||
|
addItem: async (productId, quantity) => {
|
||||||
|
await addCartApi({ product_id: productId, quantity })
|
||||||
|
await get().fetchCart()
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 修改数量 */
|
||||||
|
updateQuantity: async (id, quantity) => {
|
||||||
|
await updateCartItemApi(id, quantity)
|
||||||
|
await get().fetchCart()
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 删除单项 */
|
||||||
|
removeItem: async (id) => {
|
||||||
|
await deleteCartItemApi(id)
|
||||||
|
await get().fetchCart()
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 清空购物车 */
|
||||||
|
clearCart: async () => {
|
||||||
|
await clearCartApi()
|
||||||
|
set({ ...EMPTY_SNAPSHOT, loaded: true })
|
||||||
|
persist(EMPTY_SNAPSHOT)
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 下单成功后本地清空 */
|
||||||
|
clearLocal: () => {
|
||||||
|
set(EMPTY_SNAPSHOT)
|
||||||
|
persist(EMPTY_SNAPSHOT)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default useCartStore
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** 接口统一响应结构 */
|
||||||
|
export interface ApiResponse<T = unknown> {
|
||||||
|
success: boolean
|
||||||
|
data: T
|
||||||
|
msg?: string
|
||||||
|
/** 提示类型(预留) */
|
||||||
|
showType?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分页数据统一结构(位于响应 data 字段内) */
|
||||||
|
export interface PaginatedData<T> {
|
||||||
|
data: T[]
|
||||||
|
total: number
|
||||||
|
pageSize: number
|
||||||
|
current: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求配置:在 Taro.request 参数基础上扩展 */
|
||||||
|
export interface RequestConfig {
|
||||||
|
url: string
|
||||||
|
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'TRACE' | 'CONNECT'
|
||||||
|
data?: any
|
||||||
|
header?: Record<string, string>
|
||||||
|
timeout?: number
|
||||||
|
/** 跳过自动附加 token(如登录接口) */
|
||||||
|
skipToken?: boolean
|
||||||
|
/** 跳过自动错误提示(业务失败时不弹 toast,由调用方处理) */
|
||||||
|
skipErrorToast?: boolean
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/** 购物车项 */
|
||||||
|
export interface CartItem {
|
||||||
|
id: number
|
||||||
|
product_id: number
|
||||||
|
/** 商品快照 */
|
||||||
|
name: string
|
||||||
|
spec: string
|
||||||
|
unit: string
|
||||||
|
/** 商品首图 URL */
|
||||||
|
image: string
|
||||||
|
/** 当前等级实际价(商品下架或未设等级价为 null) */
|
||||||
|
price: string | null
|
||||||
|
quantity: string
|
||||||
|
/** 金额 = price × quantity(不可购为 null) */
|
||||||
|
amount: string | null
|
||||||
|
/** 1 可购 / 0 商品下架、缺失或未设等级价 */
|
||||||
|
status: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 购物车列表数据 */
|
||||||
|
export interface CartData {
|
||||||
|
items: CartItem[]
|
||||||
|
/** 总项数 */
|
||||||
|
total_count: number
|
||||||
|
/** 可购项总数量 */
|
||||||
|
total_quantity: string
|
||||||
|
/** 可购项总金额 */
|
||||||
|
total_amount: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/** 通知类型:order 订单 / price 价格变更 / system 系统 */
|
||||||
|
export type NoticeType = 'order' | 'price' | 'system'
|
||||||
|
|
||||||
|
export const NOTICE_TYPE_MAP: Record<NoticeType, string> = {
|
||||||
|
order: '订单',
|
||||||
|
price: '价格变更',
|
||||||
|
system: '系统',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知 */
|
||||||
|
export interface Notice {
|
||||||
|
id: number
|
||||||
|
type: NoticeType
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
/** 附加数据(价格变更通知含 product_ids、level_ids) */
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
/** 0 未读 / 1 已读 */
|
||||||
|
is_read: 0 | 1
|
||||||
|
read_at: string | null
|
||||||
|
created_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通知列表(分页结构 + 未读总数) */
|
||||||
|
export interface NoticeData {
|
||||||
|
unread_count: number
|
||||||
|
data: Notice[]
|
||||||
|
total: number
|
||||||
|
pageSize: number
|
||||||
|
current: number
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/** 门店订单状态:0 待汇总 / 1 已汇总 / 2 配送中 / 3 已完成 / 9 已取消 */
|
||||||
|
export type OrderStatus = 0 | 1 | 2 | 3 | 9
|
||||||
|
|
||||||
|
export const ORDER_STATUS_MAP: Record<OrderStatus, string> = {
|
||||||
|
0: '待汇总',
|
||||||
|
1: '已汇总',
|
||||||
|
2: '配送中',
|
||||||
|
3: '已完成',
|
||||||
|
9: '已取消',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 状态筛选(value 为 null 表示全部) */
|
||||||
|
export const ORDER_STATUS_FILTERS: Array<{ value: OrderStatus | null; label: string }> = [
|
||||||
|
{ value: null, label: '全部' },
|
||||||
|
{ value: 0, label: '待汇总' },
|
||||||
|
{ value: 1, label: '已汇总' },
|
||||||
|
{ value: 2, label: '配送中' },
|
||||||
|
{ value: 3, label: '已完成' },
|
||||||
|
{ value: 9, label: '已取消' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 门店订单 */
|
||||||
|
export interface Order {
|
||||||
|
id: number
|
||||||
|
order_no: string
|
||||||
|
/** 订货日期(Y-m-d) */
|
||||||
|
order_date: string
|
||||||
|
total_quantity: string
|
||||||
|
total_amount: string
|
||||||
|
status: OrderStatus
|
||||||
|
remark: string
|
||||||
|
/** 详情接口返回 */
|
||||||
|
items?: OrderItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 订单明细 */
|
||||||
|
export interface OrderItem {
|
||||||
|
id: number
|
||||||
|
order_id: number
|
||||||
|
product_id: number
|
||||||
|
product_name: string
|
||||||
|
product_spec: string
|
||||||
|
/** 下单时等级实际价快照 */
|
||||||
|
price: string
|
||||||
|
quantity: string
|
||||||
|
/** 称重(默认 0) */
|
||||||
|
weight: string
|
||||||
|
amount: string
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下单返回 */
|
||||||
|
export interface OrderCreateResult {
|
||||||
|
id: number
|
||||||
|
order_no: string
|
||||||
|
total_amount: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 周期汇总分组 */
|
||||||
|
export interface SummaryGroup {
|
||||||
|
period_label: string
|
||||||
|
total_amount: string
|
||||||
|
total_quantity: string
|
||||||
|
order_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 周期汇总 */
|
||||||
|
export interface OrderSummary {
|
||||||
|
period: 'day' | 'week' | 'month'
|
||||||
|
groups: SummaryGroup[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/** 商品分类节点(children 递归) */
|
||||||
|
export interface Category {
|
||||||
|
id: number
|
||||||
|
parent_id: number
|
||||||
|
name: string
|
||||||
|
children: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品图片 */
|
||||||
|
export interface ProductImage {
|
||||||
|
id: number
|
||||||
|
file_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品 */
|
||||||
|
export interface Product {
|
||||||
|
id: number
|
||||||
|
category_id: number
|
||||||
|
supplier_id: number
|
||||||
|
name: string
|
||||||
|
spec: string
|
||||||
|
unit: string
|
||||||
|
/** 商品图文详情(HTML) */
|
||||||
|
content: string
|
||||||
|
/** 当前门店等级的实际销售价(未设等级价为 null) */
|
||||||
|
price: string | null
|
||||||
|
images_arr: ProductImage[]
|
||||||
|
/** 排序 / 保质期 / 库存 / 状态(仅返回上架商品) */
|
||||||
|
sort?: number
|
||||||
|
shelf_life?: string | null
|
||||||
|
stock?: number | null
|
||||||
|
status?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 商品首图地址 */
|
||||||
|
export function getProductCover(product: Product): string {
|
||||||
|
const first = product.images_arr?.[0]
|
||||||
|
if (!first || !first.file_url) return ''
|
||||||
|
if (/^https?:\/\//i.test(first.file_url)) return first.file_url
|
||||||
|
return first.file_url
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/** 对账单状态:0 待对账 / 1 已对账 / 2 已结算 */
|
||||||
|
export type StatementStatus = 0 | 1 | 2
|
||||||
|
|
||||||
|
export const STATEMENT_STATUS_MAP: Record<StatementStatus, string> = {
|
||||||
|
0: '待对账',
|
||||||
|
1: '已对账',
|
||||||
|
2: '已结算',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对账单 */
|
||||||
|
export interface Statement {
|
||||||
|
id: number
|
||||||
|
statement_no: string
|
||||||
|
period_start: string
|
||||||
|
period_end: string
|
||||||
|
total_amount: string
|
||||||
|
/** 生成时快照的回款周期 */
|
||||||
|
payment_cycle_days: number
|
||||||
|
/** 应结算日期 = 周期结束 + 回款周期天 */
|
||||||
|
settlement_date: string | null
|
||||||
|
status: StatementStatus
|
||||||
|
reconciled_at: string | null
|
||||||
|
settled_at: string | null
|
||||||
|
remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对账单明细行 */
|
||||||
|
export interface StatementItem {
|
||||||
|
order_id: number
|
||||||
|
order_item_id: number
|
||||||
|
product_id: number
|
||||||
|
product_name: string
|
||||||
|
price: string
|
||||||
|
quantity: string
|
||||||
|
weight: string
|
||||||
|
amount: string
|
||||||
|
/** 0 未对账 / 1 已对账 */
|
||||||
|
is_reconciled: number
|
||||||
|
store_remark: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对账单详情 */
|
||||||
|
export interface StatementDetail extends Statement {
|
||||||
|
items: StatementItem[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/** 客户等级 */
|
||||||
|
export interface StoreLevel {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 门店信息 */
|
||||||
|
export interface StoreInfo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
/** 客户等级(level_id > 0 才可展示价格) */
|
||||||
|
level: StoreLevel | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供应商信息 */
|
||||||
|
export interface SupplierInfo {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户类型:0 待绑定 / 1 门店 / 2 供应商 */
|
||||||
|
export type UserType = 0 | 1 | 2
|
||||||
|
|
||||||
|
export const USER_TYPE_MAP: Record<UserType, string> = {
|
||||||
|
0: '待绑定',
|
||||||
|
1: '门店',
|
||||||
|
2: '供应商',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户信息 */
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
nickname: string
|
||||||
|
avatar: string
|
||||||
|
phone: string
|
||||||
|
type: UserType
|
||||||
|
store: StoreInfo | null
|
||||||
|
supplier: SupplierInfo | null
|
||||||
|
}
|
||||||