账单与支付

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
-427
View File
@@ -1,427 +0,0 @@
# 小程序订单与账单接口文档
> 适用端:微信小程序(h5 仓库,Taro)
> 覆盖接口:订单列表 / 订单详情、账单列表 / 账单详情
> 更新日期:2026-08-14
## 通用约定
| 项 | 说明 |
|---|---|
| 根地址 | `BASE_URL`(见 `src/utils/request.ts`,如 `http://localhost:8000/index.php` |
| 认证 | 请求头 `Authorization: Bearer {token}`,token 来自登录接口,本地存储 key `auth_token` |
| 响应包络 | `{ success: boolean, data: T, msg?: string, showType?: number }` |
| 分页结构 | `{ data: T[], total: number, pageSize: number, current: number }`(账单列表在此结构上附加 `summary` |
| 失败处理 | `success=false``msg` 为中文错误信息(含参数校验失败);HTTP 401 表示登录过期,需重新登录 |
| 金额字段 | 一律为**字符串**(decimal 序列化,如 `"140.00"`),直接展示即可;参与计算需自行转 number |
| 门店隔离 | 两个列表均强制按当前用户绑定门店过滤,无需也不能传门店参数 |
## 状态枚举
**订单状态(status**
| 值 | 名称 | 说明 |
|---|---|---|
| 0 | 待接单 | 下单成功待后台接单,**仅此状态可取消** |
| 1 | 已接单 | 后台已接单,等待归集生成采购单 |
| 2 | 采购中 | 已归集进采购单 |
| 3 | 配送中 | |
| 4 | 已完成 | |
| 9 | 已取消 | |
**账单支付进度(pay_state**——由后端推导,覆盖原始支付状态(status)的展示口径
| 值 | 名称 | 推导条件 | 说明 |
|---|---|---|---|
| 0 | 待支付 | status=0 且 payment_id=0 | `can_pay=true`,可发起合并付款 |
| 1 | 审核中 | status=0 且 payment_id>0 | 已提交合并付款凭证,待后台审核;**审核拒绝后自动回到待支付** |
| 2 | 已支付 | status=1 | 线上审核通过或后台线下收款登记 |
---
## GET /mini/order
门店历史订单列表,按订货日期倒序(同日期按 ID 倒序)分页。行数据附带**商品预览**(前 3 条明细),完整明细请调订单详情。
- **权限**:需登录且已绑定正常门店
### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| status | number | 否 | 订单状态(见枚举表),不传=全部 |
| start_date | string | 否 | 订货日期起,`Y-m-d`;可配合 `/mini/order/summary` 返回的 `period_label` 下钻 |
| end_date | string | 否 | 订货日期止,`Y-m-d`,不得早于 start_date |
| page | number | 否 | 页码,默认 1 |
| pageSize | number | 否 | 每页数量,默认 10**最大 50** |
### 响应示例
```json
{
"success": true,
"msg": "ok",
"data": {
"data": [
{
"id": 1024,
"order_no": "SO202608140001",
"order_date": "2026-08-14",
"status": 0,
"status_name": "待接单",
"can_cancel": true,
"total_quantity": 8,
"total_weight": "0.000",
"total_amount": "44.00",
"remark": "下午送到",
"purchase_id": 0,
"bill_id": 0,
"created_at": "2026-08-14 09:30:00",
"item_count": 4,
"items": [
{ "product_name": "白菜", "quantity": 2, "unit": "件" },
{ "product_name": "土豆", "quantity": 2, "unit": "件" },
{ "product_name": "番茄", "quantity": 2, "unit": "件" }
]
}
],
"total": 36,
"pageSize": 10,
"current": 1
}
}
```
### 字段说明(列表行)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | number | 订单 ID |
| order_no | string | 订单编号(SO 前缀) |
| order_date | string | 订货日期 `Y-m-d` |
| status | number | 订单状态(见枚举表) |
| **status_name** | string | 状态中文名,**直接展示,前端不要再硬编码映射** |
| **can_cancel** | boolean | 是否可取消(=待接单);取消按钮据此渲染 |
| total_quantity | number | 订货总数量(件/包数) |
| total_weight | string | 总称重(斤,手动录入参考值,常为 0) |
| total_amount | string | 订单金额 |
| remark | string | 订单备注,可能为空字符串 |
| purchase_id | number | 关联采购单 ID0=未归集 |
| bill_id | number | 关联账单 ID0=未出账(>0 可跳账单详情) |
| created_at | string | 下单时间 `Y-m-d H:i:s` |
| **item_count** | number | 明细种数(如「共 4 种」) |
| **items** | array | 商品预览,**仅前 3 条**(`product_name` / `quantity` / `unit`),完整明细走详情接口 |
---
## GET /mini/order/{id}
订单详情(校验本店归属),返回订单全部字段 + 完整明细(`items` 数组,含下单时商品快照)。
- **权限**:需登录且已绑定正常门店;非本店订单返回 `success=false`「订单不存在」
### 响应示例
```json
{
"success": true,
"msg": "ok",
"data": {
"id": 1024,
"order_no": "SO202608140001",
"store_id": 3,
"order_date": "2026-08-14",
"total_quantity": 8,
"total_weight": "0.000",
"total_amount": "44.00",
"status": 0,
"remark": "下午送到",
"purchase_id": 0,
"bill_id": 0,
"created_at": "2026-08-14 09:30:00",
"items": [
{
"id": 501,
"order_id": 1024,
"product_id": 11,
"product_name": "白菜",
"product_spec": "约30斤/件",
"unit": "件",
"price": "5.50",
"quantity": 2,
"weight": "0.000",
"amount": "11.00",
"remark": ""
}
]
}
}
```
### 字段说明
订单本体字段同列表行(无 `status_name` / `can_cancel` / 预览字段,状态展示沿用列表行数据或自行映射);`items` 为完整明细:
| 字段 | 类型 | 说明 |
|---|---|---|
| product_name / product_spec / unit | string | 下单时商品快照(品名/规格包规/单位),商品档案后续变更不影响 |
| price | string | 下单时门店等级实际价快照 |
| quantity | number | 订货量(件/包数) |
| weight | string | 称重(斤,参考值) |
| amount | string | 明细金额 = price × quantity |
---
## GET /mini/bill
门店账单列表(采购单完成后由后台按门店生成,门店端只读),按账单日期倒序分页。响应在分页结构上**附加 `summary` 待支付汇总**(仅按门店口径统计,不受筛选参数影响),供页面头部与合并付款入口展示。
- **权限**:需登录且已绑定正常门店
### 请求参数
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| status | number | 否 | 原始支付状态:0 未支付(含审核中)/ 1 已支付,不传=全部 |
| payable | number | 否 | 传 `1` = 仅可发起付款的账单(未支付且未在审核中),**合并付款选择页专用** |
| start_date | string | 否 | 账单日期起,`Y-m-d` |
| end_date | string | 否 | 账单日期止,`Y-m-d`,不得早于 start_date |
| page | number | 否 | 页码,默认 1 |
| pageSize | number | 否 | 每页数量,默认 10**最大 50** |
### 响应示例
```json
{
"success": true,
"msg": "ok",
"data": {
"data": [
{
"id": 88,
"bill_no": "ZD202608140001",
"bill_date": "2026-08-14",
"purchase_id": 31,
"purchase": { "id": 31, "purchase_no": "PO202608130001", "purchase_date": "2026-08-13" },
"product_amount": "100.00",
"delivery_fee": "10.00",
"box_num": 2,
"tray_num": 1,
"box_price": "5.00",
"tray_price": "20.00",
"added_amount": "30.00",
"total_amount": "140.00",
"status": 0,
"status_name": "未支付",
"pay_state": 0,
"pay_state_name": "待支付",
"can_pay": true,
"payment_id": 0,
"settlement_date": "2026-08-17",
"paid_at": null,
"pay_remark": "",
"remark": "",
"created_at": "2026-08-14 03:17:01"
}
],
"total": 12,
"pageSize": 10,
"current": 1,
"summary": { "unpaid_count": 3, "unpaid_amount": "420.50" }
}
}
```
### 字段说明(列表行)
| 字段 | 类型 | 说明 |
|---|---|---|
| id | number | 账单 ID |
| bill_no | string | 账单编号(ZD 前缀) |
| bill_date | string | 账单日期(出账日)`Y-m-d` |
| purchase_id | number | 关联采购单 ID |
| purchase | object \| null | 关联采购单 `{ id, purchase_no, purchase_date }` |
| product_amount | string | 商品金额(订单汇总快照,不可修改) |
| delivery_fee | string | 配送费 |
| box_num / tray_num | number | 周转筐 / 周转托盘数量 |
| box_price / tray_price | string | 筐 / 托盘单价(出账时快照) |
| added_amount | string | 附加金额 = box_num×box_price + tray_num×tray_price |
| total_amount | string | **账单总金额 = 商品金额 + 配送费 + 附加金额** |
| status | number | 原始支付状态:0 未支付 / 1 已支付(展示用 pay_state 系列字段) |
| status_name | string | 支付状态中文名 |
| **pay_state** | number | 支付进度:0 待支付 / 1 审核中 / 2 已支付(见枚举表) |
| **pay_state_name** | string | 支付进度中文名,**列表状态标签直接用它** |
| **can_pay** | boolean | 是否可勾选发起合并付款(=待支付) |
| payment_id | number | 关联支付记录 ID,0=未发起付款;审核中时可跳支付记录详情查看进度 |
| **settlement_date** | string | 应结算日期 = 账单日期 + 门店回款周期天数 |
| paid_at | string \| null | 付款时间(已支付时非空) |
| pay_remark | string | 付款备注(如「微信支付(支付单号 ZF…)」、线下收款说明) |
| remark | string | 账单备注 |
| created_at | string | 出账时间 |
### summary 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| unpaid_count | number | 待支付账单笔数(**含审核中**) |
| unpaid_amount | string | 待支付金额合计(含审核中) |
---
## GET /mini/bill/{id}
账单详情(校验本店归属):账单信息(字段同列表行)+ 合并后的商品明细 + 关联订单。
- **权限**:需登录且已绑定正常门店;非本店账单返回 `success=false`「账单不存在」
### 响应示例
```json
{
"success": true,
"msg": "ok",
"data": {
"bill": { "id": 88, "bill_no": "ZD202608140001", "...": "(字段同列表行)" },
"items": [
{
"product_id": 11,
"product_name": "白菜",
"product_spec": "约30斤/件",
"unit": "件",
"price": "5.50",
"quantity": 20,
"weight": "0.000",
"amount": "110.00"
}
],
"orders": [
{
"id": 1024,
"order_no": "SO202608130001",
"order_date": "2026-08-13",
"total_quantity": 8,
"total_weight": "0.000",
"total_amount": "44.00",
"status": 4
}
]
}
}
```
### 字段说明
- **bill**:与列表行完全一致(含 `pay_state_name` / `can_pay` / `settlement_date` 等派生字段)
- **items**:按商品聚合的合并明细(跨本账单全部订单),`price` 为加权平均口径(Σ金额÷Σ数量,保证 单价×数量=金额);`quantity` 为合计数量(number)、`amount` 为合计金额
- **orders**:本账单关联的门店订单(单价明细可继续下钻 `GET /mini/order/{id}`),`status` 为订单状态枚举
---
## 前端接入示例
`src/types/order.ts` 关键类型(列表行替换为接口返回,**删除本地 `ORDER_STATUS_MAP` 硬编码**):
```ts
/** 订单列表行(状态名/可取消/商品预览均由后端给出) */
export interface OrderListItem {
id: number
order_no: string
order_date: string
status: number
status_name: string
can_cancel: boolean
total_quantity: number
total_weight: string
total_amount: string
remark: string
purchase_id: number
bill_id: number
created_at: string
item_count: number
items: Array<{ product_name: string; quantity: number; unit: string }>
}
```
`src/services/bill.ts`**替代旧 `statement.ts`——旧 `/mini/statement` 已下线**):
```ts
import { get } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
/** 账单(列表行与详情的 bill 字段一致) */
export interface Bill {
id: number
bill_no: string
bill_date: string
purchase_id: number
purchase: { id: number; purchase_no: string; purchase_date: string } | null
product_amount: string
delivery_fee: string
box_num: number
tray_num: number
box_price: string
tray_price: string
added_amount: string
total_amount: string
status: 0 | 1
status_name: string
/** 支付进度:0 待支付 / 1 审核中 / 2 已支付 */
pay_state: 0 | 1 | 2
pay_state_name: string
can_pay: boolean
payment_id: number
settlement_date: string
paid_at: string | null
pay_remark: string
remark: string
created_at: string
}
export interface BillSummary {
unpaid_count: number
unpaid_amount: string
}
export interface BillDetail {
bill: Bill
items: Array<{
product_id: number; product_name: string; product_spec: string
unit: string; price: string; quantity: number; weight: string; amount: string
}>
orders: Array<{
id: number; order_no: string; order_date: string
total_quantity: number; total_weight: string; total_amount: string; status: number
}>
}
/** 账单列表:GET /mini/billpayable=1 为合并付款选择页口径) */
export function getBillListApi(
params: { status?: number; payable?: 1; start_date?: string; end_date?: string; page?: number; pageSize?: number } = {},
) {
return get<PaginatedData<Bill> & { summary: BillSummary }>('/mini/bill', { data: params })
}
/** 账单详情:GET /mini/bill/{id} */
export function getBillDetailApi(id: number) {
return get<BillDetail>(`/mini/bill/${id}`)
}
```
订单列表行商品预览渲染建议:
```tsx
<Text className='order-item__preview'>
{order.items.map(i => `${i.product_name}×${i.quantity}`).join('、')}
{order.item_count > 3 ? `${order.item_count}` : ''}
</Text>
```
## 注意事项
1. **状态文案一律用接口返回的 `status_name` / `pay_state_name`**:订单状态为 6 态(0/1/2/3/4/9),h5 现有 `ORDER_STATUS_MAP` 是旧 5 态(缺采购中/配送中),继续硬编码会显示异常。
2. **账单状态标签用 `pay_state_name` 而非 `status`**`status=0` 同时覆盖「待支付」与「审核中」两种展示;`payable=1` 筛选与 `can_pay` 同口径(待支付),合并付款页直接用它。
3. **审核拒绝后账单自动回到待支付**payment_id 归零),列表刷新即可重新勾选付款,无需额外处理。
4. `summary` 统计的是**全部门店口径**(含审核中),与列表筛选参数无关;筛选「已支付」时 summary 仍返回待支付汇总。
5. 订单列表 `items` 仅为前 3 条预览(`item_count` 为总种数),完整明细必须调详情接口;两个详情接口均校验门店归属,跨店访问返回「不存在」。
6. `settlement_date` 由门店回款周期实时计算(门店改周期后历史账单随之变化),如需固化口径请后续提需求加快照列。
7. 参数校验失败与业务失败一样返回 `success=false` + 中文 `msg`(HTTP 200),无需特殊分支处理。
+1 -1
View File
@@ -5,7 +5,7 @@
"appid": "wx7ed74d60503b5ee3",
"setting": {
"urlCheck": false,
"es6": false,
"es6": true,
"postcss": false,
"minified": true,
"enhance": false
+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
}