Compare commits

...

23 Commits

Author SHA1 Message Date
xinadmin 8f9b54ce76 运营报表优化 2026-09-07 23:09:44 +08:00
xinadmin a85da7a5d1 账单备注调整 2026-09-07 22:37:53 +08:00
xinadmin 1b9e505feb 调整样式 2026-09-06 23:15:13 +08:00
xinadmin 74aee003e7 H5支付 2026-09-04 21:54:10 +08:00
xinadmin e2926b0c70 H5适配 2026-09-04 14:10:33 +08:00
xinadmin 1701169c9d 账单样式 2026-09-03 20:47:55 +08:00
xinadmin e97cc128ec 首页推荐修改 2026-09-03 19:37:42 +08:00
xinadmin 9d893b4ed4 小程序样式更新 2026-08-31 15:43:01 +08:00
xinadmin 130ef2c949 优化金额显示等 2026-08-29 13:52:57 +08:00
xinadmin 2317c9d973 支付 2026-08-27 21:18:01 +08:00
xinadmin 260d8086bf 购物车悬浮加减 2026-08-27 18:08:14 +08:00
xinadmin 78c787d207 协议 2026-08-27 14:25:03 +08:00
xinadmin 4bbe92d8e8 样式 2026-08-21 12:41:15 +08:00
xinadmin 8a7bdca6b1 远程地址 2026-08-21 12:06:55 +08:00
xinadmin 69b72a9c1c 运营报表 2026-08-21 12:04:39 +08:00
xinadmin ffe983b9da 显示格式优化 2026-08-21 11:35:05 +08:00
xinadmin f0d8f6bac4 账户密码登录 2026-08-21 11:01:37 +08:00
xinadmin 3bd26acbb9 账单与支付 2026-08-14 23:48:15 +08:00
xinadmin 4138164bd8 账单导出 2026-08-14 20:41:25 +08:00
xinadmin 03434feb35 账单 2026-08-14 20:21:50 +08:00
xinadmin 4a5b7003ca 修复分类 2026-08-14 16:21:41 +08:00
xinadmin 8d308288bb 商品列表 2026-08-14 15:24:05 +08:00
xinadmin d1d8d7ab52 首页优化 2026-08-14 12:34:25 +08:00
81 changed files with 6515 additions and 1449 deletions
+2 -2
View File
@@ -2,10 +2,10 @@
"miniprogramRoot": "./", "miniprogramRoot": "./",
"projectname": "pure-project-vantui", "projectname": "pure-project-vantui",
"description": "", "description": "",
"appid": "wx7ed74d60503b5ee3", "appid": "wx8f48874e3bf1dccd",
"setting": { "setting": {
"urlCheck": false, "urlCheck": false,
"es6": false, "es6": true,
"postcss": false, "postcss": false,
"minified": true, "minified": true,
"enhance": false "enhance": false
+10 -2
View File
@@ -2,14 +2,22 @@ export default defineAppConfig({
pages: [ pages: [
'pages/index/index', 'pages/index/index',
'pages/product/index', 'pages/product/index',
'pages/product-detail/index',
'pages/cart/index', 'pages/cart/index',
'pages/message/index', 'pages/message/index',
'pages/profile/index', 'pages/profile/index',
'pages/order-list/index', 'pages/order-list/index',
'pages/statement/index', 'pages/report/index',
'pages/bill/index',
'pages/bill-detail/index',
'pages/payment/index',
'pages/payment-records/index',
'pages/payment-detail/index',
'pages/settings/index', 'pages/settings/index',
'pages/login/index', 'pages/login/index',
'pages/register/index', 'pages/agreement/index',
'pages/privacy/index',
'pages/change-password/index',
'pages/store-info/index', 'pages/store-info/index',
], ],
window: { window: {
+57
View File
@@ -0,0 +1,57 @@
// 购物车悬浮球:右下角,底部避让自定义 tabBar(110rpx + 安全区)
.cart-ball {
position: fixed;
left: 24rpx;
bottom: calc(150rpx + env(safe-area-inset-bottom));
z-index: 998;
display: flex;
align-items: center;
height: 88rpx;
padding: 0 32rpx 0 8rpx;
background: #fff;
border-radius: 999rpx;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
box-sizing: border-box;
&__icon {
position: relative;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(135deg, #ee0a24, #ff6034);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__badge {
position: absolute;
top: -8rpx;
right: -16rpx;
min-width: 32rpx;
height: 32rpx;
padding: 0 8rpx;
box-sizing: border-box;
background: #fff;
border: 2rpx solid #ee0a24;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
}
&__badge-text {
color: #ee0a24;
font-size: 20rpx;
font-weight: 600;
line-height: 1;
}
&__amount {
margin-left: 16rpx;
color: #ee0a24;
font-size: 32rpx;
font-weight: 700;
}
}
+38
View File
@@ -0,0 +1,38 @@
import { useCallback } from 'react'
import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Icon } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import useCartStore from '@/stores/cart/useCartStore'
import { formatQuantity } from '@/utils/format'
import './index.less'
/**
* 购物车悬浮球(首页 / 商品列表页右下角,位于自定义 tabBar 上方):
* 展示可购总数量徽标与总金额,点击跳转购物车页;
* 未登录或购物车为空(total_count = 0)时隐藏
*/
export default function CartBall() {
const token = useAuthStore(s => s.token)
const totalCount = useCartStore(s => s.totalCount)
const totalQuantity = useCartStore(s => s.totalQuantity)
const totalAmount = useCartStore(s => s.totalAmount)
const goCart = useCallback(() => {
Taro.switchTab({ url: '/pages/cart/index' })
}, [])
if (!token || totalCount <= 0) return null
return (
<View className='cart-ball' onClick={goCart}>
<View className='cart-ball__icon'>
<Icon name='shopping-cart-o' size='40rpx' color='#ffffff' />
<View className='cart-ball__badge'>
<Text className='cart-ball__badge-text'>{formatQuantity(totalQuantity)}</Text>
</View>
</View>
<Text className='cart-ball__amount'>¥{totalAmount}</Text>
</View>
)
}
+42
View File
@@ -0,0 +1,42 @@
.cart-stepper {
display: flex;
align-items: center;
flex-shrink: 0;
&__btn {
width: 42rpx;
height: 42rpx;
border-radius: 50%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
border: 2rpx solid #ee0a24;
&--plus {
background: linear-gradient(135deg, #ee0a24, #ff6034);
border: none;
}
}
&__btn-icon {
font-size: 30rpx;
line-height: 1;
color: #ee0a24;
}
&__btn--plus &__btn-icon {
color: #fff;
}
&__qty {
min-width: 64rpx;
padding: 0 4rpx;
box-sizing: border-box;
text-align: center;
font-size: 28rpx;
color: #323233;
font-weight: 500;
}
}
+121
View File
@@ -0,0 +1,121 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { View, Text } from '@tarojs/components'
import { addCartApi, deleteCartItemApi, updateCartItemApi } from '@/services/cart'
import useCartStore from '@/stores/cart/useCartStore'
import { formatQuantity } from '@/utils/format'
import type { Product, ProductCartPatch } from '@/types/product'
import './index.less'
/** 加减防抖间隔(ms):连续点击合并为一次提交 */
const DEBOUNCE_MS = 400
interface CartStepperProps {
/** 商品行(使用 id / price / cart_id / cart_quantity */
product: Product
/** 服务端确认后的行数据回写(父组件更新列表项的 cart_id/cart_quantity */
onSync: (productId: number, patch: ProductCartPatch) => void
}
/**
* 商品行内购物车加减(商品列表 / 首页推荐共用,仅在 cart_quantity > 0 时由父组件渲染):
* - 点击即时更新本地数量与悬浮球(乐观展示),防抖后提交服务端
* - 不在购物车(cart_id=0)→ POST /mini/cart 合并加购;已存在 → PUT 绝对数量;减到 0 → DELETE
* (数量为 0 不能调 PUT,后端校验数量必须 > 0)
* - 同一商品的提交串行执行,避免并发导致数量错乱
* - 失败回滚本地数量(请求层已 toast),并立即整体校准悬浮球
*/
export default function CartStepper({ product, onSync }: CartStepperProps) {
const applyDelta = useCartStore(s => s.applyDelta)
const fetchSummary = useCartStore(s => s.fetchSummary)
/** 本地编辑数量(乐观值;null = 展示服务端确认值) */
const [draft, setDraft] = useState<number | null>(null)
/** 最新待提交的目标数量 */
const targetRef = useRef<number | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 提交串行队列 */
const chainRef = useRef<Promise<void>>(Promise.resolve())
/** 最新商品行快照(供防抖/串行回调读取服务端确认值,避免闭包过期) */
const productRef = useRef(product)
productRef.current = product
/** 卸载时清理防抖定时器 */
useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current)
},
[],
)
/** 提交目标数量(串行执行;与服务器一致时跳过) */
const runSubmit = useCallback(
async (target: number) => {
const p = productRef.current
const confirmed = Number(p.cart_quantity ?? 0)
if (target === confirmed) return
try {
if (target <= 0) {
if (p.cart_id) await deleteCartItemApi(p.cart_id)
onSync(p.id, { cart_id: 0, cart_quantity: '0.00' })
} else if (p.cart_id) {
const res = await updateCartItemApi(p.cart_id, target)
onSync(p.id, { cart_id: p.cart_id, cart_quantity: res.data.quantity })
} else {
// 未加购过:POST 合并加购,用返回的行 id 回写本地 cart_id
const res = await addCartApi({ product_id: p.id, quantity: target })
onSync(p.id, { cart_id: res.data.id, cart_quantity: res.data.quantity })
}
// 提交期间用户未再改动 → 本地数量落回服务端确认值(onSync 已回写,展示不变)
setDraft(prev => (prev === target ? null : prev))
} catch {
// 失败(超上限等,请求层已 toast):放弃后续目标,回滚本地展示并校准悬浮球
targetRef.current = null
setDraft(null)
fetchSummary().catch(() => {})
}
},
[onSync, fetchSummary],
)
/** 点击加/减:乐观更新本地数量与悬浮球,防抖后入队提交 */
const handleTap = useCallback(
(delta: 1 | -1) => {
const before = draft ?? Number(productRef.current.cart_quantity ?? 0)
const after = Math.round(Math.max(0, before + delta) * 100) / 100
if (after === before) return
setDraft(after)
targetRef.current = after
// 悬浮球乐观增减(金额按行内售价估算,防抖结束后由服务端汇总校准);
// 数量跨过 0 时同步增减商品种数
const price = Number(productRef.current.price ?? 0)
applyDelta({
quantity: delta,
amount: Math.round(price * delta * 100) / 100,
count: before === 0 && after > 0 ? 1 : before > 0 && after === 0 ? -1 : 0,
})
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
timerRef.current = null
const target = targetRef.current
if (target === null) return
targetRef.current = null
chainRef.current = chainRef.current.then(() => runSubmit(target))
}, DEBOUNCE_MS)
},
[draft, applyDelta, runSubmit],
)
const shown = draft ?? Number(product.cart_quantity ?? 0)
return (
<View className='cart-stepper' onClick={e => e.stopPropagation()}>
<View className='cart-stepper__btn' onClick={() => handleTap(-1)}>
<Text className='cart-stepper__btn-icon'></Text>
</View>
<Text className='cart-stepper__qty'>{formatQuantity(shown)}</Text>
<View className='cart-stepper__btn cart-stepper__btn--plus' onClick={() => handleTap(1)}>
<Text className='cart-stepper__btn-icon'></Text>
</View>
</View>
)
}
@@ -1,8 +1,6 @@
import {useEffect, useState} from 'react' import {useEffect, useState} from 'react'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import {View, Text, Image} 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 IndexImage from '@/static/images/nav/index.png';
import IndexActiveImage from '@/static/images/nav/index_active.png'; import IndexActiveImage from '@/static/images/nav/index_active.png';
import CartImage from '@/static/images/nav/cart.png'; import CartImage from '@/static/images/nav/cart.png';
+21
View File
@@ -0,0 +1,21 @@
// ===== 零售价标注:小字置灰 =====
.price-text__retail {
font-size: 20rpx;
color: #969799;
font-weight: 400;
line-height: 1.4;
white-space: nowrap;
}
// inline 模式:与大价格同行,左间距分隔
.price-text__retail--inline {
margin-left: 8rpx;
}
// block 模式:大价格 / 零售价上下两行(窄卡片布局)
.price-text {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: 1.3;
}
+49
View File
@@ -0,0 +1,49 @@
import { Text, View } from '@tarojs/components'
import { formatRetailPrice } from '@/utils/format'
import './index.less'
interface PriceTextProps {
/** 售价(展示为 ¥price */
price: string | number
/** 包规(用于计算零售价;无法计算时不展示零售价) */
spec?: string | number | null
/** 大价格样式类(字号 / 颜色由调用方控制) */
className?: string
/**
* 零售价布局:
* - inline 跟随大价格同行(宽裕区域:商品详情、各类弹层行)
* - block 独占一行(窄卡片:首页推荐、商品列表、购物车)
*/
mode?: 'inline' | 'block'
/** 单位 */
price_unit?: string | null
}
/**
* 商品价格:售价 + 零售价标注(小字)
* price=30、spec=15 → ¥30 零售价:¥2
*/
export default function PriceText({ price, spec, className, mode = 'inline', price_unit }: PriceTextProps) {
const retail = formatRetailPrice(price, spec)
// 窄卡片:大价格 / 零售价上下两行,避免与右侧按钮(+/步进器)挤压换行
if (mode === 'block') {
return (
<View className={`price-text ${className ?? ''}`}>
<Text className='price-text__main'>{price}</Text>
{retail !== null && (
<Text className='price-text__retail'>{retail} {price_unit}</Text>
)}
</View>
)
}
return (
<Text className={className}>
{price}
{retail !== null && (
<Text className='price-text__retail price-text__retail--inline'>{retail} {price_unit}</Text>
)}
</Text>
)
}
+1
View File
@@ -10,6 +10,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" > <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
<title>订货采购</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 src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<script><%= htmlWebpackPlugin.options.script %></script> <script><%= htmlWebpackPlugin.options.script %></script>
</head> </head>
<body> <body>
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '用户服务协议',
})
+54
View File
@@ -0,0 +1,54 @@
/* ========================================
协议/政策页面(用户协议、隐私政策共用)
======================================== */
.agreement-page {
min-height: 100vh;
background: #fff;
}
.agreement-scroll {
height: 100vh;
}
.agreement-content {
padding: 32px 40px 80px;
.doc-title {
display: block;
font-size: 40px;
font-weight: 600;
color: #323233;
text-align: center;
margin-bottom: 16px;
}
.doc-updated {
display: block;
font-size: 24px;
color: #969799;
text-align: center;
margin-bottom: 48px;
}
.doc-p {
display: block;
font-size: 28px;
color: #323233;
line-height: 1.8;
margin-bottom: 24px;
text-align: justify;
}
.doc-h2 {
display: block;
font-size: 32px;
font-weight: 600;
color: #323233;
margin: 48px 0 16px;
}
.doc-bold {
font-weight: 600;
}
}
+67
View File
@@ -0,0 +1,67 @@
import { View, Text, ScrollView } from '@tarojs/components'
import './index.less'
/**
* 用户服务协议
* 静态协议文本页,由登录页/设置页进入
*/
export default function AgreementPage() {
return (
<View className='agreement-page'>
<ScrollView scrollY className='agreement-scroll'>
<View className='agreement-content'>
<Text className='doc-title'></Text>
<Text className='doc-updated'>2026821 2026821</Text>
<Text className='doc-p'>
使使
</Text>
<Text className='doc-p doc-bold'>
使
</Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>1.1 </Text>
<Text className='doc-p'>1.2 使</Text>
<Text className='doc-p'>1.3 使</Text>
<Text className='doc-p'>1.4 --</Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>2.1 线线</Text>
<Text className='doc-p'>2.2 </Text>
<Text className='doc-p'>2.3 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>3.1 使</Text>
<Text className='doc-p'>3.2 </Text>
<Text className='doc-p'>3.3 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>4.1 退</Text>
<Text className='doc-p'>4.2 </Text>
<Text className='doc-p'>4.3 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>5.1 </Text>
<Text className='doc-p'>5.2 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>6.1 </Text>
<Text className='doc-p'>6.2 </Text>
<Text className='doc-p'>6.3 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>7.1 使使</Text>
<Text className='doc-p'>7.2 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>8.1 </Text>
<Text className='doc-p'>8.2 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'></Text>
</View>
</ScrollView>
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '账单详情',
})
+327
View File
@@ -0,0 +1,327 @@
.bill-detail {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
// 可付款时为底部操作栏留出空间
&--pay {
padding-bottom: 160rpx;
}
.bill-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
&__no {
font-size: 30rpx;
font-weight: 600;
color: #323233;
}
&__status {
font-size: 24rpx;
// 0 待支付 / 1 审核中 / 2 已支付
&--0 { color: #ee0a24; }
&--1 { color: #ff976a; }
&--2 { color: #07c160; }
}
&__tip {
display: block;
margin: -4rpx 0 16rpx;
padding: 12rpx 16rpx;
background: #fffbe8;
border-radius: 8rpx;
font-size: 22rpx;
color: #ed6a0c;
}
&__row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10rpx 0;
&--total {
margin-top: 12rpx;
padding-top: 20rpx;
border-top: 1rpx solid #ebedf0;
}
}
&__label {
font-size: 26rpx;
color: #969799;
flex: 1;
min-width: 0;
padding-right: 20rpx;
}
&__value {
font-size: 26rpx;
color: #323233;
flex-shrink: 0;
// 回筐抵扣(负附加金额)
&--return {
color: #07c160;
}
}
&__total {
font-size: 34rpx;
font-weight: 600;
color: #ee0a24;
}
}
.bill-section {
&__title {
font-size: 28rpx;
font-weight: 600;
color: #323233;
display: block;
margin-bottom: 8rpx;
}
&__desc {
font-size: 22rpx;
color: #c8c9cc;
display: block;
margin-bottom: 12rpx;
}
}
.bill-goods {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
&__img {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
background: #f2f3f5;
flex-shrink: 0;
margin-right: 16rpx;
}
&__main {
flex: 1;
min-width: 0;
}
&__name {
font-size: 28rpx;
color: #323233;
display: block;
}
&__spec {
font-size: 22rpx;
color: #c8c9cc;
display: block;
margin-top: 4rpx;
}
&__side {
text-align: right;
margin-left: 16rpx;
flex-shrink: 0;
}
&__amount {
font-size: 28rpx;
color: #ee0a24;
font-weight: 500;
display: block;
margin-top: 4rpx;
}
&__price {
font-size: 24rpx;
color: #323233;
font-weight: 500;
display: block;
margin-top: 4rpx;
}
}
.bill-order {
display: flex;
align-items: center;
padding: 14rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&__no {
font-size: 26rpx;
color: #323233;
}
&__date {
font-size: 22rpx;
color: #969799;
margin-top: 4rpx;
}
&__status {
font-size: 24rpx;
color: #969799;
margin: 0 16rpx;
flex-shrink: 0;
}
&__amount {
font-size: 26rpx;
color: #323233;
flex-shrink: 0;
}
}
// ===== 订单明细弹层 =====
.order-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;
}
}
// ===== 去付款操作栏 =====
.bill-pay-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: center;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
&__info {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
}
&__label {
font-size: 26rpx;
color: #646566;
}
&__amount {
margin-left: 16rpx;
font-size: 36rpx;
color: #ee0a24;
font-weight: 600;
}
&__btn {
padding: 14rpx 48rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
font-weight: 500;
}
}
}
+247
View File
@@ -0,0 +1,247 @@
import { useCallback, useEffect, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import { Empty, Popup } from '@antmjs/vantui'
import { getBillDetailApi } from '@/services/bill'
import { getOrderDetailApi } from '@/services/order'
import { ORDER_STATUS_TEXT } from '@/types/order'
import {formatRetailPrice, formatSpec, resolveFileUrl} from '@/utils/format'
import type { OrderDetail } from '@/types/order'
import type { BillDetail } from '@/services/bill'
import './index.less'
/**
* 账单详情
* 账单信息 + 金额构成(商品/配送费/筐托盘附加)+ 合并商品明细 + 关联订单(可下钻订单明细)
*/
export default function BillDetailPage() {
const router = useRouter()
const id = Number(router.params.id ?? 0)
const [detail, setDetail] = useState<BillDetail | null>(null)
const [loading, setLoading] = useState(false)
/** 关联订单明细弹层 */
const [showOrder, setShowOrder] = useState(false)
const [orderDetail, setOrderDetail] = useState<OrderDetail | null>(null)
useEffect(() => {
if (!id) return
setLoading(true)
getBillDetailApi(id)
.then(res => setDetail(res.data))
.catch(() => {})
.finally(() => setLoading(false))
}, [id])
/** 下钻关联订单明细 */
const handleOrderTap = useCallback(async (orderId: number) => {
try {
const res = await getOrderDetailApi(orderId)
setOrderDetail(res.data)
setShowOrder(true)
} catch {
// 错误已由 request 层 toast
}
}, [])
/** 去付款 → 发起付款页(预选本账单) */
const goPay = () => {
Taro.navigateTo({ url: `/pages/payment/index` })
}
if (loading && !detail) {
return <View className='bill-detail'><Empty description='加载中...' /></View>
}
if (!detail) {
return <View className='bill-detail'><Empty description='账单不存在' /></View>
}
const { bill, items, orders } = detail
/** 筐/托盘明细:正压负回,数量取绝对值(单价为出账时快照) */
const boxTotalPrice = (Number(bill.box_price) * bill.box_num).toFixed(2)
const trayTotalPrice = (Number(bill.tray_price) * bill.tray_num).toFixed(2)
return (
<View className={`bill-detail ${bill.can_pay ? 'bill-detail--pay' : ''}`}>
{/* ===== 账单信息 ===== */}
<View className='bill-card'>
<View className='bill-card__header'>
<Text className='bill-card__no'>{bill.bill_no}</Text>
<Text className={`bill-card__status bill-card__status--${bill.pay_state}`}>
{bill.pay_state_name}
</Text>
</View>
{bill.pay_state === 1 && (
<Text className='bill-card__tip'></Text>
)}
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.bill_date}</Text>
</View>
{bill.purchase && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>
{bill.purchase.purchase_no}{bill.purchase.purchase_date}
</Text>
</View>
)}
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.settlement_date}</Text>
</View>
{bill.pay_state === 2 && bill.paid_at && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.paid_at}</Text>
</View>
)}
{bill.pay_remark && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.pay_remark}</Text>
</View>
)}
</View>
{/* ===== 金额构成 ===== */}
<View className='bill-card'>
<Text className='bill-section__title'></Text>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.product_amount}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.delivery_fee}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'>{bill.box_price} × {bill.box_num}</Text>
<Text className={`bill-card__value ${Number(boxTotalPrice) < 0 ? 'bill-card__value--return' : ''}`}>
{Number(boxTotalPrice) < 0 ? `- ¥${boxTotalPrice}` : `${boxTotalPrice}`}
</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'>{bill.tray_price} × {bill.tray_num}</Text>
<Text className={`bill-card__value ${Number(trayTotalPrice) < 0 ? 'bill-card__value--return' : ''}`}>
{Number(trayTotalPrice) < 0 ? `- ¥${trayTotalPrice}` : `${trayTotalPrice}`}
</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className={`bill-card__value ${Number(bill.after_sale) < 0 ? 'bill-card__value--return' : ''}`}>
{Number(bill.after_sale) < 0 ? `- ¥${bill.after_sale}` : `${bill.after_sale}`}
</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.remark || '暂无备注'}</Text>
</View>
<View className='bill-card__row bill-card__row--total'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__total'>{bill.total_amount}</Text>
</View>
</View>
{/* ===== 商品明细(跨订单按商品合并) ===== */}
<View className='bill-card'>
<Text className='bill-section__title'>{items?.length ?? 0}</Text>
{(items ?? []).map(item => (
<View key={item.product_id} className='bill-goods'>
{!!item.image && (
<Image
className='bill-goods__img'
src={resolveFileUrl(item.image)}
mode='aspectFill'
lazyLoad
/>
)}
<View className='bill-goods__main'>
<View className='bill-goods__name'>{item.product_name}</View>
<View className='bill-goods__spec'>
{formatSpec(item.product_spec, item.unit)}
</View>
<View className='bill-goods__spec'>
{formatRetailPrice(item.price, item.spec)} {item.price_unit}
</View>
</View>
<View className='bill-goods__side'>
<Text className='bill-goods__price'>{item.price} × {item.quantity}</Text>
<View className='bill-goods__amount'>{item.amount}</View>
</View>
</View>
))}
{(items ?? []).length === 0 && <Empty description='暂无商品记录' />}
</View>
{/* ===== 关联订单 ===== */}
<View className='bill-card'>
<Text className='bill-section__title'>{orders?.length ?? 0}</Text>
{(orders ?? []).map(order => (
<View key={order.id} className='bill-order' onClick={() => handleOrderTap(order.id)}>
<View className='bill-order__main'>
<Text className='bill-order__no'>{order.order_no}</Text>
<Text className='bill-order__date'>{order.order_date}</Text>
</View>
<Text className='bill-order__status'>{ORDER_STATUS_TEXT[order.status] || ''}</Text>
<Text className='bill-order__amount'>{order.total_amount}</Text>
</View>
))}
{(orders ?? []).length === 0 && <Empty description='暂无订单记录' />}
</View>
{/* ===== 订单明细弹层 ===== */}
<Popup
show={showOrder}
position='bottom'
round
closeable
closeOnClickOverlay
safeAreaInsetBottom
onClose={() => setShowOrder(false)}
>
{orderDetail && (
<View className='order-popup'>
<Text className='order-popup__title'>{orderDetail.order_no}</Text>
<View className='order-popup__meta'>
<Text>{orderDetail.order_date}</Text>
<Text className='order-popup__status'>
{ORDER_STATUS_TEXT[orderDetail.status] || ''}
</Text>
</View>
<ScrollView scrollY className='order-popup__list'>
{(orderDetail.items ?? []).map(item => (
<View key={item.id} className='order-popup__item'>
<View className='order-popup__item-info'>
<Text className='order-popup__item-name'>{item.product_name}</Text>
<Text className='order-popup__item-spec'>
{formatSpec(item.product_spec, item.unit)}{' '}
{formatRetailPrice(item.price, item.product_spec)} {item.price_unit}
</Text>
</View>
<Text className='order-popup__item-amount'>{item.price} × {item.quantity}</Text>
</View>
))}
</ScrollView>
<View className='order-popup__footer'>
<Text className='order-popup__total'> {orderDetail.total_amount}</Text>
</View>
</View>
)}
</Popup>
{/* ===== 去付款操作栏(可付款账单) ===== */}
{bill.can_pay && (
<View className='bill-pay-bar'>
<View className='bill-pay-bar__info'>
<Text className='bill-pay-bar__label'></Text>
<Text className='bill-pay-bar__amount'>{bill.total_amount}</Text>
</View>
<View className='bill-pay-bar__btn' onClick={goPay}></View>
</View>
)}
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '账单',
})
+319
View File
@@ -0,0 +1,319 @@
.bill-page {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
// 多选模式为底部导出栏留出空间
&--select {
padding-bottom: 160rpx;
}
// 底部待支付汇总栏留出空间
&--pay {
padding-bottom: 180rpx;
}
// ===== 底部待支付汇总栏 =====
.bill-paybar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
&__info {
display: flex;
flex-direction: column;
}
&__label {
font-size: 22rpx;
color: #969799;
}
&__amount {
margin-top: 4rpx;
font-size: 36rpx;
color: #ee0a24;
font-weight: 600;
}
&__btn {
padding: 16rpx 56rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ee0a24, #ff6034);
color: #fff;
font-size: 28rpx;
font-weight: 500;
}
}
// ===== 状态筛选 + 导出入口 =====
.bill-toolbar {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.status-scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
margin-bottom: 0;
}
&__export {
flex-shrink: 0;
margin-left: 16rpx;
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: #fff;
border: 1rpx solid #ee0a24;
color: #ee0a24;
font-size: 26rpx;
&.active {
background: #ee0a24;
color: #fff;
}
}
}
.status-scroll {
white-space: nowrap;
margin-bottom: 20rpx;
}
.status-chip {
display: inline-flex;
padding: 12rpx 28rpx;
margin-right: 16rpx;
border-radius: 999rpx;
background: #fff;
font-size: 26rpx;
color: #646566;
&.active {
background: #ee0a24;
color: #fff;
}
}
.bill-empty {
padding-top: 120rpx;
&__btn {
margin-top: 24rpx;
padding: 14rpx 60rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
border-radius: 999rpx;
}
}
.bill-loading {
padding: 30rpx 0;
text-align: center;
font-size: 24rpx;
color: #c8c9cc;
}
// ===== 账单单项 =====
.bill-item {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
&--select {
display: flex;
align-items: center;
}
&__content {
flex: 1;
min-width: 0;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__no {
font-size: 28rpx;
color: #323233;
font-weight: 500;
}
&__status {
font-size: 24rpx;
// 0 待支付 / 1 审核中 / 2 已支付
&--0 { color: #ee0a24; }
&--1 { color: #ff976a; }
&--2 { color: #07c160; }
}
&__body {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
&__meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&__date {
font-size: 24rpx;
color: #969799;
}
&__purchase {
margin-top: 6rpx;
font-size: 22rpx;
color: #c8c9cc;
}
&__amount {
font-size: 32rpx;
color: #323233;
font-weight: 600;
margin-left: 20rpx;
}
&__footer {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
&__settle {
font-size: 22rpx;
color: #969799;
}
&__paid {
font-size: 22rpx;
color: #07c160;
}
}
// ===== 勾选圆圈 =====
.bill-check {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 2rpx solid #c8c9cc;
margin-right: 20rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
&.on {
background: #ee0a24;
border-color: #ee0a24;
}
}
// ===== 导出操作栏 =====
.export-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: center;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
&__count {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #646566;
}
&__all {
padding: 12rpx 24rpx;
margin-right: 16rpx;
border: 1rpx solid #dcdee0;
border-radius: 999rpx;
font-size: 26rpx;
color: #323233;
}
&__btn {
padding: 14rpx 40rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
font-weight: 500;
&.disabled {
opacity: 0.5;
}
}
}
// ===== 分类选择弹层 =====
.category-popup {
padding: 32rpx 32rpx 24rpx;
&__title {
font-size: 32rpx;
font-weight: 600;
display: block;
text-align: center;
}
&__desc {
margin-top: 8rpx;
font-size: 22rpx;
color: #969799;
display: block;
text-align: center;
}
&__list {
max-height: 560rpx;
margin-top: 24rpx;
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 8rpx;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__name {
font-size: 28rpx;
color: #323233;
}
}
}
+324
View File
@@ -0,0 +1,324 @@
import { useCallback, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components'
import { Empty, Icon, Popup } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { getBillExportUrl, getBillListApi } from '@/services/bill'
import { getCategoriesApi } from '@/services/product'
import { downloadExportFile } from '@/utils/download'
import type { Bill, BillSummary } from '@/services/bill'
import './index.less'
const PAGE_SIZE = 10
/** 单次导出上限(后端限制 1~100 张) */
const EXPORT_MAX = 100
/** 状态筛选(接口 status 为原始支付状态:0 未支付含审核中 / 1 已支付) */
const STATUS_FILTERS: Array<{ value: 0 | 1 | undefined; label: string }> = [
{ value: undefined, label: '全部' },
{ value: 0, label: '未支付' },
{ value: 1, label: '已支付' },
]
/** 导出分类选项(id=0 全部分类) */
interface CategoryOption {
id: number
name: string
}
/**
* 账单列表页
* 采购单完成后由后台按门店生成(只读);底部汇总栏为门店口径待支付汇总(含审核中,不受筛选影响)
* 支持多选账单合并导出 Excel(可按一级分类过滤商品明细)
*/
export default function BillListPage() {
const token = useAuthStore(s => s.token)
const [status, setStatus] = useState<0 | 1 | undefined>(undefined)
const [bills, setBills] = useState<Bill[]>([])
const [summary, setSummary] = useState<BillSummary | null>(null)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const loadingRef = useRef(false)
/** 导出:多选模式 + 已选账单 + 分类弹层 */
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [showCategory, setShowCategory] = useState(false)
const [categories, setCategories] = useState<CategoryOption[]>([])
const loggedIn = !!token
/** 拉取账单列表(summary 每次随响应刷新) */
const loadList = useCallback(
async (pageNum: number, reset: boolean, statusParam?: 0 | 1) => {
if (!loggedIn || loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const res = await getBillListApi({ status: statusParam, page: pageNum, pageSize: PAGE_SIZE })
const { data, total, summary: sum } = res.data
setBills(prev => (reset ? data : [...prev, ...data]))
setSummary(sum)
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= total)
} catch {
// 错误已由 request 层 toast
} finally {
loadingRef.current = false
setLoading(false)
}
},
[loggedIn],
)
useDidShow(() => {
loadList(1, true, status)
})
useReachBottom(() => {
if (!finished && !loadingRef.current && loggedIn) {
loadList(page + 1, false, status)
}
})
/** 切换状态筛选 */
const handleStatusTap = useCallback(
(value?: 0 | 1) => {
setStatus(value)
setFinished(false)
loadList(1, true, value)
},
[loadList],
)
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
const goDetail = useCallback((id: number) => {
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${id}` })
}, [])
/** 合并付款 → 发起付款页 */
const goPay = useCallback(() => {
Taro.navigateTo({ url: '/pages/payment/index' })
}, [])
/** 进入/退出多选导出模式 */
const toggleSelectMode = useCallback(() => {
setSelectMode(prev => !prev)
setSelectedIds([])
}, [])
/** 账单点击:多选模式切换勾选,否则进详情 */
const handleItemTap = useCallback(
(bill: Bill) => {
if (!selectMode) {
goDetail(bill.id)
return
}
setSelectedIds(prev => {
if (prev.includes(bill.id)) return prev.filter(i => i !== bill.id)
if (prev.length >= EXPORT_MAX) {
Taro.showToast({ title: `最多导出 ${EXPORT_MAX}`, icon: 'none' })
return prev
}
return [...prev, bill.id]
})
},
[selectMode, goDetail],
)
/** 全选当前已加载账单(受导出上限约束) */
const handleSelectAll = useCallback(() => {
setSelectedIds(prev => {
if (prev.length === bills.length) return []
if (bills.length > EXPORT_MAX) {
Taro.showToast({ title: `最多导出 ${EXPORT_MAX} 张,已选前 ${EXPORT_MAX}`, icon: 'none' })
return bills.slice(0, EXPORT_MAX).map(b => b.id)
}
return bills.map(b => b.id)
})
}, [bills])
/** 打开分类选择弹层(首次加载分类树根节点) */
const handleExportTap = useCallback(async () => {
if (selectedIds.length === 0) {
Taro.showToast({ title: '请先勾选要导出的账单', icon: 'none' })
return
}
if (categories.length === 0) {
try {
const res = await getCategoriesApi()
setCategories([
{ id: 0, name: '全部分类' },
...res.data.map(c => ({ id: c.id, name: c.name })),
])
} catch {
return // 错误已由 request 层 toast
}
}
setShowCategory(true)
}, [selectedIds, categories])
/** 按所选分类导出合并 Excel */
const handleExport = useCallback(
async (categoryId: number) => {
const isH5 = process.env.TARO_ENV === 'h5'
Taro.showLoading({ title: '导出中...', mask: true })
try {
await downloadExportFile(getBillExportUrl(selectedIds, categoryId), '门店账单.xlsx')
setShowCategory(false)
toggleSelectMode()
if (isH5) {
Taro.showToast({ title: '导出成功', icon: 'success' })
}
} catch (e: any) {
Taro.showToast({ title: e?.message || '导出失败,请稍后重试', icon: 'none' })
} finally {
Taro.hideLoading()
}
},
[selectedIds, toggleSelectMode],
)
/** 底部待支付汇总栏是否可见(多选导出时让位给导出栏) */
const showPayBar = loggedIn && !selectMode && !!summary && summary.unpaid_count > 0
return (
<View className={`bill-page ${selectMode ? 'bill-page--select' : ''} ${showPayBar ? 'bill-page--pay' : ''}`}>
{/* ========== 状态筛选 + 导出入口 ========== */}
<View className='bill-toolbar'>
<ScrollView scrollX className='status-scroll'>
{STATUS_FILTERS.map(item => (
<View
key={item.label}
className={`status-chip ${status === item.value ? 'active' : ''}`}
onClick={() => handleStatusTap(item.value)}
>
<Text>{item.label}</Text>
</View>
))}
</ScrollView>
{loggedIn && bills.length > 0 && (
<View className={`bill-toolbar__export ${selectMode ? 'active' : ''}`} onClick={toggleSelectMode}>
<Text>{selectMode ? '取消' : '导出'}</Text>
</View>
)}
</View>
{/* ========== 账单列表 ========== */}
{!loggedIn ? (
<Empty description='登录后查看账单' className='bill-empty'>
<View className='bill-empty__btn' onClick={goLogin}></View>
</Empty>
) : bills.length === 0 ? (
loading ? (
<View className='bill-loading'><Text>...</Text></View>
) : (
<Empty description='暂无账单' className='bill-empty' />
)
) : (
bills.map(bill => {
const checked = selectMode && selectedIds.includes(bill.id)
return (
<View
key={bill.id}
className={`bill-item ${selectMode ? 'bill-item--select' : ''}`}
onClick={() => handleItemTap(bill)}
>
{selectMode && (
<View className={`bill-check ${checked ? 'on' : ''}`}>
{checked && <Icon name='success' size={14} color='#fff' />}
</View>
)}
<View className='bill-item__content'>
<View className='bill-item__header'>
<Text className='bill-item__no'>{bill.bill_no}</Text>
<Text className={`bill-item__status bill-item__status--${bill.pay_state}`}>
{bill.pay_state_name}
</Text>
</View>
<View className='bill-item__body'>
<View className='bill-item__meta'>
<Text className='bill-item__date'> {bill.bill_date}</Text>
{bill.purchase && (
<Text className='bill-item__purchase'> {bill.purchase.purchase_no}</Text>
)}
</View>
<Text className='bill-item__amount'>{bill.total_amount}</Text>
</View>
<View className='bill-item__footer'>
<Text className='bill-item__settle'> {bill.settlement_date}</Text>
{bill.pay_state === 2 && bill.paid_at && (
<Text className='bill-item__paid'> {bill.paid_at} </Text>
)}
</View>
</View>
</View>
)
})
)}
{loggedIn && finished && bills.length > 0 && (
<View className='bill-loading'><Text></Text></View>
)}
{/* ========== 底部待支付汇总栏(门店口径,含审核中) ========== */}
{showPayBar && summary && (
<View className='bill-paybar'>
<View className='bill-paybar__info'>
<Text className='bill-paybar__label'>{summary.unpaid_count} </Text>
<Text className='bill-paybar__amount'>{summary.unpaid_amount}</Text>
</View>
<View className='bill-paybar__btn' onClick={goPay}></View>
</View>
)}
{/* ========== 导出操作栏 ========== */}
{selectMode && (
<View className='export-bar'>
<Text className='export-bar__count'> {selectedIds.length} </Text>
<View className='export-bar__all' onClick={handleSelectAll}>
{selectedIds.length === bills.length && bills.length > 0 ? '取消全选' : '全选'}
</View>
<View
className={`export-bar__btn ${selectedIds.length === 0 ? 'disabled' : ''}`}
onClick={handleExportTap}
>
Excel
</View>
</View>
)}
{/* ========== 分类选择弹层 ========== */}
<Popup
show={showCategory}
position='bottom'
round
closeable
closeOnClickOverlay
safeAreaInsetBottom
onClose={() => setShowCategory(false)}
>
<View className='category-popup'>
<Text className='category-popup__title'></Text>
<Text className='category-popup__desc'>
</Text>
<ScrollView scrollY className='category-popup__list'>
{categories.map(c => (
<View key={c.id} className='category-popup__item' onClick={() => handleExport(c.id)}>
<Text className='category-popup__name'>{c.name}</Text>
<Icon name='arrow' size={16} color='#c8c9cc' />
</View>
))}
</ScrollView>
</View>
</Popup>
</View>
)
}
+21 -3
View File
@@ -24,6 +24,15 @@
.cart-empty { .cart-empty {
padding-top: 160rpx; padding-top: 160rpx;
&__btn {
margin-top: 24rpx;
padding: 14rpx 60rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
border-radius: 999rpx;
}
} }
.cart-loading { .cart-loading {
@@ -50,8 +59,8 @@
} }
&__image { &__image {
width: 150rpx; width: 180rpx;
height: 150rpx; height: 180rpx;
border-radius: 12rpx; border-radius: 12rpx;
background: #f2f3f5; background: #f2f3f5;
flex-shrink: 0; flex-shrink: 0;
@@ -79,6 +88,15 @@
white-space: nowrap; white-space: nowrap;
} }
&__spec-tag {
margin-left: 12rpx;
font-size: 24rpx;
color: #969799;
border-radius: 6rpx;
padding: 2rpx 8rpx;
flex-shrink: 0;
}
&__invalid-tag { &__invalid-tag {
margin-left: 12rpx; margin-left: 12rpx;
font-size: 20rpx; font-size: 20rpx;
@@ -90,7 +108,6 @@
} }
&__spec { &__spec {
margin-top: 10rpx;
font-size: 24rpx; font-size: 24rpx;
color: #969799; color: #969799;
} }
@@ -158,6 +175,7 @@
padding: 16rpx 24rpx; padding: 16rpx 24rpx;
border-top: 1rpx solid #ebedf0; border-top: 1rpx solid #ebedf0;
box-sizing: border-box; box-sizing: border-box;
z-index: 99;
&__total { &__total {
flex: 1; flex: 1;
+32 -13
View File
@@ -2,14 +2,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text, Image, Textarea, ScrollView } from '@tarojs/components' import { View, Text, Image, Textarea, ScrollView } from '@tarojs/components'
import { Button, Empty, Icon, Popup, Stepper } from '@antmjs/vantui' import { Button, Empty, Icon, Popup, Stepper } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import useCartStore from '@/stores/cart/useCartStore' import useCartStore from '@/stores/cart/useCartStore'
import { createOrderApi } from '@/services/order' import { createOrderApi } from '@/services/order'
import { getStoreInfoApi } from '@/services/store' import { getStoreInfoApi } from '@/services/store'
import {formatRetailPrice, formatSpec} from '@/utils/format'
import type { CartItem } from '@/types/cart' import type { CartItem } from '@/types/cart'
import type { StoreDetail } from '@/types/store' import type { StoreDetail } from '@/types/store'
import './index.less' import './index.less'
import CustomTabBar from "@/components/CustomTabBar";
export default function CartPage() { export default function CartPage() {
const token = useAuthStore(s => s.token)
const items = useCartStore(s => s.items) const items = useCartStore(s => s.items)
const totalQuantity = useCartStore(s => s.totalQuantity) const totalQuantity = useCartStore(s => s.totalQuantity)
const totalAmount = useCartStore(s => s.totalAmount) const totalAmount = useCartStore(s => s.totalAmount)
@@ -37,6 +41,12 @@ export default function CartPage() {
const purchasable = items.filter(item => item.status === 1) const purchasable = items.filter(item => item.status === 1)
const hasInvalid = items.length > 0 && purchasable.length < items.length const hasInvalid = items.length > 0 && purchasable.length < items.length
const loggedIn = !!token
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
/** 拉取门店配送信息 */ /** 拉取门店配送信息 */
const fetchStoreInfo = useCallback(() => { const fetchStoreInfo = useCallback(() => {
setStoreLoading(true) setStoreLoading(true)
@@ -47,6 +57,8 @@ export default function CartPage() {
}, []) }, [])
useDidShow(() => { useDidShow(() => {
// 未登录不请求接口,直接展示去登录空态(参考消息页)
if (!loggedIn) return
fetchCart().catch(() => {}) fetchCart().catch(() => {})
// 从门店信息编辑页返回且弹层仍打开时 → 刷新配送信息 // 从门店信息编辑页返回且弹层仍打开时 → 刷新配送信息
if (showOrder) fetchStoreInfo() if (showOrder) fetchStoreInfo()
@@ -147,7 +159,7 @@ export default function CartPage() {
if (submitting) return if (submitting) return
setSubmitting(true) setSubmitting(true)
try { try {
const res = await createOrderApi({ await createOrderApi({
items: purchasable.map(item => ({ items: purchasable.map(item => ({
product_id: item.product_id, product_id: item.product_id,
quantity: Number(qtyMap[item.id] ?? item.quantity), quantity: Number(qtyMap[item.id] ?? item.quantity),
@@ -175,13 +187,17 @@ export default function CartPage() {
{/* ========== 头部 ========== */} {/* ========== 头部 ========== */}
<View className='cart-header'> <View className='cart-header'>
<Text className='cart-header__title'></Text> <Text className='cart-header__title'></Text>
{items.length > 0 && ( {loggedIn && items.length > 0 && (
<Text className='cart-header__clear' onClick={handleClear}></Text> <Text className='cart-header__clear' onClick={handleClear}></Text>
)} )}
</View> </View>
{/* ========== 列表 ========== */} {/* ========== 列表 ========== */}
{items.length === 0 ? ( {!loggedIn ? (
<Empty description='登录后查看购物车' className='cart-empty'>
<View className='cart-empty__btn' onClick={goLogin}></View>
</Empty>
) : items.length === 0 ? (
loading ? ( loading ? (
<View className='cart-loading'><Text>...</Text></View> <View className='cart-loading'><Text>...</Text></View>
) : ( ) : (
@@ -205,13 +221,12 @@ export default function CartPage() {
<Text className='cart-item__name'>{item.name}</Text> <Text className='cart-item__name'>{item.name}</Text>
{item.status === 0 && <Text className='cart-item__invalid-tag'></Text>} {item.status === 0 && <Text className='cart-item__invalid-tag'></Text>}
</View> </View>
<Text className='cart-item__spec'>{item.spec} / {item.unit}</Text> <Text className='cart-item__spec'>
{formatSpec(item.spec, item.unit)}{' '}
<View>{formatRetailPrice(item.price, item.spec)} {item.price_unit}</View>
</Text>
<View className='cart-item__bottom'> <View className='cart-item__bottom'>
{item.price !== null ? ( <Text className='cart-item__price'>{item.price}</Text>
<Text className='cart-item__price'>{item.price}</Text>
) : (
<Text className='cart-item__price cart-item__price--none'></Text>
)}
{item.status === 1 ? ( {item.status === 1 ? (
<Stepper <Stepper
value={displayQty(item)} value={displayQty(item)}
@@ -236,17 +251,19 @@ export default function CartPage() {
)) ))
)} )}
{hasInvalid && ( <View style={{ height: 100 }}></View>
{loggedIn && hasInvalid && (
<View className='cart-invalid-hint'> <View className='cart-invalid-hint'>
<Text></Text> <Text></Text>
</View> </View>
)} )}
{/* ========== 底部结算栏 ========== */} {/* ========== 底部结算栏 ========== */}
{items.length > 0 && ( {loggedIn && items.length > 0 && (
<View className='cart-footer'> <View className='cart-footer'>
<View className='cart-footer__total'> <View className='cart-footer__total'>
<Text className='cart-footer__label'>{totalQuantity}</Text> <Text className='cart-footer__label'>{totalQuantity}</Text>
<Text className='cart-footer__amount'>{totalAmount}</Text> <Text className='cart-footer__amount'>{totalAmount}</Text>
</View> </View>
<Button type='danger' className='cart-footer__submit' onClick={handleOrderTap}> <Button type='danger' className='cart-footer__submit' onClick={handleOrderTap}>
@@ -302,7 +319,7 @@ export default function CartPage() {
<Image className='order-popup__item-image' src={item.image} mode='aspectFill' lazyLoad /> <Image className='order-popup__item-image' src={item.image} mode='aspectFill' lazyLoad />
<View className='order-popup__item-title'> <View className='order-popup__item-title'>
<Text className='order-popup__item-name'>{item.name}</Text> <Text className='order-popup__item-name'>{item.name}</Text>
<Text className='order-popup__item-spec'>{item.spec} / {item.unit}</Text> <Text className='order-popup__item-spec'>{formatSpec(item.spec, item.unit)}</Text>
</View> </View>
</View> </View>
<View className='order-popup__item-right'> <View className='order-popup__item-right'>
@@ -335,6 +352,8 @@ export default function CartPage() {
</View> </View>
</View> </View>
</Popup> </Popup>
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View> </View>
) )
} }
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '修改密码',
})
+48
View File
@@ -0,0 +1,48 @@
.change-password-page {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
// ===== 表单区块 =====
.pwd-section {
background: #fff;
border-radius: 20rpx;
padding: 8rpx 28rpx;
margin-bottom: 20rpx;
}
// ===== 表单行 =====
.pwd-field {
display: flex;
align-items: center;
padding: 28rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
&__label {
width: 160rpx;
flex-shrink: 0;
font-size: 28rpx;
color: #323233;
}
&__input {
flex: 1;
font-size: 28rpx;
color: #323233;
}
&__placeholder {
color: #c8c9cc;
}
}
// ===== 提交按钮 =====
.pwd-submit {
margin-top: 40rpx;
}
}
+111
View File
@@ -0,0 +1,111 @@
import { useCallback, useState } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Input } from '@tarojs/components'
import { Button } from '@antmjs/vantui'
import { changePasswordApi } from '@/services/auth'
import './index.less'
/** 新密码长度限制(与后端一致:6~20 位) */
const LIMITS = {
passwordMin: 6,
passwordMax: 20,
} as const
/**
* 修改密码页
* 修改成功后现有 token 仍然有效,无需重新登录
*/
export default function ChangePasswordPage() {
/** 原密码 */
const [oldPassword, setOldPassword] = useState('')
/** 新密码 */
const [newPassword, setNewPassword] = useState('')
/** 确认新密码 */
const [rePassword, setRePassword] = useState('')
const [saving, setSaving] = useState(false)
/** 提交:PUT /mini/auth/password */
const handleSubmit = useCallback(async () => {
if (saving) return
if (!oldPassword) {
Taro.showToast({ title: '请输入原密码', icon: 'none' })
return
}
if (newPassword.length < LIMITS.passwordMin) {
Taro.showToast({ title: `新密码至少 ${LIMITS.passwordMin}`, icon: 'none' })
return
}
if (newPassword !== rePassword) {
Taro.showToast({ title: '两次输入的密码不一致', icon: 'none' })
return
}
setSaving(true)
try {
await changePasswordApi({ oldPassword, newPassword, rePassword })
Taro.showToast({ title: '密码修改成功', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 800)
} catch {
// 错误提示已由 request 层 toast(原密码不正确等)
} finally {
setSaving(false)
}
}, [saving, oldPassword, newPassword, rePassword])
return (
<View className='change-password-page'>
{/* ========== 密码表单 ========== */}
<View className='pwd-section'>
<View className='pwd-field'>
<Text className='pwd-field__label'></Text>
<Input
className='pwd-field__input'
password
value={oldPassword}
maxlength={LIMITS.passwordMax}
placeholder='请输入原密码'
placeholderClass='pwd-field__placeholder'
onInput={e => setOldPassword(e.detail.value)}
/>
</View>
<View className='pwd-field'>
<Text className='pwd-field__label'></Text>
<Input
className='pwd-field__input'
password
value={newPassword}
maxlength={LIMITS.passwordMax}
placeholder={`请输入新密码(${LIMITS.passwordMin}~${LIMITS.passwordMax} 位)`}
placeholderClass='pwd-field__placeholder'
onInput={e => setNewPassword(e.detail.value)}
/>
</View>
<View className='pwd-field'>
<Text className='pwd-field__label'></Text>
<Input
className='pwd-field__input'
password
value={rePassword}
maxlength={LIMITS.passwordMax}
placeholder='请再次输入新密码'
placeholderClass='pwd-field__placeholder'
confirmType='done'
onInput={e => setRePassword(e.detail.value)}
onConfirm={handleSubmit}
/>
</View>
</View>
{/* ========== 提交 ========== */}
<Button
type='danger'
block
round
loading={saving}
className='pwd-submit'
onClick={handleSubmit}
>
</Button>
</View>
)
}
+1
View File
@@ -1,3 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '首页', navigationBarTitleText: '首页',
navigationStyle: 'custom',
}) })
+170 -25
View File
@@ -1,20 +1,66 @@
.home-page { .home-page {
min-height: 100vh; min-height: 100vh;
background: #f7f8fa; background: #f7f8fa;
padding-bottom: calc(140rpx + env(safe-area-inset-bottom)); // 底部预留自定义 tabBar(110rpx + 安全区)+ 购物车悬浮球空间,避免内容被遮挡
padding-bottom: calc(250rpx + env(safe-area-inset-bottom));
box-sizing: border-box; box-sizing: border-box;
// ===== 顶部搜索 ===== // ===== 自定义顶部导航栏 =====
.home-search { .home-header {
background: linear-gradient(135deg, #ee0a24, #ff4d4f); background: linear-gradient(160deg, #d60410 0%, #ee0a24 55%, #ff4d4f 100%);
padding: 16rpx 24rpx; // 底部留白供轮播图上移叠放
padding-bottom: 88rpx;
&__bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 32rpx 8rpx;
}
&__brand {
display: flex;
align-items: baseline;
}
&__title {
color: #ffffff;
font-size: 44rpx;
font-weight: 700;
letter-spacing: 4rpx;
}
&__slogan {
margin-left: 16rpx;
color: rgba(255, 255, 255, 0.75);
font-size: 22rpx;
letter-spacing: 2rpx;
}
&__notice {
width: 64rpx;
height: 64rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
display: flex;
align-items: center;
justify-content: center;
}
&__search {
padding: 8rpx 16rpx 0;
}
} }
// ===== 轮播图 ===== // ===== 轮播图 =====
.home-banner { .home-banner {
margin: 20rpx 24rpx 0; // 上移叠在红色导航栏上,形成视觉连贯
margin: -72rpx 24rpx 0;
border-radius: 20rpx; border-radius: 20rpx;
overflow: hidden; overflow: hidden;
box-shadow: 0 8rpx 24rpx rgba(238, 10, 36, 0.15);
position: relative;
z-index: 2;
&__swiper { &__swiper {
border-radius: 20rpx; border-radius: 20rpx;
@@ -22,52 +68,128 @@
&__image { &__image {
width: 100%; width: 100%;
height: 320rpx; height: 300rpx;
display: block;
} }
&__placeholder { &__placeholder {
width: 100%; width: 100%;
height: 320rpx; height: 300rpx;
background: linear-gradient(135deg, #ff9a9e, #ff4d4f); background: linear-gradient(135deg, #ff8a5c, #ff4d4f);
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
&__placeholder-text { &__placeholder-title {
color: #fff; color: #fff;
font-size: 40rpx; font-size: 44rpx;
font-weight: 600; font-weight: 700;
letter-spacing: 8rpx; letter-spacing: 8rpx;
} }
&__placeholder-sub {
margin-top: 16rpx;
color: rgba(255, 255, 255, 0.85);
font-size: 24rpx;
letter-spacing: 2rpx;
}
} }
// ===== 导航菜单 ===== // ===== 宫格导航 =====
.home-menu { .home-menu {
margin: 20rpx 24rpx 0; margin: 20rpx 24rpx 0;
background: #fff; background: #fff;
border-radius: 20rpx; border-radius: 20rpx;
padding: 20rpx 0 8rpx; padding: 28rpx 0 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
} }
.menu-icon { .menu-icon {
width: 84rpx; width: 88rpx;
height: 84rpx; height: 88rpx;
border-radius: 24rpx; border-radius: 28rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
&__image {
width: 88rpx;
height: 88rpx;
display: block;
}
&__text { &__text {
color: #fff; color: #fff;
font-size: 36rpx; font-size: 38rpx;
font-weight: 600; font-weight: 600;
} }
} }
// ===== 促销推荐卡片 =====
.home-promo {
margin: 20rpx 24rpx 0;
}
.promo-card {
position: relative;
height: 180rpx;
border-radius: 20rpx;
overflow: hidden;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
&:last-child {
margin-bottom: 0;
}
&__bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
&__mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, rgba(160, 4, 16, 0.82) 0%, rgba(238, 10, 36, 0.45) 55%, rgba(238, 10, 36, 0) 100%);
}
&__content {
position: absolute;
left: 32rpx;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
z-index: 2;
}
&__title {
color: #ffffff;
font-size: 38rpx;
font-weight: 700;
letter-spacing: 2rpx;
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.25);
}
&__sub {
margin-top: 10rpx;
color: rgba(255, 255, 255, 0.9);
font-size: 24rpx;
letter-spacing: 1rpx;
}
}
// ===== 推荐商品 ===== // ===== 推荐商品 =====
.home-recommend { .home-recommend {
margin: 20rpx 24rpx 0; margin: 28rpx 24rpx 0;
&__header { &__header {
display: flex; display: flex;
@@ -76,6 +198,19 @@
padding: 8rpx 8rpx 20rpx; padding: 8rpx 8rpx 20rpx;
} }
&__title-wrap {
display: flex;
align-items: center;
}
&__title-bar {
width: 8rpx;
height: 30rpx;
border-radius: 4rpx;
background: linear-gradient(180deg, #ee0a24, #ff6034);
margin-right: 14rpx;
}
&__title { &__title {
font-size: 32rpx; font-size: 32rpx;
font-weight: 600; font-weight: 600;
@@ -100,6 +235,17 @@
color: #969799; color: #969799;
} }
&__loading {
display: flex;
justify-content: center;
padding: 20rpx 0 8rpx;
}
&__loading-text {
font-size: 24rpx;
color: #969799;
}
&__login-btn { &__login-btn {
margin-top: 24rpx; margin-top: 24rpx;
padding: 14rpx 60rpx; padding: 14rpx 60rpx;
@@ -139,8 +285,8 @@
font-size: 28rpx; font-size: 28rpx;
color: #323233; color: #323233;
font-weight: 500; font-weight: 500;
display: block;
overflow: hidden; overflow: hidden;
margin-right: 20rpx;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@@ -149,14 +295,13 @@
margin-top: 8rpx; margin-top: 8rpx;
font-size: 22rpx; font-size: 22rpx;
color: #969799; color: #969799;
display: block;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
&__bottom { &__bottom {
margin-top: 16rpx; margin-top: 8rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -175,10 +320,10 @@
} }
&__add { &__add {
width: 52rpx; width: 42rpx;
height: 52rpx; height: 42rpx;
border-radius: 50%; border-radius: 50%;
background: #ee0a24; background: linear-gradient(135deg, #ee0a24, #ff6034);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
+282 -132
View File
@@ -1,221 +1,371 @@
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro' import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components' import { View, Text, Image } from '@tarojs/components'
import { Grid, GridItem, Search, Swiper, SwiperItem } from '@antmjs/vantui' import { Grid, GridItem, Icon, Search, Swiper, SwiperItem } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import useCartStore from '@/stores/cart/useCartStore' import useCartStore from '@/stores/cart/useCartStore'
import { getCategoriesApi, getProductListApi } from '@/services/product' import { getHomeConfigApi } from '@/services/home'
import type { HomeConfig } from '@/services/home'
import { getSpecialListApi, normalizeSpecialCart } from '@/services/special'
import { getProductCover } from '@/types/product' import { getProductCover } from '@/types/product'
import type { Category, Product } from '@/types/product' import type { Product, ProductCartPatch } from '@/types/product'
import { getToken } from '@/utils/request'
import CartBall from '@/components/CartBall'
import CartStepper from '@/components/CartStepper'
import {formatRetailPrice, formatSpec} from '@/utils/format'
import './index.less' import './index.less'
import CustomTabBar from "@/components/CustomTabBar";
/** 首页 → 商品页的本地存储传参 key(switchTab 无法带参) */ /** 首页 → 商品页的本地存储传参 key(switchTab 无法带参) */
const PENDING_CATEGORY_KEY = 'product_category_id' const PENDING_CATEGORY_KEY = 'product_category_id'
const PENDING_KEYWORD_KEY = 'product_keyword' const PENDING_KEYWORD_KEY = 'product_keyword'
/** 分类菜单色块配色 */ /** 特价推荐每页条数 */
const MENU_COLORS = ['#ee0a24', '#ff9f43', '#07c160', '#1989fa', '#8a5cf6', '#00b8d9'] const SPECIAL_PAGE_SIZE = 10
/** tabBar 页面路径(link 跳转需改用 switchTab */
const TAB_PATHS = [
'pages/index/index',
'pages/product/index',
'pages/cart/index',
'pages/message/index',
'pages/profile/index',
]
/** 宫格导航无图时的兜底色块配色(生鲜红橙系) */
const NAV_COLORS = ['#ee0a24', '#ff7a1a', '#07c160', '#1989fa', '#8a5cf6', '#ff976a', '#00b8d9', '#f56c6c']
/** 获取状态栏高度(H5 端为 0) */
function getStatusBarHeight(): number {
try {
const info = typeof Taro.getWindowInfo === 'function' ? Taro.getWindowInfo() : Taro.getSystemInfoSync()
return info.statusBarHeight || 0
} catch {
return 0
}
}
export default function IndexPage() { export default function IndexPage() {
const token = useAuthStore(s => s.token)
const addItem = useCartStore(s => s.addItem) const addItem = useCartStore(s => s.addItem)
const setSummary = useCartStore(s => s.setSummary)
/** 顶级分类(导航菜单前 6 个 */ /** 首页配置(轮播图 / 宫格导航 / 促销卡片 */
const [categories, setCategories] = useState<Category[]>([]) const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
/** 推荐商品 */ /** 特价推荐商品(后台「客户端配置 → 特价推荐」标记,价格为登录门店的等级价) */
const [products, setProducts] = useState<Product[]>([]) const [specials, setSpecials] = useState<Product[]>([])
/** 特价推荐分页 */
const [specialPage, setSpecialPage] = useState(1)
const [specialHasMore, setSpecialHasMore] = useState(false)
/** 加载更多中(首屏重置不展示,避免已渲染列表下方闪烁) */
const [specialLoading, setSpecialLoading] = useState(false)
/** 特价推荐请求序号(返回 tab 重置与上拉加载并发时,仅采用最后一次响应) */
const specialSeqRef = useRef(0)
/** 是否有「加载更多」请求进行中 */
const specialLoadingRef = useRef(false)
/** 搜索框输入 */ /** 搜索框输入 */
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const loggedIn = !!token const statusBarHeight = useMemo(() => getStatusBarHeight(), [])
useDidShow(() => { useDidShow(() => {
loadCategories() loadHomeConfig()
if (loggedIn) { loadSpecials(1, true)
loadRecommend()
}
}) })
/** 商品分类 */ /** 上拉加载更多特价推荐 */
const loadCategories = useCallback(async () => { useReachBottom(() => {
if (!specialHasMore) return
loadSpecials(specialPage + 1, false)
})
/** 首页配置聚合数据(响应附带悬浮球汇总) */
const loadHomeConfig = useCallback(async () => {
try { try {
const res = await getCategoriesApi() const res = await getHomeConfigApi()
setCategories(res.data.filter(c => c.parent_id === 0)) setConfig(res.data)
// 旧版本后端可能未返回 cart 块
if (res.data.cart) setSummary(res.data.cart)
} catch { } catch {
// 错误已由 request 层 toast // 错误已由 request 层 toast
} }
}, []) }, [setSummary])
/** 推荐商品(默认排序第一页) */ /**
const loadRecommend = useCallback(async () => { * 特价推荐商品(reset 时回到第一页整体替换)。
try { * 行结构与 /mini/product/list 一致;响应附带悬浮球汇总
const res = await getProductListApi({ page: 1, pageSize: 10 }) */
setProducts(res.data.data) const loadSpecials = useCallback(
} catch { async (pageNum: number, reset: boolean) => {
// 错误已由 request 层 toast if (!reset && specialLoadingRef.current) return
} const seq = ++specialSeqRef.current
}, []) specialLoadingRef.current = true
if (!reset) setSpecialLoading(true)
/** 轮播数据:取有图商品的前 4 张图 */ try {
const bannerImages = useMemo( const res = await getSpecialListApi({ page: pageNum, pageSize: SPECIAL_PAGE_SIZE })
() => products.filter(p => getProductCover(p)).slice(0, 4), if (seq !== specialSeqRef.current) return // 已有更新的请求,丢弃本次响应
[products], const { data, total, cart } = res.data
setSpecials(prev => (reset ? data : [...prev, ...data]))
setSpecialPage(pageNum)
setSpecialHasMore(pageNum * SPECIAL_PAGE_SIZE < total)
// 列表响应附带悬浮球汇总(旧版本后端可能未返回)
const summary = normalizeSpecialCart(cart)
if (summary) setSummary(summary)
} catch {
// 错误已由 request 层 toast
} finally {
if (seq === specialSeqRef.current) {
specialLoadingRef.current = false
setSpecialLoading(false)
}
}
},
[setSummary],
) )
/** 跳转商品页并带上分类/关键词 */ /**
const goProduct = useCallback((categoryId?: number, kw?: string) => { * 后台配置的 link 统一跳转:
* - 空字符串不跳转
* - tabBar 页面用 switchTab,其余用 navigateTo
*/
const handleLink = useCallback((link: string) => {
if (!link) return
const path = link.split('?')[0].replace(/^\//, '')
if (TAB_PATHS.includes(path)) {
Taro.switchTab({ url: `/${path}` })
} else {
Taro.navigateTo({ url: link })
}
}, [])
/** 跳转商品页并带上关键词 */
const goProduct = useCallback((kw?: string) => {
try { try {
if (categoryId !== undefined) Taro.setStorageSync(PENDING_CATEGORY_KEY, categoryId)
if (kw !== undefined) Taro.setStorageSync(PENDING_KEYWORD_KEY, kw) if (kw !== undefined) Taro.setStorageSync(PENDING_KEYWORD_KEY, kw)
Taro.removeStorageSync(PENDING_CATEGORY_KEY)
} catch { } catch {
// noop // noop
} }
Taro.switchTab({ url: '/pages/product/index' }) Taro.switchTab({ url: '/pages/product/index' })
}, []) }, [])
/** 跳转商品详情 */
const goDetail = useCallback((id: number) => {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
}, [])
/** 搜索框聚焦/提交 → 商品页搜索 */ /** 搜索框聚焦/提交 → 商品页搜索 */
const handleSearchFocus = useCallback(() => { const handleSearchFocus = useCallback(() => {
goProduct(undefined, keyword.trim()) goProduct(keyword.trim())
}, [goProduct, keyword]) }, [goProduct, keyword])
/** 快捷加购 */ /** 行内加减购确认后回写特价商品项的购物车字段 */
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
setSpecials(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
}, [])
/** 快捷加购(用返回的购物车行回写,卡片随即展示加减器) */
const handleQuickAdd = useCallback( const handleQuickAdd = useCallback(
async (product: Product, e: any) => { async (product: Product, e: any) => {
e.stopPropagation() e.stopPropagation()
try { try {
await addItem(product.id, 1) const res = await addItem(product.id, 1)
handleRowSync(product.id, { cart_id: res.id, cart_quantity: res.quantity })
Taro.showToast({ title: '已加入购物车', icon: 'success' }) Taro.showToast({ title: '已加入购物车', icon: 'success' })
} catch { } catch {
// 错误(未设等级价等)已由 request 层 toast // 错误(未设等级价等)已由 request 层 toast
} }
}, },
[addItem], [addItem, handleRowSync],
) )
/** 无价格时点击:未登录引导登录,已登录但未设等级价提示原因 */
const handlePriceGuide = useCallback((e: any) => {
e.stopPropagation()
if (getToken()) {
Taro.showToast({ title: '该商品暂未设置等级价', icon: 'none' })
} else {
Taro.navigateTo({ url: '/pages/login/index' })
}
}, [])
return ( return (
<View className='home-page'> <View className='home-page'>
{/* ========== 顶部搜索 ========== */} {/* ========== 自定义顶部导航栏 ========== */}
<View className='home-search'> <View className='home-header' style={{ paddingTop: `${statusBarHeight}px` }}>
<Search <View className='home-header__bar'>
value={keyword} <View className='home-header__brand'>
placeholder='搜索商品名称/规格' <Text className='home-header__title'></Text>
shape='round' <Text className='home-header__slogan'> · </Text>
background='transparent' </View>
onChange={e => setKeyword(String(e.detail))} <View className='home-header__notice' onClick={() => Taro.switchTab({ url: '/pages/message/index' })}>
onFocus={handleSearchFocus} <Icon name='bell' size='44rpx' color='#ffffff' />
onSearch={handleSearchFocus} </View>
/> </View>
<View className='home-header__search'>
<Search
value={keyword}
placeholder='搜索商品名称/规格'
shape='round'
background='transparent'
onChange={e => setKeyword(String(e.detail))}
onFocus={handleSearchFocus}
onSearch={handleSearchFocus}
/>
</View>
</View> </View>
{/* ========== 轮播图 ========== */} {/* ========== 轮播图 ========== */}
<View className='home-banner'> <View className='home-banner'>
<Swiper <Swiper
className='home-banner__swiper' className='home-banner__swiper'
height='320rpx' height='300rpx'
autoPlay={3000} autoPlay={3000}
loop loop
paginationVisible paginationVisible
paginationColor='#ffffff' paginationColor='#ffffff'
> >
{bannerImages.length > 0 ? ( {config.banners.length > 0 ? (
bannerImages.map((product, idx) => ( config.banners.map(banner =>
<SwiperItem key={product.id}> banner.image_url ? (
<Image <SwiperItem key={banner.id}>
className='home-banner__image' <Image
src={getProductCover(product)} className='home-banner__image'
mode='aspectFill' src={banner.image_url}
onClick={() => goProduct()} mode='aspectFill'
/> onClick={() => handleLink(banner.link)}
</SwiperItem> />
)) </SwiperItem>
) : null,
)
) : ( ) : (
<SwiperItem> <SwiperItem>
<View className='home-banner__placeholder' onClick={() => goProduct()}> <View className='home-banner__placeholder'>
<Text className='home-banner__placeholder-text'></Text> <Text className='home-banner__placeholder-title'></Text>
<Text className='home-banner__placeholder-sub'> · </Text>
</View> </View>
</SwiperItem> </SwiperItem>
)} )}
</Swiper> </Swiper>
</View> </View>
{/* ========== 导航菜单 ========== */} {/* ========== 宫格导航(一行四个) ========== */}
<View className='home-menu'> {config.navs.length > 0 && (
<Grid columnNum={4} border={false} iconSize={52}> <View className='home-menu'>
{categories.slice(0, 6).map((cat, idx) => ( <Grid columnNum={4} border={false} iconSize={88}>
<GridItem {config.navs.map((nav, idx) => (
key={cat.id} <GridItem
text={cat.name} key={nav.id}
onClick={() => goProduct(cat.id)} text={nav.name}
renderIcon={ onClick={() => handleLink(nav.link)}
<View className='menu-icon' style={{ background: MENU_COLORS[idx % MENU_COLORS.length] }}> renderIcon={
<Text className='menu-icon__text'>{cat.name.slice(0, 1)}</Text> nav.image_url ? (
</View> <Image className='menu-icon__image' src={nav.image_url} mode='aspectFit' />
} ) : (
/> <View className='menu-icon' style={{ background: NAV_COLORS[idx % NAV_COLORS.length] }}>
))} <Text className='menu-icon__text'>{nav.name.slice(0, 1)}</Text>
<GridItem </View>
icon='apps-o' )
text='全部商品' }
onClick={() => goProduct()} />
/> ))}
<GridItem </Grid>
icon='orders-o' </View>
text='我的订单' )}
onClick={() => Taro.navigateTo({ url: '/pages/order-list/index?status=all' })}
/>
</Grid>
</View>
{/* ========== 推荐商品 ========== */} {/* ========== 促销推荐卡片 ========== */}
{config.promos.length > 0 && (
<View className='home-promo'>
{config.promos.map(promo => (
<View key={promo.id} className='promo-card' onClick={() => handleLink(promo.link)}>
{promo.image_url && (
<Image className='promo-card__bg' src={promo.image_url} mode='aspectFill' lazyLoad />
)}
{promo.title && <View className='promo-card__mask' />}
<View className='promo-card__content'>
<Text className='promo-card__title'>{promo.title}</Text>
{promo.sub_title && <Text className='promo-card__sub'>{promo.sub_title}</Text>}
</View>
</View>
))}
</View>
)}
{/* ========== 特价推荐 ========== */}
<View className='home-recommend'> <View className='home-recommend'>
<View className='home-recommend__header'> <View className='home-recommend__header'>
<Text className='home-recommend__title'></Text> <View className='home-recommend__title-wrap'>
<View className='home-recommend__title-bar' />
<Text className='home-recommend__title'></Text>
</View>
<Text className='home-recommend__more' onClick={() => goProduct()}> </Text> <Text className='home-recommend__more' onClick={() => goProduct()}> </Text>
</View> </View>
{!loggedIn ? ( { specials.length === 0 ? (
<View className='home-recommend__empty'> <View className='home-recommend__empty'>
<Text className='home-recommend__empty-text'></Text> <Text className='home-recommend__empty-text'></Text>
<View
className='home-recommend__login-btn'
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
>
</View>
</View>
) : products.length === 0 ? (
<View className='home-recommend__empty'>
<Text className='home-recommend__empty-text'></Text>
</View> </View>
) : ( ) : (
<View className='product-grid'> <>
{products.map(product => ( <View className='product-grid'>
<View key={product.id} className='product-card' onClick={() => goProduct()}> {specials.map(product => (
<Image <View key={product.id} className='product-card' onClick={() => goDetail(product.id)}>
className='product-card__image' <Image
src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'} className='product-card__image'
mode='aspectFill' src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
lazyLoad mode='aspectFill'
/> lazyLoad
<View className='product-card__info'> />
<Text className='product-card__name'>{product.name}</Text> <View className='product-card__info'>
<Text className='product-card__spec'>{product.spec} / {product.unit}</Text> <Text className='product-card__name'>{product.name}</Text>
<View className='product-card__bottom'> <View className='product-card__spec'>
{product.price !== null ? ( {formatSpec(product.spec, product.unit)}{' '}
<Text className='product-card__price'>{product.price}</Text> <View>
) : ( {product.price !== null && <>
<Text className='product-card__price product-card__price--none'></Text> {formatRetailPrice(product.price, product.spec)} {product.price_unit}
)} </>}
<View className='product-card__add' onClick={e => handleQuickAdd(product, e)}> </View>
<Text className='product-card__add-icon'></Text> </View>
<View className='product-card__bottom'>
{product.price !== null ? (
<Text className='product-card__price'>{product.price}</Text>
) : (
<Text
className='product-card__price product-card__price--none'
onClick={handlePriceGuide}
>
</Text>
)}
{/* 已加购展示行内加减器,否则展示快捷加购按钮 */}
{Number(product.cart_quantity ?? 0) > 0 ? (
<CartStepper product={product} onSync={handleRowSync} />
) : (
<View className='product-card__add' onClick={e => handleQuickAdd(product, e)}>
<Text className='product-card__add-icon'></Text>
</View>
)}
</View> </View>
</View> </View>
</View> </View>
))}
</View>
{/* 加载更多状态 */}
{specialLoading && (
<View className='home-recommend__loading'>
<Text className='home-recommend__loading-text'></Text>
</View> </View>
))} )}
</View> {!specialHasMore && specialPage > 1 && (
<View className='home-recommend__loading'>
<Text className='home-recommend__loading-text'></Text>
</View>
)}
</>
)} )}
</View> </View>
{/* ========== 购物车悬浮球 ========== */}
<CartBall />
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View> </View>
) )
} }
+73 -24
View File
@@ -73,12 +73,12 @@
width: 160px; width: 160px;
height: 160px; height: 160px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%); background: linear-gradient(160deg, #ee0a24 0%, #ff4d4f 100%);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 24px; margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 137, 250, 0.3); box-shadow: 0 8px 32px rgba(238, 10, 36, 0.3);
} }
.logo-text { .logo-text {
@@ -100,17 +100,46 @@
} }
} }
/* ========== 功能介绍 ========== */ /* ========== 登录表单 ========== */
.login-features { .login-form {
margin-bottom: 80px; width: 100%;
background: #f7f8fa;
border-radius: 24px;
padding: 0 32px;
margin-bottom: 60px;
}
.feature-text { .form-item {
font-size: 26px; display: flex;
color: #c8c9cc; align-items: center;
letter-spacing: 2px; height: 112px;
border-bottom: 1px solid #ebedf0;
&:last-child {
border-bottom: none;
} }
} }
.form-label {
width: 120px;
font-size: 30px;
color: #323233;
flex-shrink: 0;
}
.form-input {
flex: 1;
height: 100%;
font-size: 30px;
color: #323233;
display: flex;
align-items: center;
}
.form-input-placeholder {
color: #c8c9cc;
}
/* ========== 登录操作区 ========== */ /* ========== 登录操作区 ========== */
.login-actions { .login-actions {
width: 100%; width: 100%;
@@ -123,7 +152,7 @@
width: 100%; width: 100%;
height: 96px; height: 96px;
line-height: 96px; line-height: 96px;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%); background: linear-gradient(160deg, #ee0a24 0%, #ff4d4f 100%);
color: #fff; color: #fff;
font-size: 34px; font-size: 34px;
font-weight: 500; font-weight: 500;
@@ -131,7 +160,7 @@
border-radius: 48px; border-radius: 48px;
text-align: center; text-align: center;
padding: 0; padding: 0;
box-shadow: 0 6px 24px rgba(25, 137, 250, 0.35); box-shadow: 0 6px 24px rgba(238, 10, 36, 0.35);
transition: opacity 0.2s; transition: opacity 0.2s;
/* 重置微信 Button 默认样式 */ /* 重置微信 Button 默认样式 */
@@ -144,26 +173,20 @@
opacity: 0.75; opacity: 0.75;
} }
/* ========== 去注册入口 ========== */ /* ========== 客服提示 ========== */
.login-switch { .login-tip {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-top: 32px; margin-top: 32px;
.switch-text { .tip-text {
font-size: 28px; font-size: 26px;
color: #969799; color: #969799;
} }
.switch-link {
font-size: 28px;
color: #1989fa;
margin-left: 8px;
}
} }
/* ========== 协议文字 ========== */ /* ========== 协议勾选区 ========== */
.login-agreement { .login-agreement {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -172,13 +195,39 @@
margin-top: 32px; margin-top: 32px;
line-height: 1.6; line-height: 1.6;
.agree-checkbox {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid #c8c9cc;
margin-right: 12px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
transition: all 0.2s;
&--checked {
background: #ee0a24;
border-color: #ee0a24;
}
}
.agree-checkbox-tick {
font-size: 22px;
color: #fff;
line-height: 1;
font-weight: 700;
}
.agree-text { .agree-text {
font-size: 24px; font-size: 24px;
color: #c8c9cc; color: #969799;
} }
.agree-link { .agree-link {
font-size: 24px; font-size: 24px;
color: #1989fa; color: #ee0a24;
} }
} }
+78 -56
View File
@@ -1,17 +1,24 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useState } from 'react'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { View, Text, Button } from '@tarojs/components' import { View, Text, Button, Input } from '@tarojs/components'
import CustomNavBar from '@/components/NavBar'
import useAuthStore from '@/stores/auth/useAuthStore' import useAuthStore from '@/stores/auth/useAuthStore'
import './index.less' import './index.less'
/** 登录账号长度限制(与后端一致:4~20 位) */
const USERNAME_MAX = 20
/** 密码长度限制 */
const PASSWORD_MAX = 20
export default function LoginPage() { export default function LoginPage() {
const login = useAuthStore(s => s.login) const login = useAuthStore(s => s.login)
const isLoggedIn = useAuthStore(s => !!s.token && !!s.user)
/** 登录账号(商家后台分配) */
const [username, setUsername] = useState('')
/** 登录密码 */
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
/** 是否已阅读并同意协议(默认不勾选,须用户自主勾选后才能登录) */
const isWeb = Taro.getEnv() === Taro.ENV_TYPE.WEB const [agreed, setAgreed] = useState(false)
/** 返回上一页(无页面栈时回首页) */ /** 返回上一页(无页面栈时回首页) */
const goBack = useCallback(() => { const goBack = useCallback(() => {
@@ -23,65 +30,51 @@ export default function LoginPage() {
} }
}, []) }, [])
/** 前往注册页 */ /** 账号密码登录:POST /mini/auth/login */
const goRegister = useCallback(() => {
Taro.navigateTo({ url: '/pages/register/index' })
}, [])
/** 已登录 → 自动返回 */
useEffect(() => {
if (isLoggedIn) goBack()
}, [isLoggedIn, goBack])
/** 微信一键登录(wx.login code 换 openid,仅已注册用户可登录) */
const handleLogin = useCallback(async () => { const handleLogin = useCallback(async () => {
if (submitting) return if (submitting) return
// H5 环境无法获取微信登录凭证 const account = username.trim()
if (isWeb) { if (!account) {
Taro.showToast({ title: '请在微信小程序中使用微信登录', icon: 'none' }) Taro.showToast({ title: '请输入登录账号', icon: 'none' })
return
}
if (!password) {
Taro.showToast({ title: '请输入登录密码', icon: 'none' })
return
}
if (!agreed) {
Taro.showToast({ title: '请先阅读并勾选同意《用户服务协议》和《隐私政策》', icon: 'none' })
return return
} }
setSubmitting(true) setSubmitting(true)
try { try {
const res = await Taro.login() await login({ username: account, password })
if (!res.code) { Taro.showToast({ title: '登录成功', icon: 'success' })
Taro.showToast({ title: '获取登录凭证失败', icon: 'none' }) goBack()
return } catch {
} // 错误提示已由 request 层 toast(账号或密码错误 / 账号已停用等)
await login({ code: res.code })
// 登录成功后由 effect 自动返回
} catch (e: any) {
// 未注册用户:引导前往注册页(其余错误已由 request 层提示)
if (typeof e?.msg === 'string' && e.msg.includes('用户不存在')) {
Taro.showModal({
title: '未注册',
content: '该微信账号尚未注册,需授权手机号并填写门店编码完成注册',
confirmText: '去注册',
cancelText: '取消',
success: res => {
if (res.confirm) goRegister()
},
})
}
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
}, [login, submitting, isWeb, goRegister]) }, [login, submitting, username, password, agreed, goBack])
/** 查看用户协议 */ /** 查看用户服务协议 */
const handleShowAgreement = useCallback(() => { const handleShowAgreement = useCallback(() => {
Taro.showToast({ title: '用户协议即将上线', icon: 'none' }) Taro.navigateTo({ url: '/pages/agreement/index' })
}, []) }, [])
/** 查看隐私政策 */ /** 查看隐私政策 */
const handleShowPrivacy = useCallback(() => { const handleShowPrivacy = useCallback(() => {
Taro.showToast({ title: '隐私政策即将上线', icon: 'none' }) Taro.navigateTo({ url: '/pages/privacy/index' })
}, [])
/** 勾选/取消勾选协议 */
const toggleAgreed = useCallback(() => {
setAgreed(v => !v)
}, []) }, [])
return ( return (
<View className='login-page'> <View className='login-page'>
{/* ========== 导航栏 ========== */}
<CustomNavBar title='登录' />
{/* ========== 内容区域 ========== */} {/* ========== 内容区域 ========== */}
<View className='login-content'> <View className='login-content'>
@@ -94,9 +87,34 @@ export default function LoginPage() {
<Text className='app-slogan'> · · </Text> <Text className='app-slogan'> · · </Text>
</View> </View>
{/* 功能介绍 */} {/* 登录表单 */}
<View className='login-features'> <View className='login-form'>
<Text className='feature-text'>线 · · </Text> <View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
type='text'
value={username}
maxlength={USERNAME_MAX}
placeholder='请输入登录账号'
placeholderClass='form-input-placeholder'
onInput={e => setUsername(e.detail.value)}
/>
</View>
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
password
value={password}
maxlength={PASSWORD_MAX}
placeholder='请输入登录密码'
placeholderClass='form-input-placeholder'
confirmType='done'
onInput={e => setPassword(e.detail.value)}
onConfirm={handleLogin}
/>
</View>
</View> </View>
{/* 登录操作 */} {/* 登录操作 */}
@@ -107,19 +125,23 @@ export default function LoginPage() {
loading={submitting} loading={submitting}
disabled={submitting} disabled={submitting}
> >
{submitting ? '登录中...' : '微信一键登录'} {submitting ? '登录中...' : '登 录'}
</Button> </Button>
{/* 未注册用户入口 */} <View className='login-tip'>
<View className='login-switch' onClick={goRegister}> <Text className='tip-text'></Text>
<Text className='switch-text'></Text>
<Text className='switch-link'></Text>
</View> </View>
<View className='login-agreement'> <View className='login-agreement'>
<Text className='agree-text'></Text> <View
className={`agree-checkbox ${agreed ? 'agree-checkbox--checked' : ''}`}
onClick={toggleAgreed}
>
{agreed && <Text className='agree-checkbox-tick'></Text>}
</View>
<Text className='agree-text'></Text>
<Text className='agree-link' onClick={handleShowAgreement}> <Text className='agree-link' onClick={handleShowAgreement}>
</Text> </Text>
<Text className='agree-text'></Text> <Text className='agree-text'></Text>
<Text className='agree-link' onClick={handleShowPrivacy}> <Text className='agree-link' onClick={handleShowPrivacy}>
+3
View File
@@ -8,6 +8,7 @@ import { formatTime } from '@/utils/format'
import { NOTICE_TYPE_MAP } from '@/types/notice' import { NOTICE_TYPE_MAP } from '@/types/notice'
import type { Notice, NoticeType } from '@/types/notice' import type { Notice, NoticeType } from '@/types/notice'
import './index.less' import './index.less'
import CustomTabBar from "@/components/CustomTabBar";
const PAGE_SIZE = 10 const PAGE_SIZE = 10
@@ -123,6 +124,8 @@ export default function MessagePage() {
{loggedIn && finished && notices.length > 0 && ( {loggedIn && finished && notices.length > 0 && (
<View className='message-loading'><Text></Text></View> <View className='message-loading'><Text></Text></View>
)} )}
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View> </View>
) )
} }
+92 -2
View File
@@ -66,7 +66,75 @@
&__status { &__status {
font-size: 24rpx; font-size: 24rpx;
color: #ee0a24;
// 0 待接单 / 1 已接单 / 2 采购中 / 3 配送中 / 4 已完成 / 9 已取消
&--0 { color: #ee0a24; }
&--1 { color: #1989fa; }
&--2 { color: #1989fa; }
&--3 { color: #ff976a; }
&--4 { color: #07c160; }
&--9 { color: #969799; }
}
&__preview {
margin-top: 16rpx;
}
&__goods {
display: flex;
align-items: center;
padding: 8rpx 0;
}
&__goods-img {
width: 64rpx;
height: 64rpx;
border-radius: 8rpx;
background: #f2f3f5;
flex-shrink: 0;
&--empty {
background: #f7f8fa;
}
}
&__goods-info {
flex: 1;
min-width: 0;
margin-left: 16rpx;
display: flex;
flex-direction: column;
}
&__goods-name {
font-size: 26rpx;
color: #323233;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__goods-spec {
margin-top: 4rpx;
font-size: 22rpx;
color: #969799;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__goods-qty {
margin-left: 20rpx;
font-size: 24rpx;
color: #646566;
flex-shrink: 0;
}
&__more {
display: block;
margin-top: 8rpx;
font-size: 22rpx;
color: #969799;
} }
&__body { &__body {
@@ -105,8 +173,23 @@
display: block; display: block;
} }
&__cancel { &__footer {
margin-top: 16rpx; margin-top: 16rpx;
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
&__bill {
display: inline-flex;
padding: 8rpx 24rpx;
border: 1rpx solid #1989fa;
color: #1989fa;
border-radius: 999rpx;
font-size: 24rpx;
}
&__cancel {
display: inline-flex; display: inline-flex;
padding: 8rpx 24rpx; padding: 8rpx 24rpx;
border: 1rpx solid #ee0a24; border: 1rpx solid #ee0a24;
@@ -180,6 +263,13 @@
} }
} }
&__remark {
margin-top: 16rpx;
font-size: 22rpx;
color: #969799;
display: block;
}
&__footer { &__footer {
margin-top: 24rpx; margin-top: 24rpx;
display: flex; display: flex;
+97 -46
View File
@@ -1,49 +1,51 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom, useRouter } from '@tarojs/taro' import Taro, { useDidShow, useReachBottom, useRouter } from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components' import { View, Text, Image, ScrollView } from '@tarojs/components'
import { Empty, Popup } from '@antmjs/vantui' import { Empty, Popup } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore' import useAuthStore from '@/stores/auth/useAuthStore'
import { cancelOrderApi, getOrderDetailApi, getOrderListApi } from '@/services/order' import { cancelOrderApi, getOrderDetailApi, getOrderListApi } from '@/services/order'
import { ORDER_NAV_ITEMS, ORDER_STATUS_MAP } from '@/types/order' import { ORDER_STATUS_FILTERS, ORDER_STATUS_TEXT } from '@/types/order'
import type { Order, OrderStatus } from '@/types/order' import {formatRetailPrice, formatSpec, resolveFileUrl} from '@/utils/format'
import PriceText from '@/components/PriceText'
import type { OrderDetail, OrderListItem, OrderStatus } from '@/types/order'
import './index.less' import './index.less'
const PAGE_SIZE = 10 const PAGE_SIZE = 10
/** /**
* 订单列表页(框架) * 订单列表页
* 入口:/pages/order-list/index?status=0|1|2|3|9|all * 入口:/pages/order-list/index?status=0|1|2|3|4|9|all
* status 与 ORDER_NAV_ITEMS 映射(业务语言 → 后端枚举),缺省为全部 * 状态文案一律使用接口返回的 status_name(详情接口无该字段时沿用列表行)
*/ */
export default function OrderListPage() { export default function OrderListPage() {
const router = useRouter() const router = useRouter()
const token = useAuthStore(s => s.token) const token = useAuthStore(s => s.token)
/** 当前状态筛选(undefined = 全部) */ /** 当前状态筛选(undefined = 全部) */
const [status, setStatus] = useState<number | undefined>(undefined) const [status, setStatus] = useState<OrderStatus | undefined>(undefined)
const [orders, setOrders] = useState<Order[]>([]) const [orders, setOrders] = useState<OrderListItem[]>([])
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false) const [finished, setFinished] = useState(false)
const loadingRef = useRef(false) const loadingRef = useRef(false)
/** 订单详情弹层 */ /** 订单详情弹层statusName 沿用列表行,详情接口不返回 status_name */
const [showDetail, setShowDetail] = useState(false) const [showDetail, setShowDetail] = useState(false)
const [orderDetail, setOrderDetail] = useState<Order | null>(null) const [orderDetail, setOrderDetail] = useState<OrderDetail | null>(null)
const [detailStatusName, setDetailStatusName] = useState('')
const loggedIn = !!token const loggedIn = !!token
/** 解析路由参数中的 status'all' / 数字 / 缺省 → undefined */ /** 解析路由参数中的 status'all' / 数字 / 缺省 → undefined */
const parseStatus = useCallback((raw?: string): number | undefined => { const parseStatus = useCallback((raw?: string): OrderStatus | undefined => {
if (!raw || raw === 'all' || raw === '') return undefined if (!raw || raw === 'all' || raw === '') return undefined
const num = Number(raw) const num = Number(raw)
return Number.isNaN(num) ? undefined : num return Number.isNaN(num) ? undefined : (num as OrderStatus)
}, []) }, [])
/** 拉取订单列表 */ /** 拉取订单列表 */
const loadOrders = useCallback( const loadOrders = useCallback(
async (pageNum: number, reset: boolean, statusParam?: number) => { async (pageNum: number, reset: boolean, statusParam?: OrderStatus) => {
if (!loggedIn || loadingRef.current) return if (!loggedIn || loadingRef.current) return
loadingRef.current = true loadingRef.current = true
setLoading(true) setLoading(true)
@@ -53,11 +55,10 @@ export default function OrderListPage() {
page: pageNum, page: pageNum,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
}) })
const { data, total: totalCount } = res.data const { data, total } = res.data
setOrders(prev => (reset ? data : [...prev, ...data])) setOrders(prev => (reset ? data : [...prev, ...data]))
setTotal(totalCount)
setPage(pageNum) setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= totalCount) setFinished(pageNum * PAGE_SIZE >= total)
} catch { } catch {
// 错误已由 request 层 toast // 错误已由 request 层 toast
} finally { } finally {
@@ -82,7 +83,7 @@ export default function OrderListPage() {
/** 切换状态筛选 */ /** 切换状态筛选 */
const handleStatusTap = useCallback( const handleStatusTap = useCallback(
(value?: number) => { (value?: OrderStatus) => {
setStatus(value) setStatus(value)
setFinished(false) setFinished(false)
loadOrders(1, true, value) loadOrders(1, true, value)
@@ -91,19 +92,20 @@ export default function OrderListPage() {
) )
/** 查看订单详情 */ /** 查看订单详情 */
const handleOrderTap = useCallback(async (order: Order) => { const handleOrderTap = useCallback(async (order: OrderListItem) => {
try { try {
const res = await getOrderDetailApi(order.id) const res = await getOrderDetailApi(order.id)
setOrderDetail(res.data) setOrderDetail(res.data)
setDetailStatusName(order.status_name)
setShowDetail(true) setShowDetail(true)
} catch { } catch {
// 错误已由 request 层 toast // 错误已由 request 层 toast
} }
}, []) }, [])
/** 取消订单(仅待汇总可取消) */ /** 取消订单(仅待接单可取消,以 can_cancel 为准 */
const handleCancelOrder = useCallback( const handleCancelOrder = useCallback(
(order: Order) => { (order: OrderListItem) => {
Taro.showModal({ Taro.showModal({
title: '取消订单', title: '取消订单',
content: `确定取消订单 ${order.order_no} 吗?`, content: `确定取消订单 ${order.order_no} 吗?`,
@@ -123,6 +125,11 @@ export default function OrderListPage() {
[loadOrders, status], [loadOrders, status],
) )
/** 跳转关联账单详情 */
const handleBillTap = useCallback((billId: number) => {
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${billId}` })
}, [])
const goLogin = useCallback(() => { const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' }) Taro.navigateTo({ url: '/pages/login/index' })
}, []) }, [])
@@ -131,17 +138,11 @@ export default function OrderListPage() {
<View className='order-list-page'> <View className='order-list-page'>
{/* ========== 状态筛选 ========== */} {/* ========== 状态筛选 ========== */}
<ScrollView scrollX className='status-scroll'> <ScrollView scrollX className='status-scroll'>
<View {ORDER_STATUS_FILTERS.map(item => (
className={`status-chip ${status === undefined ? 'active' : ''}`}
onClick={() => handleStatusTap(undefined)}
>
<Text></Text>
</View>
{ORDER_NAV_ITEMS.map(item => (
<View <View
key={item.key} key={item.label}
className={`status-chip ${status === item.status ? 'active' : ''}`} className={`status-chip ${status === item.value ? 'active' : ''}`}
onClick={() => handleStatusTap(item.status)} onClick={() => handleStatusTap(item.value)}
> >
<Text>{item.label}</Text> <Text>{item.label}</Text>
</View> </View>
@@ -164,25 +165,69 @@ export default function OrderListPage() {
<View key={order.id} className='order-item' onClick={() => handleOrderTap(order)}> <View key={order.id} className='order-item' onClick={() => handleOrderTap(order)}>
<View className='order-item__header'> <View className='order-item__header'>
<Text className='order-item__no'>{order.order_no}</Text> <Text className='order-item__no'>{order.order_no}</Text>
<Text className='order-item__status'>{ORDER_STATUS_MAP[order.status]}</Text> <Text className={`order-item__status order-item__status--${order.status}`}>
{order.status_name}
</Text>
</View>
{/* 商品预览 */}
<View className='order-item__preview'>
{order.items.map((i, idx) => (
<View key={idx} className='order-item__goods'>
{i.image ? (
<Image
className='order-item__goods-img'
src={resolveFileUrl(i.image)}
mode='aspectFill'
lazyLoad
/>
) : (
<View className='order-item__goods-img order-item__goods-img--empty' />
)}
<View className='order-item__goods-info'>
<Text className='order-item__goods-name'>{i.product_name}</Text>
{!!formatSpec(i.product_spec, i.unit) && (
<Text className='order-item__goods-spec'>{formatSpec(i.product_spec, i.unit)}</Text>
)}
</View>
<Text className='order-item__goods-qty'>×{i.quantity} </Text>
</View>
))}
{order.item_count > 3 && (
<Text className='order-item__more'> {order.item_count} </Text>
)}
</View> </View>
<View className='order-item__body'> <View className='order-item__body'>
<Text className='order-item__date'>{order.order_date}</Text> <Text className='order-item__date'> {order.order_date}</Text>
<View className='order-item__amounts'> <View className='order-item__amounts'>
<Text className='order-item__qty'> {order.total_quantity} </Text> <Text className='order-item__qty'> {order.total_quantity} </Text>
<Text className='order-item__amount'>{order.total_amount}</Text> <Text className='order-item__amount'>{order.total_amount}</Text>
</View> </View>
</View> </View>
{order.remark && <Text className='order-item__remark'>{order.remark}</Text>} {order.remark && <Text className='order-item__remark'>{order.remark}</Text>}
{order.status === 0 && ( {(order.can_cancel || order.bill_id > 0) && (
<View <View className='order-item__footer'>
className='order-item__cancel' {order.bill_id > 0 && (
onClick={e => { <View
e.stopPropagation() className='order-item__bill'
handleCancelOrder(order) onClick={e => {
}} e.stopPropagation()
> handleBillTap(order.bill_id)
}}
>
</View>
)}
{order.can_cancel && (
<View
className='order-item__cancel'
onClick={e => {
e.stopPropagation()
handleCancelOrder(order)
}}
>
</View>
)}
</View> </View>
)} )}
</View> </View>
@@ -207,8 +252,10 @@ export default function OrderListPage() {
<View className='detail-popup'> <View className='detail-popup'>
<Text className='detail-popup__title'>{orderDetail.order_no}</Text> <Text className='detail-popup__title'>{orderDetail.order_no}</Text>
<View className='detail-popup__meta'> <View className='detail-popup__meta'>
<Text>{orderDetail.order_date}</Text> <Text>{orderDetail.order_date} {orderDetail.created_at}</Text>
<Text className='detail-popup__status'>{ORDER_STATUS_MAP[orderDetail.status]}</Text> <Text className='detail-popup__status'>
{detailStatusName || ORDER_STATUS_TEXT[orderDetail.status] || ''}
</Text>
</View> </View>
<ScrollView scrollY className='detail-popup__list'> <ScrollView scrollY className='detail-popup__list'>
{(orderDetail.items ?? []).map(item => ( {(orderDetail.items ?? []).map(item => (
@@ -216,13 +263,17 @@ export default function OrderListPage() {
<View className='detail-popup__item-info'> <View className='detail-popup__item-info'>
<Text className='detail-popup__item-name'>{item.product_name}</Text> <Text className='detail-popup__item-name'>{item.product_name}</Text>
<Text className='detail-popup__item-spec'> <Text className='detail-popup__item-spec'>
{item.product_spec} {item.price} × {item.quantity} {formatSpec(item.product_spec, item.unit)}{' '}
{formatRetailPrice(item.price, item.product_spec)} {item.price_unit}
</Text> </Text>
</View> </View>
<Text className='detail-popup__item-amount'>{item.amount}</Text> <Text className='detail-popup__item-amount'>{item.price} × {item.quantity}</Text>
</View> </View>
))} ))}
</ScrollView> </ScrollView>
{orderDetail.remark && (
<Text className='detail-popup__remark'>{orderDetail.remark}</Text>
)}
<View className='detail-popup__footer'> <View className='detail-popup__footer'>
<Text className='detail-popup__total'> {orderDetail.total_amount}</Text> <Text className='detail-popup__total'> {orderDetail.total_amount}</Text>
</View> </View>
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '支付详情',
})
+176
View File
@@ -0,0 +1,176 @@
.pay-detail {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
// 已拒绝时为底部操作栏留出空间
&--reject {
padding-bottom: 160rpx;
}
.pay-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__no {
font-size: 28rpx;
color: #323233;
font-weight: 500;
}
&__status {
font-size: 24rpx;
// 0 待审核 / 1 已通过 / 2 已拒绝
&--0 { color: #ff976a; }
&--1 { color: #07c160; }
&--2 { color: #ee0a24; }
}
&__amount {
display: block;
margin-top: 16rpx;
font-size: 48rpx;
font-weight: 600;
color: #323233;
text-align: center;
}
&__tip {
display: block;
margin: 16rpx 0 8rpx;
padding: 12rpx 16rpx;
background: #fff8ec;
border-radius: 8rpx;
font-size: 22rpx;
color: #ff976a;
line-height: 1.5;
text-align: left;
&--reject {
background: #fff5f5;
color: #ee0a24;
}
}
&__row {
margin-top: 16rpx;
display: flex;
align-items: flex-start;
justify-content: space-between;
}
&__label {
flex-shrink: 0;
font-size: 26rpx;
color: #969799;
margin-right: 24rpx;
}
&__value {
font-size: 26rpx;
color: #323233;
text-align: right;
word-break: break-all;
}
}
.pay-section__title {
font-size: 30rpx;
font-weight: 600;
color: #323233;
}
// ===== 汇款凭证 =====
.pay-vouchers {
display: flex;
flex-wrap: wrap;
margin-top: 16rpx;
&__img {
width: 200rpx;
height: 200rpx;
margin: 0 16rpx 16rpx 0;
border-radius: 12rpx;
background: #f7f8fa;
}
}
// ===== 合并账单 =====
.pay-bill {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&__no {
font-size: 28rpx;
color: #323233;
font-weight: 500;
}
&__date {
margin-top: 6rpx;
font-size: 22rpx;
color: #969799;
}
&__amount {
font-size: 28rpx;
color: #323233;
font-weight: 600;
margin: 0 20rpx;
}
&__status {
font-size: 24rpx;
&--0 { color: #ee0a24; }
&--1 { color: #07c160; }
}
}
// ===== 底部操作栏 =====
.pay-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
&__btn {
padding: 14rpx 48rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
font-weight: 500;
}
}
}
+211
View File
@@ -0,0 +1,211 @@
import { useCallback, useEffect, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components'
import { Empty } from '@antmjs/vantui'
import {
getPaymentDetailApi,
getPayStatusName,
PAY_METHOD_NAMES,
queryOnlinePaymentApi,
} from '@/services/payment'
import { resolveFileUrl } from '@/utils/format'
import type { PaymentDetail } from '@/services/payment'
import './index.less'
/**
* 支付详情页
* 线下凭证单:支付信息 + 汇款凭证(可预览)+ 合并付款的账单(可下钻账单详情),审核拒绝后可重新发起付款
* 在线支付单:无凭证,待支付时可「刷新支付结果」主动同步网关结果(后台通知延迟/丢失时的兜底)
*/
export default function PaymentDetailPage() {
const router = useRouter()
const id = Number(router.params.id ?? 0)
const [detail, setDetail] = useState<PaymentDetail | null>(null)
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState(false)
const loadDetail = useCallback(async () => {
if (!id) return
setLoading(true)
try {
const res = await getPaymentDetailApi(id)
setDetail(res.data)
} catch {
// 错误已由 request 层 toast
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
loadDetail()
}, [loadDetail])
/** 预览凭证图片 */
const previewVoucher = useCallback((current: string) => {
const urls = (detail?.payment.voucher_urls ?? []).map(resolveFileUrl)
Taro.previewImage({ urls, current })
}, [detail])
/** 下钻账单详情 */
const goBill = useCallback((billId: number) => {
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${billId}` })
}, [])
/** 已拒绝 / 支付失败 → 携带本组账单重新发起付款(账单已由后台释放) */
const handleRepay = useCallback(() => {
if (!detail) return
const ids = detail.bills.map(b => b.id).join(',')
Taro.redirectTo({ url: `/pages/payment/index?ids=${ids}` })
}, [detail])
/** 在线支付待支付 → 主动查询网关同步结果(已支付则后端立即结账),随后刷新详情 */
const handleSync = useCallback(async () => {
if (!detail || syncing) return
setSyncing(true)
try {
const res = await queryOnlinePaymentApi(detail.payment.payment_no)
if (res.data.status === 1) {
Taro.showToast({ title: '支付成功', icon: 'success' })
loadDetail()
} else if (res.data.status === 2) {
Taro.showToast({ title: '支付失败,账单已释放', icon: 'none' })
loadDetail()
} else {
Taro.showToast({ title: '暂未查询到支付结果,请稍后再试', icon: 'none' })
}
} catch {
// 错误已由 request 层 toast
} finally {
setSyncing(false)
}
}, [detail, syncing, loadDetail])
if (loading && !detail) {
return <View className='pay-detail'><Empty description='加载中...' /></View>
}
if (!detail) {
return <View className='pay-detail'><Empty description='支付记录不存在' /></View>
}
const { payment, bills } = detail
const vouchers = payment.voucher_urls.map(resolveFileUrl)
/** 在线支付单(旺铺网关):状态语义与线下凭证单不同,无凭证 */
const isOnline = payment.pay_type === 2
return (
<View className={`pay-detail ${payment.status === 2 ? 'pay-detail--reject' : ''}`}>
{/* ===== 支付信息 ===== */}
<View className='pay-card'>
<View className='pay-card__header'>
<Text className='pay-card__no'>{payment.payment_no}</Text>
<Text className={`pay-card__status pay-card__status--${payment.status}`}>
{getPayStatusName(payment)}
</Text>
</View>
<Text className='pay-card__amount'>{payment.amount}</Text>
{payment.status === 0 && !isOnline && (
<Text className='pay-card__tip'></Text>
)}
{payment.status === 0 && isOnline && (
<Text className='pay-card__tip'>
</Text>
)}
{payment.status === 2 && (
<Text className='pay-card__tip pay-card__tip--reject'>
{isOnline
? '支付失败,账单已释放,可重新发起付款'
: `审核未通过${payment.audit_remark ? `${payment.audit_remark}` : ''},账单已释放,可重新发起付款`}
</Text>
)}
<View className='pay-card__row'>
<Text className='pay-card__label'></Text>
<Text className='pay-card__value'>{PAY_METHOD_NAMES[payment.pay_method]}</Text>
</View>
<View className='pay-card__row'>
<Text className='pay-card__label'></Text>
<Text className='pay-card__value'>{payment.created_at}</Text>
</View>
{payment.audited_at && (
<View className='pay-card__row'>
<Text className='pay-card__label'></Text>
<Text className='pay-card__value'>{payment.audited_at}</Text>
</View>
)}
{isOnline && payment.paid_at && (
<View className='pay-card__row'>
<Text className='pay-card__label'></Text>
<Text className='pay-card__value'>{payment.paid_at}</Text>
</View>
)}
{isOnline && payment.trade_no && (
<View className='pay-card__row'>
<Text className='pay-card__label'></Text>
<Text className='pay-card__value'>{payment.trade_no}</Text>
</View>
)}
{payment.remark && (
<View className='pay-card__row'>
<Text className='pay-card__label'></Text>
<Text className='pay-card__value'>{payment.remark}</Text>
</View>
)}
</View>
{/* ===== 汇款凭证(在线支付单无凭证) ===== */}
{!isOnline && (
<View className='pay-card'>
<Text className='pay-section__title'>{vouchers.length}</Text>
<View className='pay-vouchers'>
{vouchers.map((url, i) => (
<Image
key={i}
className='pay-vouchers__img'
src={url}
mode='aspectFill'
onClick={() => previewVoucher(url)}
/>
))}
</View>
{vouchers.length === 0 && <Empty description='暂无凭证图片' />}
</View>
)}
{/* ===== 合并账单 ===== */}
<View className='pay-card'>
<Text className='pay-section__title'>{bills.length}</Text>
{bills.map(bill => (
<View key={bill.id} className='pay-bill' onClick={() => goBill(bill.id)}>
<View className='pay-bill__main'>
<Text className='pay-bill__no'>{bill.bill_no}</Text>
<Text className='pay-bill__date'>{bill.bill_date}</Text>
</View>
<Text className='pay-bill__amount'>{bill.total_amount}</Text>
<Text className={`pay-bill__status pay-bill__status--${bill.status}`}>
{bill.status === 1 ? '已支付' : '未支付'}
</Text>
</View>
))}
{bills.length === 0 && <Empty description='暂无关联账单' />}
</View>
{/* ===== 已拒绝 / 支付失败 → 重新付款 ===== */}
{payment.status === 2 && (
<View className='pay-bar'>
<View className='pay-bar__btn' onClick={handleRepay}></View>
</View>
)}
{/* ===== 在线支付待支付 → 主动同步支付结果 ===== */}
{isOnline && payment.status === 0 && (
<View className='pay-bar'>
<View className='pay-bar__btn' onClick={handleSync}>
{syncing ? '查询中...' : '刷新支付结果'}
</View>
</View>
)}
</View>
)
}
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '支付记录',
})
@@ -1,31 +1,30 @@
.statement-page { .payment-page {
min-height: 100vh; min-height: 100vh;
background: #f7f8fa; background: #f7f8fa;
padding: 20rpx 24rpx 60rpx; padding: 20rpx 24rpx 60rpx;
box-sizing: border-box; box-sizing: border-box;
// ===== 头部 ===== .status-scroll {
.statement-header { white-space: nowrap;
display: flex; margin-bottom: 20rpx;
align-items: center; }
justify-content: space-between;
padding: 8rpx 8rpx 24rpx;
&__title { .status-chip {
font-size: 36rpx; display: inline-flex;
font-weight: 600; padding: 12rpx 28rpx;
} margin-right: 16rpx;
border-radius: 999rpx;
background: #fff;
font-size: 26rpx;
color: #646566;
&__btn { &.active {
font-size: 26rpx;
color: #fff;
background: #ee0a24; background: #ee0a24;
padding: 12rpx 28rpx; color: #fff;
border-radius: 999rpx;
} }
} }
.statement-empty { .payment-empty {
padding-top: 120rpx; padding-top: 120rpx;
&__btn { &__btn {
@@ -38,15 +37,15 @@
} }
} }
.statement-loading { .payment-loading {
padding: 30rpx 0; padding: 30rpx 0;
text-align: center; text-align: center;
font-size: 24rpx; font-size: 24rpx;
color: #c8c9cc; color: #c8c9cc;
} }
// ===== 对账单项 ===== // ===== 支付记录单项 =====
.statement-item { .payment-item {
background: #fff; background: #fff;
border-radius: 16rpx; border-radius: 16rpx;
padding: 24rpx; padding: 24rpx;
@@ -66,7 +65,11 @@
&__status { &__status {
font-size: 24rpx; font-size: 24rpx;
color: #ee0a24;
// 0 待审核 / 1 已通过 / 2 已拒绝
&--0 { color: #ff976a; }
&--1 { color: #07c160; }
&--2 { color: #ee0a24; }
} }
&__body { &__body {
@@ -76,64 +79,52 @@
justify-content: space-between; justify-content: space-between;
} }
&__period { &__meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&__method {
font-size: 24rpx; font-size: 24rpx;
color: #969799; color: #646566;
}
&__date {
margin-top: 6rpx;
font-size: 22rpx;
color: #c8c9cc;
}
&__side {
display: flex;
flex-direction: column;
align-items: flex-end;
margin-left: 20rpx;
} }
&__amount { &__amount {
font-size: 28rpx; font-size: 32rpx;
color: #323233; color: #323233;
font-weight: 600; font-weight: 600;
} }
&__settle { &__bills {
margin-top: 8rpx; margin-top: 6rpx;
font-size: 22rpx; font-size: 22rpx;
color: #969799; color: #969799;
display: block;
}
}
// ===== 生成弹层 =====
.gen-popup {
padding: 32rpx 32rpx 24rpx;
&__title {
font-size: 34rpx;
font-weight: 600;
display: block;
} }
&__desc { &__reject {
display: block;
margin-top: 12rpx; margin-top: 12rpx;
font-size: 24rpx; padding: 12rpx 16rpx;
color: #969799; background: #fff5f5;
display: block; border-radius: 8rpx;
} font-size: 22rpx;
color: #ee0a24;
&__row { line-height: 1.5;
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;
} }
} }
} }
+143
View File
@@ -0,0 +1,143 @@
import { useCallback, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components'
import { Empty } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { getPaymentListApi, getPayStatusName, PAY_METHOD_NAMES } from '@/services/payment'
import type { Payment, PayStatus } from '@/services/payment'
import './index.less'
const PAGE_SIZE = 10
/** 状态筛选(undefined = 全部) */
const STATUS_FILTERS: Array<{ value: PayStatus | undefined; label: string }> = [
{ value: undefined, label: '全部' },
{ value: 0, label: '待审核' },
{ value: 1, label: '已通过' },
{ value: 2, label: '已拒绝' },
]
/**
* 支付记录列表页
* 门店口径支付记录(合并付款申请),支持状态筛选;点击进支付详情
*/
export default function PaymentRecordsPage() {
const token = useAuthStore(s => s.token)
const [status, setStatus] = useState<PayStatus | undefined>(undefined)
const [records, setRecords] = useState<Payment[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const loadingRef = useRef(false)
const loggedIn = !!token
/** 拉取支付记录 */
const loadList = useCallback(
async (pageNum: number, reset: boolean, statusParam?: PayStatus) => {
if (!loggedIn || loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const res = await getPaymentListApi({ status: statusParam, page: pageNum, pageSize: PAGE_SIZE })
const { data, total } = res.data
setRecords(prev => (reset ? data : [...prev, ...data]))
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= total)
} catch {
// 错误已由 request 层 toast
} finally {
loadingRef.current = false
setLoading(false)
}
},
[loggedIn],
)
useDidShow(() => {
loadList(1, true, status)
})
useReachBottom(() => {
if (!finished && !loadingRef.current && loggedIn) {
loadList(page + 1, false, status)
}
})
/** 切换状态筛选 */
const handleStatusTap = useCallback(
(value?: PayStatus) => {
setStatus(value)
setFinished(false)
loadList(1, true, value)
},
[loadList],
)
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
const goDetail = useCallback((id: number) => {
Taro.navigateTo({ url: `/pages/payment-detail/index?id=${id}` })
}, [])
return (
<View className='payment-page'>
{/* ========== 状态筛选 ========== */}
<ScrollView scrollX className='status-scroll'>
{STATUS_FILTERS.map(item => (
<View
key={item.label}
className={`status-chip ${status === item.value ? 'active' : ''}`}
onClick={() => handleStatusTap(item.value)}
>
<Text>{item.label}</Text>
</View>
))}
</ScrollView>
{/* ========== 支付记录列表 ========== */}
{!loggedIn ? (
<Empty description='登录后查看支付记录' className='payment-empty'>
<View className='payment-empty__btn' onClick={goLogin}></View>
</Empty>
) : records.length === 0 ? (
loading ? (
<View className='payment-loading'><Text>...</Text></View>
) : (
<Empty description='暂无支付记录' className='payment-empty' />
)
) : (
records.map(record => (
<View key={record.id} className='payment-item' onClick={() => goDetail(record.id)}>
<View className='payment-item__header'>
<Text className='payment-item__no'>{record.payment_no}</Text>
<Text className={`payment-item__status payment-item__status--${record.status}`}>
{getPayStatusName(record)}
</Text>
</View>
<View className='payment-item__body'>
<View className='payment-item__meta'>
<Text className='payment-item__method'>{PAY_METHOD_NAMES[record.pay_method]}</Text>
<Text className='payment-item__date'>{record.created_at}</Text>
</View>
<View className='payment-item__side'>
<Text className='payment-item__amount'>{record.amount}</Text>
<Text className='payment-item__bills'> {record.bills_count ?? 0} </Text>
</View>
</View>
{record.status === 2 && record.pay_type !== 2 && !!record.audit_remark && (
<Text className='payment-item__reject'>{record.audit_remark}</Text>
)}
</View>
))
)}
{loggedIn && finished && records.length > 0 && (
<View className='payment-loading'><Text></Text></View>
)}
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '账单付款',
})
+323
View File
@@ -0,0 +1,323 @@
.pay-page {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 160rpx;
box-sizing: border-box;
.pay-section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
&__title {
font-size: 30rpx;
font-weight: 600;
color: #323233;
}
&__extra {
font-size: 26rpx;
color: #ee0a24;
}
&__hint {
font-size: 22rpx;
color: #969799;
}
}
.pay-empty {
padding: 40rpx 0;
&__btn {
margin-top: 24rpx;
padding: 14rpx 60rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
border-radius: 999rpx;
}
}
.pay-loading {
padding: 24rpx 0 8rpx;
text-align: center;
font-size: 24rpx;
color: #c8c9cc;
}
// ===== 账单选择行 =====
.pay-bill {
display: flex;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
&__check {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 2rpx solid #c8c9cc;
margin-right: 20rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
&.on {
background: #ee0a24;
border-color: #ee0a24;
}
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&__no {
font-size: 28rpx;
color: #323233;
font-weight: 500;
}
&__meta {
margin-top: 6rpx;
font-size: 22rpx;
color: #969799;
}
&__amount {
font-size: 30rpx;
color: #ee0a24;
font-weight: 600;
margin-left: 20rpx;
}
}
// ===== 支付方式 =====
.pay-methods {
margin-top: 8rpx;
}
.pay-method {
display: flex;
align-items: center;
padding: 24rpx 0;
&.active {
.pay-method__label {
color: #ee0a24;
}
}
&__info {
flex: 1;
min-width: 0;
margin-left: 16rpx;
display: flex;
flex-direction: column;
}
&__label {
font-size: 28rpx;
color: #323233;
}
&__desc {
margin-top: 4rpx;
font-size: 22rpx;
color: #969799;
}
&__radio {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
border: 2rpx solid #c8c9cc;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
&.on {
background: #ee0a24;
border-color: #ee0a24;
}
}
&__content {
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 0 8rpx;
}
&__qrcode {
width: 360rpx;
height: 360rpx;
background: #f7f8fa;
border-radius: 12rpx;
}
&__qrcode-tip {
margin-top: 16rpx;
font-size: 22rpx;
color: #969799;
}
&__bank {
width: 100%;
font-size: 26rpx;
color: #323233;
line-height: 1.7;
white-space: pre-wrap;
background: #f7f8fa;
border-radius: 12rpx;
padding: 20rpx;
box-sizing: border-box;
}
&__copy {
margin-top: 16rpx;
padding: 8rpx 40rpx;
border: 1rpx solid #ee0a24;
border-radius: 999rpx;
color: #ee0a24;
font-size: 24rpx;
}
&__empty {
display: block;
padding: 24rpx 0 8rpx;
font-size: 24rpx;
color: #969799;
text-align: center;
}
}
// ===== 汇款凭证 =====
.pay-vouchers {
display: flex;
flex-wrap: wrap;
margin-top: 16rpx;
}
.pay-voucher {
position: relative;
width: 200rpx;
height: 200rpx;
margin: 0 16rpx 16rpx 0;
border-radius: 12rpx;
overflow: hidden;
&__img {
width: 100%;
height: 100%;
background: #f7f8fa;
}
&__del {
position: absolute;
top: 0;
right: 0;
width: 36rpx;
height: 36rpx;
border-radius: 0 0 0 12rpx;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
&--add {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2rpx dashed #dcdee0;
background: #fafafa;
box-sizing: border-box;
}
&__add-text {
margin-top: 8rpx;
font-size: 22rpx;
color: #969799;
}
}
// ===== 备注 =====
.pay-remark {
width: 100%;
height: 140rpx;
margin-top: 16rpx;
padding: 16rpx;
background: #f7f8fa;
border-radius: 12rpx;
font-size: 26rpx;
box-sizing: border-box;
}
// ===== 提交栏 =====
.pay-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: center;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
&__info {
flex: 1;
min-width: 0;
display: flex;
align-items: baseline;
}
&__count {
font-size: 26rpx;
color: #646566;
}
&__amount {
margin-left: 16rpx;
font-size: 36rpx;
color: #ee0a24;
font-weight: 600;
}
&__btn {
padding: 14rpx 48rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
font-weight: 500;
&.disabled {
opacity: 0.5;
}
}
}
}
+597
View File
@@ -0,0 +1,597 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom, useRouter } from '@tarojs/taro'
import { View, Text, Image, Textarea } from '@tarojs/components'
import { Empty, Icon } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { getBillListApi } from '@/services/bill'
import { createOnlinePaymentApi, createPaymentApi, getPaymentConfigApi, queryOnlinePaymentApi } from '@/services/payment'
import { chooseAndUploadImages } from '@/utils/upload'
import { resolveFileUrl } from '@/utils/format'
import type { Bill } from '@/services/bill'
import type { PayMethod, PaymentConfig } from '@/services/payment'
import type { UploadedFile } from '@/utils/upload'
import './index.less'
const PAGE_SIZE = 20
/** 凭证最多上传张数 */
const MAX_VOUCHERS = 3
/** 在线支付(调起 wx.requestPayment)仅微信小程序支持 */
const IS_WEAPP = process.env.TARO_ENV === 'weapp'
/** H5 端处于微信内置浏览器时,可走公众号网页授权 + JSAPI 在线支付 */
const IS_H5_WECHAT =
process.env.TARO_ENV === 'h5' &&
typeof navigator !== 'undefined' &&
/micromessenger/i.test(navigator.userAgent)
/** 在线支付(旺铺网关 JSAPI)是否可用:小程序 / 微信内 H5 */
const ONLINE_PAY_AVAILABLE = IS_WEAPP || IS_H5_WECHAT
/** H5 公众号支付草稿存储 key(授权跳转前暂存账单选择,回跳后恢复) */
const H5_PAY_DRAFT_KEY = 'h5_online_pay_draft'
/** H5 公众号支付草稿(授权回跳页面重载,勾选状态经 sessionStorage 恢复) */
interface H5PayDraft {
bill_ids: number[]
remark?: string
}
/**
* 调起公众号 JSAPI 支付(WeixinJSBridge 未注入时等待 WeixinJSBridgeReady 事件)
* resolve: 'ok' 支付成功 / 'cancel' 用户取消 / 'fail' 调起失败
*/
function invokeWechatJsapiPay(payParams: Record<string, any>): Promise<'ok' | 'cancel' | 'fail'> {
return new Promise(resolve => {
const invoke = () => {
;(window as any).WeixinJSBridge.invoke(
'getBrandWCPayRequest',
{
appId: String(payParams.appId || ''),
timeStamp: String(payParams.timeStamp || ''),
nonceStr: String(payParams.nonceStr || ''),
package: String(payParams.package || ''),
signType: String(payParams.signType || 'RSA'),
paySign: String(payParams.paySign || ''),
},
(res: any) => {
const msg: string = res?.err_msg || ''
if (msg === 'get_brand_wcpay_request:ok') resolve('ok')
else if (msg === 'get_brand_wcpay_request:cancel') resolve('cancel')
else resolve('fail')
},
)
}
if ((window as any).WeixinJSBridge) {
invoke()
} else {
document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
}
})
}
/** 支付方式选项(在线支付仅小程序/微信内 H5 展示,排在最前) */
const PAY_METHODS: Array<{ value: PayMethod; label: string; icon: string; desc: string }> = [
...(ONLINE_PAY_AVAILABLE
? [
{
value: 4 as PayMethod,
label: '微信在线支付',
icon: 'wechat',
desc: IS_WEAPP ? '小程序内直接付款,免上传凭证' : '微信内直接付款,免上传凭证',
},
]
: []),
{ value: 1, label: '微信支付', icon: 'wechat', desc: '扫码完成转账' },
{ value: 2, label: '支付宝', icon: 'alipay', desc: '扫码完成转账' },
{ value: 3, label: '对公汇款', icon: 'credit-pay', desc: '银行转账至对公账户' },
]
/**
* 发起付款页(合并付款)
*/
export default function PaymentPage() {
const token = useAuthStore(s => s.token)
const loggedIn = !!token
const [bills, setBills] = useState<Bill[]>([])
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const loadingRef = useRef(false)
const [config, setConfig] = useState<PaymentConfig | null>(null)
const [payMethod, setPayMethod] = useState<PayMethod>(ONLINE_PAY_AVAILABLE ? 4 : 1)
const [vouchers, setVouchers] = useState<UploadedFile[]>([])
const [remark, setRemark] = useState('')
const [uploading, setUploading] = useState(false)
const [submitting, setSubmitting] = useState(false)
/** 在线支付(旺铺网关 JSAPI):免凭证,调起微信支付 */
const isOnline = payMethod === 4
/** 拉取可付款账单(首次加载应用路由预选) */
const loadBills = useCallback(
async (pageNum: number, reset: boolean) => {
if (!loggedIn || loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const res = await getBillListApi({ payable: 1, page: pageNum, pageSize: PAGE_SIZE })
const { data, total } = res.data
setBills(prev => (reset ? data : [...prev, ...data]))
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= total)
setSelectedIds(prev => Array.from(new Set([...prev, ...data.map(i => i.id)])))
} catch {
// 错误已由 request 层 toast
} finally {
loadingRef.current = false
setLoading(false)
}
},
[loggedIn],
)
useDidShow(() => {
loadBills(1, true)
})
useReachBottom(() => {
if (!finished && !loadingRef.current && loggedIn) {
loadBills(page + 1, false)
}
})
/** 收款配置(收款码 / 对公账户信息,挂载时加载一次) */
useEffect(() => {
if (!loggedIn) return
getPaymentConfigApi()
.then(res => setConfig(res.data))
.catch(() => {})
}, [loggedIn])
/** 勾选账单 */
const toggleBill = useCallback((id: number) => {
setSelectedIds(prev => (prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]))
}, [])
/** 全选已加载账单 */
const allChecked = bills.length > 0 && selectedIds.length >= bills.length
const toggleSelectAll = useCallback(() => {
setSelectedIds(prev => (prev.length >= bills.length ? [] : bills.map(b => b.id)))
}, [bills])
/** 已选账单合计金额(展示口径,实际以后端计算为准) */
const totalAmount = bills
.filter(b => selectedIds.includes(b.id))
.reduce((sum, b) => sum + Number(b.total_amount), 0)
.toFixed(2)
/** 上传凭证 */
const handleAddVoucher = useCallback(async () => {
if (uploading) return
const remain = MAX_VOUCHERS - vouchers.length
if (remain <= 0) {
Taro.showToast({ title: `最多上传 ${MAX_VOUCHERS}`, icon: 'none' })
return
}
setUploading(true)
try {
const files = await chooseAndUploadImages(remain)
setVouchers(prev => [...prev, ...files])
} catch {
// 用户取消或上传失败(upload 内已 toast
} finally {
setUploading(false)
}
}, [uploading, vouchers.length])
const handleRemoveVoucher = useCallback((index: number) => {
setVouchers(prev => prev.filter((_, i) => i !== index))
}, [])
/** 预览凭证 / 收款码 */
const previewImage = useCallback((urls: string[], current: string) => {
Taro.previewImage({ urls, current })
}, [])
/** 复制对公账户信息 */
const copyBankInfo = useCallback(() => {
if (!config?.bank_info) return
Taro.setClipboardData({ data: config.bank_info })
}, [config])
/** 提交线下凭证付款申请(后台审核) */
const handleVoucherSubmit = useCallback(async () => {
if (submitting) return
if (selectedIds.length === 0) {
Taro.showToast({ title: '请选择要付款的账单', icon: 'none' })
return
}
if (vouchers.length === 0) {
Taro.showToast({ title: '请上传汇款凭证', icon: 'none' })
return
}
setSubmitting(true)
try {
const res = await createPaymentApi({
bill_ids: selectedIds,
pay_method: payMethod,
voucher_ids: vouchers.map(v => v.id),
remark: remark.trim() || undefined,
})
Taro.showToast({ title: res.msg || '付款申请已提交', icon: 'success' })
setTimeout(() => {
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${res.data.id}` })
}, 800)
} catch {
// 账单状态可能已变化(如已被其他端付款),刷新列表
loadBills(1, true)
} finally {
setSubmitting(false)
}
}, [submitting, selectedIds, vouchers, payMethod, remark, loadBills])
/**
* 在线支付:wx.login 取 code → 后端经旺铺网关下单 → 调起微信支付 → 主动查询同步结果
* 无论支付成功/取消都跳转支付详情(待支付单可在详情页刷新同步结果)
*/
const handleOnlinePay = useCallback(async () => {
if (submitting) return
if (!IS_WEAPP) {
Taro.showToast({ title: '请在微信小程序中使用在线支付', icon: 'none' })
return
}
if (selectedIds.length === 0) {
Taro.showToast({ title: '请选择要付款的账单', icon: 'none' })
return
}
setSubmitting(true)
try {
// 1. 获取微信登录凭证(后端换付款人 openid)
const { code } = await Taro.login()
if (!code) {
Taro.showToast({ title: '微信登录失败,请稍后重试', icon: 'none' })
return
}
// 2. 后端下单(创建支付单并锁定账单)
const res = await createOnlinePaymentApi({
bill_ids: selectedIds,
code,
remark: remark.trim() || undefined,
})
const { id, payment_no, pay_params } = res.data
// 3. 调起微信支付(pay_params 为网关透传的调起参数)
try {
await Taro.requestPayment({
timeStamp: String(pay_params.timeStamp || ''),
nonceStr: String(pay_params.nonceStr || ''),
package: String(pay_params.package || ''),
signType: (pay_params.signType || 'RSA') as 'MD5' | 'HMAC-SHA256' | 'RSA',
paySign: String(pay_params.paySign || ''),
})
} catch (e: any) {
// 用户取消或调起失败:账单仍锁定在支付单中,进详情页可刷新同步/稍后处理
const errMsg = e?.errMsg || ''
Taro.showToast({
title: errMsg.includes('cancel') ? '已取消支付' : '支付调起失败,请稍后重试',
icon: 'none',
})
setTimeout(() => {
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
}, 800)
return
}
// 4. 主动查询同步结果(网关后台通知延迟/丢失时的兜底结账)
let paid = false
try {
const q = await queryOnlinePaymentApi(payment_no)
paid = q.data.status === 1
} catch {
// 查询失败不阻断,进详情页可手动刷新
}
Taro.showToast({ title: paid ? '支付成功' : '支付结果确认中', icon: paid ? 'success' : 'none' })
setTimeout(() => {
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
}, 800)
} catch {
// 下单失败:账单可能已被其他端付款/锁定,刷新列表
loadBills(1, true)
} finally {
setSubmitting(false)
}
}, [submitting, selectedIds, remark, loadBills])
/**
* H5 公众号支付主流程(授权回跳后执行):
* 授权 code 下单(scene=mp,后端换付款人 openid)→ WeixinJSBridge 调起支付 → 主动查询同步结果
* 与小程序端一致:无论成功/取消都跳转支付详情(待支付单可在详情页刷新同步结果)
*/
const runH5OnlinePay = useCallback(
async (billIds: number[], code: string, remarkText?: string) => {
setSubmitting(true)
try {
const res = await createOnlinePaymentApi({
bill_ids: billIds,
code,
scene: 'mp',
remark: remarkText,
})
const { id, payment_no, pay_params } = res.data
const result = await invokeWechatJsapiPay(pay_params)
if (result !== 'ok') {
// 用户取消或调起失败:账单仍锁定在支付单中,进详情页可刷新同步/稍后处理
Taro.showToast({
title: result === 'cancel' ? '已取消支付' : '支付调起失败,请稍后重试',
icon: 'none',
})
setTimeout(() => {
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
}, 800)
return
}
// 主动查询同步结果(网关后台通知延迟/丢失时的兜底结账)
let paid = false
try {
const q = await queryOnlinePaymentApi(payment_no)
paid = q.data.status === 1
} catch {
// 查询失败不阻断,进详情页可手动刷新
}
Taro.showToast({ title: paid ? '支付成功' : '支付结果确认中', icon: paid ? 'success' : 'none' })
setTimeout(() => {
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
}, 800)
} catch {
// 下单失败:账单可能已被其他端付款/锁定,刷新列表
loadBills(1, true)
} finally {
setSubmitting(false)
}
},
[loadBills],
)
/** H5 公众号支付:处理微信授权回跳(URL 携带 code 且本地存在支付草稿时自动继续支付) */
const h5CallbackRef = useRef(false)
useEffect(() => {
if (!IS_H5_WECHAT || h5CallbackRef.current || !loggedIn) return
const code = new URLSearchParams(window.location.search).get('code')
if (!code) return
// 清理地址栏授权参数,避免刷新/分享带出已失效的 code
window.history.replaceState(null, '', window.location.pathname)
let draft: H5PayDraft | null = null
try {
draft = JSON.parse(window.sessionStorage.getItem(H5_PAY_DRAFT_KEY) || 'null')
window.sessionStorage.removeItem(H5_PAY_DRAFT_KEY)
} catch {
draft = null
}
if (!draft || !Array.isArray(draft.bill_ids) || draft.bill_ids.length === 0) return
h5CallbackRef.current = true
setSelectedIds(draft.bill_ids)
if (draft.remark) setRemark(draft.remark)
runH5OnlinePay(draft.bill_ids, code, draft.remark)
}, [loggedIn, runH5OnlinePay])
/**
* H5 公众号支付入口:暂存支付草稿 → 跳转微信网页授权(snsapi_base 静默授权)
* 授权后回跳本页携带 code,由上方 effect 恢复草稿并继续支付
*/
const handleH5OnlinePay = useCallback(async () => {
if (submitting) return
if (selectedIds.length === 0) {
Taro.showToast({ title: '请选择要付款的账单', icon: 'none' })
return
}
setSubmitting(true)
try {
// mp_appid 可能尚未加载完成,兜底重新拉取
let appid = config?.mp_appid
if (!appid) {
const res = await getPaymentConfigApi()
setConfig(res.data)
appid = res.data.mp_appid
}
if (!appid) {
Taro.showToast({ title: '公众号支付暂未开通,请选择其他支付方式', icon: 'none' })
setSubmitting(false)
return
}
const draft: H5PayDraft = { bill_ids: selectedIds, remark: remark.trim() || undefined }
window.sessionStorage.setItem(H5_PAY_DRAFT_KEY, JSON.stringify(draft))
const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname)
window.location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base#wechat_redirect`
} catch {
setSubmitting(false)
}
}, [submitting, selectedIds, remark, config])
/** 提交入口:按支付方式与端分发(小程序 wx.requestPayment / H5 公众号 JSAPI / 线下凭证) */
const handleSubmit = useCallback(() => {
if (isOnline) {
if (IS_H5_WECHAT) {
handleH5OnlinePay()
} else {
handleOnlinePay()
}
} else {
handleVoucherSubmit()
}
}, [isOnline, handleOnlinePay, handleH5OnlinePay, handleVoucherSubmit])
/** 当前支付方式的收款展示 */
const renderMethodContent = () => {
if (isOnline) {
return (
<Text className='pay-method__empty'>
</Text>
)
}
if (payMethod === 3) {
return config?.bank_info ? (
<View className='pay-method__content'>
<Text className='pay-method__bank'>{config.bank_info}</Text>
<View className='pay-method__copy' onClick={copyBankInfo}></View>
</View>
) : (
<Text className='pay-method__empty'></Text>
)
}
const qrcode = resolveFileUrl(payMethod === 1 ? config?.wechat_qrcode : config?.alipay_qrcode)
return qrcode ? (
<View className='pay-method__content'>
<Image
className='pay-method__qrcode'
src={qrcode}
mode='aspectFit'
onClick={() => previewImage([qrcode], qrcode)}
/>
<Text className='pay-method__qrcode-tip'></Text>
</View>
) : (
<Text className='pay-method__empty'></Text>
)
}
return (
<View className='pay-page'>
{/* ========== 选择账单 ========== */}
<View className='pay-section'>
<View className='pay-section__header'>
<Text className='pay-section__title'></Text>
{bills.length > 0 && (
<Text className='pay-section__extra' onClick={toggleSelectAll}>
{allChecked ? '取消全选' : '全选'}
</Text>
)}
</View>
{!loggedIn ? (
<Empty description='登录后发起付款' className='pay-empty'>
<View
className='pay-empty__btn'
onClick={() => Taro.navigateTo({ url: '/pages/login/index' })}
>
</View>
</Empty>
) : bills.length === 0 ? (
loading ? (
<View className='pay-loading'><Text>...</Text></View>
) : (
<Empty description='暂无可付款账单' className='pay-empty' />
)
) : (
bills.map(bill => {
const checked = selectedIds.includes(bill.id)
return (
<View key={bill.id} className='pay-bill' onClick={() => toggleBill(bill.id)}>
<View className={`pay-bill__check ${checked ? 'on' : ''}`}>
{checked && <Icon name='success' size={14} color='#fff' />}
</View>
<View className='pay-bill__main'>
<Text className='pay-bill__no'>{bill.bill_no}</Text>
<Text className='pay-bill__meta'> {bill.bill_date} · {bill.settlement_date}</Text>
</View>
<Text className='pay-bill__amount'>{bill.total_amount}</Text>
</View>
)
})
)}
{loggedIn && !finished && bills.length > 0 && (
<View className='pay-loading'><Text>{loading ? '加载中...' : '上拉加载更多'}</Text></View>
)}
</View>
{/* ========== 支付方式 ========== */}
<View className='pay-section'>
<Text className='pay-section__title'></Text>
<View className='pay-methods'>
{PAY_METHODS.map(m => (
<View
key={m.value}
className={`pay-method ${payMethod === m.value ? 'active' : ''}`}
onClick={() => setPayMethod(m.value)}
>
<Icon name={m.icon} size={22} color={payMethod === m.value ? '#ee0a24' : '#969799'} />
<View className='pay-method__info'>
<Text className='pay-method__label'>{m.label}</Text>
<Text className='pay-method__desc'>{m.desc}</Text>
</View>
<View className={`pay-method__radio ${payMethod === m.value ? 'on' : ''}`}>
{payMethod === m.value && <Icon name='success' size={12} color='#fff' />}
</View>
</View>
))}
</View>
{renderMethodContent()}
</View>
{/* ========== 汇款凭证(在线支付免凭证) ========== */}
{!isOnline && (
<View className='pay-section'>
<View className='pay-section__header'>
<Text className='pay-section__title'></Text>
<Text className='pay-section__hint'> {MAX_VOUCHERS} </Text>
</View>
<View className='pay-vouchers'>
{vouchers.map((v, i) => {
const url = resolveFileUrl(v.url)
return (
<View key={v.id} className='pay-voucher'>
<Image
className='pay-voucher__img'
src={url}
mode='aspectFill'
onClick={() => previewImage(vouchers.map(x => resolveFileUrl(x.url)), url)}
/>
<View className='pay-voucher__del' onClick={() => handleRemoveVoucher(i)}>
<Icon name='cross' size={12} color='#fff' />
</View>
</View>
)
})}
{vouchers.length < MAX_VOUCHERS && (
<View className='pay-voucher pay-voucher--add' onClick={handleAddVoucher}>
<Icon name={uploading ? 'more' : 'plus'} size={24} color='#969799' />
<Text className='pay-voucher__add-text'>{uploading ? '上传中' : '上传凭证'}</Text>
</View>
)}
</View>
</View>
)}
{/* ========== 备注 ========== */}
<View className='pay-section'>
<Text className='pay-section__title'></Text>
<Textarea
className='pay-remark'
value={remark}
maxlength={255}
placeholder={isOnline ? '可填写付款说明' : '如:汇款人姓名、转账时间等'}
onInput={e => setRemark(e.detail.value)}
/>
</View>
{/* ========== 提交栏 ========== */}
{loggedIn && bills.length > 0 && (
<View className='pay-bar'>
<View className='pay-bar__info'>
<Text className='pay-bar__count'> {selectedIds.length} </Text>
<Text className='pay-bar__amount'>{totalAmount}</Text>
</View>
<View
className={`pay-bar__btn ${selectedIds.length === 0 || submitting ? 'disabled' : ''}`}
onClick={handleSubmit}
>
{submitting ? (isOnline ? '支付中...' : '提交中...') : isOnline ? '立即支付' : '提交付款'}
</View>
</View>
)}
</View>
)
}
@@ -1,4 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '注册', navigationBarTitleText: '隐私政策',
}) })
+54
View File
@@ -0,0 +1,54 @@
/* ========================================
隐私政策页面(与用户协议共用样式)
======================================== */
.privacy-page {
min-height: 100vh;
background: #fff;
}
.privacy-scroll {
height: 100vh;
}
.privacy-content {
padding: 32px 40px 80px;
.doc-title {
display: block;
font-size: 40px;
font-weight: 600;
color: #323233;
text-align: center;
margin-bottom: 16px;
}
.doc-updated {
display: block;
font-size: 24px;
color: #969799;
text-align: center;
margin-bottom: 48px;
}
.doc-p {
display: block;
font-size: 28px;
color: #323233;
line-height: 1.8;
margin-bottom: 24px;
text-align: justify;
}
.doc-h2 {
display: block;
font-size: 32px;
font-weight: 600;
color: #323233;
margin: 48px 0 16px;
}
.doc-bold {
font-weight: 600;
}
}
+62
View File
@@ -0,0 +1,62 @@
import { View, Text, ScrollView } from '@tarojs/components'
import './index.less'
/**
* 隐私政策
* 静态政策文本页,由登录页/设置页进入
*/
export default function PrivacyPage() {
return (
<View className='privacy-page'>
<ScrollView scrollY className='privacy-scroll'>
<View className='privacy-content'>
<Text className='doc-title'></Text>
<Text className='doc-updated'>2026821 2026821</Text>
<Text className='doc-p'>
</Text>
<Text className='doc-p doc-bold'>
使使使
</Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>使</Text>
<Text className='doc-p'>1.1 使</Text>
<Text className='doc-p'>1.2 使</Text>
<Text className='doc-p'>1.3 </Text>
<Text className='doc-p'>1.4 </Text>
<Text className='doc-h2'>使</Text>
<Text className='doc-p'>2.1 </Text>
<Text className='doc-p'>2.2 使</Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>3.1 </Text>
<Text className='doc-p'>3.2 </Text>
<Text className='doc-p'>3.3 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>4.1 </Text>
<Text className='doc-p'>4.2 访访使</Text>
<Text className='doc-p'>4.3 </Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>5.1 </Text>
<Text className='doc-p'>5.2 --</Text>
<Text className='doc-p'>5.3 </Text>
<Text className='doc-p'>5.4 使使</Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>使</Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'>使使</Text>
<Text className='doc-h2'></Text>
<Text className='doc-p'></Text>
</View>
</ScrollView>
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '商品详情',
})
+146
View File
@@ -0,0 +1,146 @@
.goods-detail {
min-height: 100vh;
background: #f7f8fa;
padding-bottom: 160rpx;
box-sizing: border-box;
&__empty {
padding-top: 160rpx;
}
&__loading {
padding-top: 160rpx;
text-align: center;
font-size: 26rpx;
color: #c8c9cc;
}
// ===== 商品图轮播 =====
.goods-swiper {
width: 100%;
height: 750rpx;
background: #f2f3f5;
&__img {
width: 100%;
height: 100%;
}
&--empty {
display: flex;
align-items: center;
justify-content: center;
}
&__empty-text {
font-size: 26rpx;
color: #c8c9cc;
}
}
// ===== 信息卡 =====
.goods-card {
background: #fff;
padding: 28rpx 24rpx;
margin-bottom: 20rpx;
&__price-row {
display: flex;
align-items: baseline;
}
&__price {
font-size: 44rpx;
color: #ee0a24;
font-weight: 600;
&--none {
font-size: 28rpx;
color: #c8c9cc;
font-weight: 400;
}
}
&__name {
display: block;
margin-top: 16rpx;
font-size: 34rpx;
color: #323233;
font-weight: 600;
line-height: 1.4;
}
&__spec {
display: block;
margin-top: 8rpx;
font-size: 26rpx;
color: #969799;
}
&__meta {
display: flex;
flex-wrap: wrap;
margin-top: 16rpx;
}
&__tag {
margin: 0 12rpx 12rpx 0;
padding: 6rpx 16rpx;
background: #f7f8fa;
border-radius: 8rpx;
font-size: 22rpx;
color: #646566;
}
&__section {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #323233;
margin-bottom: 16rpx;
}
&__content {
font-size: 28rpx;
color: #323233;
line-height: 1.7;
}
}
// ===== 底部加购栏 =====
.goods-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
&__hint {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #969799;
}
&__btn {
margin-left: 24rpx;
padding: 16rpx 48rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
font-weight: 500;
flex-shrink: 0;
&.disabled {
opacity: 0.5;
}
}
}
}
+181
View File
@@ -0,0 +1,181 @@
import { useCallback, useState } from 'react'
import Taro, { useDidShow, useRouter } from '@tarojs/taro'
import { View, Text, Image, RichText } from '@tarojs/components'
import { Empty, Stepper, Swiper, SwiperItem } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import useCartStore from '@/stores/cart/useCartStore'
import { getProductDetailApi } from '@/services/product'
import {formatRetailPrice, formatSpec, resolveFileUrl} from '@/utils/format'
import type { Product } from '@/types/product'
import './index.less'
/** 图文详情图片自适应(rich-text 内部节点不吃页面样式,预处理内联样式) */
function normalizeContent(html: string): string {
return html.replace(/<img\b/gi, '<img style="max-width:100%;height:auto;display:block;"')
}
/**
* 商品详情页(免登录浏览)
* 未登录/未绑店/未设等级 price=null → 不展示价格、加购引导登录;
* 已登录门店展示该店等级换算价,可直接加购
*/
export default function ProductDetailPage() {
const router = useRouter()
const id = Number(router.params.id ?? 0)
const token = useAuthStore(s => s.token)
const addItem = useCartStore(s => s.addItem)
const [product, setProduct] = useState<Product | null>(null)
const [failed, setFailed] = useState(false)
const [qty, setQty] = useState(1)
const [adding, setAdding] = useState(false)
const loggedIn = !!token
useDidShow(() => {
if (!id) {
setFailed(true)
return
}
setFailed(false)
getProductDetailApi(id)
.then(res => setProduct(res.data))
.catch(() => setFailed(true)) // 下架/不存在:request 层已 toast
})
/** 图片地址列表(preview_url 优先) */
const images = (product?.images_arr ?? [])
.map(img => resolveFileUrl(img.preview_url || img.file_url))
.filter(Boolean)
const previewImage = useCallback(
(current: string) => {
Taro.previewImage({ urls: images, current })
},
[images],
)
/** 加入购物车(服务端校验上架与等级价) */
const handleAdd = useCallback(async () => {
if (!product || adding) return
setAdding(true)
try {
await addItem(product.id, qty)
Taro.showToast({ title: '已加入购物车', icon: 'success' })
} catch {
// 错误已由 request 层 toast
} finally {
setAdding(false)
}
}, [product, adding, qty, addItem])
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
if (failed) {
return (
<View className='goods-detail'>
<Empty description='商品不存在或已下架' className='goods-detail__empty' />
</View>
)
}
if (!product) {
return (
<View className='goods-detail'>
<View className='goods-detail__loading'><Text>...</Text></View>
</View>
)
}
return (
<View className='goods-detail'>
{/* ========== 商品图轮播 ========== */}
{images.length > 0 ? (
<Swiper className='goods-swiper' height={375} loop={images.length > 1} autoPlay={0} paginationColor='#ee0a24'>
{images.map(url => (
<SwiperItem key={url}>
<Image
className='goods-swiper__img'
src={url}
mode='aspectFill'
onClick={() => previewImage(url)}
/>
</SwiperItem>
))}
</Swiper>
) : (
<View className='goods-swiper goods-swiper--empty'>
<Text className='goods-swiper__empty-text'></Text>
</View>
)}
{/* ========== 基本信息 ========== */}
<View className='goods-card'>
<View className='goods-card__price-row'>
{product.price !== null ? (
<Text className='goods-card__price'>{product.price}</Text>
) : (
<Text className='goods-card__price goods-card__price--none'>
{loggedIn ? '价格待定' : '登录后查看价格'}
</Text>
)}
</View>
<Text className='goods-card__name'>{product.name}</Text>
<Text className='goods-card__spec'>
{formatSpec(product.spec, product.unit)}{' '}
{product.price !== null && <>
{formatRetailPrice(product.price, product.spec)} {product.price_unit}
</>}
</Text>
<View className='goods-card__meta'>
{!!product.shelf_life && product.shelf_life > 0 && (
<Text className='goods-card__tag'> {product.shelf_life} </Text>
)}
{product.stock !== null && product.stock !== undefined && (
<Text className='goods-card__tag'> {product.stock}</Text>
)}
{product.category?.name && (
<Text className='goods-card__tag'>{product.category.name}</Text>
)}
</View>
</View>
{/* ========== 图文详情 ========== */}
{!!product.content && (
<View className='goods-card'>
<Text className='goods-card__section'></Text>
<RichText className='goods-card__content' nodes={normalizeContent(product.content)} />
</View>
)}
{/* ========== 底部加购栏 ========== */}
<View className='goods-bar'>
{!loggedIn ? (
<>
<Text className='goods-bar__hint'></Text>
<View className='goods-bar__btn' onClick={goLogin}></View>
</>
) : product.price === null ? (
<Text className='goods-bar__hint'></Text>
) : (
<>
<Stepper
value={qty}
min={1}
max={99999999.99}
onChange={e => setQty(Number(e.detail))}
/>
<View
className={`goods-bar__btn ${adding ? 'disabled' : ''}`}
onClick={handleAdd}
>
{adding ? '加入中...' : '加入购物车'}
</View>
</>
)}
</View>
</View>
)
}
+48 -33
View File
@@ -5,7 +5,6 @@
background: #f7f8fa; background: #f7f8fa;
.product-search { .product-search {
padding: 16rpx 24rpx;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -17,22 +16,29 @@
// ===== 左侧分类 ===== // ===== 左侧分类 =====
.product-categories { .product-categories {
width: 180rpx; width: 200rpx;
height: 100%; height: 100%;
background: #fff; background: #fff;
flex-shrink: 0; flex-shrink: 0;
border-right: 1rpx solid #ebedf0;
} }
.category-item { .category-item {
padding: 28rpx 16rpx; padding: 30rpx 16rpx 30rpx 28rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
position: relative; position: relative;
color: #323233;
&.expanded {
color: #ee0a24;
font-weight: 500;
}
&.active { &.active {
background: #f7f8fa; background: rgba(238, 10, 36, 0.06);
color: #ee0a24; color: #ee0a24;
font-weight: 500;
&::before { &::before {
content: ''; content: '';
@@ -47,10 +53,38 @@
} }
} }
&--child {
padding: 24rpx 16rpx 24rpx 48rpx;
color: #646566;
&.active::before {
height: 24rpx;
}
}
&__name { &__name {
flex: 1;
min-width: 0;
font-size: 26rpx; font-size: 26rpx;
line-height: 1.4; line-height: 1.4;
text-align: center; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&--child &__name {
font-size: 24rpx;
}
&__arrow {
margin-left: 8rpx;
font-size: 20rpx;
color: #c8c9cc;
}
&.expanded &__arrow,
&.active &__arrow {
color: #ee0a24;
} }
} }
@@ -58,31 +92,12 @@
.product-main { .product-main {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
padding: 16rpx 20rpx 40rpx; height: 100%;
overflow-y: auto; // 底部留白避免最后一行被购物车悬浮球遮挡
padding: 20rpx 20rpx 20rpx;
box-sizing: border-box; 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 { .product-empty {
padding-top: 120rpx; padding-top: 120rpx;
} }
@@ -93,10 +108,11 @@
border-radius: 16rpx; border-radius: 16rpx;
padding: 20rpx; padding: 20rpx;
margin-bottom: 16rpx; margin-bottom: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
&__image { &__image {
width: 160rpx; width: 180rpx;
height: 160rpx; height: 180rpx;
border-radius: 12rpx; border-radius: 12rpx;
background: #f2f3f5; background: #f2f3f5;
flex-shrink: 0; flex-shrink: 0;
@@ -120,7 +136,6 @@
} }
&__spec { &__spec {
margin-top: 10rpx;
font-size: 24rpx; font-size: 24rpx;
color: #969799; color: #969799;
} }
@@ -145,8 +160,8 @@
} }
&__add { &__add {
width: 56rpx; width: 42rpx;
height: 56rpx; height: 42rpx;
border-radius: 50%; border-radius: 50%;
background: #ee0a24; background: #ee0a24;
display: flex; display: flex;
+176 -161
View File
@@ -1,12 +1,17 @@
import { useCallback, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text, Image, ScrollView } from '@tarojs/components' import { View, Text, Image, ScrollView } from '@tarojs/components'
import { Button, Empty, Popup, Search, Stepper } from '@antmjs/vantui' import { Empty, Search } from '@antmjs/vantui'
import useCartStore from '@/stores/cart/useCartStore' import useCartStore from '@/stores/cart/useCartStore'
import { getCategoriesApi, getProductListApi } from '@/services/product' import { getCategoriesApi, getProductListApi } from '@/services/product'
import type { ProductListParams } from '@/services/product'
import { getProductCover } from '@/types/product' import { getProductCover } from '@/types/product'
import type { Category, Product } from '@/types/product' import type { Category, Product, ProductCartPatch } from '@/types/product'
import CartBall from '@/components/CartBall'
import CartStepper from '@/components/CartStepper'
import {formatRetailPrice, formatSpec} from '@/utils/format'
import './index.less' import './index.less'
import CustomTabBar from "@/components/CustomTabBar";
const PAGE_SIZE = 10 const PAGE_SIZE = 10
/** 存储 key:首页点击分类/搜索跳转时经本地存储传参(switchTab 无法带参) */ /** 存储 key:首页点击分类/搜索跳转时经本地存储传参(switchTab 无法带参) */
@@ -15,13 +20,14 @@ const PENDING_KEYWORD_KEY = 'product_keyword'
export default function ProductPage() { export default function ProductPage() {
const addItem = useCartStore(s => s.addItem) const addItem = useCartStore(s => s.addItem)
const setSummary = useCartStore(s => s.setSummary)
/** 分类树 */ /** 分类树 */
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
/** 选中的顶级分类(null = 全部) */ /** 选中的分类IDnull = 全部;仅叶子分类可选:二级分类或无子分类的一级分类 */
const [activeTop, setActiveTop] = useState<number | null>(null) const [activeId, setActiveId] = useState<number | null>(null)
/** 选中的子分类(null = 顶级分类下全部 */ /** 展开的一级分类ID(纯 UI 状态:有子分类的一级分类不可选中,点击只展开/收起 */
const [activeChild, setActiveChild] = useState<number | null>(null) const [expandedTop, setExpandedTop] = useState<number | null>(null)
/** 已提交的搜索词(onSearch 才生效) */ /** 已提交的搜索词(onSearch 才生效) */
const [searchKey, setSearchKey] = useState('') const [searchKey, setSearchKey] = useState('')
/** 输入框内容 */ /** 输入框内容 */
@@ -32,53 +38,63 @@ export default function ProductPage() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false) const [finished, setFinished] = useState(false)
const listLoadingRef = useRef(false) /** 请求序号:并发时仅采用最后一次请求的结果,避免分类快速切换时旧响应覆盖新数据 */
const reqSeqRef = useRef(0)
/** 是否有请求进行中(仅用于避免"加载更多"并发) */
const loadingRef = useRef(false)
/** 加购弹层 */ /** 当前选中二级分类所属的一级分类ID(用于父级高亮) */
const [showPopup, setShowPopup] = useState(false) const activeParentId = useMemo(() => {
const [current, setCurrent] = useState<Product | null>(null) if (activeId == null) return null
const [qty, setQty] = useState(1) return categories.find(c => (c.children ?? []).some(ch => ch.id === activeId))?.id ?? null
const addingRef = useRef(false) }, [categories, activeId])
/** 选中的顶级分类的子分类 */ /** 当前生效的分类ID */
const childCategories = useMemo(() => { const effectiveCategoryId = activeId ?? undefined
const top = categories.find(c => c.id === activeTop)
return top?.children ?? []
}, [categories, activeTop])
/** 当前生效的分类ID(子分类优先) */
const effectiveCategoryId = useMemo(
() => activeChild ?? activeTop ?? undefined,
[activeChild, activeTop],
)
/** 拉取商品列表(keywordOverride 用于状态未更新时显式传本次搜索词) */ /** 拉取商品列表(keywordOverride 用于状态未更新时显式传本次搜索词) */
const fetchList = useCallback( const fetchList = useCallback(
async (pageNum: number, reset: boolean, keywordOverride?: string) => { async (pageNum: number, reset: boolean, keywordOverride?: string) => {
if (listLoadingRef.current) return if (!reset && loadingRef.current) return
listLoadingRef.current = true const seq = ++reqSeqRef.current
loadingRef.current = true
setLoading(true) setLoading(true)
try { try {
const res = await getProductListApi({ // undefined 会被序列化成字符串 "undefined" 拼进 URL,导致后端过滤为空,只传有值参数
category_id: effectiveCategoryId, const params: ProductListParams = { page: pageNum, pageSize: PAGE_SIZE }
keyword: keywordOverride ?? (searchKey || undefined), if (effectiveCategoryId != null) params.category_id = effectiveCategoryId
page: pageNum, const keyword = keywordOverride ?? (searchKey || undefined)
pageSize: PAGE_SIZE, if (keyword) params.keyword = keyword
}) const res = await getProductListApi(params)
const { data, total: totalCount } = res.data if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应
const { data, total: totalCount, cart } = res.data
setProducts(prev => (reset ? data : [...prev, ...data])) setProducts(prev => (reset ? data : [...prev, ...data]))
setPage(pageNum) setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= totalCount) setFinished(pageNum * PAGE_SIZE >= totalCount)
// 列表响应附带悬浮球汇总(旧版本后端可能未返回)
if (cart) setSummary(cart)
} catch { } catch {
// 错误已由 request 层 toast // 错误已由 request 层 toast
} finally { } finally {
listLoadingRef.current = false if (seq === reqSeqRef.current) {
setLoading(false) loadingRef.current = false
setLoading(false)
}
} }
}, },
[effectiveCategoryId, searchKey], [effectiveCategoryId, searchKey, setSummary],
) )
/** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */
const firstEffectRef = useRef(true)
useEffect(() => {
if (firstEffectRef.current) {
firstEffectRef.current = false
return
}
fetchList(1, true)
}, [fetchList])
/** 分类树(加载完成后处理首页跳转带入的分类) */ /** 分类树(加载完成后处理首页跳转带入的分类) */
const loadCategories = useCallback(async () => { const loadCategories = useCallback(async () => {
try { try {
@@ -93,14 +109,21 @@ export default function ProductPage() {
// noop // noop
} }
if (pending) { if (pending) {
const parentOfChild = res.data.find(c => (c.children ?? []).some(ch => ch.id === pending))
const top = res.data.find(c => c.id === pending) const top = res.data.find(c => c.id === pending)
const topOfChild = res.data.find(c => c.children.some(ch => ch.id === pending)) if (parentOfChild) {
if (top) { // 带入的是二级分类:展开父级并选中
setActiveTop(pending) setExpandedTop(parentOfChild.id)
setActiveChild(null) setActiveId(pending)
} else if (topOfChild) { } else if (top) {
setActiveTop(topOfChild.id) const children = top.children ?? []
setActiveChild(pending) if (children.length > 0) {
// 有子分类的一级分类不可选中:展开并默认选中第一个子分类
setExpandedTop(top.id)
setActiveId(children[0].id)
} else {
setActiveId(top.id)
}
} }
} }
} catch { } catch {
@@ -125,21 +148,30 @@ export default function ProductPage() {
fetchList(1, true, pendingKeyword ?? undefined) fetchList(1, true, pendingKeyword ?? undefined)
}) })
useReachBottom(() => { /** 右侧列表触底加载(页面为固定布局不滚动,由 ScrollView 触发) */
if (!finished && !listLoadingRef.current) { const handleLoadMore = useCallback(() => {
if (!finished) {
fetchList(page + 1, false) fetchList(page + 1, false)
} }
}) }, [finished, page, fetchList])
/** 切换顶级分类 */ /** 点击一级分类:有子分类仅展开/收起(不可选中),无子分类则选中 */
const handleTopTap = useCallback((id: number | null) => { const handleTopTap = useCallback((cat: Category) => {
setActiveTop(id) if ((cat.children ?? []).length > 0) {
setActiveChild(null) setExpandedTop(prev => (prev === cat.id ? null : cat.id))
return
}
setActiveId(cat.id)
}, []) }, [])
/** 切换子分类 */ /** 选中二级分类 */
const handleChildTap = useCallback((id: number | null) => { const handleChildTap = useCallback((id: number) => {
setActiveChild(id) setActiveId(id)
}, [])
/** 选中"全部" */
const handleAllTap = useCallback(() => {
setActiveId(null)
}, []) }, [])
/** 提交搜索 */ /** 提交搜索 */
@@ -153,27 +185,26 @@ export default function ProductPage() {
setSearchKey('') setSearchKey('')
}, []) }, [])
/** 打开加购弹层 */ /** 行内加减购确认后回写列表项的购物车字段 */
const handleAddTap = useCallback((product: Product) => { const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
setCurrent(product) setProducts(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
setQty(1)
setShowPopup(true)
}, []) }, [])
/** 确认加购 */ /** 跳转商品详情 */
const handleConfirmAdd = useCallback(async () => { const goDetail = useCallback((id: number) => {
if (!current || addingRef.current) return Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
addingRef.current = true }, [])
/** 确认加购(用返回的购物车行回写列表项,行内随即展示加减器) */
const handleConfirmAdd = useCallback(async (product: Product) => {
try { try {
await addItem(current.id, qty) const res = await addItem(product.id, 1)
Taro.showToast({ title: '已加入购物车', icon: 'success' }) handleRowSync(product.id, { cart_id: res.id, cart_quantity: res.quantity })
setShowPopup(false) // Taro.showToast({ title: '已加入购物车', icon: 'success' })
} catch { } catch {
// 错误(未设等级价/数量上限)已由 request 层 toast
} finally {
addingRef.current = false
} }
}, [current, qty, addItem]) }, [addItem, handleRowSync])
return ( return (
<View className='product-page'> <View className='product-page'>
@@ -183,7 +214,6 @@ export default function ProductPage() {
value={inputKey} value={inputKey}
placeholder='搜索品名/规格' placeholder='搜索品名/规格'
shape='round' shape='round'
background='#f7f8fa'
onChange={e => setInputKey(String(e.detail))} onChange={e => setInputKey(String(e.detail))}
onSearch={handleSearch} onSearch={handleSearch}
onClear={handleClear} onClear={handleClear}
@@ -191,54 +221,66 @@ export default function ProductPage() {
</View> </View>
<View className='product-body'> <View className='product-body'>
{/* ========== 左侧分类 ========== */} {/* ========== 左侧分类(一级为分组,叶子分类可选) ========== */}
<ScrollView scrollY className='product-categories'> <ScrollView scrollY className='product-categories'>
<View <View
className={`category-item ${activeTop === null ? 'active' : ''}`} className={`category-item ${activeId === null ? 'active' : ''}`}
onClick={() => handleTopTap(null)} onClick={handleAllTap}
> >
<Text className='category-item__name'></Text> <Text className='category-item__name'></Text>
</View> </View>
{categories.map(cat => ( {categories.map(cat => {
<View const children = cat.children ?? []
key={cat.id} const hasChildren = children.length > 0
className={`category-item ${activeTop === cat.id ? 'active' : ''}`} return (
onClick={() => handleTopTap(cat.id)} <View key={cat.id}>
> <View
<Text className='category-item__name'>{cat.name}</Text> className={[
</View> 'category-item',
))} // 有子分类:子分类被选中时父级高亮;无子分类:自身可选中
hasChildren && activeParentId === cat.id && 'expanded',
!hasChildren && activeId === cat.id && 'active',
].filter(Boolean).join(' ')}
onClick={() => handleTopTap(cat)}
>
<Text className='category-item__name'>{cat.name}</Text>
{hasChildren && (
<Text className='category-item__arrow'>
{expandedTop === cat.id ? '▾' : '▸'}
</Text>
)}
</View>
{/* 二级分类 */}
{expandedTop === cat.id &&
children.map(child => (
<View
key={child.id}
className={`category-item category-item--child ${
activeId === child.id ? 'active' : ''
}`}
onClick={() => handleChildTap(child.id)}
>
<Text className='category-item__name'>{child.name}</Text>
</View>
))}
</View>
)
})}
</ScrollView> </ScrollView>
{/* ========== 右侧商品列表 ========== */} {/* ========== 右侧商品列表ScrollView 滚动 + 触底加载) ========== */}
<View className='product-main'> <ScrollView
{/* 子分类 chips */} scrollY
{childCategories.length > 0 && ( className='product-main'
<ScrollView scrollX className='child-scroll'> lowerThreshold={80}
<View onScrollToLower={handleLoadMore}
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 ? ( {products.length === 0 && !loading ? (
<Empty description='暂无商品' className='product-empty' /> <Empty description='暂无商品' className='product-empty' />
) : ( ) : (
products.map(product => ( products.map(product => (
<View key={product.id} className='product-item'> <View key={product.id} className='product-item' onClick={() => goDetail(product.id)}>
<Image <Image
className='product-item__image' className='product-item__image'
src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'} src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
@@ -247,16 +289,34 @@ export default function ProductPage() {
/> />
<View className='product-item__info'> <View className='product-item__info'>
<Text className='product-item__name'>{product.name}</Text> <Text className='product-item__name'>{product.name}</Text>
<Text className='product-item__spec'>{product.spec} / {product.unit}</Text> <Text className='product-item__spec'>
{formatSpec(product.spec, product.unit)}{' '}
<View>
{product.price !== null && <>
{formatRetailPrice(product.price, product.spec)} {product.price_unit}
</>}
</View>
</Text>
<View className='product-item__bottom'> <View className='product-item__bottom'>
{product.price !== null ? ( {product.price !== null ? (
<Text className='product-item__price'>{product.price}</Text> <Text className='product-item__price'>{product.price}</Text>
) : ( ) : (
<Text className='product-item__price product-item__price--none'></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> {Number(product.cart_quantity ?? 0) > 0 ? (
</View> <CartStepper product={product} onSync={handleRowSync} />
) : (
<View
className='product-item__add'
onClick={e => {
e.stopPropagation()
handleConfirmAdd(product)
}}
>
<Text className='product-item__add-icon'></Text>
</View>
)}
</View> </View>
</View> </View>
</View> </View>
@@ -268,58 +328,13 @@ export default function ProductPage() {
{finished && products.length > 0 && ( {finished && products.length > 0 && (
<View className='product-loading'><Text></Text></View> <View className='product-loading'><Text></Text></View>
)} )}
</View> <View style={{ height: 68 }}></View>
</ScrollView>
</View> </View>
{/* ========== 加购弹层 ========== */} {/* ========== 购物车悬浮球 ========== */}
<Popup <CartBall />
show={showPopup} {process.env.TARO_ENV === 'h5' && <CustomTabBar />}
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>
) )
} }
+111
View File
@@ -144,6 +144,117 @@
} }
} }
// ===== 运营报表入口 =====
.report-entry {
margin-top: 12rpx;
border-top: 1rpx solid #f2f3f5;
padding-top: 20rpx;
display: flex;
align-items: center;
&__icon {
width: 84rpx;
height: 84rpx;
border-radius: 24rpx;
background: #fff0f0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__info {
flex: 1;
min-width: 0;
margin-left: 20rpx;
display: flex;
flex-direction: column;
}
&__title {
font-size: 28rpx;
color: #323233;
}
&__desc {
margin-top: 6rpx;
font-size: 22rpx;
color: #969799;
}
&__arrow {
font-size: 32rpx;
color: #c8c9cc;
line-height: 1;
margin-left: 12rpx;
flex-shrink: 0;
}
}
// ===== 账单入口 =====
.bill-entry {
display: flex;
align-items: center;
&__icon {
width: 84rpx;
height: 84rpx;
border-radius: 24rpx;
background: #fff0f0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__info {
flex: 1;
min-width: 0;
margin-left: 20rpx;
display: flex;
flex-direction: column;
}
&__amount {
font-size: 36rpx;
font-weight: 600;
color: #ee0a24;
}
&__title {
font-size: 28rpx;
color: #323233;
}
&__desc {
margin-top: 6rpx;
font-size: 22rpx;
color: #969799;
}
&__badge {
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 22rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__arrow {
font-size: 32rpx;
color: #c8c9cc;
line-height: 1;
margin-left: 12rpx;
flex-shrink: 0;
}
}
// ===== 功能菜单 ===== // ===== 功能菜单 =====
.menu-cell { .menu-cell {
display: flex; display: flex;
+86 -42
View File
@@ -1,28 +1,18 @@
import { useCallback, useMemo } from 'react' import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components' import { View, Text, Image } from '@tarojs/components'
import { Icon } from '@antmjs/vantui' import { Icon } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore' import useAuthStore from '@/stores/auth/useAuthStore'
import { getUserInfoApi } from '@/services/auth' import { getUserInfoApi } from '@/services/auth'
import { getBillListApi } from '@/services/bill'
import type { BillSummary } from '@/services/bill'
import { ORDER_NAV_ITEMS } from '@/types/order' import { ORDER_NAV_ITEMS } from '@/types/order'
import { resolveAvatarUrl } from '@/utils/format' import { resolveAvatarUrl } from '@/utils/format'
import type { UserType } from '@/types/user'
import './index.less' import './index.less'
import CustomTabBar from "@/components/CustomTabBar";
/** 菜单项(后续单独页面开发时在此追加) */ /** 菜单项(订单/账单入口已由上方专区承载,后续单独页面开发时在此追加) */
const MENU_ITEMS = [ const MENU_ITEMS = [
{
key: 'orders',
label: '所有订单',
icon: 'orders-o',
onClick: () => Taro.navigateTo({ url: '/pages/order-list/index?status=all' }),
},
{
key: 'statement',
label: '对账单',
icon: 'balance-list-o',
onClick: () => Taro.navigateTo({ url: '/pages/statement/index' }),
},
{ {
key: 'message', key: 'message',
label: '消息中心', label: '消息中心',
@@ -43,11 +33,14 @@ export default function ProfilePage() {
const setUser = useAuthStore(s => s.setUser) const setUser = useAuthStore(s => s.setUser)
const logout = useAuthStore(s => s.logout) const logout = useAuthStore(s => s.logout)
/** 账单待支付汇总(门店口径,含审核中) */
const [billSummary, setBillSummary] = useState<BillSummary | null>(null)
const loggedIn = !!token && !!user const loggedIn = !!token && !!user
/** 功能菜单(门店账号追加「门店信息」入口 */ /** 功能菜单(登录门店可用:门店信息 / 支付记录 / 修改密码 */
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
if (!user?.store) return MENU_ITEMS if (!loggedIn) return MENU_ITEMS
return [ return [
{ {
key: 'store-info', key: 'store-info',
@@ -55,9 +48,21 @@ export default function ProfilePage() {
icon: 'shop-o', icon: 'shop-o',
onClick: () => Taro.navigateTo({ url: '/pages/store-info/index' }), onClick: () => Taro.navigateTo({ url: '/pages/store-info/index' }),
}, },
{
key: 'payment-records',
label: '支付记录',
icon: 'balance-o',
onClick: () => Taro.navigateTo({ url: '/pages/payment-records/index' }),
},
{
key: 'change-password',
label: '修改密码',
icon: 'lock',
onClick: () => Taro.navigateTo({ url: '/pages/change-password/index' }),
},
...MENU_ITEMS, ...MENU_ITEMS,
] ]
}, [user?.store]) }, [loggedIn])
useDidShow(() => { useDidShow(() => {
if (!loggedIn) return if (!loggedIn) return
@@ -65,20 +70,27 @@ export default function ProfilePage() {
getUserInfoApi() getUserInfoApi()
.then(res => setUser(res.data)) .then(res => setUser(res.data))
.catch(() => {}) .catch(() => {})
// 刷新账单待支付汇总(pageSize=1 仅取 summary
getBillListApi({ page: 1, pageSize: 1 })
.then(res => setBillSummary(res.data.summary))
.catch(() => {})
}) })
/** 身份标签 */
const getTypeLabel = useCallback((type: UserType): string => {
if (type === 1) return '门店'
if (type === 2) return '供应商'
return '待绑定'
}, [])
/** 订单总汇 → 订单列表页(按状态) */ /** 订单总汇 → 订单列表页(按状态) */
const handleOrderNav = useCallback((status?: number) => { const handleOrderNav = useCallback((status?: number) => {
Taro.navigateTo({ url: `/pages/order-list/index?status=${status ?? 'all'}` }) Taro.navigateTo({ url: `/pages/order-list/index?status=${status ?? 'all'}` })
}, []) }, [])
/** 运营报表入口 → 运营报表页 */
const goReport = useCallback(() => {
Taro.navigateTo({ url: '/pages/report/index' })
}, [])
/** 账单入口 → 账单列表页 */
const goBill = useCallback(() => {
Taro.navigateTo({ url: '/pages/bill/index' })
}, [])
/** 退出登录 */ /** 退出登录 */
const handleLogout = useCallback(() => { const handleLogout = useCallback(() => {
Taro.showModal({ Taro.showModal({
@@ -122,29 +134,18 @@ export default function ProfilePage() {
/> />
) : ( ) : (
<View className='profile-card__avatar profile-card__avatar--text'> <View className='profile-card__avatar profile-card__avatar--text'>
{user?.nickname?.[0] || ''} {user?.name?.[0] || ''}
</View> </View>
)} )}
<View className='profile-card__info'> <View className='profile-card__info'>
<Text className='profile-card__name'>{user?.nickname}</Text> <Text className='profile-card__name'>{user?.name}</Text>
<Text className='profile-card__desc'>{user?.phone || '未绑定手机号'}</Text> <Text className='profile-card__desc'>{user?.phone || '未设置联系电话'}</Text>
</View> </View>
{user?.type === 0 && (
<View className='profile-card__btn' onClick={goLogin}></View>
)}
</View> </View>
<View className='profile-card__identity'> <View className='profile-card__identity'>
{user?.store ? ( <Text className='profile-card__tag'> · {user?.code}</Text>
<> {user?.level && (
<Text className='profile-card__tag'> · {user.store.name}</Text> <Text className='profile-card__tag profile-card__tag--level'>{user.level.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'>{getTypeLabel(user?.type ?? 0)}</Text>
)} )}
</View> </View>
</> </>
@@ -167,6 +168,47 @@ export default function ProfilePage() {
</View> </View>
))} ))}
</View> </View>
{/* 运营报表入口 */}
<View className='report-entry' onClick={goReport}>
<View className='report-entry__icon'>
<Icon name='bar-chart-o' size={28} color='#ee0a24' />
</View>
<View className='report-entry__info'>
<Text className='report-entry__title'></Text>
<Text className='report-entry__desc'>/</Text>
</View>
<Text className='report-entry__arrow'></Text>
</View>
</View>
{/* ========== 我的账单 ========== */}
<View className='profile-section'>
<View className='profile-section__header'>
<Text className='profile-section__title'></Text>
<Text className='profile-section__more' onClick={goBill}> </Text>
</View>
<View className='bill-entry' onClick={goBill}>
<View className='bill-entry__icon'>
<Icon name='balance-list-o' size={28} color='#ee0a24' />
</View>
{loggedIn && billSummary && billSummary.unpaid_count > 0 ? (
<>
<View className='bill-entry__info'>
<Text className='bill-entry__amount'>{billSummary.unpaid_amount}</Text>
<Text className='bill-entry__desc'> {billSummary.unpaid_count} </Text>
</View>
<View className='bill-entry__badge'>{billSummary.unpaid_count}</View>
</>
) : (
<View className='bill-entry__info'>
<Text className='bill-entry__title'>
{loggedIn ? '暂无待支付账单' : '登录后查看账单'}
</Text>
<Text className='bill-entry__desc'></Text>
</View>
)}
<Text className='bill-entry__arrow'></Text>
</View>
</View> </View>
{/* ========== 功能菜单 ========== */} {/* ========== 功能菜单 ========== */}
@@ -188,6 +230,8 @@ export default function ProfilePage() {
<Text>退</Text> <Text>退</Text>
</View> </View>
)} )}
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View> </View>
) )
} }
-196
View File
@@ -1,196 +0,0 @@
/* ========================================
注册页面
======================================== */
.register-page {
min-height: 100vh;
background: #fff;
}
/* ========== 内容区域 ========== */
.register-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 60px 0;
}
/* ========== 品牌区域 ========== */
.register-brand {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 60px;
.logo-wrapper {
width: 160px;
height: 160px;
border-radius: 50%;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(25, 137, 250, 0.3);
}
.logo-text {
font-size: 80px;
color: #fff;
font-weight: 700;
}
.app-name {
font-size: 44px;
font-weight: 600;
color: #323233;
margin-bottom: 12px;
}
.app-slogan {
font-size: 28px;
color: #969799;
}
}
/* ========== 注册表单 ========== */
.register-form {
width: 100%;
background: #f7f8fa;
border-radius: 24px;
padding: 0 32px;
margin-bottom: 80px;
}
.form-item {
display: flex;
align-items: center;
height: 112px;
border-bottom: 1px solid #ebedf0;
&:last-child {
border-bottom: none;
}
}
.form-label {
width: 160px;
font-size: 30px;
color: #323233;
flex-shrink: 0;
}
.form-input {
flex: 1;
height: 100%;
font-size: 30px;
color: #323233;
}
.form-input-placeholder {
color: #c8c9cc;
}
/* 手机号已授权状态 */
.form-phone-ok {
flex: 1;
display: flex;
align-items: center;
.form-phone-ok__text {
font-size: 30px;
color: #07c160;
}
}
/* 手机号授权按钮(微信原生 Button 需重置样式) */
.phone-auth-btn {
flex: 1;
height: 64px;
line-height: 64px;
background: #e8f7ef;
color: #07c160;
font-size: 28px;
font-weight: 500;
border: none;
border-radius: 32px;
text-align: center;
padding: 0 32px;
/* 重置微信 Button 默认样式 */
&::after {
border: none;
}
}
/* ========== 注册操作区 ========== */
.register-actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.register-btn {
width: 100%;
height: 96px;
line-height: 96px;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
color: #fff;
font-size: 34px;
font-weight: 500;
border: none;
border-radius: 48px;
text-align: center;
padding: 0;
box-shadow: 0 6px 24px rgba(25, 137, 250, 0.35);
transition: opacity 0.2s;
/* 重置微信 Button 默认样式 */
&::after {
border: none;
}
}
.register-btn--loading {
opacity: 0.75;
}
/* ========== 去登录入口 ========== */
.register-switch {
display: flex;
align-items: center;
justify-content: center;
margin-top: 32px;
.switch-text {
font-size: 28px;
color: #969799;
}
.switch-link {
font-size: 28px;
color: #1989fa;
margin-left: 8px;
}
}
/* ========== 协议文字 ========== */
.register-agreement {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
margin-top: 32px;
line-height: 1.6;
.agree-text {
font-size: 24px;
color: #c8c9cc;
}
.agree-link {
font-size: 24px;
color: #1989fa;
}
}
-222
View File
@@ -1,222 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Button, Input } from '@tarojs/components'
import CustomNavBar from '@/components/NavBar'
import useAuthStore from '@/stores/auth/useAuthStore'
import './index.less'
export default function RegisterPage() {
const register = useAuthStore(s => s.register)
const isLoggedIn = useAuthStore(s => !!s.token && !!s.user)
/** 门店编码(后台门店管理维护) */
const [storeCode, setStoreCode] = useState('')
/** 微信手机号授权得到的 code */
const [phoneCode, setPhoneCode] = useState('')
const [submitting, setSubmitting] = useState(false)
const isWeb = Taro.getEnv() === Taro.ENV_TYPE.WEB
/** 返回上一页(无页面栈时回首页) */
const goBack = useCallback(() => {
const pages = Taro.getCurrentPages()
if (pages.length > 1) {
Taro.navigateBack()
} else {
Taro.switchTab({ url: '/pages/index/index' })
}
}, [])
/** 前往登录页 */
const goLogin = useCallback(() => {
const pages = Taro.getCurrentPages()
if (pages.length > 1) {
Taro.navigateBack()
} else {
Taro.navigateTo({ url: '/pages/login/index' })
}
}, [])
/** 已注册成功(登录态就绪)→ 自动返回 */
useEffect(() => {
if (isLoggedIn) goBack()
}, [isLoggedIn, goBack])
/** 发起注册:wx.login 换 code → POST /mini/auth/register */
const doRegister = useCallback(
async (phoneCodeValue: string) => {
if (submitting) return
if (isWeb) {
Taro.showToast({ title: '请在微信小程序中注册', icon: 'none' })
return
}
const code = storeCode.trim()
if (!code) {
Taro.showToast({ title: '请填写门店编码', icon: 'none' })
return
}
setSubmitting(true)
try {
const res = await Taro.login()
if (!res.code) {
Taro.showToast({ title: '获取登录凭证失败', icon: 'none' })
return
}
await register({ code: res.code, phoneCode: phoneCodeValue, storeCode: code })
// 注册成功后由 effect 自动返回
} catch (e: any) {
// 该微信已注册:引导前往登录(其余错误已由 request 层提示)
if (typeof e?.msg === 'string' && e.msg.includes('已经注册')) {
Taro.showModal({
title: '提示',
content: '该微信已经注册,请直接登录',
confirmText: '去登录',
cancelText: '取消',
success: res => {
if (res.confirm) goLogin()
},
})
}
} finally {
setSubmitting(false)
}
},
[register, submitting, isWeb, storeCode, goLogin],
)
/** 微信手机号授权(openType getPhoneNumber */
const handleGetPhoneNumber = useCallback(
(e: any) => {
if (isWeb) {
Taro.showToast({ title: '请在微信小程序中授权手机号', icon: 'none' })
return
}
const detail = e.detail || {}
// 用户拒绝授权
if (detail.errMsg && !detail.errMsg.includes(':ok')) {
Taro.showToast({ title: '需要授权手机号才能注册', icon: 'none' })
return
}
if (!detail.code) {
Taro.showToast({ title: '未获取到手机号授权凭证', icon: 'none' })
return
}
setPhoneCode(detail.code)
if (storeCode.trim()) {
// 门店编码已填 → 直接发起注册
doRegister(detail.code)
} else {
Taro.showToast({ title: '手机号已授权,请填写门店编码', icon: 'none' })
}
},
[isWeb, storeCode, doRegister],
)
/** 点击注册按钮(门店编码已填 + 手机号已授权) */
const handleSubmit = useCallback(() => {
if (!phoneCode) {
Taro.showToast({ title: '请先授权手机号', icon: 'none' })
return
}
if (!storeCode.trim()) {
Taro.showToast({ title: '请填写门店编码', icon: 'none' })
return
}
doRegister(phoneCode)
}, [phoneCode, storeCode, doRegister])
/** 查看用户协议 */
const handleShowAgreement = useCallback(() => {
Taro.showToast({ title: '用户协议即将上线', icon: 'none' })
}, [])
/** 查看隐私政策 */
const handleShowPrivacy = useCallback(() => {
Taro.showToast({ title: '隐私政策即将上线', icon: 'none' })
}, [])
return (
<View className='register-page'>
{/* ========== 导航栏 ========== */}
<CustomNavBar title='注册' />
{/* ========== 内容区域 ========== */}
<View className='register-content'>
{/* 品牌区域 */}
<View className='register-brand'>
<View className='logo-wrapper'>
<Text className='logo-text'></Text>
</View>
<Text className='app-name'></Text>
<Text className='app-slogan'></Text>
</View>
{/* 注册表单 */}
<View className='register-form'>
{/* 门店编码 */}
<View className='form-item'>
<Text className='form-label'></Text>
<Input
className='form-input'
type='text'
value={storeCode}
placeholder='请输入门店编码(门店管理员提供)'
placeholderClass='form-input-placeholder'
onInput={e => setStoreCode(e.detail.value)}
/>
</View>
{/* 手机号授权 */}
<View className='form-item'>
<Text className='form-label'></Text>
{phoneCode ? (
<View className='form-phone-ok'>
<Text className='form-phone-ok__text'></Text>
</View>
) : (
<Button
className='phone-auth-btn'
openType='getPhoneNumber'
onGetPhoneNumber={handleGetPhoneNumber}
>
</Button>
)}
</View>
</View>
{/* 注册操作 */}
<View className='register-actions'>
<Button
className={`register-btn ${submitting ? 'register-btn--loading' : ''}`}
onClick={handleSubmit}
loading={submitting}
disabled={submitting}
>
{submitting ? '注册中...' : '注 册'}
</Button>
{/* 已注册用户入口 */}
<View className='register-switch' onClick={goLogin}>
<Text className='switch-text'></Text>
<Text className='switch-link'></Text>
</View>
<View className='register-agreement'>
<Text className='agree-text'></Text>
<Text className='agree-link' onClick={handleShowAgreement}>
</Text>
<Text className='agree-text'></Text>
<Text className='agree-link' onClick={handleShowPrivacy}>
</Text>
</View>
</View>
</View>
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '运营报表',
})
+222
View File
@@ -0,0 +1,222 @@
.report-page {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
// ===== 周期切换 =====
.period-bar {
display: flex;
gap: 16rpx;
}
.period-chip {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 14rpx 0;
border-radius: 999rpx;
background: #fff;
font-size: 26rpx;
color: #323233;
&.active {
background: #ee0a24;
color: #fff;
font-weight: 600;
}
}
// ===== 汇总卡片 =====
.report-summary {
margin-top: 20rpx;
background: linear-gradient(135deg, #ee0a24, #ff4d4f);
border-radius: 20rpx;
padding: 32rpx 28rpx;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
&__label {
font-size: 24rpx;
opacity: 0.85;
}
&__amount {
margin-top: 12rpx;
font-size: 56rpx;
font-weight: 700;
line-height: 1.2;
}
&__range {
margin-top: 12rpx;
font-size: 22rpx;
opacity: 0.85;
}
&__meta {
margin-top: 28rpx;
width: 100%;
display: flex;
border-top: 1rpx solid rgba(255, 255, 255, 0.25);
padding-top: 24rpx;
}
&__meta-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
}
&__meta-value {
font-size: 34rpx;
font-weight: 600;
}
&__meta-label {
margin-top: 6rpx;
font-size: 22rpx;
opacity: 0.85;
}
}
// ===== 单品排行 =====
.report-list {
margin-top: 20rpx;
background: #fff;
border-radius: 20rpx;
padding: 24rpx 28rpx 8rpx;
&__header {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8rpx;
}
&__title {
font-size: 30rpx;
font-weight: 600;
}
&__desc {
font-size: 22rpx;
color: #969799;
}
}
.report-item {
display: flex;
padding: 24rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
&__rank {
width: 44rpx;
height: 44rpx;
border-radius: 12rpx;
background: #f2f3f5;
color: #969799;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 4rpx;
&--top {
background: #fff0f0;
color: #ee0a24;
font-weight: 600;
}
}
&__main {
flex: 1;
min-width: 0;
margin-left: 20rpx;
}
&__row {
display: flex;
align-items: center;
justify-content: space-between;
}
&__name {
font-size: 28rpx;
color: #323233;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__amount {
font-size: 30rpx;
color: #ee0a24;
font-weight: 600;
margin-left: 16rpx;
flex-shrink: 0;
}
&__spec {
margin-top: 8rpx;
font-size: 22rpx;
color: #969799;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__percent {
margin-top: 8rpx;
font-size: 24rpx;
color: #323233;
margin-left: 16rpx;
flex-shrink: 0;
}
&__bar {
margin-top: 14rpx;
height: 8rpx;
border-radius: 999rpx;
background: #f2f3f5;
overflow: hidden;
}
&__bar-inner {
height: 100%;
border-radius: 999rpx;
background: linear-gradient(90deg, #ff8a8f, #ee0a24);
}
}
// ===== 空态 / 加载中 =====
.report-empty {
margin-top: 60rpx;
&__btn {
margin-top: 20rpx;
font-size: 26rpx;
padding: 14rpx 48rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
}
}
.report-loading {
padding: 60rpx 0;
text-align: center;
font-size: 26rpx;
color: #969799;
}
}
+225
View File
@@ -0,0 +1,225 @@
import { useCallback, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Calendar, Empty } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { getPurchaseReportApi } from '@/services/report'
import type { PurchaseReport, PurchaseReportParams, ReportPreset } from '@/services/report'
import './index.less'
/** 周期选项 key(custom 为前端伪预设:选中自定义区间后生效) */
type PeriodKey = ReportPreset
/** 周期切换 chips */
const PERIOD_TABS: Array<{ key: PeriodKey; label: string }> = [
{ key: 'week', label: '本周' },
{ key: 'last_week', label: '上周' },
{ key: 'month', label: '本月' },
{ key: 'last_month', label: '上月' },
{ key: 'custom', label: '自定义' },
]
/** 自定义区间可选范围:2020-01-01 ~ 今天(进行中的周期由后端封顶今天) */
const MIN_DATE = new Date(2020, 0, 1).getTime()
const MAX_DATE = Date.now()
/** Date → Y-m-d */
function formatDate(d: Date): string {
const m = `${d.getMonth() + 1}`.padStart(2, '0')
const day = `${d.getDate()}`.padStart(2, '0')
return `${d.getFullYear()}-${m}-${day}`
}
/**
* 运营报表页
* 按周期(本周/上周/本月/上月/自定义区间)统计门店采购总金额与单品累计金额占比;
* 数据为下单快照口径,已取消/已删除订单不计入
*/
export default function ReportPage() {
const token = useAuthStore(s => s.token)
const [period, setPeriod] = useState<PeriodKey>('month')
/** 自定义区间(period=custom 时使用) */
const [range, setRange] = useState<{ start: string; end: string } | null>(null)
const [report, setReport] = useState<PurchaseReport | null>(null)
const [loading, setLoading] = useState(false)
const [showCalendar, setShowCalendar] = useState(false)
const loadingRef = useRef(false)
const loggedIn = !!token
/** 拉取报表 */
const load = useCallback(
async (params: PurchaseReportParams) => {
if (!loggedIn || loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const res = await getPurchaseReportApi(params)
setReport(res.data)
} catch {
// 错误已由 request 层 toast
} finally {
loadingRef.current = false
setLoading(false)
}
},
[loggedIn],
)
useDidShow(() => {
load(
period === 'custom' && range
? { start_date: range.start, end_date: range.end }
: { preset: period === 'custom' ? 'month' : period },
)
})
/** 切换周期;自定义打开日历选择区间 */
const handlePeriodTap = useCallback(
(key: PeriodKey) => {
if (key === 'custom') {
setShowCalendar(true)
return
}
if (key === period) return
setPeriod(key)
load({ preset: key })
},
[period, load],
)
/** 日历确认区间 → 自定义区间查询(优先于 preset) */
const handleCalendarConfirm = useCallback(
(e: { detail: { value: Date | Date[] } }) => {
const value = Array.isArray(e.detail.value) ? e.detail.value : [e.detail.value]
const [start, end] = value
if (!start || !end) return
const next = { start: formatDate(start), end: formatDate(end) }
setRange(next)
setPeriod('custom')
setShowCalendar(false)
load({ start_date: next.start, end_date: next.end })
},
[load],
)
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
return (
<View className='report-page'>
{/* ========== 周期切换 ========== */}
<View className='period-bar'>
{PERIOD_TABS.map(tab => (
<View
key={tab.key}
className={`period-chip ${period === tab.key ? 'active' : ''}`}
onClick={() => handlePeriodTap(tab.key)}
>
<Text>{tab.label}</Text>
</View>
))}
</View>
{!loggedIn ? (
<Empty description='登录后查看运营报表' className='report-empty'>
<View className='report-empty__btn' onClick={goLogin}></View>
</Empty>
) : (
<>
{/* ========== 汇总卡片 ========== */}
{report && (
<View className='report-summary'>
<Text className='report-summary__label'></Text>
<Text className='report-summary__amount'>{report.total_amount}</Text>
<Text className='report-summary__range'>
{report.start_date} ~ {report.end_date}
</Text>
<View className='report-summary__meta'>
<View className='report-summary__meta-item'>
<Text className='report-summary__meta-value'>{report.order_count}</Text>
<Text className='report-summary__meta-label'></Text>
</View>
<View className='report-summary__meta-item'>
<Text className='report-summary__meta-value'>{report.item_count}</Text>
<Text className='report-summary__meta-label'></Text>
</View>
<View className='report-summary__meta-item'>
<Text className='report-summary__meta-value'>{report.total_quantity}</Text>
<Text className='report-summary__meta-label'></Text>
</View>
</View>
</View>
)}
{/* ========== 单品排行 ========== */}
{report && report.items.length > 0 && (
<View className='report-list'>
<View className='report-list__header'>
<Text className='report-list__title'></Text>
<Text className='report-list__desc'> {report.item_count} </Text>
</View>
{report.items.map((item, index) => (
<View key={item.product_id} className='report-item'>
<View className={`report-item__rank ${index < 3 ? 'report-item__rank--top' : ''}`}>
{index + 1}
</View>
<View className='report-item__main'>
<View className='report-item__row'>
<Text className='report-item__name'>{item.product_name}</Text>
<Text className='report-item__amount'>{item.amount}</Text>
</View>
<View className='report-item__row'>
<Text className='report-item__spec'>
{item.product_spec ? `${item.product_spec}` : ''}{item.unit}
</Text>
</View>
<View className='report-item__row'>
<Text className='report-item__spec'>
{item.quantity}
{parseFloat(item.weight) > 0 ? ` · 重量 ${item.weight}` : ''}
</Text>
<Text className='report-item__percent'>{item.percent}%</Text>
</View>
<View className='report-item__bar'>
<View
className='report-item__bar-inner'
style={{ width: `${Math.min(Math.max(item.percent, 0), 100)}%` }}
/>
</View>
</View>
</View>
))}
</View>
)}
{/* ========== 空态 / 加载中 ========== */}
{(!report || report.items.length === 0) && (
loading ? (
<View className='report-loading'><Text>...</Text></View>
) : (
<Empty description='该时间段暂无采购数据' className='report-empty' />
)
)}
</>
)}
{/* ========== 自定义区间日历 ========== */}
<Calendar
show={showCalendar}
type='range'
allowSameDay
firstDayOfWeek={1}
minDate={MIN_DATE}
maxDate={MAX_DATE}
color='#ee0a24'
title='选择统计区间'
defaultDate={range ? [new Date(range.start).getTime(), new Date(range.end).getTime()] : undefined}
onClose={() => setShowCalendar(false)}
onConfirm={handleCalendarConfirm}
/>
</View>
)
}
+3 -3
View File
@@ -21,11 +21,11 @@ export default function SettingsPage() {
<Text className='setting-cell__label'></Text> <Text className='setting-cell__label'></Text>
<Text className='setting-cell__value'></Text> <Text className='setting-cell__value'></Text>
</View> </View>
<View className='setting-cell' onClick={() => handlePlaceholder('用户协议')}> <View className='setting-cell' onClick={() => Taro.navigateTo({ url: '/pages/agreement/index' })}>
<Text className='setting-cell__label'></Text> <Text className='setting-cell__label'></Text>
<Text className='setting-cell__value'></Text> <Text className='setting-cell__value'></Text>
</View> </View>
<View className='setting-cell' onClick={() => handlePlaceholder('隐私政策')}> <View className='setting-cell' onClick={() => Taro.navigateTo({ url: '/pages/privacy/index' })}>
<Text className='setting-cell__label'></Text> <Text className='setting-cell__label'></Text>
<Text className='setting-cell__value'></Text> <Text className='setting-cell__value'></Text>
</View> </View>
-3
View File
@@ -1,3 +0,0 @@
export default definePageConfig({
navigationBarTitleText: '对账单',
})
-180
View File
@@ -1,180 +0,0 @@
import { useCallback, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, Picker } from '@tarojs/components'
import { Button, Empty, Popup } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { generateStatementApi, getStatementListApi } from '@/services/statement'
import { STATEMENT_STATUS_MAP } from '@/types/statement'
import type { Statement } from '@/types/statement'
import './index.less'
const 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 StatementPage() {
const token = useAuthStore(s => s.token)
const [statements, setStatements] = useState<Statement[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const loadingRef = useRef(false)
/** 生成弹层 */
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 loggedIn = !!token
/** 拉取对账单列表 */
const loadList = useCallback(
async (pageNum: number, reset: boolean) => {
if (!loggedIn || loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const res = await getStatementListApi({ page: pageNum, pageSize: PAGE_SIZE })
const { data, total } = res.data
setStatements(prev => (reset ? data : [...prev, ...data]))
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= total)
} catch {
// 错误已由 request 层 toast
} finally {
loadingRef.current = false
setLoading(false)
}
},
[loggedIn],
)
useDidShow(() => {
loadList(1, true)
})
useReachBottom(() => {
if (!finished && !loadingRef.current && loggedIn) {
loadList(page + 1, false)
}
})
/** 生成对账单 */
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)
loadList(1, true)
} catch {
// 错误(周期内无订单等)已由 request 层 toast
} finally {
setGenLoading(false)
}
}, [genStart, genEnd, genLoading, loadList])
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
return (
<View className='statement-page'>
{/* ========== 头部 ========== */}
<View className='statement-header'>
<Text className='statement-header__title'></Text>
<View className='statement-header__btn' onClick={() => setShowGen(true)}>
<Text></Text>
</View>
</View>
{/* ========== 列表 ========== */}
{!loggedIn ? (
<Empty description='登录后查看对账单' className='statement-empty'>
<View className='statement-empty__btn' onClick={goLogin}></View>
</Empty>
) : statements.length === 0 ? (
loading ? (
<View className='statement-loading'><Text>...</Text></View>
) : (
<Empty description='暂无对账单' className='statement-empty' />
)
) : (
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>
))
)}
{loggedIn && finished && statements.length > 0 && (
<View className='statement-loading'><Text></Text></View>
)}
{/* ========== 生成弹层 ========== */}
<Popup
show={showGen}
position='bottom'
round
closeable
closeOnClickOverlay
safeAreaInsetBottom
onClose={() => setShowGen(false)}
>
<View className='gen-popup'>
<Text className='gen-popup__title'></Text>
<Text className='gen-popup__desc'></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>
</View>
)
}
+27 -24
View File
@@ -1,39 +1,42 @@
import { get, post } from '@/utils/request' import { get, post, put } from '@/utils/request'
import type { User } from '@/types/user' import type { User } from '@/types/user'
/** 微信登录参数 */ /** 账号密码登录参数 */
export interface WxLoginParams { export interface LoginParams {
/** wx.login 的临时凭证 */ /** 登录账号(商家后台分配,4~20 位) */
code: string username: string
/** 登录密码 */
password: string
} }
/** 微信注册参数 */ /** 登录返回 */
export interface RegisterParams {
/** wx.login 的临时凭证 */
code: string
/** wx.getPhoneNumber 授权得到的 code */
phoneCode: string
/** 门店编码(后台门店管理维护) */
storeCode: string
}
/** 登录 / 注册返回 */
export interface AuthResult { export interface AuthResult {
token: string token: string
/** 门店即用户(扁平结构) */
user: User user: User
} }
/** 微信登录(仅已注册用户可登录):POST /mini/auth/login */ /** 修改密码参数 */
export function wxLoginApi(params: WxLoginParams) { export interface ChangePasswordParams {
/** 原密码 */
oldPassword: string
/** 新密码(6~20 位) */
newPassword: string
/** 确认新密码(须与 newPassword 一致) */
rePassword: string
}
/** 账号密码登录:POST /mini/auth/login */
export function loginApi(params: LoginParams) {
return post<AuthResult>('/mini/auth/login', params) return post<AuthResult>('/mini/auth/login', params)
} }
/** 微信注册(code 换 openid + phoneCode 换手机号 + storeCode 绑定门店):POST /mini/auth/register */ /** 当前门店信息(含客户等级):GET /mini/auth/info */
export function registerApi(params: RegisterParams) {
return post<AuthResult>('/mini/auth/register', params)
}
/** 当前用户信息(含门店客户等级):GET /mini/auth/info */
export function getUserInfoApi() { export function getUserInfoApi() {
return get<User>('/mini/auth/info') return get<User>('/mini/auth/info')
} }
/** 修改密码(成功后现有 token 仍有效):PUT /mini/auth/password */
export function changePasswordApi(params: ChangePasswordParams) {
return put<null>('/mini/auth/password', params)
}
+131
View File
@@ -0,0 +1,131 @@
import { BASE_URL, get } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
/** 账单支付进度:0 待支付 / 1 审核中 / 2 已支付(由后端推导,展示以此为准) */
export type BillPayState = 0 | 1 | 2
/** 账单(列表行与详情的 bill 字段一致) */
export interface Bill {
id: number
/** 账单编号(ZD 前缀) */
bill_no: string
/** 账单日期(出账日,Y-m-d) */
bill_date: string
/** 关联采购单 ID */
purchase_id: number
/** 关联采购单 */
purchase: { id: number; purchase_no: string; purchase_date: string } | null
/** 商品金额(订单汇总快照) */
product_amount: string
/** 配送费 */
delivery_fee: string
/** 周转筐 / 周转托盘数量(可能为负数:负=回筐/回托盘抵扣) */
box_num: number
tray_num: number
/** 筐 / 托盘单价(出账时快照) */
box_price: string
tray_price: string
/** 附加金额 = box_num×box_price + tray_num×tray_price(可能为负数:回筐抵扣) */
added_amount: string
/** 账单总金额 = 商品金额 + 配送费 + 附加金额 */
total_amount: string
/** 原始支付状态:0 未支付 / 1 已支付(展示用 pay_state 系列字段) */
status: 0 | 1
status_name: string
/** 支付进度:0 待支付 / 1 审核中 / 2 已支付 */
pay_state: BillPayState
/** 支付进度中文名(列表状态标签直接用它) */
pay_state_name: string
/** 是否可发起合并付款(=待支付) */
can_pay: boolean
/** 关联支付记录 ID,0=未发起付款 */
payment_id: number
/** 应结算日期 = 账单日期 + 门店回款周期天数 */
settlement_date: string
/** 付款时间(已支付时非空) */
paid_at: string | null
/** 售后金额 */
after_sale: string
/** 付款备注 */
pay_remark: string
/** 账单备注 */
remark: string
created_at: string
}
/** 待支付汇总(门店口径,含审核中,不受筛选参数影响) */
export interface BillSummary {
unpaid_count: number
unpaid_amount: string
}
/** 账单合并商品明细(跨本账单全部订单按商品聚合) */
export interface BillItem {
product_id: number
product_name: string
product_spec: string
unit: string
/** 加权平均单价(Σ金额÷Σ数量) */
price: string
/** 合计数量 */
quantity: number
weight: string
/** 合计金额 */
amount: string
/** 商品首图 URL(无图为空字符串) */
image: string
price_unit: string
spec: string
}
/** 账单关联订单 */
export interface BillOrder {
id: number
order_no: string
order_date: string
total_quantity: number
total_weight: string
total_amount: string
/** 订单状态枚举(0/1/2/3/4/9 */
status: number
}
/** 账单详情 */
export interface BillDetail {
bill: Bill
items: BillItem[]
orders: BillOrder[]
}
/** 账单列表查询参数 */
export interface BillListParams {
/** 原始支付状态:0 未支付(含审核中)/ 1 已支付,不传=全部 */
status?: 0 | 1
/** 传 1 = 仅可发起付款的账单(合并付款选择页专用) */
payable?: 1
/** 账单日期起(Y-m-d */
start_date?: string
/** 账单日期止(Y-m-d),不得早于 start_date */
end_date?: string
page?: number
/** 每页数量,默认 10,最大 50 */
pageSize?: number
}
/** 账单列表(附加 summary 待支付汇总):GET /mini/bill */
export function getBillListApi(params: BillListParams = {}) {
return get<PaginatedData<Bill> & { summary: BillSummary }>('/mini/bill', { data: params })
}
/** 账单详情(校验本店归属):GET /mini/bill/{id} */
export function getBillDetailApi(id: number) {
return get<BillDetail>(`/mini/bill/${id}`)
}
/**
* 账单合并导出文件地址:GET /mini/bill/export
* 返回 xlsx 文件流(非 JSON,需带 token 下载);categoryId 仅过滤商品明细,费用仍全额汇总
*/
export function getBillExportUrl(ids: number[], categoryId = 0): string {
return `${BASE_URL}/mini/bill/export?ids=${ids.join(',')}&category_id=${categoryId}`
}
+6 -1
View File
@@ -1,5 +1,5 @@
import { del, get, post, put } from '@/utils/request' import { del, get, post, put } from '@/utils/request'
import type { CartData } from '@/types/cart' import type { CartData, CartSummary } from '@/types/cart'
/** 加购 / 改数量返回 */ /** 加购 / 改数量返回 */
export interface CartMutationResult { export interface CartMutationResult {
@@ -17,6 +17,11 @@ export function getCartApi() {
return get<CartData>('/mini/cart') return get<CartData>('/mini/cart')
} }
/** 轻量汇总(悬浮球单独刷新用;必须登录,未登录 401):GET /mini/cart/summary */
export function getCartSummaryApi() {
return get<CartSummary>('/mini/cart/summary')
}
/** 修改数量:PUT /mini/cart/{id} */ /** 修改数量:PUT /mini/cart/{id} */
export function updateCartItemApi(id: number, quantity: number) { export function updateCartItemApi(id: number, quantity: number) {
return put<CartMutationResult>(`/mini/cart/${id}`, { quantity }) return put<CartMutationResult>(`/mini/cart/${id}`, { quantity })
+47
View File
@@ -0,0 +1,47 @@
import { get } from '@/utils/request'
import type { CartSummary } from '@/types/cart'
/** 首页轮播图项 */
export interface HomeBanner {
id: number
title: string
image_id: number
image_url: string | null
link: string
sort: number
}
/** 首页宫格导航项 */
export interface HomeNav {
id: number
name: string
image_id: number
image_url: string | null
link: string
sort: number
}
/** 首页促销推荐卡片 */
export interface HomePromo {
id: number
title: string
sub_title: string
image_id: number
image_url: string | null
link: string
sort: number
}
/** 首页配置聚合数据 */
export interface HomeConfig {
banners: HomeBanner[]
navs: HomeNav[]
promos: HomePromo[]
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
cart?: CartSummary
}
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
export function getHomeConfigApi() {
return get<HomeConfig>('/mini/home', { skipToken: true })
}
+20 -14
View File
@@ -1,6 +1,6 @@
import { get, post, put } from '@/utils/request' import { get, post, put } from '@/utils/request'
import type { PaginatedData } from '@/types/api' import type { PaginatedData } from '@/types/api'
import type { Order, OrderCreateResult, OrderSummary } from '@/types/order' import type { OrderCreateResult, OrderDetail, OrderListItem, OrderStatus } from '@/types/order'
/** 下单明细行(金额一律服务端重算,前端不传金额) */ /** 下单明细行(金额一律服务端重算,前端不传金额) */
export interface OrderItemParam { export interface OrderItemParam {
@@ -15,29 +15,35 @@ export interface CreateOrderParams {
remark?: string remark?: string
} }
/** 订单列表查询参数 */
export interface OrderListParams {
/** 订单状态,不传=全部 */
status?: OrderStatus
/** 订货日期起(Y-m-d */
start_date?: string
/** 订货日期止(Y-m-d),不得早于 start_date */
end_date?: string
page?: number
/** 每页数量,默认 10,最大 50 */
pageSize?: number
}
/** 下单:POST /mini/order */ /** 下单:POST /mini/order */
export function createOrderApi(params: CreateOrderParams) { export function createOrderApi(params: CreateOrderParams) {
return post<OrderCreateResult>('/mini/order', params) return post<OrderCreateResult>('/mini/order', params)
} }
/** 历史订单列表(强制本店隔离):GET /mini/order */ /** 历史订单列表(强制本店隔离,按订货日期倒序):GET /mini/order */
export function getOrderListApi( export function getOrderListApi(params: OrderListParams = {}) {
params: { status?: number; page?: number; pageSize?: number } = {}, return get<PaginatedData<OrderListItem>>('/mini/order', { data: params })
) {
return get<PaginatedData<Order>>('/mini/order', { data: params })
} }
/** 周期汇总GET /mini/order/summary */ /** 订单详情(校验本店归属,含完整明细)GET /mini/order/{id} */
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) { export function getOrderDetailApi(id: number) {
return get<Order>(`/mini/order/${id}`) return get<OrderDetail>(`/mini/order/${id}`)
} }
/** 取消订单(仅待汇总可取消):PUT /mini/order/{id}/cancel */ /** 取消订单(仅待接单可取消):PUT /mini/order/{id}/cancel */
export function cancelOrderApi(id: number) { export function cancelOrderApi(id: number) {
return put(`/mini/order/${id}/cancel`) return put(`/mini/order/${id}/cancel`)
} }
+176
View File
@@ -0,0 +1,176 @@
import { get, post } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
/** 支付方式:1 微信 / 2 支付宝 / 3 对公汇款 / 4 旺铺支付(小程序在线支付) */
export type PayMethod = 1 | 2 | 3 | 4
export const PAY_METHOD_NAMES: Record<PayMethod, string> = {
1: '微信支付',
2: '支付宝',
3: '对公汇款',
4: '微信在线支付',
}
/** 支付类型:1 线下凭证支付 / 2 在线支付(旧数据可能缺省,缺省按线下处理) */
export type PayType = 1 | 2
/** 支付状态:0 待审核 / 1 已通过 / 2 已拒绝(线下凭证支付单语义) */
export type PayStatus = 0 | 1 | 2
export const PAY_STATUS_NAMES: Record<PayStatus, string> = {
0: '待审核',
1: '已通过',
2: '已拒绝',
}
/** 在线支付状态:0 待支付 / 1 支付成功 / 2 支付失败(与线下同字段,按 pay_type 区分语义) */
export type OnlinePayStatus = 0 | 1 | 2
export const ONLINE_PAY_STATUS_NAMES: Record<OnlinePayStatus, string> = {
0: '待支付',
1: '支付成功',
2: '支付失败',
}
/** 支付单状态展示名(在线支付单与线下凭证单同字段不同语义,按 pay_type 取名) */
export function getPayStatusName(payment: { status: PayStatus; pay_type?: PayType }): string {
return payment.pay_type === 2 ? ONLINE_PAY_STATUS_NAMES[payment.status] : PAY_STATUS_NAMES[payment.status]
}
/** 支付配置(付款页展示,图片为解析后的预览地址,可能为空串) */
export interface PaymentConfig {
wechat_qrcode: string
alipay_qrcode: string
bank_info: string
/** 公众号 appid(H5 网页授权取 code 拼授权链接用,配置了公众号支付才返回) */
mp_appid?: string
}
/** 支付记录(列表行与详情的 payment 字段一致) */
export interface Payment {
id: number
/** 支付编号(ZF 前缀) */
payment_no: string
store_id: number
user_id: number
/** 合并付款总金额 */
amount: string
/** 支付类型:1 线下凭证 / 2 在线支付(旺铺网关) */
pay_type?: PayType
pay_method: PayMethod
/** 凭证图片 ID 数组(模型 casts 为 array,在线支付单为空) */
voucher_ids: number[]
status: PayStatus
/** 提交备注 */
remark: string
/** 审核时间(线下凭证) */
audited_at: string | null
auditor_id: number | null
/** 审核备注(拒绝原因,线下凭证) */
audit_remark: string | null
/** 在线支付成功时间(在线支付单非空) */
paid_at?: string | null
/** 网关交易号(在线支付单非空) */
trade_no?: string | null
created_at: string
/** 列表返回:合并账单数 */
bills_count?: number
}
/** 支付记录详情(payment 附加凭证图片 URL 列表) */
export interface PaymentDetail {
payment: Payment & { voucher_urls: string[] }
/** 合并付款的账单 */
bills: Array<{
id: number
bill_no: string
bill_date: string
product_amount: string
delivery_fee: string
added_amount: string
total_amount: string
status: 0 | 1
}>
}
/** 发起付款返回 */
export interface PaymentCreateResult {
id: number
payment_no: string
amount: string
}
/** 支付配置:GET /mini/payment/config */
export function getPaymentConfigApi() {
return get<PaymentConfig>('/mini/payment/config')
}
/** 支付记录列表:GET /mini/payment?status=&page=&pageSize= */
export function getPaymentListApi(params: { status?: PayStatus; page?: number; pageSize?: number } = {}) {
return get<PaginatedData<Payment>>('/mini/payment', { data: params })
}
/** 发起合并付款:POST /mini/payment */
export function createPaymentApi(data: {
bill_ids: number[]
pay_method: PayMethod
voucher_ids: number[]
remark?: string
}) {
return post<PaymentCreateResult>('/mini/payment', data)
}
/** 支付记录详情:GET /mini/payment/{id} */
export function getPaymentDetailApi(id: number) {
return get<PaymentDetail>(`/mini/payment/${id}`)
}
/** 在线支付下单返回(pay_params 为旺铺网关透传的调起参数:小程序给 wx.requestPaymentH5 公众号给 getBrandWCPayRequest,以网关实际返回为准) */
export interface OnlinePaymentCreateResult {
id: number
/** 支付单号(ZF 前缀,= 上送网关的商户订单号 mer_order_id),查询/对账用 */
payment_no: string
/** 应付金额(= 所选账单总额合计,元) */
amount: string
pay_params: {
appId?: string
timeStamp?: string
nonceStr?: string
package?: string
signType?: string
paySign?: string
[key: string]: any
}
}
/** 在线支付结果查询返回 */
export interface OnlinePaymentQueryResult {
payment_no: string
/** 0 待支付 / 1 支付成功(账单已置已支付)/ 2 支付失败(账单已释放) */
status: OnlinePayStatus
status_name: string
paid_at: string | null
trade_no: string | null
}
/**
* 发起在线支付(合并账单下单):POST /mini/payment/online
* code:小程序传 wx.login() 登录凭证(不传 scene);
* H5 公众号传网页授权回调 code,需带 scene: 'mp'(后端按场景换付款人 openid)
*/
export function createOnlinePaymentApi(data: {
bill_ids: number[]
code: string
scene?: 'mp'
remark?: string
}) {
return post<OnlinePaymentCreateResult>('/mini/payment/online', data)
}
/**
* 主动查询在线支付结果(网关后台通知延迟/丢失时的兜底):GET /mini/payment/online/{payment_no}/query
* 网关返回已支付则立即结账(与后台通知同一幂等逻辑)
*/
export function queryOnlinePaymentApi(paymentNo: string) {
return get<OnlinePaymentQueryResult>(`/mini/payment/online/${paymentNo}/query`)
}
+15 -3
View File
@@ -1,5 +1,6 @@
import { get } from '@/utils/request' import { get } from '@/utils/request'
import type { PaginatedData } from '@/types/api' import type { PaginatedData } from '@/types/api'
import type { CartSummary } from '@/types/cart'
import type { Category, Product } from '@/types/product' import type { Category, Product } from '@/types/product'
/** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */ /** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */
@@ -17,7 +18,18 @@ export interface ProductListParams {
pageSize?: number pageSize?: number
} }
/** 商品列表(当前门店等级实际价):GET /mini/product/list */ /** 商品列表响应(分页 + 购物车悬浮球汇总) */
export function getProductListApi(params: ProductListParams = {}) { export interface ProductListData extends PaginatedData<Product> {
return get<PaginatedData<Product>>('/mini/product/list', { data: params }) /** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
cart?: CartSummary
}
/** 商品列表(当前门店等级实际价 + 行内购物车字段):GET /mini/product/list */
export function getProductListApi(params: ProductListParams = {}) {
return get<ProductListData>('/mini/product/list', { data: params })
}
/** 商品详情(免登录;未登录/未绑店/未设等级 price=null;下架或不存在业务报错):GET /mini/product/{id} */
export function getProductDetailApi(id: number) {
return get<Product>(`/mini/product/${id}`)
} }
+61
View File
@@ -0,0 +1,61 @@
import { get } from '@/utils/request'
/**
* 统计周期预设:
* week 本周 / last_week 上周 / month 本月 / last_month 上月;
* custom 仅出现在响应中(传 start_date + end_date 自定义区间时生效,优先于 preset)
*/
export type ReportPreset = 'week' | 'last_week' | 'month' | 'last_month' | 'custom'
/** 单品累计行(按金额降序) */
export interface PurchaseReportItem {
product_id: number
/** 品名(下单时快照) */
product_name: string
/** 规格/包规(快照) */
product_spec: string
/** 计价单位(快照) */
unit: string
/** 周期内累计订货量 */
quantity: number
/** 周期内累计重量(3 位小数,未称重为 0.000) */
weight: string
/** 周期内累计采购金额(元,2 位小数字符串) */
amount: string
/** 金额占比(%,1 位小数;如 8.8 表示 8.8% */
percent: number
}
/** 采购运营报表 */
export interface PurchaseReport {
/** 实际生效的周期预设 */
preset: ReportPreset
/** 实际统计开始日期(Y-m-d,进行中的周期封顶为今天) */
start_date: string
/** 实际统计结束日期(Y-m-d) */
end_date: string
/** 周期内采购总金额(元,2 位小数字符串) */
total_amount: string
/** 周期内订货总量(各单品数量之和) */
total_quantity: number
/** 周期内有效订货单数 */
order_count: number
/** 单品个数(= items 长度) */
item_count: number
/** 单品累计列表,按金额降序 */
items: PurchaseReportItem[]
}
/** 报表查询参数:自定义区间(start_date + end_date 需成对)优先于 presetpreset 缺省为 month */
export interface PurchaseReportParams {
preset?: Exclude<ReportPreset, 'custom'>
/** 自定义开始日期(Y-m-d */
start_date?: string
/** 自定义结束日期(Y-m-d),不得早于 start_date */
end_date?: string
}
/** 采购运营报表:GET /mini/report/purchase(仅当前门店自身数据) */
export function getPurchaseReportApi(params: PurchaseReportParams = {}) {
return get<PurchaseReport>('/mini/report/purchase', { data: params })
}
+43
View File
@@ -0,0 +1,43 @@
import { get } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
import type { CartSummary } from '@/types/cart'
import type { Product } from '@/types/product'
/** 特价推荐列表参数 */
export interface SpecialListParams {
page?: number
pageSize?: number
}
/**
* 特价推荐列表响应(分页 + 购物车悬浮球汇总)。
* 行结构与 /mini/product/list 一致:商品基础字段 + price + cart_id/cart_quantity
*/
export interface SpecialListData extends PaginatedData<Product> {
/**
* 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空)。
* 接口文档示例为 count/quantity/amount 简写,与 /mini/home 等接口的 total_* 命名不一致,
* 统一经 normalizeSpecialCart 转换后再写入 store
*/
cart?: CartSummary | { count: number; quantity: string; amount: string }
}
/** 特价推荐商品列表(免登录;携带门店 token 时返回等级价与购物车字段):GET /mini/special/list */
export function getSpecialListApi(params: SpecialListParams = {}) {
return get<SpecialListData>('/mini/special/list', { data: params })
}
/** 特价推荐响应附带的悬浮球汇总归一化(兼容 count/quantity/amount 与 total_* 两种命名) */
export function normalizeSpecialCart(cart: SpecialListData['cart']): CartSummary | null {
if (!cart) return null
const raw = cart as Record<string, unknown>
const count = raw.total_count ?? raw.count
const quantity = raw.total_quantity ?? raw.quantity
const amount = raw.total_amount ?? raw.amount
if (count == null || quantity == null || amount == null) return null
return {
total_count: Number(count),
total_quantity: String(quantity),
total_amount: String(amount),
}
}
-23
View File
@@ -1,23 +0,0 @@
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}`)
}
+9 -18
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand' import { create } from 'zustand'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { registerApi, wxLoginApi } from '@/services/auth' import { loginApi } from '@/services/auth'
import type { RegisterParams, WxLoginParams } from '@/services/auth' import type { LoginParams } from '@/services/auth'
import type { User } from '@/types/user' import type { User } from '@/types/user'
/** 存储 key */ /** 存储 key */
@@ -26,7 +26,7 @@ function loadFromStorage(): { user: User | null; token: string | null } {
return { user: null, token: null } return { user: null, token: null }
} }
/** 登录 / 注册成功后持久化 token 与用户信息 */ /** 登录成功后持久化 token 与门店信息 */
function persistAuth(token: string, user: User): void { function persistAuth(token: string, user: User): void {
try { try {
Taro.setStorageSync(STORAGE_KEYS.TOKEN, token) Taro.setStorageSync(STORAGE_KEYS.TOKEN, token)
@@ -40,11 +40,10 @@ interface AuthState {
user: User | null user: User | null
token: string | null token: string | null
loading: boolean loading: boolean
login: (params: WxLoginParams) => Promise<void> /** 账号密码登录(门店账号由商家后台分配) */
/** 微信注册(手机号授权 + 门店编码绑定门店) */ login: (params: LoginParams) => Promise<void>
register: (params: RegisterParams) => Promise<void>
logout: () => void logout: () => void
/** 更新用户信息(用于编辑资料后同步 store) */ /** 更新门店信息(用于编辑资料后同步 store) */
setUser: (user: User) => void setUser: (user: User) => void
} }
@@ -57,17 +56,9 @@ const useAuthStore = create<AuthState>((set) => {
token: initial.token, token: initial.token,
loading: !!(initial.token && initial.user), // 已恢复则立即 ready loading: !!(initial.token && initial.user), // 已恢复则立即 ready
/** 登录(仅已注册用户可登录,未注册由页面引导去注册) */ /** 账号密码登录:POST /mini/auth/login */
login: async (params: WxLoginParams) => { login: async (params: LoginParams) => {
const res = await wxLoginApi(params) const res = await loginApi(params)
const { token, user } = res.data
set({ user, token })
persistAuth(token, user)
},
/** 注册:POST /mini/auth/register */
register: async (params: RegisterParams) => {
const res = await registerApi(params)
const { token, user } = res.data const { token, user } = res.data
set({ user, token }) set({ user, token })
persistAuth(token, user) persistAuth(token, user)
+66 -4
View File
@@ -5,13 +5,26 @@ import {
clearCartApi, clearCartApi,
deleteCartItemApi, deleteCartItemApi,
getCartApi, getCartApi,
getCartSummaryApi,
updateCartItemApi, updateCartItemApi,
} from '@/services/cart' } from '@/services/cart'
import type { CartItem } from '@/types/cart' import type { CartMutationResult } from '@/services/cart'
import { getToken } from '@/utils/request'
import type { CartItem, CartSummary } from '@/types/cart'
/** 存储 key */ /** 存储 key */
const STORAGE_KEY = 'cart_data' const STORAGE_KEY = 'cart_data'
/** 汇总请求序号(并发时仅采用最后一次响应) */
let summarySeq = 0
/** 汇总防抖校准定时器(列表加减停止 800ms 后整体拉取一次,以服务端为准) */
let summaryTimer: ReturnType<typeof setTimeout> | null = null
/** 数值 → 2 位小数字符串(与服务端金额/数量口径一致) */
function to2(n: number): string {
return (Math.round(n * 100) / 100).toFixed(2)
}
/** 持久化的购物车快照(服务端为准,此处仅作展示缓存) */ /** 持久化的购物车快照(服务端为准,此处仅作展示缓存) */
interface StoredCart { interface StoredCart {
items: CartItem[] items: CartItem[]
@@ -46,8 +59,8 @@ interface CartState {
loading: boolean loading: boolean
/** 拉取购物车(以服务端为准,金额一律服务端重算) */ /** 拉取购物车(以服务端为准,金额一律服务端重算) */
fetchCart: () => Promise<void> fetchCart: () => Promise<void>
/** 加购 */ /** 加购(返回合并后的购物车行,供列表页回写 cart_id/cart_quantity */
addItem: (productId: number, quantity: number) => Promise<void> addItem: (productId: number, quantity: number) => Promise<CartMutationResult>
/** 修改数量 */ /** 修改数量 */
updateQuantity: (id: number, quantity: number) => Promise<void> updateQuantity: (id: number, quantity: number) => Promise<void>
/** 删除单项 */ /** 删除单项 */
@@ -56,6 +69,12 @@ interface CartState {
clearCart: () => Promise<void> clearCart: () => Promise<void>
/** 下单成功后本地清空(不请求接口) */ /** 下单成功后本地清空(不请求接口) */
clearLocal: () => void clearLocal: () => void
/** 用接口附带的汇总块(首页/商品列表响应的 cart 字段)直接更新悬浮球 */
setSummary: (summary: CartSummary) => void
/** 拉取轻量汇总(需登录;未登录跳过,避免 401 跳转) */
fetchSummary: () => Promise<void>
/** 列表行内加减后本地增减悬浮球(乐观展示),并防抖调 fetchSummary 校准 */
applyDelta: (delta: { quantity: number; amount: number; count?: number }) => void
} }
/** 空的购物车快照 */ /** 空的购物车快照 */
@@ -80,6 +99,18 @@ const useCartStore = create<CartState>((set, get) => {
} }
} }
/** 写入悬浮球汇总并持久化(列表项快照保持不变) */
const applySummary = (summary: CartSummary) => {
const next = {
items: get().items,
totalCount: summary.total_count,
totalQuantity: summary.total_quantity,
totalAmount: summary.total_amount,
}
set(next)
persist(next)
}
return { return {
...EMPTY_SNAPSHOT, ...EMPTY_SNAPSHOT,
items: cached?.items ?? [], items: cached?.items ?? [],
@@ -111,8 +142,9 @@ const useCartStore = create<CartState>((set, get) => {
/** 加购:服务端校验上架与等级价,成功后重新同步 */ /** 加购:服务端校验上架与等级价,成功后重新同步 */
addItem: async (productId, quantity) => { addItem: async (productId, quantity) => {
await addCartApi({ product_id: productId, quantity }) const res = await addCartApi({ product_id: productId, quantity })
await get().fetchCart() await get().fetchCart()
return res.data
}, },
/** 修改数量 */ /** 修改数量 */
@@ -139,6 +171,36 @@ const useCartStore = create<CartState>((set, get) => {
set(EMPTY_SNAPSHOT) set(EMPTY_SNAPSHOT)
persist(EMPTY_SNAPSHOT) persist(EMPTY_SNAPSHOT)
}, },
/** 写入接口附带的汇总块(首页/商品列表) */
setSummary: (summary) => {
applySummary(summary)
},
/** 拉取轻量汇总(并发时仅采用最后一次响应) */
fetchSummary: async () => {
// 未登录无汇总(接口固定 401,会触发清理登录态),直接跳过
if (!getToken()) return
const seq = ++summarySeq
const res = await getCartSummaryApi()
if (seq !== summarySeq) return // 已有更新的请求,丢弃本次响应
applySummary(res.data)
},
/** 列表加减后的本地增减:即时反馈,防抖后以服务端汇总校准 */
applyDelta: ({ quantity, amount, count = 0 }) => {
const s = get()
applySummary({
total_count: Math.max(0, s.totalCount + count),
total_quantity: to2(Math.max(0, Number(s.totalQuantity) + quantity)),
total_amount: to2(Math.max(0, Number(s.totalAmount) + amount)),
})
if (summaryTimer) clearTimeout(summaryTimer)
summaryTimer = setTimeout(() => {
summaryTimer = null
get().fetchSummary().catch(() => {})
}, 800)
},
} }
}) })
+14
View File
@@ -15,6 +15,7 @@ export interface CartItem {
amount: string | null amount: string | null
/** 1 可购 / 0 商品下架、缺失或未设等级价 */ /** 1 可购 / 0 商品下架、缺失或未设等级价 */
status: number status: number
price_unit: string
} }
/** 购物车列表数据 */ /** 购物车列表数据 */
@@ -27,3 +28,16 @@ export interface CartData {
/** 可购项总金额 */ /** 可购项总金额 */
total_amount: string total_amount: string
} }
/**
* 购物车悬浮球汇总(/mini/home、/mini/product/list 响应附带;
* GET /mini/cart/summary 同构。未登录时列表/首页返回零值结构)
*/
export interface CartSummary {
/** 商品种数(全部行数,含已下架项) */
total_count: number
/** 总数量(仅可购项,2 位小数字符串) */
total_quantity: string
/** 总金额(仅可购项,元,2 位小数字符串) */
total_amount: string
}
+84 -49
View File
@@ -1,85 +1,120 @@
/** 门店订单状态:0 待汇总 / 1 已汇总 / 2 配送中 / 3 已完成 / 9 已取消 */ /**
export type OrderStatus = 0 | 1 | 2 | 3 | 9 * 门店订单状态(新枚举):
* 0 待接单 / 1 已接单 / 2 采购中 / 3 配送中 / 4 已完成 / 9 已取消
*/
export type OrderStatus = 0 | 1 | 2 | 3 | 4 | 9
export const ORDER_STATUS_MAP: Record<OrderStatus, string> = { /**
0: '待汇总', * 状态文案兜底映射(完整 6 态)
1: '已汇总', * 仅用于接口未返回 status_name 的场景(订单详情、账单关联订单);
2: '配送中', * 列表展示一律使用接口返回的 status_name,不要再硬编码
3: '已完成', */
export const ORDER_STATUS_TEXT: Record<number, string> = {
0: '待接单',
1: '已接单',
2: '采购中',
3: '配送中',
4: '已完成',
9: '已取消', 9: '已取消',
} }
/** 状态筛选(value 为 null 表示全部) */ /** 状态筛选(订单列表页 chipsvalue 为 undefined 表示全部) */
export const ORDER_STATUS_FILTERS: Array<{ value: OrderStatus | null; label: string }> = [ export const ORDER_STATUS_FILTERS: Array<{ value: OrderStatus | undefined; label: string }> = [
{ value: null, label: '全部' }, { value: undefined, label: '全部' },
{ value: 0, label: '待汇总' }, { value: 0, label: '待接单' },
{ value: 1, label: '已汇总' }, { value: 1, label: '已接单' },
{ value: 2, label: '配送中' }, { value: 2, label: '采购中' },
{ value: 3, label: '已完成' }, { value: 3, label: '配送中' },
{ value: 4, label: '已完成' },
{ value: 9, label: '已取消' }, { value: 9, label: '已取消' },
] ]
/** /** 我的页「订单总汇」导航(status 映射后端订单状态枚举) */
* 我的页「订单总汇」导航(业务语言 → 后端枚举)
* status 映射 StoreOrder.status0 待汇总 / 1 已汇总 / 2 配送中 / 3 已完成 / 9 已取消
* 注意:后端当前无「待付款」状态(status 缺省 = 不传参,显示全部),待后端补充后在此对齐
*/
export const ORDER_NAV_ITEMS: Array<{ key: string; label: string; status?: number; icon: string }> = [ export const ORDER_NAV_ITEMS: Array<{ key: string; label: string; status?: number; icon: string }> = [
{ key: 'pending_review', label: '待审核', status: 0, icon: 'clock-o' }, { key: 'pending', label: '待接单', status: 0, icon: 'clock-o' },
{ key: 'pending_stock', label: '待配货', status: 1, icon: 'logistics' }, { key: 'accepted', label: '已接单', status: 1, icon: 'passed' },
{ key: 'stocking', label: '配货中', status: 2, icon: 'van-o' }, { key: 'purchasing', label: '采购中', status: 2, icon: 'shopping-cart-o' },
{ key: 'pending_pay', label: '待付款', icon: 'pending-payment' }, { key: 'delivering', label: '配送中', status: 3, icon: 'logistics' },
{ key: 'completed', label: '已完成', status: 3, icon: 'completed' }, { key: 'completed', label: '已完成', status: 4, icon: 'completed' },
{ key: 'all', label: '我的订单', icon: 'orders-o' }, { key: 'all', label: '全部订单', icon: 'orders-o' },
] ]
/** 门店订单 */ /** 订单列表行商品预览(仅前 3 条) */
export interface Order { export interface OrderItemPreview {
product_name: string
/** 规格包规 */
product_spec: string
quantity: number
unit: string
/** 首图 URL(无图为空字符串) */
image: string
}
/** 订单列表行(状态名/可取消/商品预览均由后端给出,直接展示) */
export interface OrderListItem {
id: number id: number
order_no: string order_no: string
/** 订货日期(Y-m-d */ /** 订货日期(Y-m-d */
order_date: string order_date: string
total_quantity: string
total_amount: string
status: OrderStatus status: OrderStatus
/** 状态中文名(直接展示,前端不要再硬编码映射) */
status_name: string
/** 是否可取消(=待接单),取消按钮据此渲染 */
can_cancel: boolean
total_quantity: number
/** 总称重(斤,常为 0 */
total_weight: string
total_amount: string
remark: string remark: string
/** 详情接口返回 */ /** 关联采购单 ID0=未归集 */
items?: OrderItem[] purchase_id: number
/** 关联账单 ID,0=未出账(>0 可跳账单详情) */
bill_id: number
created_at: string
/** 明细种数(如「共 4 种」) */
item_count: number
/** 商品预览,仅前 3 条;完整明细走详情接口 */
items: OrderItemPreview[]
} }
/** 订单明细 */ /** 订单明细(详情接口,下单时商品快照) */
export interface OrderItem { export interface OrderItem {
id: number id: number
order_id: number order_id: number
product_id: number product_id: number
product_name: string product_name: string
product_spec: string product_spec: string
/** 下单时等级实际价快照 */ unit: string
price_unit: string
/** 下单时门店等级实际价快照 */
price: string price: string
quantity: string quantity: number
/** 称重(默认 0 */ /** 称重(斤,参考值 */
weight: string weight: string
amount: string amount: string
remark: string remark: string
} }
/** 订单详情(无 status_name/can_cancel,状态展示沿用列表行或 ORDER_STATUS_TEXT */
export interface OrderDetail {
id: number
order_no: string
store_id: number
order_date: string
total_quantity: number
total_weight: string
total_amount: string
status: OrderStatus
remark: string
purchase_id: number
bill_id: number
created_at: string
items: OrderItem[]
}
/** 下单返回 */ /** 下单返回 */
export interface OrderCreateResult { export interface OrderCreateResult {
id: number id: number
order_no: string order_no: string
total_amount: 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[]
}
+25 -9
View File
@@ -1,15 +1,18 @@
/** 商品分类节点(children 递归) */ import { resolveFileUrl } from '@/utils/format'
/** 商品分类节点(children 递归;叶子分类无 children 字段) */
export interface Category { export interface Category {
id: number id: number
parent_id: number parent_id: number
name: string name: string
children: Category[] children?: Category[]
} }
/** 商品图片 */ /** 商品图片(SysFile 序列化,含预览地址与文件地址) */
export interface ProductImage { export interface ProductImage {
id: number id: number
file_url: string file_url: string
preview_url: string
} }
/** 商品 */ /** 商品 */
@@ -22,20 +25,33 @@ export interface Product {
unit: string unit: string
/** 商品图文详情(HTML */ /** 商品图文详情(HTML */
content: string content: string
/** 当前门店等级的实际销售价(未设等级为 null */ /** 当前门店等级的实际销售价(未登录/未绑店/未设等级为 null */
price: string | null price: string | null
price_unit: string | null
images_arr: ProductImage[] images_arr: ProductImage[]
/** 排序 / 保质期 / 库存 / 状态(仅返回上架商品 */ /** 所属分类(详情接口 with 返回 */
category?: { id: number; name: string } | null
/** 排序 / 库存 / 状态(仅返回上架商品) */
sort?: number sort?: number
shelf_life?: string | null /** 保质期(天,0=未设置) */
shelf_life?: number | null
stock?: number | null stock?: number | null
status?: number status?: number
/** 该商品对应的购物车行 ID(不在购物车/未登录为 0;列表加减、删除时需要) */
cart_id?: number
/** 购物车中该商品数量(2 位小数字符串;不在购物车/未登录为 "0.00" */
cart_quantity?: string
}
/** 商品行购物车字段回写(行内加减购确认后更新列表项) */
export interface ProductCartPatch {
cart_id: number
cart_quantity: string
} }
/** 商品首图地址 */ /** 商品首图地址 */
export function getProductCover(product: Product): string { export function getProductCover(product: Product): string {
const first = product.images_arr?.[0] const first = product.images_arr?.[0]
if (!first || !first.file_url) return '' if (!first) return ''
if (/^https?:\/\//i.test(first.file_url)) return first.file_url return resolveFileUrl(first.preview_url || first.file_url)
return first.file_url
} }
-45
View File
@@ -1,45 +0,0 @@
/** 对账单状态: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[]
}
+20 -41
View File
@@ -4,54 +4,33 @@ export interface StoreLevel {
name: string name: string
} }
/** 门店信息 */ /**
export interface StoreInfo { * 登录门店信息(门店即用户)
id: number * 用户表与门店表已合并:登录 / auth/info 返回的 user 就是门店本身(扁平结构)
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: '供应商',
}
/** 用户信息(user 表实际返回字段) */
export interface User { export interface User {
id: number id: number
/** 用户名(注册时生成 wx_xxxx */ /** 门店名称 */
name: string
/** 门店编码(后台分配) */
code: string
/** 登录账号(后台分配,4~20 位) */
username: string username: string
/** 昵称(注册默认「微信用户」 */ /** 头像(可能为空 */
nickname: string
avatar: string avatar: string
/** 手机号(未绑定为空) */ level_id: number
/** 客户等级(level_id > 0 才可展示价格) */
level: StoreLevel | null
/** 联系人 */
contact: string
/** 联系电话 */
phone: string phone: string
/** 绑定门店ID0 未绑定) */ /** 地址 */
store_id: number address: string
/** 回款周期天数 */
payment_cycle_days: number
/** 1 正常 / 0 停用 */ /** 1 正常 / 0 停用 */
status: number status: number
/** 微信标识 */
openid: string
unionid: string
email: string
last_login_at: string | null last_login_at: string | null
created_at: string | null created_at: string | null
updated_at: string | null
/** 兼容旧 /mini/auth/info 返回(身份类型) */
type?: UserType
/** 兼容旧 /mini/auth/info 返回(门店信息) */
store?: StoreInfo | null
/** 兼容旧 /mini/auth/info 返回(供应商信息) */
supplier?: SupplierInfo | null
} }
+110
View File
@@ -0,0 +1,110 @@
import Taro from '@tarojs/taro'
import { getToken } from '@/utils/request'
/**
* 导出文件下载(双端)
* 后端导出接口成功返回 xlsx 文件流,业务失败时仍以文件流形式返回 JSON
* blob.type 为 application/json),两端都需探测并解析 msg 提示
*/
/** 从 Content-Disposition 解析文件名(优先 RFC 5987 filename*=utf-8''... */
export function parseExportFilename(disposition?: string | null): string {
if (!disposition) return ''
const star = disposition.match(/filename\*=utf-8''([^;]+)/i)
if (star?.[1]) {
try {
return decodeURIComponent(star[1])
} catch {
// 编码异常时退化为普通 filename
}
}
const plain = disposition.match(/filename="?([^";]+)"?/i)
return plain?.[1] ?? ''
}
/** 解析 JSON 文本中的业务错误信息 */
function parseJsonError(text: string): Error {
try {
const json = JSON.parse(text)
return new Error(json.msg || '导出失败,请稍后重试')
} catch {
return new Error('导出失败,请稍后重试')
}
}
/** H5fetch 带鉴权拉取 blob → <a download> 触发保存 */
async function downloadFileH5(url: string, fallbackName: string): Promise<void> {
const token = getToken()
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
throw new Error(`下载失败(${res.status}`)
}
const blob = await res.blob()
// 业务失败:返回的是 JSON blob
if (blob.type.includes('application/json')) {
throw parseJsonError(await blob.text())
}
const filename =
parseExportFilename(res.headers.get('Content-Disposition')) || fallbackName
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(objectUrl)
}
/** 读取小程序本地文件内容(utf8) */
function readTempFile(filePath: string, length?: number): Promise<string> {
return new Promise((resolve, reject) => {
Taro.getFileSystemManager().readFile({
filePath,
encoding: 'utf8',
...(length !== undefined ? { position: 0, length } : {}),
success: r => resolve(r.data as string),
fail: reject,
})
})
}
/** 小程序:downloadFile 下载后 openDocument 打开;业务失败时下载"成功"但内容是 JSON */
async function downloadFileWeapp(url: string): Promise<void> {
const token = getToken()
const { tempFilePath } = await Taro.downloadFile({
url,
header: token ? { Authorization: `Bearer ${token}` } : {},
})
// 探测前 200 字节:以 '{' 开头即为 JSON 错误响应
const head = await readTempFile(tempFilePath, 200)
if (head.trimStart().startsWith('{')) {
throw parseJsonError(await readTempFile(tempFilePath))
}
await Taro.openDocument({
filePath: tempFilePath,
fileType: 'xlsx',
showMenu: true,
fail: () => {
Taro.showToast({ title: '文件已下载,打开失败', icon: 'none' })
},
} as Parameters<typeof Taro.openDocument>[0])
}
/**
* 带鉴权下载导出文件
* - H5:保存到浏览器下载目录,返回保存的文件名
* - 小程序:直接调起系统文档预览(showMenu 支持转发/保存)
*/
export async function downloadExportFile(url: string, fallbackName: string): Promise<void> {
if (process.env.TARO_ENV === 'h5') {
return downloadFileH5(url, fallbackName)
}
return downloadFileWeapp(url)
}
+44 -3
View File
@@ -40,11 +40,52 @@ export function formatTime(value?: string): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
} }
/**
* 解析服务器文件地址(收款码、汇款凭证等):绝对地址直接用,相对路径拼接服务器域名
*/
export function resolveFileUrl(url?: string): string {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
return `${SERVER_ORIGIN}${url.startsWith('/') ? '' : '/'}${url}`
}
/** /**
* 解析头像地址:绝对地址直接用,相对路径拼接服务器域名 * 解析头像地址:绝对地址直接用,相对路径拼接服务器域名
*/ */
export function resolveAvatarUrl(avatar?: string): string { export function resolveAvatarUrl(avatar?: string): string {
if (!avatar) return '' return resolveFileUrl(avatar)
if (/^https?:\/\//i.test(avatar)) return avatar }
return `${SERVER_ORIGIN}${avatar.startsWith('/') ? '' : '/'}${avatar}`
/**
* 商品规格展示:包规与单位直接拼接
* spec=20、unit=斤/箱 → 20斤/箱
*/
export function formatSpec(spec?: string | number | null, unit?: string | null): string {
const s = spec === null || spec === undefined ? '' : String(spec).trim()
const u = (unit ?? '').trim()
return `${s}${u}`
}
/**
* 零售价 = 售价 ÷ 包规(如 30¥/箱 ÷ 20斤/箱 = 2¥/斤)
* 保留两位小数并去掉尾零(2 → "2"2.50 → "2.5"
* 售价为空、包规非数字或 ≤0 时返回 null(不展示零售价)
*/
export function formatRetailPrice(price?: string | number | null, spec?: string | number | null): string | null {
if (price === null || price === undefined || price === '') return null
const p = Number(price)
const s = Number(spec)
if (!Number.isFinite(p) || !Number.isFinite(s) || s <= 0) return null
return String(Math.round((p / s) * 100) / 100)
}
/**
* 数量展示:保留两位小数并去掉尾零("2.50" → "2.5""3.00" → "3"
* 用于悬浮球徽标、行内加减器等窄空间;非法值按 0 处理
*/
export function formatQuantity(value?: string | number | null): string {
if (value === null || value === undefined || value === '') return '0'
const n = Number(value)
if (!Number.isFinite(n)) return '0'
return String(Math.round(n * 100) / 100)
} }
+22 -1
View File
@@ -10,7 +10,8 @@ const LOGIN_PATH = '/pages/login/index'
/** 默认请求超时(ms */ /** 默认请求超时(ms */
const DEFAULT_TIMEOUT = 15000 const DEFAULT_TIMEOUT = 15000
/** 接口根地址(uploadFile 等原生请求同样使用) */ /** 接口根地址(uploadFile 等原生请求同样使用) */
export const BASE_URL = "http://localhost:8000/index.php" // export const BASE_URL = "http://localhost:8000"
export const BASE_URL = "https://purchase.henanklkj.com/index.php"
/** /**
* HTTP 状态码 → 错误提示映射 * HTTP 状态码 → 错误提示映射
@@ -90,6 +91,25 @@ function handleBusinessError(data: ApiResponse): void {
} }
} }
/**
* 清理请求参数
* data 中的 undefined 在 GET 查询串里会被序列化为字符串 'undefined'null 同理),
* 导致后端收到字符串而非缺省;统一在此剔除。GET 额外剔除 null,
* POST/PUT 等请求体保留 null(JSON 序列化语义可能依赖它)
*/
function sanitizeData(data: any, method?: RequestConfig['method']): any {
if (!data || typeof data !== 'object' || Array.isArray(data)) return data
const isGet = !method || method === 'GET'
const result: Record<string, any> = {}
Object.keys(data).forEach(key => {
const value = data[key]
if (value === undefined) return
if (isGet && value === null) return
result[key] = value
})
return result
}
/** /**
* 发起网络请求 * 发起网络请求
* *
@@ -126,6 +146,7 @@ export function request<T = any>(config: RequestConfig): Promise<ApiResponse<T>>
Taro.request({ Taro.request({
...restConfig, ...restConfig,
url: BASE_URL + restConfig.url, url: BASE_URL + restConfig.url,
data: sanitizeData(restConfig.data, restConfig.method),
header, header,
timeout: restConfig.timeout || DEFAULT_TIMEOUT, timeout: restConfig.timeout || DEFAULT_TIMEOUT,
success(res) { success(res) {
+58
View File
@@ -0,0 +1,58 @@
import Taro from '@tarojs/taro'
import { BASE_URL, getToken } from '@/utils/request'
import type { ApiResponse } from '@/types/api'
/** 上传结果(/mini/upload 返回) */
export interface UploadedFile {
/** 文件 ID(提交业务接口时使用的 voucher_ids 元素) */
id: number
/** 预览地址 */
url: string
}
/**
* 上传单张图片到 /mini/upload(凭证等场景,≤5MB
* uploadFile 不受 request 层封装(multipart),此处自行解析统一响应结构并 toast
*/
export function uploadImage(filePath: string): Promise<UploadedFile> {
const token = getToken()
return new Promise((resolve, reject) => {
Taro.uploadFile({
url: `${BASE_URL}/mini/upload`,
filePath,
name: 'file',
header: token ? { Authorization: `Bearer ${token}` } : {},
success(res) {
let body: ApiResponse<UploadedFile> | null = null
try {
body = JSON.parse(res.data)
} catch {
// 非 JSON 响应(网关错误页等)
}
if (res.statusCode >= 200 && res.statusCode < 300 && body?.success) {
resolve(body.data)
return
}
const msg = body?.msg || `上传失败(${res.statusCode}`
Taro.showToast({ title: msg, icon: 'none' })
reject(new Error(msg))
},
fail(err) {
Taro.showToast({ title: '上传失败,请检查网络', icon: 'none' })
reject(err)
},
})
})
}
/**
* 选择并上传图片:一次选择 count 张,逐张上传,全部成功才返回
*/
export async function chooseAndUploadImages(count: number): Promise<UploadedFile[]> {
const res = await Taro.chooseImage({ count, sizeType: ['compressed'] })
const files: UploadedFile[] = []
for (const path of res.tempFilePaths) {
files.push(await uploadImage(path))
}
return files
}