Compare commits

...

2 Commits

Author SHA1 Message Date
xinadmin 4138164bd8 账单导出 2026-08-14 20:41:25 +08:00
xinadmin 03434feb35 账单 2026-08-14 20:21:50 +08:00
22 changed files with 1938 additions and 629 deletions
+427
View File
@@ -0,0 +1,427 @@
# 小程序订单与账单接口文档
> 适用端:微信小程序(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),无需特殊分支处理。
+2 -2
View File
@@ -6,8 +6,8 @@ export default defineAppConfig({
'pages/message/index',
'pages/profile/index',
'pages/order-list/index',
'pages/statement/index',
'pages/statement-detail/index',
'pages/bill/index',
'pages/bill-detail/index',
'pages/settings/index',
'pages/login/index',
'pages/register/index',
@@ -1,7 +1,7 @@
.bill-detail {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 120rpx;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
.bill-card {
@@ -26,13 +26,20 @@
&__status {
font-size: 24rpx;
&--unpaid {
color: #ee0a24;
}
// 0 待支付 / 1 审核中 / 2 已支付
&--0 { color: #ee0a24; }
&--1 { color: #ff976a; }
&--2 { color: #07c160; }
}
&--paid {
color: #07c160;
}
&__tip {
display: block;
margin: -4rpx 0 16rpx;
padding: 12rpx 16rpx;
background: #fffbe8;
border-radius: 8rpx;
font-size: 22rpx;
color: #ed6a0c;
}
&__row {
@@ -51,15 +58,15 @@
&__label {
font-size: 26rpx;
color: #969799;
flex: 1;
min-width: 0;
padding-right: 20rpx;
}
&__value {
font-size: 26rpx;
color: #323233;
&--credit {
color: #07c160;
}
flex-shrink: 0;
}
&__total {
@@ -118,6 +125,7 @@
&__side {
text-align: right;
margin-left: 16rpx;
flex-shrink: 0;
}
&__qty {
@@ -145,35 +153,112 @@
border-bottom: none;
}
&__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&__no {
font-size: 26rpx;
color: #323233;
flex: 1;
min-width: 0;
}
&__date {
font-size: 22rpx;
color: #969799;
margin-top: 4rpx;
}
&__status {
font-size: 24rpx;
color: #969799;
margin: 0 16rpx;
flex-shrink: 0;
}
&__amount {
font-size: 26rpx;
color: #323233;
flex-shrink: 0;
}
}
.bill-pay {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
// ===== 订单明细弹层 =====
.order-popup {
padding: 32rpx 32rpx 24rpx;
&__btn {
border: none;
&__title {
font-size: 32rpx;
font-weight: 600;
display: block;
}
&__meta {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 24rpx;
color: #969799;
}
&__status {
color: #ee0a24;
}
&__list {
max-height: 480rpx;
margin-top: 20rpx;
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 0;
border-bottom: 1rpx solid #f2f3f5;
&-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&-name {
font-size: 28rpx;
color: #323233;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-spec {
margin-top: 6rpx;
font-size: 22rpx;
color: #969799;
}
&-amount {
font-size: 28rpx;
color: #323233;
font-weight: 500;
margin-left: 20rpx;
}
}
&__footer {
margin-top: 24rpx;
display: flex;
justify-content: flex-end;
}
&__total {
font-size: 30rpx;
color: #ee0a24;
font-weight: 600;
}
}
}
+205
View File
@@ -0,0 +1,205 @@
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from '@tarojs/taro'
import { View, Text, 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 type { OrderDetail } from '@/types/order'
import type { BillDetail } from '@/services/bill'
import './index.less'
/**
* 账单详情
* 账单信息 + 金额构成(商品/配送费/筐托盘附加)+ 合并商品明细 + 关联订单(可下钻订单明细)
*/
export default function BillDetailPage() {
const router = useRouter()
const id = Number(router.params.id ?? 0)
const [detail, setDetail] = useState<BillDetail | null>(null)
const [loading, setLoading] = useState(false)
/** 关联订单明细弹层 */
const [showOrder, setShowOrder] = useState(false)
const [orderDetail, setOrderDetail] = useState<OrderDetail | null>(null)
useEffect(() => {
if (!id) return
setLoading(true)
getBillDetailApi(id)
.then(res => setDetail(res.data))
.catch(() => {})
.finally(() => setLoading(false))
}, [id])
/** 下钻关联订单明细 */
const handleOrderTap = useCallback(async (orderId: number) => {
try {
const res = await getOrderDetailApi(orderId)
setOrderDetail(res.data)
setShowOrder(true)
} catch {
// 错误已由 request 层 toast
}
}, [])
if (loading && !detail) {
return <View className='bill-detail'><Empty description='加载中...' /></View>
}
if (!detail) {
return <View className='bill-detail'><Empty description='账单不存在' /></View>
}
const { bill, items, orders } = detail
return (
<View className='bill-detail'>
{/* ===== 账单信息 ===== */}
<View className='bill-card'>
<View className='bill-card__header'>
<Text className='bill-card__no'>{bill.bill_no}</Text>
<Text className={`bill-card__status bill-card__status--${bill.pay_state}`}>
{bill.pay_state_name}
</Text>
</View>
{bill.pay_state === 1 && (
<Text className='bill-card__tip'></Text>
)}
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.bill_date}</Text>
</View>
{bill.purchase && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>
{bill.purchase.purchase_no}{bill.purchase.purchase_date}
</Text>
</View>
)}
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.settlement_date}</Text>
</View>
{bill.pay_state === 2 && bill.paid_at && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.paid_at}</Text>
</View>
)}
{bill.pay_remark && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.pay_remark}</Text>
</View>
)}
{bill.remark && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.remark}</Text>
</View>
)}
</View>
{/* ===== 金额构成 ===== */}
<View className='bill-card'>
<Text className='bill-section__title'></Text>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.product_amount}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{bill.delivery_fee}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'>
{bill.box_num}×{bill.box_price} {bill.tray_num}×{bill.tray_price}
</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>
<Text className='bill-card__total'>{bill.total_amount}</Text>
</View>
</View>
{/* ===== 商品明细(跨订单按商品合并) ===== */}
<View className='bill-card'>
<Text className='bill-section__title'>{items?.length ?? 0}</Text>
<Text className='bill-section__desc'></Text>
{(items ?? []).map(item => (
<View key={item.product_id} className='bill-goods'>
<View className='bill-goods__main'>
<Text className='bill-goods__name'>{item.product_name}</Text>
<Text className='bill-goods__spec'>
{item.product_spec ? `${item.product_spec} ` : ''}{item.price}/{item.unit}
</Text>
</View>
<View className='bill-goods__side'>
<Text className='bill-goods__qty'>×{item.quantity}</Text>
<Text className='bill-goods__amount'>{item.amount}</Text>
</View>
</View>
))}
{(items ?? []).length === 0 && <Empty description='暂无商品记录' />}
</View>
{/* ===== 关联订单 ===== */}
<View className='bill-card'>
<Text className='bill-section__title'>{orders?.length ?? 0}</Text>
{(orders ?? []).map(order => (
<View key={order.id} className='bill-order' onClick={() => handleOrderTap(order.id)}>
<View className='bill-order__main'>
<Text className='bill-order__no'>{order.order_no}</Text>
<Text className='bill-order__date'>{order.order_date}</Text>
</View>
<Text className='bill-order__status'>{ORDER_STATUS_TEXT[order.status] || ''}</Text>
<Text className='bill-order__amount'>{order.total_amount}</Text>
</View>
))}
{(orders ?? []).length === 0 && <Empty description='暂无订单记录' />}
</View>
{/* ===== 订单明细弹层 ===== */}
<Popup
show={showOrder}
position='bottom'
round
closeable
closeOnClickOverlay
safeAreaInsetBottom
onClose={() => setShowOrder(false)}
>
{orderDetail && (
<View className='order-popup'>
<Text className='order-popup__title'>{orderDetail.order_no}</Text>
<View className='order-popup__meta'>
<Text>{orderDetail.order_date}</Text>
<Text className='order-popup__status'>
{ORDER_STATUS_TEXT[orderDetail.status] || ''}
</Text>
</View>
<ScrollView scrollY className='order-popup__list'>
{(orderDetail.items ?? []).map(item => (
<View key={item.id} className='order-popup__item'>
<View className='order-popup__item-info'>
<Text className='order-popup__item-name'>{item.product_name}</Text>
<Text className='order-popup__item-spec'>
{item.product_spec ? `${item.product_spec} ` : ''}{item.price}/{item.unit} × {item.quantity}
</Text>
</View>
<Text className='order-popup__item-amount'>{item.amount}</Text>
</View>
))}
</ScrollView>
<View className='order-popup__footer'>
<Text className='order-popup__total'> {orderDetail.total_amount}</Text>
</View>
</View>
)}
</Popup>
</View>
)
}
+306
View File
@@ -0,0 +1,306 @@
.bill-page {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
// 多选模式为底部导出栏留出空间
&--select {
padding-bottom: 160rpx;
}
// ===== 待支付汇总 =====
.bill-summary {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #ee0a24, #ff4d4f);
border-radius: 16rpx;
padding: 28rpx;
margin-bottom: 20rpx;
color: #fff;
&__info {
display: flex;
flex-direction: column;
}
&__label {
font-size: 24rpx;
opacity: 0.85;
}
&__count {
margin-top: 8rpx;
font-size: 28rpx;
font-weight: 600;
}
&__amount {
font-size: 40rpx;
font-weight: 600;
}
}
// ===== 状态筛选 + 导出入口 =====
.bill-toolbar {
display: flex;
align-items: center;
margin-bottom: 20rpx;
.status-scroll {
flex: 1;
min-width: 0;
white-space: nowrap;
margin-bottom: 0;
}
&__export {
flex-shrink: 0;
margin-left: 16rpx;
padding: 12rpx 28rpx;
border-radius: 999rpx;
background: #fff;
border: 1rpx solid #ee0a24;
color: #ee0a24;
font-size: 26rpx;
&.active {
background: #ee0a24;
color: #fff;
}
}
}
.status-scroll {
white-space: nowrap;
margin-bottom: 20rpx;
}
.status-chip {
display: inline-flex;
padding: 12rpx 28rpx;
margin-right: 16rpx;
border-radius: 999rpx;
background: #fff;
font-size: 26rpx;
color: #646566;
&.active {
background: #ee0a24;
color: #fff;
}
}
.bill-empty {
padding-top: 120rpx;
&__btn {
margin-top: 24rpx;
padding: 14rpx 60rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
border-radius: 999rpx;
}
}
.bill-loading {
padding: 30rpx 0;
text-align: center;
font-size: 24rpx;
color: #c8c9cc;
}
// ===== 账单单项 =====
.bill-item {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
&--select {
display: flex;
align-items: center;
}
&__content {
flex: 1;
min-width: 0;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
}
&__no {
font-size: 28rpx;
color: #323233;
font-weight: 500;
}
&__status {
font-size: 24rpx;
// 0 待支付 / 1 审核中 / 2 已支付
&--0 { color: #ee0a24; }
&--1 { color: #ff976a; }
&--2 { color: #07c160; }
}
&__body {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
&__meta {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
&__date {
font-size: 24rpx;
color: #969799;
}
&__purchase {
margin-top: 6rpx;
font-size: 22rpx;
color: #c8c9cc;
}
&__amount {
font-size: 32rpx;
color: #323233;
font-weight: 600;
margin-left: 20rpx;
}
&__footer {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
&__settle {
font-size: 22rpx;
color: #969799;
}
&__paid {
font-size: 22rpx;
color: #07c160;
}
}
// ===== 勾选圆圈 =====
.bill-check {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
border: 2rpx solid #c8c9cc;
margin-right: 20rpx;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
&.on {
background: #ee0a24;
border-color: #ee0a24;
}
}
// ===== 导出操作栏 =====
.export-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
align-items: center;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
&__count {
flex: 1;
min-width: 0;
font-size: 26rpx;
color: #646566;
}
&__all {
padding: 12rpx 24rpx;
margin-right: 16rpx;
border: 1rpx solid #dcdee0;
border-radius: 999rpx;
font-size: 26rpx;
color: #323233;
}
&__btn {
padding: 14rpx 40rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
font-weight: 500;
&.disabled {
opacity: 0.5;
}
}
}
// ===== 分类选择弹层 =====
.category-popup {
padding: 32rpx 32rpx 24rpx;
&__title {
font-size: 32rpx;
font-weight: 600;
display: block;
text-align: center;
}
&__desc {
margin-top: 8rpx;
font-size: 22rpx;
color: #969799;
display: block;
text-align: center;
}
&__list {
max-height: 560rpx;
margin-top: 24rpx;
}
&__item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 8rpx;
border-bottom: 1rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
&__name {
font-size: 28rpx;
color: #323233;
}
}
}
+316
View File
@@ -0,0 +1,316 @@
import { useCallback, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text, ScrollView } from '@tarojs/components'
import { Empty, Icon, Popup } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { getBillExportUrl, getBillListApi } from '@/services/bill'
import { getCategoriesApi } from '@/services/product'
import { downloadExportFile } from '@/utils/download'
import type { Bill, BillSummary } from '@/services/bill'
import './index.less'
const PAGE_SIZE = 10
/** 单次导出上限(后端限制 1~100 张) */
const EXPORT_MAX = 100
/** 状态筛选(接口 status 为原始支付状态:0 未支付含审核中 / 1 已支付) */
const STATUS_FILTERS: Array<{ value: 0 | 1 | undefined; label: string }> = [
{ value: undefined, label: '全部' },
{ value: 0, label: '未支付' },
{ value: 1, label: '已支付' },
]
/** 导出分类选项(id=0 全部分类) */
interface CategoryOption {
id: number
name: string
}
/**
* 账单列表页
* 采购单完成后由后台按门店生成(只读);头部 summary 为门店口径待支付汇总(含审核中,不受筛选影响)
* 支持多选账单合并导出 Excel(可按一级分类过滤商品明细)
*/
export default function BillListPage() {
const token = useAuthStore(s => s.token)
const [status, setStatus] = useState<0 | 1 | undefined>(undefined)
const [bills, setBills] = useState<Bill[]>([])
const [summary, setSummary] = useState<BillSummary | null>(null)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const loadingRef = useRef(false)
/** 导出:多选模式 + 已选账单 + 分类弹层 */
const [selectMode, setSelectMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [showCategory, setShowCategory] = useState(false)
const [categories, setCategories] = useState<CategoryOption[]>([])
const loggedIn = !!token
/** 拉取账单列表(summary 每次随响应刷新) */
const loadList = useCallback(
async (pageNum: number, reset: boolean, statusParam?: 0 | 1) => {
if (!loggedIn || loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const res = await getBillListApi({ status: statusParam, page: pageNum, pageSize: PAGE_SIZE })
const { data, total, summary: sum } = res.data
setBills(prev => (reset ? data : [...prev, ...data]))
setSummary(sum)
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= total)
} catch {
// 错误已由 request 层 toast
} finally {
loadingRef.current = false
setLoading(false)
}
},
[loggedIn],
)
useDidShow(() => {
loadList(1, true, status)
})
useReachBottom(() => {
if (!finished && !loadingRef.current && loggedIn) {
loadList(page + 1, false, status)
}
})
/** 切换状态筛选 */
const handleStatusTap = useCallback(
(value?: 0 | 1) => {
setStatus(value)
setFinished(false)
loadList(1, true, value)
},
[loadList],
)
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
const goDetail = useCallback((id: number) => {
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${id}` })
}, [])
/** 进入/退出多选导出模式 */
const toggleSelectMode = useCallback(() => {
setSelectMode(prev => !prev)
setSelectedIds([])
}, [])
/** 账单点击:多选模式切换勾选,否则进详情 */
const handleItemTap = useCallback(
(bill: Bill) => {
if (!selectMode) {
goDetail(bill.id)
return
}
setSelectedIds(prev => {
if (prev.includes(bill.id)) return prev.filter(i => i !== bill.id)
if (prev.length >= EXPORT_MAX) {
Taro.showToast({ title: `最多导出 ${EXPORT_MAX}`, icon: 'none' })
return prev
}
return [...prev, bill.id]
})
},
[selectMode, goDetail],
)
/** 全选当前已加载账单(受导出上限约束) */
const handleSelectAll = useCallback(() => {
setSelectedIds(prev => {
if (prev.length === bills.length) return []
if (bills.length > EXPORT_MAX) {
Taro.showToast({ title: `最多导出 ${EXPORT_MAX} 张,已选前 ${EXPORT_MAX}`, icon: 'none' })
return bills.slice(0, EXPORT_MAX).map(b => b.id)
}
return bills.map(b => b.id)
})
}, [bills])
/** 打开分类选择弹层(首次加载分类树根节点) */
const handleExportTap = useCallback(async () => {
if (selectedIds.length === 0) {
Taro.showToast({ title: '请先勾选要导出的账单', icon: 'none' })
return
}
if (categories.length === 0) {
try {
const res = await getCategoriesApi()
setCategories([
{ id: 0, name: '全部分类' },
...res.data.map(c => ({ id: c.id, name: c.name })),
])
} catch {
return // 错误已由 request 层 toast
}
}
setShowCategory(true)
}, [selectedIds, categories])
/** 按所选分类导出合并 Excel */
const handleExport = useCallback(
async (categoryId: number) => {
const isH5 = process.env.TARO_ENV === 'h5'
Taro.showLoading({ title: '导出中...', mask: true })
try {
await downloadExportFile(getBillExportUrl(selectedIds, categoryId), '门店账单.xlsx')
setShowCategory(false)
toggleSelectMode()
if (isH5) {
Taro.showToast({ title: '导出成功', icon: 'success' })
}
} catch (e: any) {
Taro.showToast({ title: e?.message || '导出失败,请稍后重试', icon: 'none' })
} finally {
Taro.hideLoading()
}
},
[selectedIds, toggleSelectMode],
)
return (
<View className={`bill-page ${selectMode ? 'bill-page--select' : ''}`}>
{/* ========== 待支付汇总(门店口径,含审核中) ========== */}
{loggedIn && summary && summary.unpaid_count > 0 && (
<View className='bill-summary'>
<View className='bill-summary__info'>
<Text className='bill-summary__label'></Text>
<Text className='bill-summary__count'>{summary.unpaid_count} </Text>
</View>
<Text className='bill-summary__amount'>{summary.unpaid_amount}</Text>
</View>
)}
{/* ========== 状态筛选 + 导出入口 ========== */}
<View className='bill-toolbar'>
<ScrollView scrollX className='status-scroll'>
{STATUS_FILTERS.map(item => (
<View
key={item.label}
className={`status-chip ${status === item.value ? 'active' : ''}`}
onClick={() => handleStatusTap(item.value)}
>
<Text>{item.label}</Text>
</View>
))}
</ScrollView>
{loggedIn && bills.length > 0 && (
<View className={`bill-toolbar__export ${selectMode ? 'active' : ''}`} onClick={toggleSelectMode}>
<Text>{selectMode ? '取消' : '导出'}</Text>
</View>
)}
</View>
{/* ========== 账单列表 ========== */}
{!loggedIn ? (
<Empty description='登录后查看账单' className='bill-empty'>
<View className='bill-empty__btn' onClick={goLogin}></View>
</Empty>
) : bills.length === 0 ? (
loading ? (
<View className='bill-loading'><Text>...</Text></View>
) : (
<Empty description='暂无账单' className='bill-empty' />
)
) : (
bills.map(bill => {
const checked = selectMode && selectedIds.includes(bill.id)
return (
<View
key={bill.id}
className={`bill-item ${selectMode ? 'bill-item--select' : ''}`}
onClick={() => handleItemTap(bill)}
>
{selectMode && (
<View className={`bill-check ${checked ? 'on' : ''}`}>
{checked && <Icon name='success' size={14} color='#fff' />}
</View>
)}
<View className='bill-item__content'>
<View className='bill-item__header'>
<Text className='bill-item__no'>{bill.bill_no}</Text>
<Text className={`bill-item__status bill-item__status--${bill.pay_state}`}>
{bill.pay_state_name}
</Text>
</View>
<View className='bill-item__body'>
<View className='bill-item__meta'>
<Text className='bill-item__date'> {bill.bill_date}</Text>
{bill.purchase && (
<Text className='bill-item__purchase'> {bill.purchase.purchase_no}</Text>
)}
</View>
<Text className='bill-item__amount'>{bill.total_amount}</Text>
</View>
<View className='bill-item__footer'>
<Text className='bill-item__settle'> {bill.settlement_date}</Text>
{bill.pay_state === 2 && bill.paid_at && (
<Text className='bill-item__paid'> {bill.paid_at} </Text>
)}
</View>
</View>
</View>
)
})
)}
{loggedIn && finished && bills.length > 0 && (
<View className='bill-loading'><Text></Text></View>
)}
{/* ========== 导出操作栏 ========== */}
{selectMode && (
<View className='export-bar'>
<Text className='export-bar__count'> {selectedIds.length} </Text>
<View className='export-bar__all' onClick={handleSelectAll}>
{selectedIds.length === bills.length && bills.length > 0 ? '取消全选' : '全选'}
</View>
<View
className={`export-bar__btn ${selectedIds.length === 0 ? 'disabled' : ''}`}
onClick={handleExportTap}
>
Excel
</View>
</View>
)}
{/* ========== 分类选择弹层 ========== */}
<Popup
show={showCategory}
position='bottom'
round
closeable
closeOnClickOverlay
safeAreaInsetBottom
onClose={() => setShowCategory(false)}
>
<View className='category-popup'>
<Text className='category-popup__title'></Text>
<Text className='category-popup__desc'>
</Text>
<ScrollView scrollY className='category-popup__list'>
{categories.map(c => (
<View key={c.id} className='category-popup__item' onClick={() => handleExport(c.id)}>
<Text className='category-popup__name'>{c.name}</Text>
<Icon name='arrow' size={16} color='#c8c9cc' />
</View>
))}
</ScrollView>
</View>
</Popup>
</View>
)
}
+41 -2
View File
@@ -66,7 +66,24 @@
&__status {
font-size: 24rpx;
color: #ee0a24;
// 0 待接单 / 1 已接单 / 2 采购中 / 3 配送中 / 4 已完成 / 9 已取消
&--0 { color: #ee0a24; }
&--1 { color: #1989fa; }
&--2 { color: #1989fa; }
&--3 { color: #ff976a; }
&--4 { color: #07c160; }
&--9 { color: #969799; }
}
&__preview {
margin-top: 12rpx;
font-size: 24rpx;
color: #646566;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__body {
@@ -105,8 +122,23 @@
display: block;
}
&__cancel {
&__footer {
margin-top: 16rpx;
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
&__bill {
display: inline-flex;
padding: 8rpx 24rpx;
border: 1rpx solid #1989fa;
color: #1989fa;
border-radius: 999rpx;
font-size: 24rpx;
}
&__cancel {
display: inline-flex;
padding: 8rpx 24rpx;
border: 1rpx solid #ee0a24;
@@ -180,6 +212,13 @@
}
}
&__remark {
margin-top: 16rpx;
font-size: 22rpx;
color: #969799;
display: block;
}
&__footer {
margin-top: 24rpx;
display: flex;
+70 -44
View File
@@ -4,46 +4,46 @@ import { View, Text, 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_NAV_ITEMS, ORDER_STATUS_MAP } from '@/types/order'
import type { Order, OrderStatus } from '@/types/order'
import { ORDER_STATUS_FILTERS, ORDER_STATUS_TEXT } from '@/types/order'
import type { OrderDetail, OrderListItem, OrderStatus } from '@/types/order'
import './index.less'
const PAGE_SIZE = 10
/**
* 订单列表页(框架)
* 入口:/pages/order-list/index?status=0|1|2|3|9|all
* status 与 ORDER_NAV_ITEMS 映射(业务语言 → 后端枚举),缺省为全部
* 订单列表页
* 入口:/pages/order-list/index?status=0|1|2|3|4|9|all
* 状态文案一律使用接口返回的 status_name(详情接口无该字段时沿用列表行)
*/
export default function OrderListPage() {
const router = useRouter()
const token = useAuthStore(s => s.token)
/** 当前状态筛选(undefined = 全部) */
const [status, setStatus] = useState<number | undefined>(undefined)
const [orders, setOrders] = useState<Order[]>([])
const [status, setStatus] = useState<OrderStatus | undefined>(undefined)
const [orders, setOrders] = useState<OrderListItem[]>([])
const [page, setPage] = useState(1)
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const loadingRef = useRef(false)
/** 订单详情弹层 */
/** 订单详情弹层statusName 沿用列表行,详情接口不返回 status_name */
const [showDetail, setShowDetail] = useState(false)
const [orderDetail, setOrderDetail] = useState<Order | null>(null)
const [orderDetail, setOrderDetail] = useState<OrderDetail | null>(null)
const [detailStatusName, setDetailStatusName] = useState('')
const loggedIn = !!token
/** 解析路由参数中的 status'all' / 数字 / 缺省 → undefined */
const parseStatus = useCallback((raw?: string): number | undefined => {
const parseStatus = useCallback((raw?: string): OrderStatus | undefined => {
if (!raw || raw === 'all' || raw === '') return undefined
const num = Number(raw)
return Number.isNaN(num) ? undefined : num
return Number.isNaN(num) ? undefined : (num as OrderStatus)
}, [])
/** 拉取订单列表 */
const loadOrders = useCallback(
async (pageNum: number, reset: boolean, statusParam?: number) => {
async (pageNum: number, reset: boolean, statusParam?: OrderStatus) => {
if (!loggedIn || loadingRef.current) return
loadingRef.current = true
setLoading(true)
@@ -53,11 +53,10 @@ export default function OrderListPage() {
page: pageNum,
pageSize: PAGE_SIZE,
})
const { data, total: totalCount } = res.data
const { data, total } = res.data
setOrders(prev => (reset ? data : [...prev, ...data]))
setTotal(totalCount)
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= totalCount)
setFinished(pageNum * PAGE_SIZE >= total)
} catch {
// 错误已由 request 层 toast
} finally {
@@ -82,7 +81,7 @@ export default function OrderListPage() {
/** 切换状态筛选 */
const handleStatusTap = useCallback(
(value?: number) => {
(value?: OrderStatus) => {
setStatus(value)
setFinished(false)
loadOrders(1, true, value)
@@ -91,19 +90,20 @@ export default function OrderListPage() {
)
/** 查看订单详情 */
const handleOrderTap = useCallback(async (order: Order) => {
const handleOrderTap = useCallback(async (order: OrderListItem) => {
try {
const res = await getOrderDetailApi(order.id)
setOrderDetail(res.data)
setDetailStatusName(order.status_name)
setShowDetail(true)
} catch {
// 错误已由 request 层 toast
}
}, [])
/** 取消订单(仅待汇总可取消) */
/** 取消订单(仅待接单可取消,以 can_cancel 为准 */
const handleCancelOrder = useCallback(
(order: Order) => {
(order: OrderListItem) => {
Taro.showModal({
title: '取消订单',
content: `确定取消订单 ${order.order_no} 吗?`,
@@ -123,6 +123,11 @@ export default function OrderListPage() {
[loadOrders, status],
)
/** 跳转关联账单详情 */
const handleBillTap = useCallback((billId: number) => {
Taro.navigateTo({ url: `/pages/bill-detail/index?id=${billId}` })
}, [])
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
@@ -131,17 +136,11 @@ export default function OrderListPage() {
<View className='order-list-page'>
{/* ========== 状态筛选 ========== */}
<ScrollView scrollX className='status-scroll'>
<View
className={`status-chip ${status === undefined ? 'active' : ''}`}
onClick={() => handleStatusTap(undefined)}
>
<Text></Text>
</View>
{ORDER_NAV_ITEMS.map(item => (
{ORDER_STATUS_FILTERS.map(item => (
<View
key={item.key}
className={`status-chip ${status === item.status ? 'active' : ''}`}
onClick={() => handleStatusTap(item.status)}
key={item.label}
className={`status-chip ${status === item.value ? 'active' : ''}`}
onClick={() => handleStatusTap(item.value)}
>
<Text>{item.label}</Text>
</View>
@@ -164,25 +163,47 @@ export default function OrderListPage() {
<View key={order.id} className='order-item' onClick={() => handleOrderTap(order)}>
<View className='order-item__header'>
<Text className='order-item__no'>{order.order_no}</Text>
<Text className='order-item__status'>{ORDER_STATUS_MAP[order.status]}</Text>
<Text className={`order-item__status order-item__status--${order.status}`}>
{order.status_name}
</Text>
</View>
{/* 商品预览(仅前 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__body'>
<Text className='order-item__date'>{order.order_date}</Text>
<Text className='order-item__date'> {order.order_date}</Text>
<View className='order-item__amounts'>
<Text className='order-item__qty'> {order.total_quantity} </Text>
<Text className='order-item__amount'>{order.total_amount}</Text>
</View>
</View>
{order.remark && <Text className='order-item__remark'>{order.remark}</Text>}
{order.status === 0 && (
<View
className='order-item__cancel'
onClick={e => {
e.stopPropagation()
handleCancelOrder(order)
}}
>
{(order.can_cancel || order.bill_id > 0) && (
<View className='order-item__footer'>
{order.bill_id > 0 && (
<View
className='order-item__bill'
onClick={e => {
e.stopPropagation()
handleBillTap(order.bill_id)
}}
>
</View>
)}
{order.can_cancel && (
<View
className='order-item__cancel'
onClick={e => {
e.stopPropagation()
handleCancelOrder(order)
}}
>
</View>
)}
</View>
)}
</View>
@@ -207,8 +228,10 @@ export default function OrderListPage() {
<View className='detail-popup'>
<Text className='detail-popup__title'>{orderDetail.order_no}</Text>
<View className='detail-popup__meta'>
<Text>{orderDetail.order_date}</Text>
<Text className='detail-popup__status'>{ORDER_STATUS_MAP[orderDetail.status]}</Text>
<Text>{orderDetail.order_date} {orderDetail.created_at}</Text>
<Text className='detail-popup__status'>
{detailStatusName || ORDER_STATUS_TEXT[orderDetail.status] || ''}
</Text>
</View>
<ScrollView scrollY className='detail-popup__list'>
{(orderDetail.items ?? []).map(item => (
@@ -216,13 +239,16 @@ 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.price} × {item.quantity}
{item.product_spec ? `${item.product_spec} ` : ''}{item.price}/{item.unit} × {item.quantity}
</Text>
</View>
<Text className='detail-popup__item-amount'>{item.amount}</Text>
</View>
))}
</ScrollView>
{orderDetail.remark && (
<Text className='detail-popup__remark'>{orderDetail.remark}</Text>
)}
<View className='detail-popup__footer'>
<Text className='detail-popup__total'> {orderDetail.total_amount}</Text>
</View>
+64
View File
@@ -144,6 +144,70 @@
}
}
// ===== 账单入口 =====
.bill-entry {
display: flex;
align-items: center;
&__icon {
width: 84rpx;
height: 84rpx;
border-radius: 24rpx;
background: #fff0f0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__info {
flex: 1;
min-width: 0;
margin-left: 20rpx;
display: flex;
flex-direction: column;
}
&__amount {
font-size: 36rpx;
font-weight: 600;
color: #ee0a24;
}
&__title {
font-size: 28rpx;
color: #323233;
}
&__desc {
margin-top: 6rpx;
font-size: 22rpx;
color: #969799;
}
&__badge {
min-width: 36rpx;
height: 36rpx;
padding: 0 10rpx;
border-radius: 999rpx;
background: #ee0a24;
color: #fff;
font-size: 22rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
&__arrow {
font-size: 32rpx;
color: #c8c9cc;
line-height: 1;
margin-left: 12rpx;
flex-shrink: 0;
}
}
// ===== 功能菜单 =====
.menu-cell {
display: flex;
+46 -14
View File
@@ -1,28 +1,18 @@
import { useCallback, useMemo } from 'react'
import { useCallback, useMemo, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components'
import { Icon } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { getUserInfoApi } from '@/services/auth'
import { getBillListApi } from '@/services/bill'
import type { BillSummary } from '@/services/bill'
import { ORDER_NAV_ITEMS } from '@/types/order'
import { resolveAvatarUrl } from '@/utils/format'
import type { UserType } from '@/types/user'
import './index.less'
/** 菜单项(后续单独页面开发时在此追加) */
/** 菜单项(订单/账单入口已由上方专区承载,后续单独页面开发时在此追加) */
const MENU_ITEMS = [
{
key: 'orders',
label: '所有订单',
icon: 'orders-o',
onClick: () => Taro.navigateTo({ url: '/pages/order-list/index?status=all' }),
},
{
key: 'statement',
label: '账单',
icon: 'balance-list-o',
onClick: () => Taro.navigateTo({ url: '/pages/statement/index' }),
},
{
key: 'message',
label: '消息中心',
@@ -43,6 +33,9 @@ export default function ProfilePage() {
const setUser = useAuthStore(s => s.setUser)
const logout = useAuthStore(s => s.logout)
/** 账单待支付汇总(门店口径,含审核中) */
const [billSummary, setBillSummary] = useState<BillSummary | null>(null)
const loggedIn = !!token && !!user
/** 功能菜单(门店账号追加「门店信息」入口) */
@@ -65,6 +58,10 @@ export default function ProfilePage() {
getUserInfoApi()
.then(res => setUser(res.data))
.catch(() => {})
// 刷新账单待支付汇总(pageSize=1 仅取 summary
getBillListApi({ page: 1, pageSize: 1 })
.then(res => setBillSummary(res.data.summary))
.catch(() => {})
})
/** 身份标签 */
@@ -79,6 +76,11 @@ export default function ProfilePage() {
Taro.navigateTo({ url: `/pages/order-list/index?status=${status ?? 'all'}` })
}, [])
/** 账单入口 → 账单列表页 */
const goBill = useCallback(() => {
Taro.navigateTo({ url: '/pages/bill/index' })
}, [])
/** 退出登录 */
const handleLogout = useCallback(() => {
Taro.showModal({
@@ -169,6 +171,36 @@ export default function ProfilePage() {
</View>
</View>
{/* ========== 我的账单 ========== */}
<View className='profile-section'>
<View className='profile-section__header'>
<Text className='profile-section__title'></Text>
<Text className='profile-section__more' onClick={goBill}> </Text>
</View>
<View className='bill-entry' onClick={goBill}>
<View className='bill-entry__icon'>
<Icon name='balance-list-o' size={28} color='#ee0a24' />
</View>
{loggedIn && billSummary && billSummary.unpaid_count > 0 ? (
<>
<View className='bill-entry__info'>
<Text className='bill-entry__amount'>{billSummary.unpaid_amount}</Text>
<Text className='bill-entry__desc'> {billSummary.unpaid_count} </Text>
</View>
<View className='bill-entry__badge'>{billSummary.unpaid_count}</View>
</>
) : (
<View className='bill-entry__info'>
<Text className='bill-entry__title'>
{loggedIn ? '暂无待支付账单' : '登录后查看账单'}
</Text>
<Text className='bill-entry__desc'></Text>
</View>
)}
<Text className='bill-entry__arrow'></Text>
</View>
</View>
{/* ========== 功能菜单 ========== */}
<View className='profile-section profile-section--menu'>
{menuItems.map(item => (
-178
View File
@@ -1,178 +0,0 @@
import { useCallback, useEffect, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Button, Empty } from '@antmjs/vantui'
import { getStatementDetailApi, payStatementApi } from '@/services/statement'
import { STATEMENT_STATUS_MAP } from '@/types/statement'
import type { StatementDetail } from '@/types/statement'
import './index.less'
/**
* 账单详情
* 商品汇总(金额直接合并,单价明细见订单记录)+ 回筐计费 + 关联订单 + 付款确认
*/
export default function StatementDetailPage() {
const router = useRouter()
const id = Number(router.params.id ?? 0)
const [detail, setDetail] = useState<StatementDetail | null>(null)
const [loading, setLoading] = useState(false)
const [paying, setPaying] = useState(false)
const loadDetail = useCallback(async () => {
if (!id) return
setLoading(true)
try {
const res = await getStatementDetailApi(id)
setDetail(res.data)
} catch {
// 错误已由 request 层 toast
} finally {
setLoading(false)
}
}, [id])
useEffect(() => {
loadDetail()
}, [loadDetail])
/** 付款确认 */
const handlePay = useCallback(async () => {
if (!detail || paying) return
const confirm = await Taro.showModal({
title: '付款确认',
content: `确认已支付账单 ${detail.statement_no} 共 ¥${detail.total_amount}`,
confirmText: '确认付款',
})
if (!confirm.confirm) return
setPaying(true)
try {
await payStatementApi(detail.id)
Taro.showToast({ title: '付款确认成功', icon: 'success' })
loadDetail()
} catch {
// 错误(重复付款等)已由 request 层 toast
} finally {
setPaying(false)
}
}, [detail, paying, loadDetail])
if (loading && !detail) {
return <View className='bill-detail'><Empty description='加载中...' /></View>
}
if (!detail) {
return <View className='bill-detail'><Empty description='账单不存在' /></View>
}
const crateNegative = Number(detail.crate_amount) < 0
return (
<View className='bill-detail'>
{/* ===== 账单信息 ===== */}
<View className='bill-card'>
<View className='bill-card__header'>
<Text className='bill-card__no'>{detail.statement_no}</Text>
<Text
className={detail.status === 0 ? 'bill-card__status bill-card__status--unpaid' : 'bill-card__status bill-card__status--paid'}
>
{STATEMENT_STATUS_MAP[detail.status]}
</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{detail.period_start} ~ {detail.period_end}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{detail.settlement_date ?? '-'}</Text>
</View>
{detail.status === 1 && detail.settled_at && (
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{detail.settled_at}</Text>
</View>
)}
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{detail.goods_amount}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className={crateNegative ? 'bill-card__value bill-card__value--credit' : 'bill-card__value'}>
{crateNegative ? `-¥${Math.abs(Number(detail.crate_amount)).toFixed(2)}` : `${detail.crate_amount}`}
{crateNegative ? '(抵扣)' : ''}
</Text>
</View>
<View className='bill-card__row bill-card__row--total'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__total'>{detail.total_amount}</Text>
</View>
</View>
{/* ===== 商品汇总 ===== */}
<View className='bill-card'>
<Text className='bill-section__title'>{detail.goods?.length ?? 0}</Text>
<Text className='bill-section__desc'></Text>
{(detail.goods ?? []).map((item, index) => (
<View key={index} className='bill-goods'>
<View className='bill-goods__main'>
<Text className='bill-goods__name'>{item.product_name}</Text>
<Text className='bill-goods__spec'>{item.product_spec || ''}</Text>
</View>
<View className='bill-goods__side'>
<Text className='bill-goods__qty'>{item.quantity} {item.unit}</Text>
<Text className='bill-goods__amount'>{item.amount}</Text>
</View>
</View>
))}
{(detail.goods ?? []).length === 0 && <Empty description='暂无商品记录' />}
</View>
{/* ===== 回筐计费 ===== */}
<View className='bill-card'>
<Text className='bill-section__title'></Text>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{detail.box_num} {detail.box_num < 0 ? '(抵扣)' : ''}</Text>
</View>
<View className='bill-card__row'>
<Text className='bill-card__label'></Text>
<Text className='bill-card__value'>{detail.tray_num} {detail.tray_num < 0 ? '(抵扣)' : ''}</Text>
</View>
</View>
{/* ===== 关联订单 ===== */}
<View className='bill-card'>
<Text className='bill-section__title'>{detail.orders?.length ?? 0}</Text>
{(detail.orders ?? []).map(order => (
<View
key={order.id}
className='bill-order'
onClick={() => Taro.navigateTo({ url: `/pages/order-list/index` })}
>
<Text className='bill-order__no'>{order.order_no}</Text>
<Text className='bill-order__date'>{order.order_date}</Text>
<Text className='bill-order__amount'>{order.total_amount}</Text>
</View>
))}
{(detail.orders ?? []).length === 0 && <Empty description='暂无订单记录' />}
</View>
{/* ===== 付款按钮 ===== */}
{detail.status === 0 && (
<View className='bill-pay'>
<Button
type='danger'
block
round
loading={paying}
className='bill-pay__btn'
onClick={handlePay}
>
{detail.total_amount}
</Button>
</View>
)}
</View>
)
}
-101
View File
@@ -1,101 +0,0 @@
.statement-page {
min-height: 100vh;
background: #f7f8fa;
padding: 20rpx 24rpx 60rpx;
box-sizing: border-box;
// ===== 头部 =====
.statement-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8rpx 8rpx 24rpx;
&__title {
font-size: 36rpx;
font-weight: 600;
}
&__tip {
font-size: 24rpx;
color: #969799;
}
}
.statement-empty {
padding-top: 120rpx;
&__btn {
margin-top: 24rpx;
padding: 14rpx 60rpx;
background: #ee0a24;
color: #fff;
font-size: 28rpx;
border-radius: 999rpx;
}
}
.statement-loading {
padding: 30rpx 0;
text-align: center;
font-size: 24rpx;
color: #c8c9cc;
}
// ===== 账单单项 =====
.statement-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;
&--unpaid {
color: #ee0a24;
}
&--paid {
color: #07c160;
}
}
&__body {
margin-top: 12rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
&__period {
font-size: 24rpx;
color: #969799;
}
&__amount {
font-size: 28rpx;
color: #323233;
font-weight: 600;
}
&__settle {
margin-top: 8rpx;
font-size: 22rpx;
color: #969799;
display: block;
}
}
}
-116
View File
@@ -1,116 +0,0 @@
import { useCallback, useRef, useState } from 'react'
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Empty } from '@antmjs/vantui'
import useAuthStore from '@/stores/auth/useAuthStore'
import { getStatementListApi } from '@/services/statement'
import { STATEMENT_STATUS_MAP } from '@/types/statement'
import type { Statement } from '@/types/statement'
import './index.less'
const PAGE_SIZE = 10
/**
* 账单页
* 账单按门店回款周期自动生成:列表 + 查看详情 + 付款
*/
export default function StatementPage() {
const token = useAuthStore(s => s.token)
const [statements, setStatements] = useState<Statement[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [finished, setFinished] = useState(false)
const loadingRef = useRef(false)
const loggedIn = !!token
/** 拉取账单列表 */
const loadList = useCallback(
async (pageNum: number, reset: boolean) => {
if (!loggedIn || loadingRef.current) return
loadingRef.current = true
setLoading(true)
try {
const res = await getStatementListApi({ page: pageNum, pageSize: PAGE_SIZE })
const { data, total } = res.data
setStatements(prev => (reset ? data : [...prev, ...data]))
setPage(pageNum)
setFinished(pageNum * PAGE_SIZE >= total)
} catch {
// 错误已由 request 层 toast
} finally {
loadingRef.current = false
setLoading(false)
}
},
[loggedIn],
)
useDidShow(() => {
loadList(1, true)
})
useReachBottom(() => {
if (!finished && !loadingRef.current && loggedIn) {
loadList(page + 1, false)
}
})
const goLogin = useCallback(() => {
Taro.navigateTo({ url: '/pages/login/index' })
}, [])
const goDetail = useCallback((id: number) => {
Taro.navigateTo({ url: `/pages/statement-detail/index?id=${id}` })
}, [])
return (
<View className='statement-page'>
{/* ========== 头部 ========== */}
<View className='statement-header'>
<Text className='statement-header__title'></Text>
<Text className='statement-header__tip'></Text>
</View>
{/* ========== 列表 ========== */}
{!loggedIn ? (
<Empty description='登录后查看账单' className='statement-empty'>
<View className='statement-empty__btn' onClick={goLogin}></View>
</Empty>
) : statements.length === 0 ? (
loading ? (
<View className='statement-loading'><Text>...</Text></View>
) : (
<Empty description='暂无账单' className='statement-empty' />
)
) : (
statements.map(st => (
<View key={st.id} className='statement-item' onClick={() => goDetail(st.id)}>
<View className='statement-item__header'>
<Text className='statement-item__no'>{st.statement_no}</Text>
<Text
className={st.status === 0 ? 'statement-item__status statement-item__status--unpaid' : 'statement-item__status statement-item__status--paid'}
>
{STATEMENT_STATUS_MAP[st.status]}
</Text>
</View>
<View className='statement-item__body'>
<Text className='statement-item__period'>
{st.period_start} ~ {st.period_end}
</Text>
<Text className='statement-item__amount'>{st.total_amount}</Text>
</View>
{st.settlement_date && (
<Text className='statement-item__settle'>{st.settlement_date}</Text>
)}
</View>
))
)}
{loggedIn && finished && statements.length > 0 && (
<View className='statement-loading'><Text></Text></View>
)}
</View>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { BASE_URL, get } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
/** 账单支付进度:0 待支付 / 1 审核中 / 2 已支付(由后端推导,展示以此为准) */
export type BillPayState = 0 | 1 | 2
/** 账单(列表行与详情的 bill 字段一致) */
export interface Bill {
id: number
/** 账单编号(ZD 前缀) */
bill_no: string
/** 账单日期(出账日,Y-m-d) */
bill_date: string
/** 关联采购单 ID */
purchase_id: number
/** 关联采购单 */
purchase: { id: number; purchase_no: string; purchase_date: string } | null
/** 商品金额(订单汇总快照) */
product_amount: string
/** 配送费 */
delivery_fee: string
/** 周转筐 / 周转托盘数量 */
box_num: number
tray_num: number
/** 筐 / 托盘单价(出账时快照) */
box_price: string
tray_price: string
/** 附加金额 = box_num×box_price + tray_num×tray_price */
added_amount: string
/** 账单总金额 = 商品金额 + 配送费 + 附加金额 */
total_amount: string
/** 原始支付状态:0 未支付 / 1 已支付(展示用 pay_state 系列字段) */
status: 0 | 1
status_name: string
/** 支付进度:0 待支付 / 1 审核中 / 2 已支付 */
pay_state: BillPayState
/** 支付进度中文名(列表状态标签直接用它) */
pay_state_name: string
/** 是否可发起合并付款(=待支付) */
can_pay: boolean
/** 关联支付记录 ID,0=未发起付款 */
payment_id: number
/** 应结算日期 = 账单日期 + 门店回款周期天数 */
settlement_date: string
/** 付款时间(已支付时非空) */
paid_at: string | null
/** 付款备注 */
pay_remark: string
/** 账单备注 */
remark: string
created_at: string
}
/** 待支付汇总(门店口径,含审核中,不受筛选参数影响) */
export interface BillSummary {
unpaid_count: number
unpaid_amount: string
}
/** 账单合并商品明细(跨本账单全部订单按商品聚合) */
export interface BillItem {
product_id: number
product_name: string
product_spec: string
unit: string
/** 加权平均单价(Σ金额÷Σ数量) */
price: string
/** 合计数量 */
quantity: number
weight: string
/** 合计金额 */
amount: string
}
/** 账单关联订单 */
export interface BillOrder {
id: number
order_no: string
order_date: string
total_quantity: number
total_weight: string
total_amount: string
/** 订单状态枚举(0/1/2/3/4/9 */
status: number
}
/** 账单详情 */
export interface BillDetail {
bill: Bill
items: BillItem[]
orders: BillOrder[]
}
/** 账单列表查询参数 */
export interface BillListParams {
/** 原始支付状态:0 未支付(含审核中)/ 1 已支付,不传=全部 */
status?: 0 | 1
/** 传 1 = 仅可发起付款的账单(合并付款选择页专用) */
payable?: 1
/** 账单日期起(Y-m-d */
start_date?: string
/** 账单日期止(Y-m-d),不得早于 start_date */
end_date?: string
page?: number
/** 每页数量,默认 10,最大 50 */
pageSize?: number
}
/** 账单列表(附加 summary 待支付汇总):GET /mini/bill */
export function getBillListApi(params: BillListParams = {}) {
return get<PaginatedData<Bill> & { summary: BillSummary }>('/mini/bill', { data: params })
}
/** 账单详情(校验本店归属):GET /mini/bill/{id} */
export function getBillDetailApi(id: number) {
return get<BillDetail>(`/mini/bill/${id}`)
}
/**
* 账单合并导出文件地址:GET /mini/bill/export
* 返回 xlsx 文件流(非 JSON,需带 token 下载);categoryId 仅过滤商品明细,费用仍全额汇总
*/
export function getBillExportUrl(ids: number[], categoryId = 0): string {
return `${BASE_URL}/mini/bill/export?ids=${ids.join(',')}&category_id=${categoryId}`
}
+20 -14
View File
@@ -1,6 +1,6 @@
import { get, post, put } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
import type { Order, OrderCreateResult, OrderSummary } from '@/types/order'
import type { OrderCreateResult, OrderDetail, OrderListItem, OrderStatus } from '@/types/order'
/** 下单明细行(金额一律服务端重算,前端不传金额) */
export interface OrderItemParam {
@@ -15,29 +15,35 @@ export interface CreateOrderParams {
remark?: string
}
/** 订单列表查询参数 */
export interface OrderListParams {
/** 订单状态,不传=全部 */
status?: OrderStatus
/** 订货日期起(Y-m-d */
start_date?: string
/** 订货日期止(Y-m-d),不得早于 start_date */
end_date?: string
page?: number
/** 每页数量,默认 10,最大 50 */
pageSize?: number
}
/** 下单:POST /mini/order */
export function createOrderApi(params: CreateOrderParams) {
return post<OrderCreateResult>('/mini/order', params)
}
/** 历史订单列表(强制本店隔离):GET /mini/order */
export function getOrderListApi(
params: { status?: number; page?: number; pageSize?: number } = {},
) {
return get<PaginatedData<Order>>('/mini/order', { data: params })
/** 历史订单列表(强制本店隔离,按订货日期倒序):GET /mini/order */
export function getOrderListApi(params: OrderListParams = {}) {
return get<PaginatedData<OrderListItem>>('/mini/order', { data: params })
}
/** 周期汇总GET /mini/order/summary */
export function getOrderSummaryApi(period: 'day' | 'week' | 'month' = 'month') {
return get<OrderSummary>('/mini/order/summary', { data: { period } })
}
/** 订单详情(校验本店归属):GET /mini/order/{id} */
/** 订单详情(校验本店归属,含完整明细)GET /mini/order/{id} */
export function getOrderDetailApi(id: number) {
return get<Order>(`/mini/order/${id}`)
return get<OrderDetail>(`/mini/order/${id}`)
}
/** 取消订单(仅待汇总可取消):PUT /mini/order/{id}/cancel */
/** 取消订单(仅待接单可取消):PUT /mini/order/{id}/cancel */
export function cancelOrderApi(id: number) {
return put(`/mini/order/${id}/cancel`)
}
-23
View File
@@ -1,23 +0,0 @@
import { get, post } from '@/utils/request'
import type { PaginatedData } from '@/types/api'
import type { PendingContainers, Statement, StatementDetail } from '@/types/statement'
/** 账单列表(仅本店,按回款周期自动生成):GET /mini/statement */
export function getStatementListApi(params: { page?: number; pageSize?: number } = {}) {
return get<PaginatedData<Statement>>('/mini/statement', { data: params })
}
/** 账单详情(校验归属,含商品汇总/回筐计费/关联订单):GET /mini/statement/{id} */
export function getStatementDetailApi(id: number) {
return get<StatementDetail>(`/mini/statement/${id}`)
}
/** 付款确认:POST /mini/statement/{id}/pay */
export function payStatementApi(id: number) {
return post<{ id: number; total_amount: string }>(`/mini/statement/${id}/pay`)
}
/** 当前门店待回筐/托盘台账:GET /mini/statement/pending-containers */
export function getPendingContainersApi() {
return get<PendingContainers>('/mini/statement/pending-containers')
}
+79 -49
View File
@@ -1,85 +1,115 @@
/** 门店订单状态:0 待汇总 / 1 已汇总 / 2 配送中 / 3 已完成 / 9 已取消 */
export type OrderStatus = 0 | 1 | 2 | 3 | 9
/**
* 门店订单状态(新枚举):
* 0 待接单 / 1 已接单 / 2 采购中 / 3 配送中 / 4 已完成 / 9 已取消
*/
export type OrderStatus = 0 | 1 | 2 | 3 | 4 | 9
export const ORDER_STATUS_MAP: Record<OrderStatus, string> = {
0: '待汇总',
1: '已汇总',
2: '配送中',
3: '已完成',
/**
* 状态文案兜底映射(完整 6 态)
* 仅用于接口未返回 status_name 的场景(订单详情、账单关联订单);
* 列表展示一律使用接口返回的 status_name,不要再硬编码
*/
export const ORDER_STATUS_TEXT: Record<number, string> = {
0: '待接单',
1: '已接单',
2: '采购中',
3: '配送中',
4: '已完成',
9: '已取消',
}
/** 状态筛选(value 为 null 表示全部) */
export const ORDER_STATUS_FILTERS: Array<{ value: OrderStatus | null; label: string }> = [
{ value: null, label: '全部' },
{ value: 0, label: '待汇总' },
{ value: 1, label: '已汇总' },
{ value: 2, label: '配送中' },
{ value: 3, label: '已完成' },
/** 状态筛选(订单列表页 chipsvalue 为 undefined 表示全部) */
export const ORDER_STATUS_FILTERS: Array<{ value: OrderStatus | undefined; label: string }> = [
{ value: undefined, label: '全部' },
{ value: 0, label: '待接单' },
{ value: 1, label: '已接单' },
{ value: 2, label: '采购中' },
{ value: 3, label: '配送中' },
{ value: 4, label: '已完成' },
{ value: 9, label: '已取消' },
]
/**
* 我的页「订单总汇」导航(业务语言 → 后端枚举)
* status 映射 StoreOrder.status0 待汇总 / 1 已汇总 / 2 配送中 / 3 已完成 / 9 已取消
* 注意:后端当前无「待付款」状态(status 缺省 = 不传参,显示全部),待后端补充后在此对齐
*/
/** 我的页「订单总汇」导航(status 映射后端订单状态枚举) */
export const ORDER_NAV_ITEMS: Array<{ key: string; label: string; status?: number; icon: string }> = [
{ key: 'pending_review', label: '待审核', status: 0, icon: 'clock-o' },
{ key: 'pending_stock', label: '待配货', status: 1, icon: 'logistics' },
{ key: 'stocking', label: '配货中', status: 2, icon: 'van-o' },
{ key: 'pending_pay', label: '待付款', icon: 'pending-payment' },
{ key: 'completed', label: '已完成', status: 3, icon: 'completed' },
{ key: 'all', label: '我的订单', icon: 'orders-o' },
{ key: 'pending', label: '待接单', status: 0, icon: 'clock-o' },
{ key: 'accepted', label: '已接单', status: 1, icon: 'passed' },
{ key: 'purchasing', label: '采购中', status: 2, icon: 'shopping-cart-o' },
{ key: 'delivering', label: '配送中', status: 3, icon: 'logistics' },
{ key: 'completed', label: '已完成', status: 4, icon: 'completed' },
{ key: 'all', label: '全部订单', icon: 'orders-o' },
]
/** 门店订单 */
export interface Order {
/** 订单列表行商品预览(仅前 3 条) */
export interface OrderItemPreview {
product_name: string
quantity: number
unit: string
}
/** 订单列表行(状态名/可取消/商品预览均由后端给出,直接展示) */
export interface OrderListItem {
id: number
order_no: string
/** 订货日期(Y-m-d */
order_date: string
total_quantity: string
total_amount: string
status: OrderStatus
/** 状态中文名(直接展示,前端不要再硬编码映射) */
status_name: string
/** 是否可取消(=待接单),取消按钮据此渲染 */
can_cancel: boolean
total_quantity: number
/** 总称重(斤,常为 0 */
total_weight: string
total_amount: string
remark: string
/** 详情接口返回 */
items?: OrderItem[]
/** 关联采购单 ID0=未归集 */
purchase_id: number
/** 关联账单 ID,0=未出账(>0 可跳账单详情) */
bill_id: number
created_at: string
/** 明细种数(如「共 4 种」) */
item_count: number
/** 商品预览,仅前 3 条;完整明细走详情接口 */
items: OrderItemPreview[]
}
/** 订单明细 */
/** 订单明细(详情接口,下单时商品快照) */
export interface OrderItem {
id: number
order_id: number
product_id: number
product_name: string
product_spec: string
/** 下单时等级实际价快照 */
unit: string
/** 下单时门店等级实际价快照 */
price: string
quantity: string
/** 称重(默认 0 */
quantity: number
/** 称重(斤,参考值 */
weight: string
amount: string
remark: string
}
/** 订单详情(无 status_name/can_cancel,状态展示沿用列表行或 ORDER_STATUS_TEXT */
export interface OrderDetail {
id: number
order_no: string
store_id: number
order_date: string
total_quantity: number
total_weight: string
total_amount: string
status: OrderStatus
remark: string
purchase_id: number
bill_id: number
created_at: string
items: OrderItem[]
}
/** 下单返回 */
export interface OrderCreateResult {
id: number
order_no: string
total_amount: string
}
/** 周期汇总分组 */
export interface SummaryGroup {
period_label: string
total_amount: string
total_quantity: string
order_count: number
}
/** 周期汇总 */
export interface OrderSummary {
period: 'day' | 'week' | 'month'
groups: SummaryGroup[]
}
-64
View File
@@ -1,64 +0,0 @@
/** 账单状态:0 待付款 / 1 已付款 */
export type StatementStatus = 0 | 1
export const STATEMENT_STATUS_MAP: Record<StatementStatus, string> = {
0: '待付款',
1: '已付款',
}
/** 账单 */
export interface Statement {
id: number
statement_no: string
period_start: string
period_end: string
/** 商品金额合计 */
goods_amount: string
/** 账单计费周转框数(可为负=抵扣) */
box_num: number
/** 账单计费托盘数(可为负=抵扣) */
tray_num: number
/** 回筐金额合计(可为负=抵扣) */
crate_amount: string
/** 账单总金额 = 商品金额 + 回筐金额 */
total_amount: string
/** 生成时快照的回款周期 */
payment_cycle_days: number
/** 应结算日期 = 周期结束 + 回款周期天 */
settlement_date: string | null
status: StatementStatus
settled_at: string | null
remark: string
}
/** 账单商品汇总记录(按商品聚合,不记单价) */
export interface StatementGoods {
product_name: string
product_spec: string
unit: string
quantity: string
weight: string
amount: string
}
/** 账单关联订单(下单单价明细在订单记录中查看) */
export interface StatementOrder {
id: number
order_no: string
order_date: string
total_amount: string
}
/** 账单详情 */
export interface StatementDetail extends Statement {
goods: StatementGoods[]
orders: StatementOrder[]
}
/** 门店待回筐台账 */
export interface PendingContainers {
pending_box_num: number
pending_tray_num: number
box_price: string
tray_price: string
}
+110
View File
@@ -0,0 +1,110 @@
import Taro from '@tarojs/taro'
import { getToken } from '@/utils/request'
/**
* 导出文件下载(双端)
* 后端导出接口成功返回 xlsx 文件流,业务失败时仍以文件流形式返回 JSON
* blob.type 为 application/json),两端都需探测并解析 msg 提示
*/
/** 从 Content-Disposition 解析文件名(优先 RFC 5987 filename*=utf-8''... */
export function parseExportFilename(disposition?: string | null): string {
if (!disposition) return ''
const star = disposition.match(/filename\*=utf-8''([^;]+)/i)
if (star?.[1]) {
try {
return decodeURIComponent(star[1])
} catch {
// 编码异常时退化为普通 filename
}
}
const plain = disposition.match(/filename="?([^";]+)"?/i)
return plain?.[1] ?? ''
}
/** 解析 JSON 文本中的业务错误信息 */
function parseJsonError(text: string): Error {
try {
const json = JSON.parse(text)
return new Error(json.msg || '导出失败,请稍后重试')
} catch {
return new Error('导出失败,请稍后重试')
}
}
/** H5fetch 带鉴权拉取 blob → <a download> 触发保存 */
async function downloadFileH5(url: string, fallbackName: string): Promise<void> {
const token = getToken()
const res = await fetch(url, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) {
throw new Error(`下载失败(${res.status}`)
}
const blob = await res.blob()
// 业务失败:返回的是 JSON blob
if (blob.type.includes('application/json')) {
throw parseJsonError(await blob.text())
}
const filename =
parseExportFilename(res.headers.get('Content-Disposition')) || fallbackName
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = objectUrl
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(objectUrl)
}
/** 读取小程序本地文件内容(utf8) */
function readTempFile(filePath: string, length?: number): Promise<string> {
return new Promise((resolve, reject) => {
Taro.getFileSystemManager().readFile({
filePath,
encoding: 'utf8',
...(length !== undefined ? { position: 0, length } : {}),
success: r => resolve(r.data as string),
fail: reject,
})
})
}
/** 小程序:downloadFile 下载后 openDocument 打开;业务失败时下载"成功"但内容是 JSON */
async function downloadFileWeapp(url: string): Promise<void> {
const token = getToken()
const { tempFilePath } = await Taro.downloadFile({
url,
header: token ? { Authorization: `Bearer ${token}` } : {},
})
// 探测前 200 字节:以 '{' 开头即为 JSON 错误响应
const head = await readTempFile(tempFilePath, 200)
if (head.trimStart().startsWith('{')) {
throw parseJsonError(await readTempFile(tempFilePath))
}
await Taro.openDocument({
filePath: tempFilePath,
fileType: 'xlsx',
showMenu: true,
fail: () => {
Taro.showToast({ title: '文件已下载,打开失败', icon: 'none' })
},
} as Parameters<typeof Taro.openDocument>[0])
}
/**
* 带鉴权下载导出文件
* - H5:保存到浏览器下载目录,返回保存的文件名
* - 小程序:直接调起系统文档预览(showMenu 支持转发/保存)
*/
export async function downloadExportFile(url: string, fallbackName: string): Promise<void> {
if (process.env.TARO_ENV === 'h5') {
return downloadFileH5(url, fallbackName)
}
return downloadFileWeapp(url)
}
+20
View File
@@ -90,6 +90,25 @@ function handleBusinessError(data: ApiResponse): void {
}
}
/**
* 清理请求参数
* data 中的 undefined 在 GET 查询串里会被序列化为字符串 'undefined'null 同理),
* 导致后端收到字符串而非缺省;统一在此剔除。GET 额外剔除 null,
* POST/PUT 等请求体保留 null(JSON 序列化语义可能依赖它)
*/
function sanitizeData(data: any, method?: RequestConfig['method']): any {
if (!data || typeof data !== 'object' || Array.isArray(data)) return data
const isGet = !method || method === 'GET'
const result: Record<string, any> = {}
Object.keys(data).forEach(key => {
const value = data[key]
if (value === undefined) return
if (isGet && value === null) return
result[key] = value
})
return result
}
/**
* 发起网络请求
*
@@ -126,6 +145,7 @@ export function request<T = any>(config: RequestConfig): Promise<ApiResponse<T>>
Taro.request({
...restConfig,
url: BASE_URL + restConfig.url,
data: sanitizeData(restConfig.data, restConfig.method),
header,
timeout: restConfig.timeout || DEFAULT_TIMEOUT,
success(res) {