账单与支付

This commit is contained in:
liu
2026-08-14 23:48:15 +08:00
parent 4138164bd8
commit 3bd26acbb9
32 changed files with 2052 additions and 475 deletions
+4
View File
@@ -2,12 +2,16 @@ export default defineAppConfig({
pages: [
'pages/index/index',
'pages/product/index',
'pages/product-detail/index',
'pages/cart/index',
'pages/message/index',
'pages/profile/index',
'pages/order-list/index',
'pages/bill/index',
'pages/bill-detail/index',
'pages/payment/index',
'pages/payment-records/index',
'pages/payment-detail/index',
'pages/settings/index',
'pages/login/index',
'pages/register/index',
+61
View File
@@ -4,6 +4,11 @@
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
// 可付款时为底部操作栏留出空间
&--pay {
padding-bottom: 160rpx;
}
.bill-card {
background: #fff;
border-radius: 16rpx;
@@ -67,6 +72,11 @@
font-size: 26rpx;
color: #323233;
flex-shrink: 0;
// 回筐抵扣(负附加金额)
&--return {
color: #07c160;
}
}
&__total {
@@ -104,6 +114,15 @@
border-bottom: none;
}
&__img {
width: 72rpx;
height: 72rpx;
border-radius: 8rpx;
background: #f2f3f5;
flex-shrink: 0;
margin-right: 16rpx;
}
&__main {
flex: 1;
min-width: 0;
@@ -261,4 +280,46 @@
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;
}
}
}
+44 -6
View File
@@ -1,10 +1,11 @@
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components'
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 { resolveFileUrl } from '@/utils/format'
import type { OrderDetail } from '@/types/order'
import type { BillDetail } from '@/services/bill'
import './index.less'
@@ -44,6 +45,11 @@ export default function BillDetailPage() {
}
}, [])
/** 去付款 → 发起付款页(预选本账单) */
const goPay = () => {
Taro.navigateTo({ url: `/pages/payment/index` })
}
if (loading && !detail) {
return <View className='bill-detail'><Empty description='加载中...' /></View>
}
@@ -53,8 +59,21 @@ 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}`
return (
<View className='bill-detail'>
<View className={`bill-detail ${bill.can_pay ? 'bill-detail--pay' : ''}`}>
{/* ===== 账单信息 ===== */}
<View className='bill-card'>
<View className='bill-card__header'>
@@ -114,10 +133,10 @@ export default function BillDetailPage() {
<Text className='bill-card__value'>{bill.delivery_fee}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'>
{bill.box_num}×{bill.box_price} {bill.tray_num}×{bill.tray_price}
<Text className='bill-card__label'>{addedLabel}{boxPart}{trayPart}</Text>
<Text className={`bill-card__value ${addedNum < 0 ? 'bill-card__value--return' : ''}`}>
{addedText}
</Text>
<Text className='bill-card__value'>{bill.added_amount}</Text>
</View>
<View className='bill-card__row bill-card__row--total'>
<Text className='bill-card__label'></Text>
@@ -131,6 +150,14 @@ export default function BillDetailPage() {
<Text className='bill-section__desc'></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'>
<Text className='bill-goods__name'>{item.product_name}</Text>
<Text className='bill-goods__spec'>
@@ -200,6 +227,17 @@ export default function BillDetailPage() {
</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>
)
}
+16
View File
@@ -40,6 +40,22 @@
font-size: 40rpx;
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;
border-radius: 999rpx;
}
}
// ===== 状态筛选 + 导出入口 =====
+9 -1
View File
@@ -102,6 +102,11 @@ export default function BillListPage() {
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${id}` })
}, [])
/** 合并付款 → 发起付款页 */
const goPay = useCallback(() => {
Taro.navigateTo({ url: '/pages/payment/index' })
}, [])
/** 进入/退出多选导出模式 */
const toggleSelectMode = useCallback(() => {
setSelectMode(prev => !prev)
@@ -189,7 +194,10 @@ export default function BillListPage() {
<Text className='bill-summary__label'></Text>
<Text className='bill-summary__count'>{summary.unpaid_count} </Text>
</View>
<Text className='bill-summary__amount'>{summary.unpaid_amount}</Text>
<View className='bill-summary__right'>
<Text className='bill-summary__amount'>{summary.unpaid_amount}</Text>
<View className='bill-summary__pay' onClick={goPay}></View>
</View>
</View>
)}
+6 -6
View File
@@ -73,12 +73,12 @@
width: 160px;
height: 160px;
border-radius: 50%;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
background: linear-gradient(160deg, #ee0a24 0%, #ff4d4f 100%);
display: flex;
align-items: center;
justify-content: center;
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 {
@@ -123,7 +123,7 @@
width: 100%;
height: 96px;
line-height: 96px;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
background: linear-gradient(160deg, #ee0a24 0%, #ff4d4f 100%);
color: #fff;
font-size: 34px;
font-weight: 500;
@@ -131,7 +131,7 @@
border-radius: 48px;
text-align: center;
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;
/* 重置微信 Button 默认样式 */
@@ -158,7 +158,7 @@
.switch-link {
font-size: 28px;
color: #1989fa;
color: #ee0a24;
margin-left: 8px;
}
}
@@ -179,6 +179,6 @@
.agree-link {
font-size: 24px;
color: #1989fa;
color: #ee0a24;
}
}
+55 -4
View File
@@ -77,15 +77,66 @@
}
&__preview {
margin-top: 12rpx;
font-size: 24rpx;
color: #646566;
display: block;
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 {
margin-top: 12rpx;
display: flex;
+30 -7
View File
@@ -1,10 +1,11 @@
import { useCallback, useRef, useState } from 'react'
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 useAuthStore from '@/stores/auth/useAuthStore'
import { cancelOrderApi, getOrderDetailApi, getOrderListApi } from '@/services/order'
import { ORDER_STATUS_FILTERS, ORDER_STATUS_TEXT } from '@/types/order'
import { resolveFileUrl } from '@/utils/format'
import type { OrderDetail, OrderListItem, OrderStatus } from '@/types/order'
import './index.less'
@@ -167,11 +168,33 @@ export default function OrderListPage() {
{order.status_name}
</Text>
</View>
{/* 商品预览(仅前 3 条,完整明细见详情) */}
<Text className='order-item__preview'>
{order.items.map(i => `${i.product_name}×${i.quantity}`).join('、')}
{order.item_count > 3 ? `${order.item_count}` : ''}
</Text>
{/* 商品预览 */}
<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>
{!!i.product_spec && (
<Text className='order-item__goods-spec'>{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 className='order-item__body'>
<Text className='order-item__date'> {order.order_date}</Text>
<View className='order-item__amounts'>
@@ -239,7 +262,7 @@ export default function OrderListPage() {
<View className='detail-popup__item-info'>
<Text className='detail-popup__item-name'>{item.product_name}</Text>
<Text className='detail-popup__item-spec'>
{item.product_spec ? `${item.product_spec} ` : ''}{item.price}/{item.unit} × {item.quantity}
{item.product_spec ? `${item.product_spec}${item.unit} ` : ''}{item.price} × {item.quantity}
</Text>
</View>
<Text className='detail-popup__item-amount'>{item.amount}</Text>
+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;
}
}
}
+143
View File
@@ -0,0 +1,143 @@
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 { 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)
useEffect(() => {
if (!id) return
setLoading(true)
getPaymentDetailApi(id)
.then(res => setDetail(res.data))
.catch(() => {})
.finally(() => setLoading(false))
}, [id])
/** 预览凭证图片 */
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])
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)
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}`}>
{PAY_STATUS_NAMES[payment.status]}
</Text>
</View>
<Text className='pay-card__amount'>{payment.amount}</Text>
{payment.status === 0 && (
<Text className='pay-card__tip'></Text>
)}
{payment.status === 2 && (
<Text className='pay-card__tip pay-card__tip--reject'>
{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>
)}
{payment.remark && (
<View className='pay-card__row'>
<Text className='pay-card__label'></Text>
<Text className='pay-card__value'>{payment.remark}</Text>
</View>
)}
</View>
{/* ===== 汇款凭证 ===== */}
<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>
)}
</View>
)
}
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '支付记录',
})
+130
View File
@@ -0,0 +1,130 @@
.payment-page {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
.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;
}
}
.payment-empty {
padding-top: 120rpx;
&__btn {
margin-top: 24rpx;
padding: 14rpx 60rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
border-radius: 999rpx;
}
}
.payment-loading {
padding: 30rpx 0;
text-align: center;
font-size: 24rpx;
color: #c8c9cc;
}
// ===== 支付记录单项 =====
.payment-item {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
&__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; }
}
&__body {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
&__meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&__method {
font-size: 24rpx;
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 {
font-size: 32rpx;
color: #323233;
font-weight: 600;
}
&__bills {
margin-top: 6rpx;
font-size: 22rpx;
color: #969799;
}
&__reject {
display: block;
margin-top: 12rpx;
padding: 12rpx 16rpx;
background: #fff5f5;
border-radius: 8rpx;
font-size: 22rpx;
color: #ee0a24;
line-height: 1.5;
}
}
}
+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, PAY_METHOD_NAMES, PAY_STATUS_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}`}>
{PAY_STATUS_NAMES[record.status]}
</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.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;
}
}
}
}
+348
View File
@@ -0,0 +1,348 @@
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 { createPaymentApi, getPaymentConfigApi } 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
/** 支付方式选项 */
const PAY_METHODS: Array<{ value: PayMethod; label: string; icon: string; desc: string }> = [
{ value: 1, label: '微信支付', icon: 'wechat', desc: '扫码完成转账' },
{ value: 2, label: '支付宝', icon: 'alipay', desc: '扫码完成转账' },
{ value: 3, label: '对公汇款', icon: 'credit-pay', 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)
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>(1)
const [vouchers, setVouchers] = useState<UploadedFile[]>([])
const [remark, setRemark] = useState('')
const [uploading, setUploading] = useState(false)
const [submitting, setSubmitting] = useState(false)
/** 拉取可付款账单(首次加载应用路由预选) */
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)
if (presetRef.current) {
const preset = presetRef.current
presetRef.current = null
setSelectedIds(prev => Array.from(new Set([...prev, ...preset])))
}
} 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 handleSubmit = 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])
/** 当前支付方式的收款展示 */
const renderMethodContent = () => {
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>
{/* ========== 汇款凭证 ========== */}
<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='如:汇款人姓名、转账时间等'
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 ? '提交中...' : '提交付款'}
</View>
</View>
)}
</View>
)
}
+3
View File
@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '商品详情',
})
+152
View File
@@ -0,0 +1,152 @@
.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;
}
}
&__unit {
margin-left: 8rpx;
font-size: 24rpx;
color: #969799;
}
&__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;
}
}
}
}
+177
View File
@@ -0,0 +1,177 @@
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 { 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>
)}
<Text className='goods-card__unit'>/{product.unit}</Text>
</View>
<Text className='goods-card__name'>{product.name}</Text>
<Text className='goods-card__spec'>{product.spec}</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>
)
}
+14 -2
View File
@@ -190,6 +190,11 @@ export default function ProductPage() {
setShowPopup(true)
}, [])
/** 跳转商品详情 */
const goDetail = useCallback((id: number) => {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
}, [])
/** 确认加购 */
const handleConfirmAdd = useCallback(async () => {
if (!current || addingRef.current) return
@@ -274,7 +279,7 @@ export default function ProductPage() {
<Empty description='暂无商品' className='product-empty' />
) : (
products.map(product => (
<View key={product.id} className='product-item'>
<View key={product.id} className='product-item' onClick={() => goDetail(product.id)}>
<Image
className='product-item__image'
src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
@@ -290,7 +295,13 @@ export default function ProductPage() {
) : (
<Text className='product-item__price product-item__price--none'></Text>
)}
<View className='product-item__add' onClick={() => handleAddTap(product)}>
<View
className='product-item__add'
onClick={e => {
e.stopPropagation()
handleAddTap(product)
}}
>
<Text className='product-item__add-icon'></Text>
</View>
</View>
@@ -315,6 +326,7 @@ export default function ProductPage() {
closeable
closeOnClickOverlay
safeAreaInsetBottom
style={{ paddingBottom: '110px' }}
onClose={() => setShowPopup(false)}
>
{current && (
+7 -1
View File
@@ -38,7 +38,7 @@ export default function ProfilePage() {
const loggedIn = !!token && !!user
/** 功能菜单(门店账号追加「门店信息」入口) */
/** 功能菜单(门店账号追加「门店信息」「支付记录」入口) */
const menuItems = useMemo(() => {
if (!user?.store) return MENU_ITEMS
return [
@@ -48,6 +48,12 @@ export default function ProfilePage() {
icon: 'shop-o',
onClick: () => Taro.navigateTo({ url: '/pages/store-info/index' }),
},
{
key: 'payment-records',
label: '支付记录',
icon: 'balance-o',
onClick: () => Taro.navigateTo({ url: '/pages/payment-records/index' }),
},
...MENU_ITEMS,
]
}, [user?.store])
+8 -8
View File
@@ -26,12 +26,12 @@
width: 160px;
height: 160px;
border-radius: 50%;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
background: linear-gradient(160deg, #ee0a24 0%, #ff4d4f 100%);
display: flex;
align-items: center;
justify-content: center;
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 {
@@ -108,8 +108,8 @@
flex: 1;
height: 64px;
line-height: 64px;
background: #e8f7ef;
color: #07c160;
background: #fff0f0;
color: #ee0a24;
font-size: 28px;
font-weight: 500;
border: none;
@@ -135,7 +135,7 @@
width: 100%;
height: 96px;
line-height: 96px;
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
background: linear-gradient(160deg, #ee0a24 0%, #ff4d4f 100%);
color: #fff;
font-size: 34px;
font-weight: 500;
@@ -143,7 +143,7 @@
border-radius: 48px;
text-align: center;
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;
/* 重置微信 Button 默认样式 */
@@ -170,7 +170,7 @@
.switch-link {
font-size: 28px;
color: #1989fa;
color: #ee0a24;
margin-left: 8px;
}
}
@@ -191,6 +191,6 @@
.agree-link {
font-size: 24px;
color: #1989fa;
color: #ee0a24;
}
}
+4 -2
View File
@@ -19,13 +19,13 @@ export interface Bill {
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 */
/** 附加金额 = box_num×box_price + tray_num×tray_price(可能为负数:回筐抵扣) */
added_amount: string
/** 账单总金额 = 商品金额 + 配送费 + 附加金额 */
total_amount: string
@@ -70,6 +70,8 @@ export interface BillItem {
weight: string
/** 合计金额 */
amount: string
/** 商品首图 URL(无图为空字符串) */
image: string
}
/** 账单关联订单 */
+100
View File
@@ -0,0 +1,100 @@
import { get, post } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
/** 支付方式:1 微信 / 2 支付宝 / 3 对公汇款 */
export type PayMethod = 1 | 2 | 3
export const PAY_METHOD_NAMES: Record<PayMethod, string> = {
1: '微信支付',
2: '支付宝',
3: '对公汇款',
}
/** 支付状态:0 待审核 / 1 已通过 / 2 已拒绝 */
export type PayStatus = 0 | 1 | 2
export const PAY_STATUS_NAMES: Record<PayStatus, string> = {
0: '待审核',
1: '已通过',
2: '已拒绝',
}
/** 支付配置(付款页展示,图片为解析后的预览地址,可能为空串) */
export interface PaymentConfig {
wechat_qrcode: string
alipay_qrcode: string
bank_info: string
}
/** 支付记录(列表行与详情的 payment 字段一致) */
export interface Payment {
id: number
/** 支付编号(ZF 前缀) */
payment_no: string
store_id: number
user_id: number
/** 合并付款总金额 */
amount: string
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
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}`)
}
+5
View File
@@ -21,3 +21,8 @@ export interface ProductListParams {
export function getProductListApi(params: ProductListParams = {}) {
return get<PaginatedData<Product>>('/mini/product/list', { data: params })
}
/** 商品详情(免登录;未登录/未绑店/未设等级 price=null;下架或不存在业务报错):GET /mini/product/{id} */
export function getProductDetailApi(id: number) {
return get<Product>(`/mini/product/${id}`)
}
+4
View File
@@ -42,8 +42,12 @@ export const ORDER_NAV_ITEMS: Array<{ key: string; label: string; status?: numbe
/** 订单列表行商品预览(仅前 3 条) */
export interface OrderItemPreview {
product_name: string
/** 规格包规 */
product_spec: string
quantity: number
unit: string
/** 首图 URL(无图为空字符串) */
image: string
}
/** 订单列表行(状态名/可取消/商品预览均由后端给出,直接展示) */
+12 -7
View File
@@ -1,3 +1,5 @@
import { resolveFileUrl } from '@/utils/format'
/** 商品分类节点(children 递归;叶子分类无 children 字段) */
export interface Category {
id: number
@@ -6,10 +8,11 @@ export interface Category {
children?: Category[]
}
/** 商品图片 */
/** 商品图片(SysFile 序列化,含预览地址与文件地址) */
export interface ProductImage {
id: number
file_url: string
preview_url: string
}
/** 商品 */
@@ -22,12 +25,15 @@ export interface Product {
unit: string
/** 商品图文详情(HTML */
content: string
/** 当前门店等级的实际销售价(未设等级为 null */
/** 当前门店等级的实际销售价(未登录/未绑店/未设等级为 null */
price: string | null
images_arr: ProductImage[]
/** 排序 / 保质期 / 库存 / 状态(仅返回上架商品 */
/** 所属分类(详情接口 with 返回 */
category?: { id: number; name: string } | null
/** 排序 / 库存 / 状态(仅返回上架商品) */
sort?: number
shelf_life?: string | null
/** 保质期(天,0=未设置) */
shelf_life?: number | null
stock?: number | null
status?: number
}
@@ -35,7 +41,6 @@ export interface Product {
/** 商品首图地址 */
export function getProductCover(product: Product): string {
const first = product.images_arr?.[0]
if (!first || !first.file_url) return ''
if (/^https?:\/\//i.test(first.file_url)) return first.file_url
return first.file_url
if (!first) return ''
return resolveFileUrl(first.preview_url || first.file_url)
}
+10 -3
View File
@@ -40,11 +40,18 @@ export function formatTime(value?: string): string {
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 {
if (!avatar) return ''
if (/^https?:\/\//i.test(avatar)) return avatar
return `${SERVER_ORIGIN}${avatar.startsWith('/') ? '' : '/'}${avatar}`
return resolveFileUrl(avatar)
}
+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
}