Compare commits

..

13 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
49 changed files with 1615 additions and 388 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
"miniprogramRoot": "./",
"projectname": "pure-project-vantui",
"description": "",
"appid": "wx7ed74d60503b5ee3",
"appid": "wx8f48874e3bf1dccd",
"setting": {
"urlCheck": false,
"es6": true,
+2
View File
@@ -15,6 +15,8 @@ export default defineAppConfig({
'pages/payment-detail/index',
'pages/settings/index',
'pages/login/index',
'pages/agreement/index',
'pages/privacy/index',
'pages/change-password/index',
'pages/store-info/index',
],
+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 Taro from '@tarojs/taro'
import {View, Text, Image} from '@tarojs/components'
import useAuthStore from '@/stores/auth/useAuthStore'
import useCartStore from '@/stores/cart/useCartStore'
import IndexImage from '@/static/images/nav/index.png';
import IndexActiveImage from '@/static/images/nav/index_active.png';
import CartImage from '@/static/images/nav/cart.png';
+5 -3
View File
@@ -15,13 +15,15 @@ interface PriceTextProps {
* - block 独占一行(窄卡片:首页推荐、商品列表、购物车)
*/
mode?: 'inline' | 'block'
/** 单位 */
price_unit?: string | null
}
/**
* 商品价格:售价 + 零售价标注(小字)
* price=30、spec=15 → ¥30 零售价:¥2
*/
export default function PriceText({ price, spec, className, mode = 'inline' }: PriceTextProps) {
export default function PriceText({ price, spec, className, mode = 'inline', price_unit }: PriceTextProps) {
const retail = formatRetailPrice(price, spec)
// 窄卡片:大价格 / 零售价上下两行,避免与右侧按钮(+/步进器)挤压换行
@@ -30,7 +32,7 @@ export default function PriceText({ price, spec, className, mode = 'inline' }: P
<View className={`price-text ${className ?? ''}`}>
<Text className='price-text__main'>{price}</Text>
{retail !== null && (
<Text className='price-text__retail'>{retail}</Text>
<Text className='price-text__retail'>{retail} {price_unit}</Text>
)}
</View>
)
@@ -40,7 +42,7 @@ export default function PriceText({ price, spec, className, mode = 'inline' }: P
<Text className={className}>
{price}
{retail !== null && (
<Text className='price-text__retail price-text__retail--inline'>{retail}</Text>
<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" >
<title>订货采购</title>
<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>
</head>
<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>
)
}
+10 -8
View File
@@ -115,8 +115,8 @@
}
&__img {
width: 72rpx;
height: 72rpx;
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
background: #f2f3f5;
flex-shrink: 0;
@@ -147,14 +147,16 @@
flex-shrink: 0;
}
&__qty {
font-size: 24rpx;
color: #969799;
display: block;
}
&__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;
+34 -33
View File
@@ -5,8 +5,7 @@ import { Empty, Popup } from '@antmjs/vantui'
import { getBillDetailApi } from '@/services/bill'
import { getOrderDetailApi } from '@/services/order'
import { ORDER_STATUS_TEXT } from '@/types/order'
import { formatSpec, resolveFileUrl } from '@/utils/format'
import PriceText from '@/components/PriceText'
import {formatRetailPrice, formatSpec, resolveFileUrl} from '@/utils/format'
import type { OrderDetail } from '@/types/order'
import type { BillDetail } from '@/services/bill'
import './index.less'
@@ -60,18 +59,9 @@ export default function BillDetailPage() {
const { bill, items, orders } = detail
/** 附加金额:正=压筐附加,负=回筐抵扣(0 不带头符号) */
const addedNum = Number(bill.added_amount)
const addedLabel = addedNum < 0 ? '回筐抵扣' : '压筐附加'
const addedText =
addedNum < 0
? `-¥${Math.abs(addedNum).toFixed(2)}`
: addedNum > 0
? `+¥${addedNum.toFixed(2)}`
: '¥0.00'
/** 筐/托盘明细:正压负回,数量取绝对值(单价为出账时快照) */
const boxPart = `${bill.box_num < 0 ? '回筐' : '压筐'} ${Math.abs(bill.box_num)}×¥${bill.box_price}`
const trayPart = `${bill.tray_num < 0 ? '回托盘' : '压托盘'} ${Math.abs(bill.tray_num)}×¥${bill.tray_price}`
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' : ''}`}>
@@ -114,17 +104,11 @@ export default function BillDetailPage() {
<Text className='bill-card__value'>{bill.pay_remark}</Text>
</View>
)}
{bill.remark && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.remark}</Text>
</View>
)}
</View>
{/* ===== 金额构成 ===== */}
<View className='bill-card'>
<Text className='bill-section__title'></Text>
<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>
@@ -134,11 +118,27 @@ export default function BillDetailPage() {
<Text className='bill-card__value'>{bill.delivery_fee}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'>{addedLabel}{boxPart}{trayPart}</Text>
<Text className={`bill-card__value ${addedNum < 0 ? 'bill-card__value--return' : ''}`}>
{addedText}
<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>
@@ -148,7 +148,6 @@ export default function BillDetailPage() {
{/* ===== 商品明细(跨订单按商品合并) ===== */}
<View className='bill-card'>
<Text className='bill-section__title'>{items?.length ?? 0}</Text>
<Text className='bill-section__desc'></Text>
{(items ?? []).map(item => (
<View key={item.product_id} className='bill-goods'>
{!!item.image && (
@@ -160,15 +159,17 @@ export default function BillDetailPage() {
/>
)}
<View className='bill-goods__main'>
<Text className='bill-goods__name'>{item.product_name}</Text>
<Text className='bill-goods__spec'>
{formatSpec(item.product_spec, item.unit)}{' '}
<PriceText price={item.price} spec={item.product_spec} />
</Text>
<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__qty'>×{item.quantity}</Text>
<Text className='bill-goods__amount'>{item.amount}</Text>
<Text className='bill-goods__price'>{item.price} × {item.quantity}</Text>
<View className='bill-goods__amount'>{item.amount}</View>
</View>
</View>
))}
@@ -217,10 +218,10 @@ export default function BillDetailPage() {
<Text className='order-popup__item-name'>{item.product_name}</Text>
<Text className='order-popup__item-spec'>
{formatSpec(item.product_spec, item.unit)}{' '}
<PriceText price={item.price} spec={item.product_spec} /> × {item.quantity}
{formatRetailPrice(item.price, item.product_spec)} {item.price_unit}
</Text>
</View>
<Text className='order-popup__item-amount'>{item.amount}</Text>
<Text className='order-popup__item-amount'>{item.price} × {item.quantity}</Text>
</View>
))}
</ScrollView>
+26 -29
View File
@@ -9,16 +9,24 @@
padding-bottom: 160rpx;
}
// ===== 待支付汇总 =====
.bill-summary {
// 底部待支付汇总栏留出空间
&--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;
background: linear-gradient(135deg, #ee0a24, #ff4d4f);
border-radius: 16rpx;
padding: 28rpx;
margin-bottom: 20rpx;
color: #fff;
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;
@@ -26,35 +34,24 @@
}
&__label {
font-size: 24rpx;
opacity: 0.85;
}
&__count {
margin-top: 8rpx;
font-size: 28rpx;
font-weight: 600;
font-size: 22rpx;
color: #969799;
}
&__amount {
font-size: 40rpx;
margin-top: 4rpx;
font-size: 36rpx;
color: #ee0a24;
font-weight: 600;
}
&__right {
display: flex;
flex-direction: column;
align-items: flex-end;
}
&__pay {
margin-top: 12rpx;
padding: 8rpx 32rpx;
background: #fff;
color: #ee0a24;
font-size: 24rpx;
font-weight: 500;
&__btn {
padding: 16rpx 56rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #ee0a24, #ff6034);
color: #fff;
font-size: 28rpx;
font-weight: 500;
}
}
+16 -16
View File
@@ -29,7 +29,7 @@ interface CategoryOption {
/**
* 账单列表页
* 采购单完成后由后台按门店生成(只读);头部 summary 为门店口径待支付汇总(含审核中,不受筛选影响)
* 采购单完成后由后台按门店生成(只读);底部汇总栏为门店口径待支付汇总(含审核中,不受筛选影响)
* 支持多选账单合并导出 Excel(可按一级分类过滤商品明细)
*/
export default function BillListPage() {
@@ -185,22 +185,11 @@ export default function BillListPage() {
[selectedIds, toggleSelectMode],
)
return (
<View className={`bill-page ${selectMode ? 'bill-page--select' : ''}`}>
{/* ========== 待支付汇总(门店口径,含审核中) ========== */}
{loggedIn && summary && summary.unpaid_count > 0 && (
<View className='bill-summary'>
<View className='bill-summary__info'>
<Text className='bill-summary__label'></Text>
<Text className='bill-summary__count'>{summary.unpaid_count} </Text>
</View>
<View className='bill-summary__right'>
<Text className='bill-summary__amount'>{summary.unpaid_amount}</Text>
<View className='bill-summary__pay' onClick={goPay}></View>
</View>
</View>
)}
/** 底部待支付汇总栏是否可见(多选导出时让位给导出栏) */
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'>
@@ -278,6 +267,17 @@ export default function BillListPage() {
<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'>
+21 -3
View File
@@ -24,6 +24,15 @@
.cart-empty {
padding-top: 160rpx;
&__btn {
margin-top: 24rpx;
padding: 14rpx 60rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
border-radius: 999rpx;
}
}
.cart-loading {
@@ -50,8 +59,8 @@
}
&__image {
width: 150rpx;
height: 150rpx;
width: 180rpx;
height: 180rpx;
border-radius: 12rpx;
background: #f2f3f5;
flex-shrink: 0;
@@ -79,6 +88,15 @@
white-space: nowrap;
}
&__spec-tag {
margin-left: 12rpx;
font-size: 24rpx;
color: #969799;
border-radius: 6rpx;
padding: 2rpx 8rpx;
flex-shrink: 0;
}
&__invalid-tag {
margin-left: 12rpx;
font-size: 20rpx;
@@ -90,7 +108,6 @@
}
&__spec {
margin-top: 10rpx;
font-size: 24rpx;
color: #969799;
}
@@ -158,6 +175,7 @@
padding: 16rpx 24rpx;
border-top: 1rpx solid #ebedf0;
box-sizing: border-box;
z-index: 99;
&__total {
flex: 1;
+31 -14
View File
@@ -2,16 +2,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text, Image, Textarea, ScrollView } from '@tarojs/components'
import { Button, Empty, Icon, Popup, Stepper } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import useCartStore from '@/stores/cart/useCartStore'
import { createOrderApi } from '@/services/order'
import { getStoreInfoApi } from '@/services/store'
import PriceText from '@/components/PriceText'
import { formatSpec } from '@/utils/format'
import {formatRetailPrice, formatSpec} from '@/utils/format'
import type { CartItem } from '@/types/cart'
import type { StoreDetail } from '@/types/store'
import './index.less'
import CustomTabBar from "@/components/CustomTabBar";
export default function CartPage() {
const token = useAuthStore(s => s.token)
const items = useCartStore(s => s.items)
const totalQuantity = useCartStore(s => s.totalQuantity)
const totalAmount = useCartStore(s => s.totalAmount)
@@ -39,6 +41,12 @@ export default function CartPage() {
const purchasable = items.filter(item => item.status === 1)
const hasInvalid = items.length > 0 && purchasable.length < items.length
const loggedIn = !!token
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
/** 拉取门店配送信息 */
const fetchStoreInfo = useCallback(() => {
setStoreLoading(true)
@@ -49,6 +57,8 @@ export default function CartPage() {
}, [])
useDidShow(() => {
// 未登录不请求接口,直接展示去登录空态(参考消息页)
if (!loggedIn) return
fetchCart().catch(() => {})
// 从门店信息编辑页返回且弹层仍打开时 → 刷新配送信息
if (showOrder) fetchStoreInfo()
@@ -149,7 +159,7 @@ export default function CartPage() {
if (submitting) return
setSubmitting(true)
try {
const res = await createOrderApi({
await createOrderApi({
items: purchasable.map(item => ({
product_id: item.product_id,
quantity: Number(qtyMap[item.id] ?? item.quantity),
@@ -177,13 +187,17 @@ export default function CartPage() {
{/* ========== 头部 ========== */}
<View className='cart-header'>
<Text className='cart-header__title'></Text>
{items.length > 0 && (
{loggedIn && items.length > 0 && (
<Text className='cart-header__clear' onClick={handleClear}></Text>
)}
</View>
{/* ========== 列表 ========== */}
{items.length === 0 ? (
{!loggedIn ? (
<Empty description='登录后查看购物车' className='cart-empty'>
<View className='cart-empty__btn' onClick={goLogin}></View>
</Empty>
) : items.length === 0 ? (
loading ? (
<View className='cart-loading'><Text>...</Text></View>
) : (
@@ -207,13 +221,12 @@ export default function CartPage() {
<Text className='cart-item__name'>{item.name}</Text>
{item.status === 0 && <Text className='cart-item__invalid-tag'></Text>}
</View>
<Text className='cart-item__spec'>{formatSpec(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'>
{item.price !== null ? (
<PriceText className='cart-item__price' price={item.price} spec={item.spec} mode='block' />
) : (
<Text className='cart-item__price cart-item__price--none'></Text>
)}
<Text className='cart-item__price'>{item.price}</Text>
{item.status === 1 ? (
<Stepper
value={displayQty(item)}
@@ -238,17 +251,19 @@ export default function CartPage() {
))
)}
{hasInvalid && (
<View style={{ height: 100 }}></View>
{loggedIn && hasInvalid && (
<View className='cart-invalid-hint'>
<Text></Text>
</View>
)}
{/* ========== 底部结算栏 ========== */}
{items.length > 0 && (
{loggedIn && items.length > 0 && (
<View className='cart-footer'>
<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>
</View>
<Button type='danger' className='cart-footer__submit' onClick={handleOrderTap}>
@@ -337,6 +352,8 @@ export default function CartPage() {
</View>
</View>
</Popup>
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View>
)
}
+17 -6
View File
@@ -1,7 +1,8 @@
.home-page {
min-height: 100vh;
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;
// ===== 自定义顶部导航栏 =====
@@ -234,6 +235,17 @@
color: #969799;
}
&__loading {
display: flex;
justify-content: center;
padding: 20rpx 0 8rpx;
}
&__loading-text {
font-size: 24rpx;
color: #969799;
}
&__login-btn {
margin-top: 24rpx;
padding: 14rpx 60rpx;
@@ -273,8 +285,8 @@
font-size: 28rpx;
color: #323233;
font-weight: 500;
display: block;
overflow: hidden;
margin-right: 20rpx;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -283,14 +295,13 @@
margin-top: 8rpx;
font-size: 22rpx;
color: #969799;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__bottom {
margin-top: 16rpx;
margin-top: 8rpx;
display: flex;
align-items: center;
justify-content: space-between;
@@ -309,8 +320,8 @@
}
&__add {
width: 52rpx;
height: 52rpx;
width: 42rpx;
height: 42rpx;
border-radius: 50%;
background: linear-gradient(135deg, #ee0a24, #ff6034);
display: flex;
+129 -29
View File
@@ -1,22 +1,27 @@
import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { useCallback, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components'
import { Grid, GridItem, Icon, Search, Swiper, SwiperItem } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import useCartStore from '@/stores/cart/useCartStore'
import { getHomeConfigApi } from '@/services/home'
import type { HomeConfig } from '@/services/home'
import { getProductListApi } from '@/services/product'
import { getSpecialListApi, normalizeSpecialCart } from '@/services/special'
import { getProductCover } from '@/types/product'
import type { Product } from '@/types/product'
import PriceText from '@/components/PriceText'
import { formatSpec } from '@/utils/format'
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 CustomTabBar from "@/components/CustomTabBar";
/** 首页 → 商品页的本地存储传参 key(switchTab 无法带参) */
const PENDING_CATEGORY_KEY = 'product_category_id'
const PENDING_KEYWORD_KEY = 'product_keyword'
/** 特价推荐每页条数 */
const SPECIAL_PAGE_SIZE = 10
/** tabBar 页面路径(link 跳转需改用 switchTab */
const TAB_PATHS = [
'pages/index/index',
@@ -41,11 +46,21 @@ function getStatusBarHeight(): number {
export default function IndexPage() {
const addItem = useCartStore(s => s.addItem)
const setSummary = useCartStore(s => s.setSummary)
/** 首页配置(轮播图 / 宫格导航 / 促销卡片) */
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('')
@@ -53,28 +68,58 @@ export default function IndexPage() {
useDidShow(() => {
loadHomeConfig()
loadRecommend()
loadSpecials(1, true)
})
/** 首页配置聚合数据 */
/** 上拉加载更多特价推荐 */
useReachBottom(() => {
if (!specialHasMore) return
loadSpecials(specialPage + 1, false)
})
/** 首页配置聚合数据(响应附带悬浮球汇总) */
const loadHomeConfig = useCallback(async () => {
try {
const res = await getHomeConfigApi()
setConfig(res.data)
// 旧版本后端可能未返回 cart 块
if (res.data.cart) setSummary(res.data.cart)
} catch {
// 错误已由 request 层 toast
}
}, [])
}, [setSummary])
/** 推荐商品 */
const loadRecommend = useCallback(async () => {
/**
* 特价推荐商品(reset 时回到第一页整体替换)。
* 行结构与 /mini/product/list 一致;响应附带悬浮球汇总
*/
const loadSpecials = useCallback(
async (pageNum: number, reset: boolean) => {
if (!reset && specialLoadingRef.current) return
const seq = ++specialSeqRef.current
specialLoadingRef.current = true
if (!reset) setSpecialLoading(true)
try {
const res = await getProductListApi({ page: 1, pageSize: 10 })
setProducts(res.data.data)
const res = await getSpecialListApi({ page: pageNum, pageSize: SPECIAL_PAGE_SIZE })
if (seq !== specialSeqRef.current) return // 已有更新的请求,丢弃本次响应
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],
)
/**
* 后台配置的 link 统一跳转:
@@ -102,25 +147,46 @@ export default function IndexPage() {
Taro.switchTab({ url: '/pages/product/index' })
}, [])
/** 跳转商品详情 */
const goDetail = useCallback((id: number) => {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
}, [])
/** 搜索框聚焦/提交 → 商品页搜索 */
const handleSearchFocus = useCallback(() => {
goProduct(keyword.trim())
}, [goProduct, keyword])
/** 快捷加购 */
/** 行内加减购确认后回写特价商品项的购物车字段 */
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
setSpecials(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
}, [])
/** 快捷加购(用返回的购物车行回写,卡片随即展示加减器) */
const handleQuickAdd = useCallback(
async (product: Product, e: any) => {
e.stopPropagation()
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' })
} catch {
// 错误(未设等级价等)已由 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 (
<View className='home-page'>
{/* ========== 自定义顶部导航栏 ========== */}
@@ -223,24 +289,25 @@ export default function IndexPage() {
</View>
)}
{/* ========== 推荐商品 ========== */}
{/* ========== 特价推荐 ========== */}
<View className='home-recommend'>
<View className='home-recommend__header'>
<View className='home-recommend__title-wrap'>
<View className='home-recommend__title-bar' />
<Text className='home-recommend__title'></Text>
<Text className='home-recommend__title'></Text>
</View>
<Text className='home-recommend__more' onClick={() => goProduct()}> </Text>
</View>
{ products.length === 0 ? (
{ specials.length === 0 ? (
<View className='home-recommend__empty'>
<Text className='home-recommend__empty-text'></Text>
<Text className='home-recommend__empty-text'></Text>
</View>
) : (
<>
<View className='product-grid'>
{products.map(product => (
<View key={product.id} className='product-card' onClick={() => goProduct()}>
{specials.map(product => (
<View key={product.id} className='product-card' onClick={() => goDetail(product.id)}>
<Image
className='product-card__image'
src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
@@ -249,23 +316,56 @@ export default function IndexPage() {
/>
<View className='product-card__info'>
<Text className='product-card__name'>{product.name}</Text>
<Text className='product-card__spec'>{formatSpec(product.spec, product.unit)}</Text>
<View className='product-card__spec'>
{formatSpec(product.spec, product.unit)}{' '}
<View>
{product.price !== null && <>
{formatRetailPrice(product.price, product.spec)} {product.price_unit}
</>}
</View>
</View>
<View className='product-card__bottom'>
{product.price !== null ? (
<PriceText className='product-card__price' price={product.price} spec={product.spec} mode='block' />
<Text className='product-card__price'>{product.price}</Text>
) : (
<Text className='product-card__price product-card__price--none'></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>
{/* 加载更多状态 */}
{specialLoading && (
<View className='home-recommend__loading'>
<Text className='home-recommend__loading-text'></Text>
</View>
)}
{!specialHasMore && specialPage > 1 && (
<View className='home-recommend__loading'>
<Text className='home-recommend__loading-text'></Text>
</View>
)}
</>
)}
</View>
{/* ========== 购物车悬浮球 ========== */}
<CartBall />
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View>
)
}
+30 -2
View File
@@ -132,6 +132,8 @@
height: 100%;
font-size: 30px;
color: #323233;
display: flex;
align-items: center;
}
.form-input-placeholder {
@@ -184,7 +186,7 @@
}
}
/* ========== 协议文字 ========== */
/* ========== 协议勾选区 ========== */
.login-agreement {
display: flex;
align-items: center;
@@ -193,9 +195,35 @@
margin-top: 32px;
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 {
font-size: 24px;
color: #c8c9cc;
color: #969799;
}
.agree-link {
+23 -9
View File
@@ -1,7 +1,6 @@
import { useCallback, 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'
@@ -18,6 +17,8 @@ export default function LoginPage() {
/** 登录密码 */
const [password, setPassword] = useState('')
const [submitting, setSubmitting] = useState(false)
/** 是否已阅读并同意协议(默认不勾选,须用户自主勾选后才能登录) */
const [agreed, setAgreed] = useState(false)
/** 返回上一页(无页面栈时回首页) */
const goBack = useCallback(() => {
@@ -41,6 +42,10 @@ export default function LoginPage() {
Taro.showToast({ title: '请输入登录密码', icon: 'none' })
return
}
if (!agreed) {
Taro.showToast({ title: '请先阅读并勾选同意《用户服务协议》和《隐私政策》', icon: 'none' })
return
}
setSubmitting(true)
try {
await login({ username: account, password })
@@ -51,22 +56,25 @@ export default function LoginPage() {
} finally {
setSubmitting(false)
}
}, [login, submitting, username, password, goBack])
}, [login, submitting, username, password, agreed, goBack])
/** 查看用户协议 */
/** 查看用户服务协议 */
const handleShowAgreement = useCallback(() => {
Taro.showToast({ title: '用户协议即将上线', icon: 'none' })
Taro.navigateTo({ url: '/pages/agreement/index' })
}, [])
/** 查看隐私政策 */
const handleShowPrivacy = useCallback(() => {
Taro.showToast({ title: '隐私政策即将上线', icon: 'none' })
Taro.navigateTo({ url: '/pages/privacy/index' })
}, [])
/** 勾选/取消勾选协议 */
const toggleAgreed = useCallback(() => {
setAgreed(v => !v)
}, [])
return (
<View className='login-page'>
{/* ========== 导航栏 ========== */}
<CustomNavBar title='登录' />
{/* ========== 内容区域 ========== */}
<View className='login-content'>
@@ -125,9 +133,15 @@ export default function LoginPage() {
</View>
<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>
<Text className='agree-text'></Text>
<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 type { Notice, NoticeType } from '@/types/notice'
import './index.less'
import CustomTabBar from "@/components/CustomTabBar";
const PAGE_SIZE = 10
@@ -123,6 +124,8 @@ export default function MessagePage() {
{loggedIn && finished && notices.length > 0 && (
<View className='message-loading'><Text></Text></View>
)}
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View>
)
}
+3 -3
View File
@@ -5,7 +5,7 @@ import { Empty, Popup } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { cancelOrderApi, getOrderDetailApi, getOrderListApi } from '@/services/order'
import { ORDER_STATUS_FILTERS, ORDER_STATUS_TEXT } from '@/types/order'
import { formatSpec, resolveFileUrl } from '@/utils/format'
import {formatRetailPrice, formatSpec, resolveFileUrl} from '@/utils/format'
import PriceText from '@/components/PriceText'
import type { OrderDetail, OrderListItem, OrderStatus } from '@/types/order'
import './index.less'
@@ -264,10 +264,10 @@ export default function OrderListPage() {
<Text className='detail-popup__item-name'>{item.product_name}</Text>
<Text className='detail-popup__item-spec'>
{formatSpec(item.product_spec, item.unit)}{' '}
<PriceText price={item.price} spec={item.product_spec} /> × {item.quantity}
{formatRetailPrice(item.price, item.product_spec)} {item.price_unit}
</Text>
</View>
<Text className='detail-popup__item-amount'>{item.amount}</Text>
<Text className='detail-popup__item-amount'>{item.price} × {item.quantity}</Text>
</View>
))}
</ScrollView>
+82 -14
View File
@@ -2,15 +2,20 @@ 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, PAY_METHOD_NAMES, PAY_STATUS_NAMES } from '@/services/payment'
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()
@@ -18,16 +23,25 @@ export default function PaymentDetailPage() {
const [detail, setDetail] = useState<PaymentDetail | null>(null)
const [loading, setLoading] = useState(false)
const [syncing, setSyncing] = useState(false)
useEffect(() => {
const loadDetail = useCallback(async () => {
if (!id) return
setLoading(true)
getPaymentDetailApi(id)
.then(res => setDetail(res.data))
.catch(() => {})
.finally(() => setLoading(false))
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)
@@ -39,13 +53,35 @@ export default function PaymentDetailPage() {
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>
}
@@ -55,6 +91,8 @@ export default function PaymentDetailPage() {
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' : ''}`}>
@@ -63,16 +101,23 @@ export default function PaymentDetailPage() {
<View className='pay-card__header'>
<Text className='pay-card__no'>{payment.payment_no}</Text>
<Text className={`pay-card__status pay-card__status--${payment.status}`}>
{PAY_STATUS_NAMES[payment.status]}
{getPayStatusName(payment)}
</Text>
</View>
<Text className='pay-card__amount'>{payment.amount}</Text>
{payment.status === 0 && (
{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'>
{payment.audit_remark ? `${payment.audit_remark}` : ''}
{isOnline
? '支付失败,账单已释放,可重新发起付款'
: `审核未通过${payment.audit_remark ? `${payment.audit_remark}` : ''},账单已释放,可重新发起付款`}
</Text>
)}
<View className='pay-card__row'>
@@ -89,6 +134,18 @@ export default function PaymentDetailPage() {
<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>
@@ -97,7 +154,8 @@ export default function PaymentDetailPage() {
)}
</View>
{/* ===== 汇款凭证 ===== */}
{/* ===== 汇款凭证(在线支付单无凭证) ===== */}
{!isOnline && (
<View className='pay-card'>
<Text className='pay-section__title'>{vouchers.length}</Text>
<View className='pay-vouchers'>
@@ -113,6 +171,7 @@ export default function PaymentDetailPage() {
</View>
{vouchers.length === 0 && <Empty description='暂无凭证图片' />}
</View>
)}
{/* ===== 合并账单 ===== */}
<View className='pay-card'>
@@ -132,12 +191,21 @@ export default function PaymentDetailPage() {
{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>
)
}
+3 -3
View File
@@ -3,7 +3,7 @@ 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, PAY_METHOD_NAMES, PAY_STATUS_NAMES } from '@/services/payment'
import { getPaymentListApi, getPayStatusName, PAY_METHOD_NAMES } from '@/services/payment'
import type { Payment, PayStatus } from '@/services/payment'
import './index.less'
@@ -115,7 +115,7 @@ export default function PaymentRecordsPage() {
<View className='payment-item__header'>
<Text className='payment-item__no'>{record.payment_no}</Text>
<Text className={`payment-item__status payment-item__status--${record.status}`}>
{PAY_STATUS_NAMES[record.status]}
{getPayStatusName(record)}
</Text>
</View>
<View className='payment-item__body'>
@@ -128,7 +128,7 @@ export default function PaymentRecordsPage() {
<Text className='payment-item__bills'> {record.bills_count ?? 0} </Text>
</View>
</View>
{record.status === 2 && !!record.audit_remark && (
{record.status === 2 && record.pay_type !== 2 && !!record.audit_remark && (
<Text className='payment-item__reject'>{record.audit_remark}</Text>
)}
</View>
+273 -24
View File
@@ -4,7 +4,7 @@ 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 { createPaymentApi, getPaymentConfigApi } from '@/services/payment'
import { createOnlinePaymentApi, createPaymentApi, getPaymentConfigApi, queryOnlinePaymentApi } from '@/services/payment'
import { chooseAndUploadImages } from '@/utils/upload'
import { resolveFileUrl } from '@/utils/format'
import type { Bill } from '@/services/bill'
@@ -17,8 +17,72 @@ 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: '银行转账至对公账户' },
@@ -26,22 +90,11 @@ const PAY_METHODS: Array<{ value: PayMethod; label: string; icon: string; desc:
/**
*
* ?payable=1 /
* ?ids=1,2 "去付款"
*/
export default function PaymentPage() {
const router = useRouter()
const token = useAuthStore(s => s.token)
const loggedIn = !!token
/** 预选账单(路由参数,仅在首次加载时消费一次) */
const presetRef = useRef<number[] | null>(
(router.params.ids || '')
.split(',')
.map(Number)
.filter(n => n > 0),
)
const [bills, setBills] = useState<Bill[]>([])
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [page, setPage] = useState(1)
@@ -50,12 +103,15 @@ export default function PaymentPage() {
const loadingRef = useRef(false)
const [config, setConfig] = useState<PaymentConfig | null>(null)
const [payMethod, setPayMethod] = useState<PayMethod>(1)
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) => {
@@ -68,11 +124,7 @@ export default function PaymentPage() {
setBills(prev => (reset ? data : [...prev, ...data]))
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= total)
if (presetRef.current) {
const preset = presetRef.current
presetRef.current = null
setSelectedIds(prev => Array.from(new Set([...prev, ...preset])))
}
setSelectedIds(prev => Array.from(new Set([...prev, ...data.map(i => i.id)])))
} catch {
// 错误已由 request 层 toast
} finally {
@@ -152,8 +204,8 @@ export default function PaymentPage() {
Taro.setClipboardData({ data: config.bank_info })
}, [config])
/** 提交付款申请 */
const handleSubmit = useCallback(async () => {
/** 提交线下凭证付款申请(后台审核) */
const handleVoucherSubmit = useCallback(async () => {
if (submitting) return
if (selectedIds.length === 0) {
Taro.showToast({ title: '请选择要付款的账单', icon: 'none' })
@@ -183,8 +235,203 @@ export default function PaymentPage() {
}
}, [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'>
@@ -284,7 +531,8 @@ export default function PaymentPage() {
{renderMethodContent()}
</View>
{/* ========== 汇款凭证 ========== */}
{/* ========== 汇款凭证(在线支付免凭证) ========== */}
{!isOnline && (
<View className='pay-section'>
<View className='pay-section__header'>
<Text className='pay-section__title'></Text>
@@ -315,6 +563,7 @@ export default function PaymentPage() {
)}
</View>
</View>
)}
{/* ========== 备注 ========== */}
<View className='pay-section'>
@@ -323,7 +572,7 @@ export default function PaymentPage() {
className='pay-remark'
value={remark}
maxlength={255}
placeholder='如:汇款人姓名、转账时间等'
placeholder={isOnline ? '可填写付款说明' : '如:汇款人姓名、转账时间等'}
onInput={e => setRemark(e.detail.value)}
/>
</View>
@@ -339,7 +588,7 @@ export default function PaymentPage() {
className={`pay-bar__btn ${selectedIds.length === 0 || submitting ? 'disabled' : ''}`}
onClick={handleSubmit}
>
{submitting ? '提交中...' : '提交付款'}
{submitting ? (isOnline ? '支付中...' : '提交中...') : isOnline ? '立即支付' : '提交付款'}
</View>
</View>
)}
+4
View File
@@ -0,0 +1,4 @@
export default definePageConfig({
navigationStyle: 'custom',
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>
)
}
+8 -4
View File
@@ -5,8 +5,7 @@ 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 { formatSpec, resolveFileUrl } from '@/utils/format'
import PriceText from '@/components/PriceText'
import {formatRetailPrice, formatSpec, resolveFileUrl} from '@/utils/format'
import type { Product } from '@/types/product'
import './index.less'
@@ -116,7 +115,7 @@ export default function ProductDetailPage() {
<View className='goods-card'>
<View className='goods-card__price-row'>
{product.price !== null ? (
<PriceText className='goods-card__price' price={product.price} spec={product.spec} />
<Text className='goods-card__price'>{product.price}</Text>
) : (
<Text className='goods-card__price goods-card__price--none'>
{loggedIn ? '价格待定' : '登录后查看价格'}
@@ -124,7 +123,12 @@ export default function ProductDetailPage() {
)}
</View>
<Text className='goods-card__name'>{product.name}</Text>
<Text className='goods-card__spec'>{formatSpec(product.spec, product.unit)}</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>
+7 -7
View File
@@ -92,8 +92,9 @@
.product-main {
flex: 1;
min-width: 0;
padding: 20rpx 20rpx 40rpx;
overflow-y: auto;
height: 100%;
// 底部留白避免最后一行被购物车悬浮球遮挡
padding: 20rpx 20rpx 20rpx;
box-sizing: border-box;
}
@@ -110,8 +111,8 @@
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
&__image {
width: 160rpx;
height: 160rpx;
width: 180rpx;
height: 180rpx;
border-radius: 12rpx;
background: #f2f3f5;
flex-shrink: 0;
@@ -135,7 +136,6 @@
}
&__spec {
margin-top: 10rpx;
font-size: 24rpx;
color: #969799;
}
@@ -160,8 +160,8 @@
}
&__add {
width: 56rpx;
height: 56rpx;
width: 42rpx;
height: 42rpx;
border-radius: 50%;
background: #ee0a24;
display: flex;
+52 -87
View File
@@ -1,15 +1,17 @@
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 { Button, Empty, Popup, Search, Stepper } from '@antmjs/vantui'
import { Empty, Search } from '@antmjs/vantui'
import useCartStore from '@/stores/cart/useCartStore'
import { getCategoriesApi, getProductListApi } from '@/services/product'
import type { ProductListParams } from '@/services/product'
import { getProductCover } from '@/types/product'
import type { Category, Product } from '@/types/product'
import PriceText from '@/components/PriceText'
import { formatSpec } from '@/utils/format'
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 CustomTabBar from "@/components/CustomTabBar";
const PAGE_SIZE = 10
/** 存储 key:首页点击分类/搜索跳转时经本地存储传参(switchTab 无法带参) */
@@ -18,6 +20,7 @@ const PENDING_KEYWORD_KEY = 'product_keyword'
export default function ProductPage() {
const addItem = useCartStore(s => s.addItem)
const setSummary = useCartStore(s => s.setSummary)
/** 分类树 */
const [categories, setCategories] = useState<Category[]>([])
@@ -40,12 +43,6 @@ export default function ProductPage() {
/** 是否有请求进行中(仅用于避免"加载更多"并发) */
const loadingRef = useRef(false)
/** 加购弹层 */
const [showPopup, setShowPopup] = useState(false)
const [current, setCurrent] = useState<Product | null>(null)
const [qty, setQty] = useState(1)
const addingRef = useRef(false)
/** 当前选中二级分类所属的一级分类ID(用于父级高亮) */
const activeParentId = useMemo(() => {
if (activeId == null) return null
@@ -70,10 +67,12 @@ export default function ProductPage() {
if (keyword) params.keyword = keyword
const res = await getProductListApi(params)
if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应
const { data, total: totalCount } = res.data
const { data, total: totalCount, cart } = res.data
setProducts(prev => (reset ? data : [...prev, ...data]))
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= totalCount)
// 列表响应附带悬浮球汇总(旧版本后端可能未返回)
if (cart) setSummary(cart)
} catch {
// 错误已由 request 层 toast
} finally {
@@ -83,7 +82,7 @@ export default function ProductPage() {
}
}
},
[effectiveCategoryId, searchKey],
[effectiveCategoryId, searchKey, setSummary],
)
/** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */
@@ -149,11 +148,12 @@ export default function ProductPage() {
fetchList(1, true, pendingKeyword ?? undefined)
})
useReachBottom(() => {
/** 右侧列表触底加载(页面为固定布局不滚动,由 ScrollView 触发) */
const handleLoadMore = useCallback(() => {
if (!finished) {
fetchList(page + 1, false)
}
})
}, [finished, page, fetchList])
/** 点击一级分类:有子分类仅展开/收起(不可选中),无子分类则选中 */
const handleTopTap = useCallback((cat: Category) => {
@@ -185,11 +185,9 @@ export default function ProductPage() {
setSearchKey('')
}, [])
/** 打开加购弹层 */
const handleAddTap = useCallback((product: Product) => {
setCurrent(product)
setQty(1)
setShowPopup(true)
/** 行内加减购确认后回写列表项的购物车字段 */
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
setProducts(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
}, [])
/** 跳转商品详情 */
@@ -197,20 +195,16 @@ export default function ProductPage() {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
}, [])
/** 确认加购 */
const handleConfirmAdd = useCallback(async () => {
if (!current || addingRef.current) return
addingRef.current = true
/** 确认加购(用返回的购物车行回写列表项,行内随即展示加减器) */
const handleConfirmAdd = useCallback(async (product: Product) => {
try {
await addItem(current.id, qty)
Taro.showToast({ title: '已加入购物车', icon: 'success' })
setShowPopup(false)
const res = await addItem(product.id, 1)
handleRowSync(product.id, { cart_id: res.id, cart_quantity: res.quantity })
// Taro.showToast({ title: '已加入购物车', icon: 'success' })
} catch {
// 错误(未设等级价/数量上限)已由 request 层 toast
} finally {
addingRef.current = false
}
}, [current, qty, addItem])
}, [addItem, handleRowSync])
return (
<View className='product-page'>
@@ -274,8 +268,13 @@ export default function ProductPage() {
})}
</ScrollView>
{/* ========== 右侧商品列表 ========== */}
<View className='product-main'>
{/* ========== 右侧商品列表ScrollView 滚动 + 触底加载) ========== */}
<ScrollView
scrollY
className='product-main'
lowerThreshold={80}
onScrollToLower={handleLoadMore}
>
{/* 商品列表 */}
{products.length === 0 && !loading ? (
<Empty description='暂无商品' className='product-empty' />
@@ -290,22 +289,34 @@ export default function ProductPage() {
/>
<View className='product-item__info'>
<Text className='product-item__name'>{product.name}</Text>
<Text className='product-item__spec'>{formatSpec(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'>
{product.price !== null ? (
<PriceText className='product-item__price' price={product.price} spec={product.spec} mode='block' />
<Text className='product-item__price'>{product.price}</Text>
) : (
<Text className='product-item__price product-item__price--none'></Text>
)}
{/* 已加购展示行内加减器,否则展示加购按钮(点击开弹层选数量) */}
{Number(product.cart_quantity ?? 0) > 0 ? (
<CartStepper product={product} onSync={handleRowSync} />
) : (
<View
className='product-item__add'
onClick={e => {
e.stopPropagation()
handleAddTap(product)
handleConfirmAdd(product)
}}
>
<Text className='product-item__add-icon'></Text>
</View>
)}
</View>
</View>
</View>
@@ -317,59 +328,13 @@ export default function ProductPage() {
{finished && products.length > 0 && (
<View className='product-loading'><Text></Text></View>
)}
</View>
<View style={{ height: 68 }}></View>
</ScrollView>
</View>
{/* ========== 加购弹层 ========== */}
<Popup
show={showPopup}
position='bottom'
round
closeable
closeOnClickOverlay
safeAreaInsetBottom
style={{ paddingBottom: '110px' }}
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'>{formatSpec(current.spec, current.unit)}</Text>
{current.price !== null ? (
<PriceText className='add-popup__price' price={current.price} spec={current.spec} />
) : (
<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>
{/* ========== 购物车悬浮球 ========== */}
<CartBall />
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View>
)
}
+3
View File
@@ -9,6 +9,7 @@ import type { BillSummary } from '@/services/bill'
import { ORDER_NAV_ITEMS } from '@/types/order'
import { resolveAvatarUrl } from '@/utils/format'
import './index.less'
import CustomTabBar from "@/components/CustomTabBar";
/** 菜单项(订单/账单入口已由上方专区承载,后续单独页面开发时在此追加) */
const MENU_ITEMS = [
@@ -229,6 +230,8 @@ export default function ProfilePage() {
<Text>退</Text>
</View>
)}
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
</View>
)
}
+7 -3
View File
@@ -173,9 +173,13 @@ export default function ReportPage() {
</View>
<View className='report-item__row'>
<Text className='report-item__spec'>
{item.product_spec ? `${item.product_spec} · ` : ''}
{item.quantity}{item.unit}
{parseFloat(item.weight) > 0 ? ` · 称重 ${item.weight}` : ''}
{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>
+3 -3
View File
@@ -21,11 +21,11 @@ export default function SettingsPage() {
<Text className='setting-cell__label'></Text>
<Text className='setting-cell__value'></Text>
</View>
<View className='setting-cell' onClick={() => handlePlaceholder('用户协议')}>
<Text className='setting-cell__label'></Text>
<View className='setting-cell' onClick={() => Taro.navigateTo({ url: '/pages/agreement/index' })}>
<Text className='setting-cell__label'></Text>
<Text className='setting-cell__value'></Text>
</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__value'></Text>
</View>
+4
View File
@@ -44,6 +44,8 @@ export interface Bill {
settlement_date: string
/** 付款时间(已支付时非空) */
paid_at: string | null
/** 售后金额 */
after_sale: string
/** 付款备注 */
pay_remark: string
/** 账单备注 */
@@ -72,6 +74,8 @@ export interface BillItem {
amount: string
/** 商品首图 URL(无图为空字符串) */
image: string
price_unit: string
spec: string
}
/** 账单关联订单 */
+6 -1
View File
@@ -1,5 +1,5 @@
import { del, get, post, put } from '@/utils/request'
import type { CartData } from '@/types/cart'
import type { CartData, CartSummary } from '@/types/cart'
/** 加购 / 改数量返回 */
export interface CartMutationResult {
@@ -17,6 +17,11 @@ export function getCartApi() {
return get<CartData>('/mini/cart')
}
/** 轻量汇总(悬浮球单独刷新用;必须登录,未登录 401):GET /mini/cart/summary */
export function getCartSummaryApi() {
return get<CartSummary>('/mini/cart/summary')
}
/** 修改数量:PUT /mini/cart/{id} */
export function updateCartItemApi(id: number, quantity: number) {
return put<CartMutationResult>(`/mini/cart/${id}`, { quantity })
+3
View File
@@ -1,4 +1,5 @@
import { get } from '@/utils/request'
import type { CartSummary } from '@/types/cart'
/** 首页轮播图项 */
export interface HomeBanner {
@@ -36,6 +37,8 @@ export interface HomeConfig {
banners: HomeBanner[]
navs: HomeNav[]
promos: HomePromo[]
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
cart?: CartSummary
}
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
+82 -6
View File
@@ -1,16 +1,20 @@
import { get, post } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
/** 支付方式:1 微信 / 2 支付宝 / 3 对公汇款 */
export type PayMethod = 1 | 2 | 3
/** 支付方式:1 微信 / 2 支付宝 / 3 对公汇款 / 4 旺铺支付(小程序在线支付) */
export type PayMethod = 1 | 2 | 3 | 4
export const PAY_METHOD_NAMES: Record<PayMethod, string> = {
1: '微信支付',
2: '支付宝',
3: '对公汇款',
4: '微信在线支付',
}
/** 支付状态:0 待审核 / 1 已通过 / 2 已拒绝 */
/** 支付类型:1 线下凭证支付 / 2 在线支付(旧数据可能缺省,缺省按线下处理) */
export type PayType = 1 | 2
/** 支付状态:0 待审核 / 1 已通过 / 2 已拒绝(线下凭证支付单语义) */
export type PayStatus = 0 | 1 | 2
export const PAY_STATUS_NAMES: Record<PayStatus, string> = {
@@ -19,11 +23,27 @@ export const PAY_STATUS_NAMES: Record<PayStatus, string> = {
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 字段一致) */
@@ -35,17 +55,23 @@ export interface Payment {
user_id: number
/** 合并付款总金额 */
amount: string
/** 支付类型:1 线下凭证 / 2 在线支付(旺铺网关) */
pay_type?: PayType
pay_method: PayMethod
/** 凭证图片 ID 数组(模型 casts 为 array */
/** 凭证图片 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
@@ -98,3 +124,53 @@ export function createPaymentApi(data: {
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`)
}
+9 -2
View File
@@ -1,5 +1,6 @@
import { get } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
import type { CartSummary } from '@/types/cart'
import type { Category, Product } from '@/types/product'
/** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */
@@ -17,9 +18,15 @@ export interface ProductListParams {
pageSize?: number
}
/** 商品列表(当前门店等级实际价):GET /mini/product/list */
/** 商品列表响应(分页 + 购物车悬浮球汇总) */
export interface ProductListData extends PaginatedData<Product> {
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
cart?: CartSummary
}
/** 商品列表(当前门店等级实际价 + 行内购物车字段):GET /mini/product/list */
export function getProductListApi(params: ProductListParams = {}) {
return get<PaginatedData<Product>>('/mini/product/list', { data: params })
return get<ProductListData>('/mini/product/list', { data: params })
}
/** 商品详情(免登录;未登录/未绑店/未设等级 price=null;下架或不存在业务报错):GET /mini/product/{id} */
+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),
}
}
+66 -4
View File
@@ -5,13 +5,26 @@ import {
clearCartApi,
deleteCartItemApi,
getCartApi,
getCartSummaryApi,
updateCartItemApi,
} 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 */
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 {
items: CartItem[]
@@ -46,8 +59,8 @@ interface CartState {
loading: boolean
/** 拉取购物车(以服务端为准,金额一律服务端重算) */
fetchCart: () => Promise<void>
/** 加购 */
addItem: (productId: number, quantity: number) => Promise<void>
/** 加购(返回合并后的购物车行,供列表页回写 cart_id/cart_quantity */
addItem: (productId: number, quantity: number) => Promise<CartMutationResult>
/** 修改数量 */
updateQuantity: (id: number, quantity: number) => Promise<void>
/** 删除单项 */
@@ -56,6 +69,12 @@ interface CartState {
clearCart: () => Promise<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 {
...EMPTY_SNAPSHOT,
items: cached?.items ?? [],
@@ -111,8 +142,9 @@ const useCartStore = create<CartState>((set, get) => {
/** 加购:服务端校验上架与等级价,成功后重新同步 */
addItem: async (productId, quantity) => {
await addCartApi({ product_id: productId, quantity })
const res = await addCartApi({ product_id: productId, quantity })
await get().fetchCart()
return res.data
},
/** 修改数量 */
@@ -139,6 +171,36 @@ const useCartStore = create<CartState>((set, get) => {
set(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
/** 1 可购 / 0 商品下架、缺失或未设等级价 */
status: number
price_unit: string
}
/** 购物车列表数据 */
@@ -27,3 +28,16 @@ export interface CartData {
/** 可购项总金额 */
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
}
+1
View File
@@ -85,6 +85,7 @@ export interface OrderItem {
product_name: string
product_spec: string
unit: string
price_unit: string
/** 下单时门店等级实际价快照 */
price: string
quantity: number
+11
View File
@@ -27,6 +27,7 @@ export interface Product {
content: string
/** 当前门店等级的实际销售价(未登录/未绑店/未设等级为 null) */
price: string | null
price_unit: string | null
images_arr: ProductImage[]
/** 所属分类(详情接口 with 返回) */
category?: { id: number; name: string } | null
@@ -36,6 +37,16 @@ export interface Product {
shelf_life?: number | null
stock?: number | null
status?: number
/** 该商品对应的购物车行 ID(不在购物车/未登录为 0;列表加减、删除时需要) */
cart_id?: number
/** 购物车中该商品数量(2 位小数字符串;不在购物车/未登录为 "0.00" */
cart_quantity?: string
}
/** 商品行购物车字段回写(行内加减购确认后更新列表项) */
export interface ProductCartPatch {
cart_id: number
cart_quantity: string
}
/** 商品首图地址 */
+11
View File
@@ -78,3 +78,14 @@ export function formatRetailPrice(price?: string | number | null, spec?: string
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)
}
+1 -1
View File
@@ -10,7 +10,7 @@ const LOGIN_PATH = '/pages/login/index'
/** 默认请求超时(ms */
const DEFAULT_TIMEOUT = 15000
/** 接口根地址(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"
/**