账单与支付
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 状态筛选 + 导出入口 =====
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '支付详情',
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: '支付记录',
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '账单付款',
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '商品详情',
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 账单关联订单 */
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user