Compare commits
18 Commits
4138164bd8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f9b54ce76 | |||
| a85da7a5d1 | |||
| 1b9e505feb | |||
| 74aee003e7 | |||
| e2926b0c70 | |||
| 1701169c9d | |||
| e97cc128ec | |||
| 9d893b4ed4 | |||
| 130ef2c949 | |||
| 2317c9d973 | |||
| 260d8086bf | |||
| 78c787d207 | |||
| 4bbe92d8e8 | |||
| 8a7bdca6b1 | |||
| 69b72a9c1c | |||
| ffe983b9da | |||
| f0d8f6bac4 | |||
| 3bd26acbb9 |
@@ -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 | 关联采购单 ID,0=未归集 |
|
||||
| bill_id | number | 关联账单 ID,0=未出账(>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/bill(payable=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
@@ -2,10 +2,10 @@
|
||||
"miniprogramRoot": "./",
|
||||
"projectname": "pure-project-vantui",
|
||||
"description": "",
|
||||
"appid": "wx7ed74d60503b5ee3",
|
||||
"appid": "wx8f48874e3bf1dccd",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"es6": false,
|
||||
"es6": true,
|
||||
"postcss": false,
|
||||
"minified": true,
|
||||
"enhance": false
|
||||
|
||||
+8
-1
@@ -2,15 +2,22 @@ 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/report/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',
|
||||
'pages/agreement/index',
|
||||
'pages/privacy/index',
|
||||
'pages/change-password/index',
|
||||
'pages/store-info/index',
|
||||
],
|
||||
window: {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// 购物车悬浮球:右下角,底部避让自定义 tabBar(110rpx + 安全区)
|
||||
.cart-ball {
|
||||
position: fixed;
|
||||
left: 24rpx;
|
||||
bottom: calc(150rpx + env(safe-area-inset-bottom));
|
||||
z-index: 998;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 88rpx;
|
||||
padding: 0 32rpx 0 8rpx;
|
||||
background: #fff;
|
||||
border-radius: 999rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
box-sizing: border-box;
|
||||
|
||||
&__icon {
|
||||
position: relative;
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
right: -16rpx;
|
||||
min-width: 32rpx;
|
||||
height: 32rpx;
|
||||
padding: 0 8rpx;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border: 2rpx solid #ee0a24;
|
||||
border-radius: 999rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__badge-text {
|
||||
color: #ee0a24;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
margin-left: 16rpx;
|
||||
color: #ee0a24;
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Icon } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { formatQuantity } from '@/utils/format'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 购物车悬浮球(首页 / 商品列表页右下角,位于自定义 tabBar 上方):
|
||||
* 展示可购总数量徽标与总金额,点击跳转购物车页;
|
||||
* 未登录或购物车为空(total_count = 0)时隐藏
|
||||
*/
|
||||
export default function CartBall() {
|
||||
const token = useAuthStore(s => s.token)
|
||||
const totalCount = useCartStore(s => s.totalCount)
|
||||
const totalQuantity = useCartStore(s => s.totalQuantity)
|
||||
const totalAmount = useCartStore(s => s.totalAmount)
|
||||
|
||||
const goCart = useCallback(() => {
|
||||
Taro.switchTab({ url: '/pages/cart/index' })
|
||||
}, [])
|
||||
|
||||
if (!token || totalCount <= 0) return null
|
||||
|
||||
return (
|
||||
<View className='cart-ball' onClick={goCart}>
|
||||
<View className='cart-ball__icon'>
|
||||
<Icon name='shopping-cart-o' size='40rpx' color='#ffffff' />
|
||||
<View className='cart-ball__badge'>
|
||||
<Text className='cart-ball__badge-text'>{formatQuantity(totalQuantity)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='cart-ball__amount'>¥{totalAmount}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
.cart-stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
&__btn {
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border: 2rpx solid #ee0a24;
|
||||
|
||||
&--plus {
|
||||
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__btn-icon {
|
||||
font-size: 30rpx;
|
||||
line-height: 1;
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
&__btn--plus &__btn-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&__qty {
|
||||
min-width: 64rpx;
|
||||
padding: 0 4rpx;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { addCartApi, deleteCartItemApi, updateCartItemApi } from '@/services/cart'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { formatQuantity } from '@/utils/format'
|
||||
import type { Product, ProductCartPatch } from '@/types/product'
|
||||
import './index.less'
|
||||
|
||||
/** 加减防抖间隔(ms):连续点击合并为一次提交 */
|
||||
const DEBOUNCE_MS = 400
|
||||
|
||||
interface CartStepperProps {
|
||||
/** 商品行(使用 id / price / cart_id / cart_quantity) */
|
||||
product: Product
|
||||
/** 服务端确认后的行数据回写(父组件更新列表项的 cart_id/cart_quantity) */
|
||||
onSync: (productId: number, patch: ProductCartPatch) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品行内购物车加减(商品列表 / 首页推荐共用,仅在 cart_quantity > 0 时由父组件渲染):
|
||||
* - 点击即时更新本地数量与悬浮球(乐观展示),防抖后提交服务端
|
||||
* - 不在购物车(cart_id=0)→ POST /mini/cart 合并加购;已存在 → PUT 绝对数量;减到 0 → DELETE
|
||||
* (数量为 0 不能调 PUT,后端校验数量必须 > 0)
|
||||
* - 同一商品的提交串行执行,避免并发导致数量错乱
|
||||
* - 失败回滚本地数量(请求层已 toast),并立即整体校准悬浮球
|
||||
*/
|
||||
export default function CartStepper({ product, onSync }: CartStepperProps) {
|
||||
const applyDelta = useCartStore(s => s.applyDelta)
|
||||
const fetchSummary = useCartStore(s => s.fetchSummary)
|
||||
|
||||
/** 本地编辑数量(乐观值;null = 展示服务端确认值) */
|
||||
const [draft, setDraft] = useState<number | null>(null)
|
||||
/** 最新待提交的目标数量 */
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
/** 提交串行队列 */
|
||||
const chainRef = useRef<Promise<void>>(Promise.resolve())
|
||||
/** 最新商品行快照(供防抖/串行回调读取服务端确认值,避免闭包过期) */
|
||||
const productRef = useRef(product)
|
||||
productRef.current = product
|
||||
|
||||
/** 卸载时清理防抖定时器 */
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
/** 提交目标数量(串行执行;与服务器一致时跳过) */
|
||||
const runSubmit = useCallback(
|
||||
async (target: number) => {
|
||||
const p = productRef.current
|
||||
const confirmed = Number(p.cart_quantity ?? 0)
|
||||
if (target === confirmed) return
|
||||
try {
|
||||
if (target <= 0) {
|
||||
if (p.cart_id) await deleteCartItemApi(p.cart_id)
|
||||
onSync(p.id, { cart_id: 0, cart_quantity: '0.00' })
|
||||
} else if (p.cart_id) {
|
||||
const res = await updateCartItemApi(p.cart_id, target)
|
||||
onSync(p.id, { cart_id: p.cart_id, cart_quantity: res.data.quantity })
|
||||
} else {
|
||||
// 未加购过:POST 合并加购,用返回的行 id 回写本地 cart_id
|
||||
const res = await addCartApi({ product_id: p.id, quantity: target })
|
||||
onSync(p.id, { cart_id: res.data.id, cart_quantity: res.data.quantity })
|
||||
}
|
||||
// 提交期间用户未再改动 → 本地数量落回服务端确认值(onSync 已回写,展示不变)
|
||||
setDraft(prev => (prev === target ? null : prev))
|
||||
} catch {
|
||||
// 失败(超上限等,请求层已 toast):放弃后续目标,回滚本地展示并校准悬浮球
|
||||
targetRef.current = null
|
||||
setDraft(null)
|
||||
fetchSummary().catch(() => {})
|
||||
}
|
||||
},
|
||||
[onSync, fetchSummary],
|
||||
)
|
||||
|
||||
/** 点击加/减:乐观更新本地数量与悬浮球,防抖后入队提交 */
|
||||
const handleTap = useCallback(
|
||||
(delta: 1 | -1) => {
|
||||
const before = draft ?? Number(productRef.current.cart_quantity ?? 0)
|
||||
const after = Math.round(Math.max(0, before + delta) * 100) / 100
|
||||
if (after === before) return
|
||||
setDraft(after)
|
||||
targetRef.current = after
|
||||
// 悬浮球乐观增减(金额按行内售价估算,防抖结束后由服务端汇总校准);
|
||||
// 数量跨过 0 时同步增减商品种数
|
||||
const price = Number(productRef.current.price ?? 0)
|
||||
applyDelta({
|
||||
quantity: delta,
|
||||
amount: Math.round(price * delta * 100) / 100,
|
||||
count: before === 0 && after > 0 ? 1 : before > 0 && after === 0 ? -1 : 0,
|
||||
})
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
const target = targetRef.current
|
||||
if (target === null) return
|
||||
targetRef.current = null
|
||||
chainRef.current = chainRef.current.then(() => runSubmit(target))
|
||||
}, DEBOUNCE_MS)
|
||||
},
|
||||
[draft, applyDelta, runSubmit],
|
||||
)
|
||||
|
||||
const shown = draft ?? Number(product.cart_quantity ?? 0)
|
||||
|
||||
return (
|
||||
<View className='cart-stepper' onClick={e => e.stopPropagation()}>
|
||||
<View className='cart-stepper__btn' onClick={() => handleTap(-1)}>
|
||||
<Text className='cart-stepper__btn-icon'>-</Text>
|
||||
</View>
|
||||
<Text className='cart-stepper__qty'>{formatQuantity(shown)}</Text>
|
||||
<View className='cart-stepper__btn cart-stepper__btn--plus' onClick={() => handleTap(1)}>
|
||||
<Text className='cart-stepper__btn-icon'>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import {View, Text, Image} from '@tarojs/components'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import IndexImage from '@/static/images/nav/index.png';
|
||||
import IndexActiveImage from '@/static/images/nav/index_active.png';
|
||||
import CartImage from '@/static/images/nav/cart.png';
|
||||
@@ -0,0 +1,21 @@
|
||||
// ===== 零售价标注:小字置灰 =====
|
||||
.price-text__retail {
|
||||
font-size: 20rpx;
|
||||
color: #969799;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// inline 模式:与大价格同行,左间距分隔
|
||||
.price-text__retail--inline {
|
||||
margin-left: 8rpx;
|
||||
}
|
||||
|
||||
// block 模式:大价格 / 零售价上下两行(窄卡片布局)
|
||||
.price-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
line-height: 1.3;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Text, View } from '@tarojs/components'
|
||||
import { formatRetailPrice } from '@/utils/format'
|
||||
import './index.less'
|
||||
|
||||
interface PriceTextProps {
|
||||
/** 售价(展示为 ¥price) */
|
||||
price: string | number
|
||||
/** 包规(用于计算零售价;无法计算时不展示零售价) */
|
||||
spec?: string | number | null
|
||||
/** 大价格样式类(字号 / 颜色由调用方控制) */
|
||||
className?: string
|
||||
/**
|
||||
* 零售价布局:
|
||||
* - inline 跟随大价格同行(宽裕区域:商品详情、各类弹层行)
|
||||
* - block 独占一行(窄卡片:首页推荐、商品列表、购物车)
|
||||
*/
|
||||
mode?: 'inline' | 'block'
|
||||
/** 单位 */
|
||||
price_unit?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品价格:售价 + 零售价标注(小字)
|
||||
* price=30、spec=15 → ¥30 零售价:¥2
|
||||
*/
|
||||
export default function PriceText({ price, spec, className, mode = 'inline', price_unit }: PriceTextProps) {
|
||||
const retail = formatRetailPrice(price, spec)
|
||||
|
||||
// 窄卡片:大价格 / 零售价上下两行,避免与右侧按钮(+/步进器)挤压换行
|
||||
if (mode === 'block') {
|
||||
return (
|
||||
<View className={`price-text ${className ?? ''}`}>
|
||||
<Text className='price-text__main'>¥{price}</Text>
|
||||
{retail !== null && (
|
||||
<Text className='price-text__retail'>单价:¥{retail} {price_unit}</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className={className}>
|
||||
¥{price}
|
||||
{retail !== null && (
|
||||
<Text className='price-text__retail price-text__retail--inline'>单价:¥{retail} {price_unit}</Text>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" >
|
||||
<title>订货采购</title>
|
||||
<script crossorigin="anonymous" src="https://polyfill.alicdn.com/polyfill.min.js?features=es2015%2Ces2016%2Ces2017%2Ces2018%2Ces2019%2Ces2020%2Ces2021%2Ces2022"></script>
|
||||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '用户服务协议',
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/* ========================================
|
||||
协议/政策页面(用户协议、隐私政策共用)
|
||||
======================================== */
|
||||
|
||||
.agreement-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.agreement-scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.agreement-content {
|
||||
padding: 32px 40px 80px;
|
||||
|
||||
.doc-title {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.doc-updated {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #969799;
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.doc-p {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #323233;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 24px;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.doc-h2 {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin: 48px 0 16px;
|
||||
}
|
||||
|
||||
.doc-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 用户服务协议
|
||||
* 静态协议文本页,由登录页/设置页进入
|
||||
*/
|
||||
export default function AgreementPage() {
|
||||
return (
|
||||
<View className='agreement-page'>
|
||||
<ScrollView scrollY className='agreement-scroll'>
|
||||
<View className='agreement-content'>
|
||||
<Text className='doc-title'>用户服务协议</Text>
|
||||
<Text className='doc-updated'>更新日期:2026年8月21日 生效日期:2026年8月21日</Text>
|
||||
|
||||
<Text className='doc-p'>
|
||||
欢迎使用「订货采购」小程序(以下简称“本小程序”)。本小程序由平台运营方(以下简称“我们”)为合作门店提供商品订货、订单管理、对账结算等采购服务。请您在使用本小程序前,认真阅读并充分理解本协议全部内容。
|
||||
</Text>
|
||||
<Text className='doc-p doc-bold'>
|
||||
您勾选“我已阅读并同意”并点击登录,即表示您已充分阅读、理解并接受本协议的全部内容,本协议即在您与我们之间产生法律效力。若您不同意本协议的任何内容,请立即停止登录或使用本小程序。
|
||||
</Text>
|
||||
|
||||
<Text className='doc-h2'>一、账号与登录</Text>
|
||||
<Text className='doc-p'>1.1 本小程序面向已与我们建立合作关系的门店用户开放,登录账号及初始密码由商家(供货方)在后台分配,本小程序不提供自助注册功能。</Text>
|
||||
<Text className='doc-p'>1.2 您应妥善保管账号和密码,不得将账号出借、转让或授权他人使用。因您主动泄露密码或遭受他人攻击、诈骗等行为导致的损失,由您自行承担。</Text>
|
||||
<Text className='doc-p'>1.3 如发现账号被他人非法使用或存在安全漏洞,请立即联系客服处理。</Text>
|
||||
<Text className='doc-p'>1.4 您可在登录后通过“我的-设置-修改密码”功能自行修改登录密码。</Text>
|
||||
|
||||
<Text className='doc-h2'>二、服务内容</Text>
|
||||
<Text className='doc-p'>2.1 本小程序为您提供以下服务:商品浏览与搜索、在线下单订货、购物车管理、订单查询与管理、账单查看与对账、在线付款及付款记录查询、门店信息查看、消息通知等。</Text>
|
||||
<Text className='doc-p'>2.2 您理解并同意,商品的价格、库存、配送等信息由商家(供货方)提供并负责,实际交易关系发生在您与商家之间。</Text>
|
||||
<Text className='doc-p'>2.3 我们有权根据业务调整对服务功能进行变更、暂停或终止,并以页面公告等方式通知您。</Text>
|
||||
|
||||
<Text className='doc-h2'>三、用户行为规范</Text>
|
||||
<Text className='doc-p'>3.1 您承诺在使用本小程序过程中遵守国家法律法规,不得利用本小程序从事任何违法违规活动,包括但不限于:发布违法信息、恶意刷单、攻击系统、窃取数据等。</Text>
|
||||
<Text className='doc-p'>3.2 您应保证下单、付款等操作的真实性,并按照与商家的约定及时完成结算。</Text>
|
||||
<Text className='doc-p'>3.3 如您违反本协议约定,我们有权视情节采取警示、限制功能、暂停或终止向您提供服务等措施。</Text>
|
||||
|
||||
<Text className='doc-h2'>四、交易与结算</Text>
|
||||
<Text className='doc-p'>4.1 您通过本小程序提交的订单,经商家确认后生效。订单的履行(发货、配送、退换货等)由商家负责。</Text>
|
||||
<Text className='doc-p'>4.2 账单金额、回款周期等结算规则以您与商家的约定及小程序内展示为准。</Text>
|
||||
<Text className='doc-p'>4.3 付款记录、账单明细等信息可在“账单”及“付款记录”页面查询,请您及时核对;如有异议,请及时联系客服。</Text>
|
||||
|
||||
<Text className='doc-h2'>五、知识产权</Text>
|
||||
<Text className='doc-p'>5.1 本小程序的页面设计、程序代码、商标标识等知识产权归我们或相关权利人所有。未经书面许可,您不得复制、传播、修改或用于任何商业用途。</Text>
|
||||
<Text className='doc-p'>5.2 商品图片、描述等内容由商家提供,相关权利归商家或其权利人所有。</Text>
|
||||
|
||||
<Text className='doc-h2'>六、免责声明</Text>
|
||||
<Text className='doc-p'>6.1 因不可抗力(自然灾害、政府行为、网络故障等)导致服务中断或数据损失的,我们不承担责任,但将尽力减少对您的影响。</Text>
|
||||
<Text className='doc-p'>6.2 因您自身原因(如账号泄露、操作失误、网络环境异常等)造成的损失,由您自行承担。</Text>
|
||||
<Text className='doc-p'>6.3 您与商家之间因商品质量、交付、售后等产生的纠纷,由您与商家协商解决,我们将提供必要的协助。</Text>
|
||||
|
||||
<Text className='doc-h2'>七、协议的变更与终止</Text>
|
||||
<Text className='doc-p'>7.1 我们有权根据法律法规及业务需要修订本协议,修订后的协议将在本页面公示。若您不同意修订后的协议,应停止使用本小程序;继续使用则视为接受修订后的协议。</Text>
|
||||
<Text className='doc-p'>7.2 如您与商家的合作关系终止,我们有权停止或注销您的登录账号。</Text>
|
||||
|
||||
<Text className='doc-h2'>八、法律适用与争议解决</Text>
|
||||
<Text className='doc-p'>8.1 本协议的订立、执行和解释及争议的解决均适用中华人民共和国法律。</Text>
|
||||
<Text className='doc-p'>8.2 因本协议引起的或与本协议有关的任何争议,双方应友好协商解决;协商不成的,任何一方均可向我们所在地有管辖权的人民法院提起诉讼。</Text>
|
||||
|
||||
<Text className='doc-h2'>九、联系我们</Text>
|
||||
<Text className='doc-p'>如您对本协议有任何疑问、意见或建议,可通过小程序内“消息”页面或商家提供的客服渠道与我们联系。</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -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: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #f2f3f5;
|
||||
flex-shrink: 0;
|
||||
margin-right: 16rpx;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -128,14 +147,16 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__qty {
|
||||
font-size: 24rpx;
|
||||
color: #969799;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 28rpx;
|
||||
color: #ee0a24;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-size: 24rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
@@ -261,4 +282,46 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 去付款操作栏 =====
|
||||
.bill-pay-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 26rpx;
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
margin-left: 16rpx;
|
||||
font-size: 36rpx;
|
||||
color: #ee0a24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
padding: 14rpx 48rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from '@tarojs/taro'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import Taro, { useRouter } from '@tarojs/taro'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import { Empty, Popup } from '@antmjs/vantui'
|
||||
import { getBillDetailApi } from '@/services/bill'
|
||||
import { getOrderDetailApi } from '@/services/order'
|
||||
import { ORDER_STATUS_TEXT } from '@/types/order'
|
||||
import {formatRetailPrice, formatSpec, 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,12 @@ export default function BillDetailPage() {
|
||||
|
||||
const { bill, items, orders } = detail
|
||||
|
||||
/** 筐/托盘明细:正压负回,数量取绝对值(单价为出账时快照) */
|
||||
const boxTotalPrice = (Number(bill.box_price) * bill.box_num).toFixed(2)
|
||||
const trayTotalPrice = (Number(bill.tray_price) * bill.tray_num).toFixed(2)
|
||||
|
||||
return (
|
||||
<View className='bill-detail'>
|
||||
<View className={`bill-detail ${bill.can_pay ? 'bill-detail--pay' : ''}`}>
|
||||
{/* ===== 账单信息 ===== */}
|
||||
<View className='bill-card'>
|
||||
<View className='bill-card__header'>
|
||||
@@ -94,17 +104,11 @@ export default function BillDetailPage() {
|
||||
<Text className='bill-card__value'>{bill.pay_remark}</Text>
|
||||
</View>
|
||||
)}
|
||||
{bill.remark && (
|
||||
<View className='bill-card__row'>
|
||||
<Text className='bill-card__label'>账单备注</Text>
|
||||
<Text className='bill-card__value'>{bill.remark}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ===== 金额构成 ===== */}
|
||||
<View className='bill-card'>
|
||||
<Text className='bill-section__title'>金额构成</Text>
|
||||
<Text className='bill-section__title'>金额明细</Text>
|
||||
<View className='bill-card__row'>
|
||||
<Text className='bill-card__label'>商品金额</Text>
|
||||
<Text className='bill-card__value'>¥{bill.product_amount}</Text>
|
||||
@@ -114,10 +118,26 @@ 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'>周转筐({bill.box_price} × {bill.box_num})</Text>
|
||||
<Text className={`bill-card__value ${Number(boxTotalPrice) < 0 ? 'bill-card__value--return' : ''}`}>
|
||||
{Number(boxTotalPrice) < 0 ? `- ¥${boxTotalPrice}` : `¥${boxTotalPrice}`}
|
||||
</Text>
|
||||
<Text className='bill-card__value'>¥{bill.added_amount}</Text>
|
||||
</View>
|
||||
<View className='bill-card__row'>
|
||||
<Text className='bill-card__label'>周转托盘({bill.tray_price} × {bill.tray_num})</Text>
|
||||
<Text className={`bill-card__value ${Number(trayTotalPrice) < 0 ? 'bill-card__value--return' : ''}`}>
|
||||
{Number(trayTotalPrice) < 0 ? `- ¥${trayTotalPrice}` : `¥${trayTotalPrice}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='bill-card__row'>
|
||||
<Text className='bill-card__label'>售后</Text>
|
||||
<Text className={`bill-card__value ${Number(bill.after_sale) < 0 ? 'bill-card__value--return' : ''}`}>
|
||||
{Number(bill.after_sale) < 0 ? `- ¥${bill.after_sale}` : `¥${bill.after_sale}`}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='bill-card__row'>
|
||||
<Text className='bill-card__label'>账单备注</Text>
|
||||
<Text className='bill-card__value'>{bill.remark || '暂无备注'}</Text>
|
||||
</View>
|
||||
<View className='bill-card__row bill-card__row--total'>
|
||||
<Text className='bill-card__label'>账单总额</Text>
|
||||
@@ -128,18 +148,28 @@ export default function BillDetailPage() {
|
||||
{/* ===== 商品明细(跨订单按商品合并) ===== */}
|
||||
<View className='bill-card'>
|
||||
<Text className='bill-section__title'>商品明细({items?.length ?? 0})</Text>
|
||||
<Text className='bill-section__desc'>同一商品跨订单合并,单价为加权平均价</Text>
|
||||
{(items ?? []).map(item => (
|
||||
<View key={item.product_id} className='bill-goods'>
|
||||
{!!item.image && (
|
||||
<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'>
|
||||
{item.product_spec ? `${item.product_spec} ` : ''}¥{item.price}/{item.unit}
|
||||
</Text>
|
||||
<View className='bill-goods__name'>{item.product_name}</View>
|
||||
<View className='bill-goods__spec'>
|
||||
{formatSpec(item.product_spec, item.unit)}
|
||||
</View>
|
||||
<View className='bill-goods__spec'>
|
||||
单价:{formatRetailPrice(item.price, item.spec)} {item.price_unit}
|
||||
</View>
|
||||
</View>
|
||||
<View className='bill-goods__side'>
|
||||
<Text className='bill-goods__qty'>×{item.quantity}</Text>
|
||||
<Text className='bill-goods__amount'>¥{item.amount}</Text>
|
||||
<Text className='bill-goods__price'>¥{item.price} × {item.quantity}</Text>
|
||||
<View className='bill-goods__amount'>¥{item.amount}</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
@@ -187,10 +217,11 @@ export default function BillDetailPage() {
|
||||
<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}
|
||||
{formatSpec(item.product_spec, item.unit)}{' '}
|
||||
单价:{formatRetailPrice(item.price, item.product_spec)} {item.price_unit}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='order-popup__item-amount'>¥{item.amount}</Text>
|
||||
<Text className='order-popup__item-amount'>¥{item.price} × {item.quantity}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -200,6 +231,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>
|
||||
)
|
||||
}
|
||||
|
||||
+29
-16
@@ -9,16 +9,24 @@
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
// ===== 待支付汇总 =====
|
||||
.bill-summary {
|
||||
// 底部待支付汇总栏留出空间
|
||||
&--pay {
|
||||
padding-bottom: 180rpx;
|
||||
}
|
||||
|
||||
// ===== 底部待支付汇总栏 =====
|
||||
.bill-paybar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(135deg, #ee0a24, #ff4d4f);
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
color: #fff;
|
||||
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__info {
|
||||
display: flex;
|
||||
@@ -26,20 +34,25 @@
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__count {
|
||||
margin-top: 8rpx;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 40rpx;
|
||||
margin-top: 4rpx;
|
||||
font-size: 36rpx;
|
||||
color: #ee0a24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
padding: 16rpx 56rpx;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 状态筛选 + 导出入口 =====
|
||||
|
||||
+21
-13
@@ -29,7 +29,7 @@ interface CategoryOption {
|
||||
|
||||
/**
|
||||
* 账单列表页
|
||||
* 采购单完成后由后台按门店生成(只读);头部 summary 为门店口径待支付汇总(含审核中,不受筛选影响)
|
||||
* 采购单完成后由后台按门店生成(只读);底部汇总栏为门店口径待支付汇总(含审核中,不受筛选影响)
|
||||
* 支持多选账单合并导出 Excel(可按一级分类过滤商品明细)
|
||||
*/
|
||||
export default function BillListPage() {
|
||||
@@ -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)
|
||||
@@ -180,19 +185,11 @@ export default function BillListPage() {
|
||||
[selectedIds, toggleSelectMode],
|
||||
)
|
||||
|
||||
return (
|
||||
<View className={`bill-page ${selectMode ? 'bill-page--select' : ''}`}>
|
||||
{/* ========== 待支付汇总(门店口径,含审核中) ========== */}
|
||||
{loggedIn && summary && summary.unpaid_count > 0 && (
|
||||
<View className='bill-summary'>
|
||||
<View className='bill-summary__info'>
|
||||
<Text className='bill-summary__label'>待支付账单(含审核中)</Text>
|
||||
<Text className='bill-summary__count'>{summary.unpaid_count} 笔</Text>
|
||||
</View>
|
||||
<Text className='bill-summary__amount'>¥{summary.unpaid_amount}</Text>
|
||||
</View>
|
||||
)}
|
||||
/** 底部待支付汇总栏是否可见(多选导出时让位给导出栏) */
|
||||
const showPayBar = loggedIn && !selectMode && !!summary && summary.unpaid_count > 0
|
||||
|
||||
return (
|
||||
<View className={`bill-page ${selectMode ? 'bill-page--select' : ''} ${showPayBar ? 'bill-page--pay' : ''}`}>
|
||||
{/* ========== 状态筛选 + 导出入口 ========== */}
|
||||
<View className='bill-toolbar'>
|
||||
<ScrollView scrollX className='status-scroll'>
|
||||
@@ -270,6 +267,17 @@ export default function BillListPage() {
|
||||
<View className='bill-loading'><Text>没有更多了</Text></View>
|
||||
)}
|
||||
|
||||
{/* ========== 底部待支付汇总栏(门店口径,含审核中) ========== */}
|
||||
{showPayBar && summary && (
|
||||
<View className='bill-paybar'>
|
||||
<View className='bill-paybar__info'>
|
||||
<Text className='bill-paybar__label'>待支付账单(含审核中){summary.unpaid_count} 笔</Text>
|
||||
<Text className='bill-paybar__amount'>¥{summary.unpaid_amount}</Text>
|
||||
</View>
|
||||
<View className='bill-paybar__btn' onClick={goPay}>去付款</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ========== 导出操作栏 ========== */}
|
||||
{selectMode && (
|
||||
<View className='export-bar'>
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
|
||||
.cart-empty {
|
||||
padding-top: 160rpx;
|
||||
|
||||
&__btn {
|
||||
margin-top: 24rpx;
|
||||
padding: 14rpx 60rpx;
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-loading {
|
||||
@@ -50,8 +59,8 @@
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 150rpx;
|
||||
height: 150rpx;
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #f2f3f5;
|
||||
flex-shrink: 0;
|
||||
@@ -79,6 +88,15 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__spec-tag {
|
||||
margin-left: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: #969799;
|
||||
border-radius: 6rpx;
|
||||
padding: 2rpx 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__invalid-tag {
|
||||
margin-left: 12rpx;
|
||||
font-size: 20rpx;
|
||||
@@ -90,7 +108,6 @@
|
||||
}
|
||||
|
||||
&__spec {
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: #969799;
|
||||
}
|
||||
@@ -158,6 +175,7 @@
|
||||
padding: 16rpx 24rpx;
|
||||
border-top: 1rpx solid #ebedf0;
|
||||
box-sizing: border-box;
|
||||
z-index: 99;
|
||||
|
||||
&__total {
|
||||
flex: 1;
|
||||
|
||||
+31
-12
@@ -2,14 +2,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { View, Text, Image, Textarea, ScrollView } from '@tarojs/components'
|
||||
import { Button, Empty, Icon, Popup, Stepper } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { createOrderApi } from '@/services/order'
|
||||
import { getStoreInfoApi } from '@/services/store'
|
||||
import {formatRetailPrice, formatSpec} from '@/utils/format'
|
||||
import type { CartItem } from '@/types/cart'
|
||||
import type { StoreDetail } from '@/types/store'
|
||||
import './index.less'
|
||||
import CustomTabBar from "@/components/CustomTabBar";
|
||||
|
||||
export default function CartPage() {
|
||||
const token = useAuthStore(s => s.token)
|
||||
const items = useCartStore(s => s.items)
|
||||
const totalQuantity = useCartStore(s => s.totalQuantity)
|
||||
const totalAmount = useCartStore(s => s.totalAmount)
|
||||
@@ -37,6 +41,12 @@ export default function CartPage() {
|
||||
const purchasable = items.filter(item => item.status === 1)
|
||||
const hasInvalid = items.length > 0 && purchasable.length < items.length
|
||||
|
||||
const loggedIn = !!token
|
||||
|
||||
const goLogin = useCallback(() => {
|
||||
Taro.navigateTo({ url: '/pages/login/index' })
|
||||
}, [])
|
||||
|
||||
/** 拉取门店配送信息 */
|
||||
const fetchStoreInfo = useCallback(() => {
|
||||
setStoreLoading(true)
|
||||
@@ -47,6 +57,8 @@ export default function CartPage() {
|
||||
}, [])
|
||||
|
||||
useDidShow(() => {
|
||||
// 未登录不请求接口,直接展示去登录空态(参考消息页)
|
||||
if (!loggedIn) return
|
||||
fetchCart().catch(() => {})
|
||||
// 从门店信息编辑页返回且弹层仍打开时 → 刷新配送信息
|
||||
if (showOrder) fetchStoreInfo()
|
||||
@@ -147,7 +159,7 @@ export default function CartPage() {
|
||||
if (submitting) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await createOrderApi({
|
||||
await createOrderApi({
|
||||
items: purchasable.map(item => ({
|
||||
product_id: item.product_id,
|
||||
quantity: Number(qtyMap[item.id] ?? item.quantity),
|
||||
@@ -175,13 +187,17 @@ export default function CartPage() {
|
||||
{/* ========== 头部 ========== */}
|
||||
<View className='cart-header'>
|
||||
<Text className='cart-header__title'>购物车</Text>
|
||||
{items.length > 0 && (
|
||||
{loggedIn && items.length > 0 && (
|
||||
<Text className='cart-header__clear' onClick={handleClear}>清空</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ========== 列表 ========== */}
|
||||
{items.length === 0 ? (
|
||||
{!loggedIn ? (
|
||||
<Empty description='登录后查看购物车' className='cart-empty'>
|
||||
<View className='cart-empty__btn' onClick={goLogin}>去登录</View>
|
||||
</Empty>
|
||||
) : items.length === 0 ? (
|
||||
loading ? (
|
||||
<View className='cart-loading'><Text>加载中...</Text></View>
|
||||
) : (
|
||||
@@ -205,13 +221,12 @@ export default function CartPage() {
|
||||
<Text className='cart-item__name'>{item.name}</Text>
|
||||
{item.status === 0 && <Text className='cart-item__invalid-tag'>已失效</Text>}
|
||||
</View>
|
||||
<Text className='cart-item__spec'>{item.spec} / {item.unit}</Text>
|
||||
<Text className='cart-item__spec'>
|
||||
{formatSpec(item.spec, item.unit)}{' '}
|
||||
<View>单价:{formatRetailPrice(item.price, item.spec)} {item.price_unit}</View>
|
||||
</Text>
|
||||
<View className='cart-item__bottom'>
|
||||
{item.price !== null ? (
|
||||
<Text className='cart-item__price'>¥{item.price}</Text>
|
||||
) : (
|
||||
<Text className='cart-item__price cart-item__price--none'>价格待定</Text>
|
||||
)}
|
||||
{item.status === 1 ? (
|
||||
<Stepper
|
||||
value={displayQty(item)}
|
||||
@@ -236,17 +251,19 @@ export default function CartPage() {
|
||||
))
|
||||
)}
|
||||
|
||||
{hasInvalid && (
|
||||
<View style={{ height: 100 }}></View>
|
||||
|
||||
{loggedIn && hasInvalid && (
|
||||
<View className='cart-invalid-hint'>
|
||||
<Text>部分商品已下架或未设置您所在等级的价格,不可下单</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ========== 底部结算栏 ========== */}
|
||||
{items.length > 0 && (
|
||||
{loggedIn && items.length > 0 && (
|
||||
<View className='cart-footer'>
|
||||
<View className='cart-footer__total'>
|
||||
<Text className='cart-footer__label'>合计(可购{totalQuantity}件)</Text>
|
||||
<Text className='cart-footer__label'>合计{totalQuantity}件</Text>
|
||||
<Text className='cart-footer__amount'>¥{totalAmount}</Text>
|
||||
</View>
|
||||
<Button type='danger' className='cart-footer__submit' onClick={handleOrderTap}>
|
||||
@@ -302,7 +319,7 @@ export default function CartPage() {
|
||||
<Image className='order-popup__item-image' src={item.image} mode='aspectFill' lazyLoad />
|
||||
<View className='order-popup__item-title'>
|
||||
<Text className='order-popup__item-name'>{item.name}</Text>
|
||||
<Text className='order-popup__item-spec'>{item.spec} / {item.unit}</Text>
|
||||
<Text className='order-popup__item-spec'>{formatSpec(item.spec, item.unit)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className='order-popup__item-right'>
|
||||
@@ -335,6 +352,8 @@ export default function CartPage() {
|
||||
</View>
|
||||
</View>
|
||||
</Popup>
|
||||
|
||||
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '修改密码',
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
.change-password-page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding: 20rpx 24rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
// ===== 表单区块 =====
|
||||
.pwd-section {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 8rpx 28rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
// ===== 表单行 =====
|
||||
.pwd-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 0;
|
||||
border-bottom: 1rpx solid #f2f3f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__label {
|
||||
width: 160rpx;
|
||||
flex-shrink: 0;
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
color: #c8c9cc;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 提交按钮 =====
|
||||
.pwd-submit {
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text, Input } from '@tarojs/components'
|
||||
import { Button } from '@antmjs/vantui'
|
||||
import { changePasswordApi } from '@/services/auth'
|
||||
import './index.less'
|
||||
|
||||
/** 新密码长度限制(与后端一致:6~20 位) */
|
||||
const LIMITS = {
|
||||
passwordMin: 6,
|
||||
passwordMax: 20,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 修改密码页
|
||||
* 修改成功后现有 token 仍然有效,无需重新登录
|
||||
*/
|
||||
export default function ChangePasswordPage() {
|
||||
/** 原密码 */
|
||||
const [oldPassword, setOldPassword] = useState('')
|
||||
/** 新密码 */
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
/** 确认新密码 */
|
||||
const [rePassword, setRePassword] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
/** 提交:PUT /mini/auth/password */
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (saving) return
|
||||
if (!oldPassword) {
|
||||
Taro.showToast({ title: '请输入原密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (newPassword.length < LIMITS.passwordMin) {
|
||||
Taro.showToast({ title: `新密码至少 ${LIMITS.passwordMin} 位`, icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (newPassword !== rePassword) {
|
||||
Taro.showToast({ title: '两次输入的密码不一致', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await changePasswordApi({ oldPassword, newPassword, rePassword })
|
||||
Taro.showToast({ title: '密码修改成功', icon: 'success' })
|
||||
setTimeout(() => Taro.navigateBack(), 800)
|
||||
} catch {
|
||||
// 错误提示已由 request 层 toast(原密码不正确等)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [saving, oldPassword, newPassword, rePassword])
|
||||
|
||||
return (
|
||||
<View className='change-password-page'>
|
||||
{/* ========== 密码表单 ========== */}
|
||||
<View className='pwd-section'>
|
||||
<View className='pwd-field'>
|
||||
<Text className='pwd-field__label'>原密码</Text>
|
||||
<Input
|
||||
className='pwd-field__input'
|
||||
password
|
||||
value={oldPassword}
|
||||
maxlength={LIMITS.passwordMax}
|
||||
placeholder='请输入原密码'
|
||||
placeholderClass='pwd-field__placeholder'
|
||||
onInput={e => setOldPassword(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='pwd-field'>
|
||||
<Text className='pwd-field__label'>新密码</Text>
|
||||
<Input
|
||||
className='pwd-field__input'
|
||||
password
|
||||
value={newPassword}
|
||||
maxlength={LIMITS.passwordMax}
|
||||
placeholder={`请输入新密码(${LIMITS.passwordMin}~${LIMITS.passwordMax} 位)`}
|
||||
placeholderClass='pwd-field__placeholder'
|
||||
onInput={e => setNewPassword(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='pwd-field'>
|
||||
<Text className='pwd-field__label'>确认新密码</Text>
|
||||
<Input
|
||||
className='pwd-field__input'
|
||||
password
|
||||
value={rePassword}
|
||||
maxlength={LIMITS.passwordMax}
|
||||
placeholder='请再次输入新密码'
|
||||
placeholderClass='pwd-field__placeholder'
|
||||
confirmType='done'
|
||||
onInput={e => setRePassword(e.detail.value)}
|
||||
onConfirm={handleSubmit}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ========== 提交 ========== */}
|
||||
<Button
|
||||
type='danger'
|
||||
block
|
||||
round
|
||||
loading={saving}
|
||||
className='pwd-submit'
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
确认修改
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
|
||||
// 底部预留自定义 tabBar(110rpx + 安全区)+ 购物车悬浮球空间,避免内容被遮挡
|
||||
padding-bottom: calc(250rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
|
||||
// ===== 自定义顶部导航栏 =====
|
||||
@@ -234,6 +235,17 @@
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20rpx 0 8rpx;
|
||||
}
|
||||
|
||||
&__loading-text {
|
||||
font-size: 24rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__login-btn {
|
||||
margin-top: 24rpx;
|
||||
padding: 14rpx 60rpx;
|
||||
@@ -273,8 +285,8 @@
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-right: 20rpx;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -283,14 +295,13 @@
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
margin-top: 16rpx;
|
||||
margin-top: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -309,8 +320,8 @@
|
||||
}
|
||||
|
||||
&__add {
|
||||
width: 52rpx;
|
||||
height: 52rpx;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ee0a24, #ff6034);
|
||||
display: flex;
|
||||
|
||||
+128
-26
@@ -1,20 +1,27 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import { Grid, GridItem, Icon, Search, Swiper, SwiperItem } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { getHomeConfigApi } from '@/services/home'
|
||||
import type { HomeConfig } from '@/services/home'
|
||||
import { getProductListApi } from '@/services/product'
|
||||
import { getSpecialListApi, normalizeSpecialCart } from '@/services/special'
|
||||
import { getProductCover } from '@/types/product'
|
||||
import type { Product } from '@/types/product'
|
||||
import type { Product, ProductCartPatch } from '@/types/product'
|
||||
import { getToken } from '@/utils/request'
|
||||
import CartBall from '@/components/CartBall'
|
||||
import CartStepper from '@/components/CartStepper'
|
||||
import {formatRetailPrice, formatSpec} from '@/utils/format'
|
||||
import './index.less'
|
||||
import CustomTabBar from "@/components/CustomTabBar";
|
||||
|
||||
/** 首页 → 商品页的本地存储传参 key(switchTab 无法带参) */
|
||||
const PENDING_CATEGORY_KEY = 'product_category_id'
|
||||
const PENDING_KEYWORD_KEY = 'product_keyword'
|
||||
|
||||
/** 特价推荐每页条数 */
|
||||
const SPECIAL_PAGE_SIZE = 10
|
||||
|
||||
/** tabBar 页面路径(link 跳转需改用 switchTab) */
|
||||
const TAB_PATHS = [
|
||||
'pages/index/index',
|
||||
@@ -39,11 +46,21 @@ function getStatusBarHeight(): number {
|
||||
|
||||
export default function IndexPage() {
|
||||
const addItem = useCartStore(s => s.addItem)
|
||||
const setSummary = useCartStore(s => s.setSummary)
|
||||
|
||||
/** 首页配置(轮播图 / 宫格导航 / 促销卡片) */
|
||||
const [config, setConfig] = useState<HomeConfig>({ banners: [], navs: [], promos: [] })
|
||||
/** 推荐商品 */
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
/** 特价推荐商品(后台「客户端配置 → 特价推荐」标记,价格为登录门店的等级价) */
|
||||
const [specials, setSpecials] = useState<Product[]>([])
|
||||
/** 特价推荐分页 */
|
||||
const [specialPage, setSpecialPage] = useState(1)
|
||||
const [specialHasMore, setSpecialHasMore] = useState(false)
|
||||
/** 加载更多中(首屏重置不展示,避免已渲染列表下方闪烁) */
|
||||
const [specialLoading, setSpecialLoading] = useState(false)
|
||||
/** 特价推荐请求序号(返回 tab 重置与上拉加载并发时,仅采用最后一次响应) */
|
||||
const specialSeqRef = useRef(0)
|
||||
/** 是否有「加载更多」请求进行中 */
|
||||
const specialLoadingRef = useRef(false)
|
||||
/** 搜索框输入 */
|
||||
const [keyword, setKeyword] = useState('')
|
||||
|
||||
@@ -51,28 +68,58 @@ export default function IndexPage() {
|
||||
|
||||
useDidShow(() => {
|
||||
loadHomeConfig()
|
||||
loadRecommend()
|
||||
loadSpecials(1, true)
|
||||
})
|
||||
|
||||
/** 首页配置聚合数据 */
|
||||
/** 上拉加载更多特价推荐 */
|
||||
useReachBottom(() => {
|
||||
if (!specialHasMore) return
|
||||
loadSpecials(specialPage + 1, false)
|
||||
})
|
||||
|
||||
/** 首页配置聚合数据(响应附带悬浮球汇总) */
|
||||
const loadHomeConfig = useCallback(async () => {
|
||||
try {
|
||||
const res = await getHomeConfigApi()
|
||||
setConfig(res.data)
|
||||
// 旧版本后端可能未返回 cart 块
|
||||
if (res.data.cart) setSummary(res.data.cart)
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
}
|
||||
}, [])
|
||||
}, [setSummary])
|
||||
|
||||
/** 推荐商品 */
|
||||
const loadRecommend = useCallback(async () => {
|
||||
/**
|
||||
* 特价推荐商品(reset 时回到第一页整体替换)。
|
||||
* 行结构与 /mini/product/list 一致;响应附带悬浮球汇总
|
||||
*/
|
||||
const loadSpecials = useCallback(
|
||||
async (pageNum: number, reset: boolean) => {
|
||||
if (!reset && specialLoadingRef.current) return
|
||||
const seq = ++specialSeqRef.current
|
||||
specialLoadingRef.current = true
|
||||
if (!reset) setSpecialLoading(true)
|
||||
try {
|
||||
const res = await getProductListApi({ page: 1, pageSize: 10 })
|
||||
setProducts(res.data.data)
|
||||
const res = await getSpecialListApi({ page: pageNum, pageSize: SPECIAL_PAGE_SIZE })
|
||||
if (seq !== specialSeqRef.current) return // 已有更新的请求,丢弃本次响应
|
||||
const { data, total, cart } = res.data
|
||||
setSpecials(prev => (reset ? data : [...prev, ...data]))
|
||||
setSpecialPage(pageNum)
|
||||
setSpecialHasMore(pageNum * SPECIAL_PAGE_SIZE < total)
|
||||
// 列表响应附带悬浮球汇总(旧版本后端可能未返回)
|
||||
const summary = normalizeSpecialCart(cart)
|
||||
if (summary) setSummary(summary)
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
} finally {
|
||||
if (seq === specialSeqRef.current) {
|
||||
specialLoadingRef.current = false
|
||||
setSpecialLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
},
|
||||
[setSummary],
|
||||
)
|
||||
|
||||
/**
|
||||
* 后台配置的 link 统一跳转:
|
||||
@@ -100,25 +147,46 @@ export default function IndexPage() {
|
||||
Taro.switchTab({ url: '/pages/product/index' })
|
||||
}, [])
|
||||
|
||||
/** 跳转商品详情 */
|
||||
const goDetail = useCallback((id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
|
||||
}, [])
|
||||
|
||||
/** 搜索框聚焦/提交 → 商品页搜索 */
|
||||
const handleSearchFocus = useCallback(() => {
|
||||
goProduct(keyword.trim())
|
||||
}, [goProduct, keyword])
|
||||
|
||||
/** 快捷加购 */
|
||||
/** 行内加减购确认后回写特价商品项的购物车字段 */
|
||||
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
|
||||
setSpecials(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
|
||||
}, [])
|
||||
|
||||
/** 快捷加购(用返回的购物车行回写,卡片随即展示加减器) */
|
||||
const handleQuickAdd = useCallback(
|
||||
async (product: Product, e: any) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
await addItem(product.id, 1)
|
||||
const res = await addItem(product.id, 1)
|
||||
handleRowSync(product.id, { cart_id: res.id, cart_quantity: res.quantity })
|
||||
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||
} catch {
|
||||
// 错误(未设等级价等)已由 request 层 toast
|
||||
}
|
||||
},
|
||||
[addItem],
|
||||
[addItem, handleRowSync],
|
||||
)
|
||||
|
||||
/** 无价格时点击:未登录引导登录,已登录但未设等级价提示原因 */
|
||||
const handlePriceGuide = useCallback((e: any) => {
|
||||
e.stopPropagation()
|
||||
if (getToken()) {
|
||||
Taro.showToast({ title: '该商品暂未设置等级价', icon: 'none' })
|
||||
} else {
|
||||
Taro.navigateTo({ url: '/pages/login/index' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View className='home-page'>
|
||||
{/* ========== 自定义顶部导航栏 ========== */}
|
||||
@@ -221,24 +289,25 @@ export default function IndexPage() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ========== 推荐商品 ========== */}
|
||||
{/* ========== 特价推荐 ========== */}
|
||||
<View className='home-recommend'>
|
||||
<View className='home-recommend__header'>
|
||||
<View className='home-recommend__title-wrap'>
|
||||
<View className='home-recommend__title-bar' />
|
||||
<Text className='home-recommend__title'>推荐商品</Text>
|
||||
<Text className='home-recommend__title'>特价推荐</Text>
|
||||
</View>
|
||||
<Text className='home-recommend__more' onClick={() => goProduct()}>查看更多 ›</Text>
|
||||
</View>
|
||||
|
||||
{ products.length === 0 ? (
|
||||
{ specials.length === 0 ? (
|
||||
<View className='home-recommend__empty'>
|
||||
<Text className='home-recommend__empty-text'>暂无商品</Text>
|
||||
<Text className='home-recommend__empty-text'>暂无特价商品</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View className='product-grid'>
|
||||
{products.map(product => (
|
||||
<View key={product.id} className='product-card' onClick={() => goProduct()}>
|
||||
{specials.map(product => (
|
||||
<View key={product.id} className='product-card' onClick={() => goDetail(product.id)}>
|
||||
<Image
|
||||
className='product-card__image'
|
||||
src={getProductCover(product) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
|
||||
@@ -247,23 +316,56 @@ export default function IndexPage() {
|
||||
/>
|
||||
<View className='product-card__info'>
|
||||
<Text className='product-card__name'>{product.name}</Text>
|
||||
<Text className='product-card__spec'>{product.spec} / {product.unit}</Text>
|
||||
<View className='product-card__spec'>
|
||||
{formatSpec(product.spec, product.unit)}{' '}
|
||||
<View>
|
||||
{product.price !== null && <>
|
||||
单价:{formatRetailPrice(product.price, product.spec)} {product.price_unit}
|
||||
</>}
|
||||
</View>
|
||||
</View>
|
||||
<View className='product-card__bottom'>
|
||||
{product.price !== null ? (
|
||||
<Text className='product-card__price'>¥{product.price}</Text>
|
||||
) : (
|
||||
<Text className='product-card__price product-card__price--none'>登陆后查看价格</Text>
|
||||
<Text
|
||||
className='product-card__price product-card__price--none'
|
||||
onClick={handlePriceGuide}
|
||||
>
|
||||
登录后查看价格
|
||||
</Text>
|
||||
)}
|
||||
{/* 已加购展示行内加减器,否则展示快捷加购按钮 */}
|
||||
{Number(product.cart_quantity ?? 0) > 0 ? (
|
||||
<CartStepper product={product} onSync={handleRowSync} />
|
||||
) : (
|
||||
<View className='product-card__add' onClick={e => handleQuickAdd(product, e)}>
|
||||
<Text className='product-card__add-icon'>+</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* 加载更多状态 */}
|
||||
{specialLoading && (
|
||||
<View className='home-recommend__loading'>
|
||||
<Text className='home-recommend__loading-text'>加载中…</Text>
|
||||
</View>
|
||||
)}
|
||||
{!specialHasMore && specialPage > 1 && (
|
||||
<View className='home-recommend__loading'>
|
||||
<Text className='home-recommend__loading-text'>已加载全部特价商品</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ========== 购物车悬浮球 ========== */}
|
||||
<CartBall />
|
||||
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
+73
-24
@@ -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 {
|
||||
@@ -100,17 +100,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 功能介绍 ========== */
|
||||
.login-features {
|
||||
margin-bottom: 80px;
|
||||
/* ========== 登录表单 ========== */
|
||||
.login-form {
|
||||
width: 100%;
|
||||
background: #f7f8fa;
|
||||
border-radius: 24px;
|
||||
padding: 0 32px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 26px;
|
||||
color: #c8c9cc;
|
||||
letter-spacing: 2px;
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 112px;
|
||||
border-bottom: 1px solid #ebedf0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
width: 120px;
|
||||
font-size: 30px;
|
||||
color: #323233;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: 30px;
|
||||
color: #323233;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-input-placeholder {
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
/* ========== 登录操作区 ========== */
|
||||
.login-actions {
|
||||
width: 100%;
|
||||
@@ -123,7 +152,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 +160,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 默认样式 */
|
||||
@@ -144,26 +173,20 @@
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ========== 去注册入口 ========== */
|
||||
.login-switch {
|
||||
/* ========== 客服提示 ========== */
|
||||
.login-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
|
||||
.switch-text {
|
||||
font-size: 28px;
|
||||
.tip-text {
|
||||
font-size: 26px;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.switch-link {
|
||||
font-size: 28px;
|
||||
color: #1989fa;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 协议文字 ========== */
|
||||
/* ========== 协议勾选区 ========== */
|
||||
.login-agreement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -172,13 +195,39 @@
|
||||
margin-top: 32px;
|
||||
line-height: 1.6;
|
||||
|
||||
.agree-checkbox {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #c8c9cc;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s;
|
||||
|
||||
&--checked {
|
||||
background: #ee0a24;
|
||||
border-color: #ee0a24;
|
||||
}
|
||||
}
|
||||
|
||||
.agree-checkbox-tick {
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agree-text {
|
||||
font-size: 24px;
|
||||
color: #c8c9cc;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.agree-link {
|
||||
font-size: 24px;
|
||||
color: #1989fa;
|
||||
color: #ee0a24;
|
||||
}
|
||||
}
|
||||
|
||||
+78
-56
@@ -1,17 +1,24 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import CustomNavBar from '@/components/NavBar'
|
||||
import { View, Text, Button, Input } from '@tarojs/components'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import './index.less'
|
||||
|
||||
/** 登录账号长度限制(与后端一致:4~20 位) */
|
||||
const USERNAME_MAX = 20
|
||||
/** 密码长度限制 */
|
||||
const PASSWORD_MAX = 20
|
||||
|
||||
export default function LoginPage() {
|
||||
const login = useAuthStore(s => s.login)
|
||||
const isLoggedIn = useAuthStore(s => !!s.token && !!s.user)
|
||||
|
||||
/** 登录账号(商家后台分配) */
|
||||
const [username, setUsername] = useState('')
|
||||
/** 登录密码 */
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const isWeb = Taro.getEnv() === Taro.ENV_TYPE.WEB
|
||||
/** 是否已阅读并同意协议(默认不勾选,须用户自主勾选后才能登录) */
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
|
||||
/** 返回上一页(无页面栈时回首页) */
|
||||
const goBack = useCallback(() => {
|
||||
@@ -23,65 +30,51 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 前往注册页 */
|
||||
const goRegister = useCallback(() => {
|
||||
Taro.navigateTo({ url: '/pages/register/index' })
|
||||
}, [])
|
||||
|
||||
/** 已登录 → 自动返回 */
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) goBack()
|
||||
}, [isLoggedIn, goBack])
|
||||
|
||||
/** 微信一键登录(wx.login code 换 openid,仅已注册用户可登录) */
|
||||
/** 账号密码登录:POST /mini/auth/login */
|
||||
const handleLogin = useCallback(async () => {
|
||||
if (submitting) return
|
||||
// H5 环境无法获取微信登录凭证
|
||||
if (isWeb) {
|
||||
Taro.showToast({ title: '请在微信小程序中使用微信登录', icon: 'none' })
|
||||
const account = username.trim()
|
||||
if (!account) {
|
||||
Taro.showToast({ title: '请输入登录账号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!password) {
|
||||
Taro.showToast({ title: '请输入登录密码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!agreed) {
|
||||
Taro.showToast({ title: '请先阅读并勾选同意《用户服务协议》和《隐私政策》', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await Taro.login()
|
||||
if (!res.code) {
|
||||
Taro.showToast({ title: '获取登录凭证失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
await login({ code: res.code })
|
||||
// 登录成功后由 effect 自动返回
|
||||
} catch (e: any) {
|
||||
// 未注册用户:引导前往注册页(其余错误已由 request 层提示)
|
||||
if (typeof e?.msg === 'string' && e.msg.includes('用户不存在')) {
|
||||
Taro.showModal({
|
||||
title: '未注册',
|
||||
content: '该微信账号尚未注册,需授权手机号并填写门店编码完成注册',
|
||||
confirmText: '去注册',
|
||||
cancelText: '取消',
|
||||
success: res => {
|
||||
if (res.confirm) goRegister()
|
||||
},
|
||||
})
|
||||
}
|
||||
await login({ username: account, password })
|
||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||
goBack()
|
||||
} catch {
|
||||
// 错误提示已由 request 层 toast(账号或密码错误 / 账号已停用等)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [login, submitting, isWeb, goRegister])
|
||||
}, [login, submitting, username, password, agreed, goBack])
|
||||
|
||||
/** 查看用户协议 */
|
||||
/** 查看用户服务协议 */
|
||||
const handleShowAgreement = useCallback(() => {
|
||||
Taro.showToast({ title: '用户协议即将上线', icon: 'none' })
|
||||
Taro.navigateTo({ url: '/pages/agreement/index' })
|
||||
}, [])
|
||||
|
||||
/** 查看隐私政策 */
|
||||
const handleShowPrivacy = useCallback(() => {
|
||||
Taro.showToast({ title: '隐私政策即将上线', icon: 'none' })
|
||||
Taro.navigateTo({ url: '/pages/privacy/index' })
|
||||
}, [])
|
||||
|
||||
/** 勾选/取消勾选协议 */
|
||||
const toggleAgreed = useCallback(() => {
|
||||
setAgreed(v => !v)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View className='login-page'>
|
||||
{/* ========== 导航栏 ========== */}
|
||||
<CustomNavBar title='登录' />
|
||||
|
||||
{/* ========== 内容区域 ========== */}
|
||||
<View className='login-content'>
|
||||
@@ -94,9 +87,34 @@ export default function LoginPage() {
|
||||
<Text className='app-slogan'>门店订货 · 对账结算 · 一站式采购</Text>
|
||||
</View>
|
||||
|
||||
{/* 功能介绍 */}
|
||||
<View className='login-features'>
|
||||
<Text className='feature-text'>在线订货 · 价格透明 · 周期对账</Text>
|
||||
{/* 登录表单 */}
|
||||
<View className='login-form'>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>账号</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
type='text'
|
||||
value={username}
|
||||
maxlength={USERNAME_MAX}
|
||||
placeholder='请输入登录账号'
|
||||
placeholderClass='form-input-placeholder'
|
||||
onInput={e => setUsername(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>密码</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
password
|
||||
value={password}
|
||||
maxlength={PASSWORD_MAX}
|
||||
placeholder='请输入登录密码'
|
||||
placeholderClass='form-input-placeholder'
|
||||
confirmType='done'
|
||||
onInput={e => setPassword(e.detail.value)}
|
||||
onConfirm={handleLogin}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 登录操作 */}
|
||||
@@ -107,19 +125,23 @@ export default function LoginPage() {
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '登录中...' : '微信一键登录'}
|
||||
{submitting ? '登录中...' : '登 录'}
|
||||
</Button>
|
||||
|
||||
{/* 未注册用户入口 */}
|
||||
<View className='login-switch' onClick={goRegister}>
|
||||
<Text className='switch-text'>还没有账号?</Text>
|
||||
<Text className='switch-link'>立即注册</Text>
|
||||
<View className='login-tip'>
|
||||
<Text className='tip-text'>账号密码由商家分配,如需帮助请联系客服</Text>
|
||||
</View>
|
||||
|
||||
<View className='login-agreement'>
|
||||
<Text className='agree-text'>登录即代表同意</Text>
|
||||
<View
|
||||
className={`agree-checkbox ${agreed ? 'agree-checkbox--checked' : ''}`}
|
||||
onClick={toggleAgreed}
|
||||
>
|
||||
{agreed && <Text className='agree-checkbox-tick'>✓</Text>}
|
||||
</View>
|
||||
<Text className='agree-text'>我已阅读并同意</Text>
|
||||
<Text className='agree-link' onClick={handleShowAgreement}>
|
||||
《用户协议》
|
||||
《用户服务协议》
|
||||
</Text>
|
||||
<Text className='agree-text'>和</Text>
|
||||
<Text className='agree-link' onClick={handleShowPrivacy}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { formatTime } from '@/utils/format'
|
||||
import { NOTICE_TYPE_MAP } from '@/types/notice'
|
||||
import type { Notice, NoticeType } from '@/types/notice'
|
||||
import './index.less'
|
||||
import CustomTabBar from "@/components/CustomTabBar";
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
@@ -123,6 +124,8 @@ export default function MessagePage() {
|
||||
{loggedIn && finished && notices.length > 0 && (
|
||||
<View className='message-loading'><Text>没有更多了</Text></View>
|
||||
)}
|
||||
|
||||
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,15 +77,66 @@
|
||||
}
|
||||
|
||||
&__preview {
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
color: #646566;
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
&__goods {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
&__goods-img {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #f2f3f5;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--empty {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
&__goods-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__goods-name {
|
||||
font-size: 26rpx;
|
||||
color: #323233;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__goods-spec {
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__goods-qty {
|
||||
margin-left: 20rpx;
|
||||
font-size: 24rpx;
|
||||
color: #646566;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__more {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 {formatRetailPrice, formatSpec, resolveFileUrl} from '@/utils/format'
|
||||
import PriceText from '@/components/PriceText'
|
||||
import type { OrderDetail, OrderListItem, OrderStatus } from '@/types/order'
|
||||
import './index.less'
|
||||
|
||||
@@ -167,11 +169,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>
|
||||
{!!formatSpec(i.product_spec, i.unit) && (
|
||||
<Text className='order-item__goods-spec'>{formatSpec(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,10 +263,11 @@ 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}
|
||||
{formatSpec(item.product_spec, item.unit)}{' '}
|
||||
单价:{formatRetailPrice(item.price, item.product_spec)} {item.price_unit}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='detail-popup__item-amount'>¥{item.amount}</Text>
|
||||
<Text className='detail-popup__item-amount'>¥{item.price} × {item.quantity}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '支付详情',
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
.pay-detail {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding: 20rpx 24rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
// 已拒绝时为底部操作栏留出空间
|
||||
&--reject {
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
.pay-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__no {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24rpx;
|
||||
|
||||
// 0 待审核 / 1 已通过 / 2 已拒绝
|
||||
&--0 { color: #ff976a; }
|
||||
&--1 { color: #07c160; }
|
||||
&--2 { color: #ee0a24; }
|
||||
}
|
||||
|
||||
&__amount {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 48rpx;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__tip {
|
||||
display: block;
|
||||
margin: 16rpx 0 8rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
background: #fff8ec;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #ff976a;
|
||||
line-height: 1.5;
|
||||
text-align: left;
|
||||
|
||||
&--reject {
|
||||
background: #fff5f5;
|
||||
color: #ee0a24;
|
||||
}
|
||||
}
|
||||
|
||||
&__row {
|
||||
margin-top: 16rpx;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__label {
|
||||
flex-shrink: 0;
|
||||
font-size: 26rpx;
|
||||
color: #969799;
|
||||
margin-right: 24rpx;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 26rpx;
|
||||
color: #323233;
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-section__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
// ===== 汇款凭证 =====
|
||||
.pay-vouchers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16rpx;
|
||||
|
||||
&__img {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
margin: 0 16rpx 16rpx 0;
|
||||
border-radius: 12rpx;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 合并账单 =====
|
||||
.pay-bill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1rpx solid #f2f3f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__no {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__date {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 600;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24rpx;
|
||||
|
||||
&--0 { color: #ee0a24; }
|
||||
&--1 { color: #07c160; }
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 底部操作栏 =====
|
||||
.pay-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__btn {
|
||||
padding: 14rpx 48rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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,
|
||||
getPayStatusName,
|
||||
PAY_METHOD_NAMES,
|
||||
queryOnlinePaymentApi,
|
||||
} from '@/services/payment'
|
||||
import { resolveFileUrl } from '@/utils/format'
|
||||
import type { PaymentDetail } from '@/services/payment'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 支付详情页
|
||||
* 线下凭证单:支付信息 + 汇款凭证(可预览)+ 合并付款的账单(可下钻账单详情),审核拒绝后可重新发起付款
|
||||
* 在线支付单:无凭证,待支付时可「刷新支付结果」主动同步网关结果(后台通知延迟/丢失时的兜底)
|
||||
*/
|
||||
export default function PaymentDetailPage() {
|
||||
const router = useRouter()
|
||||
const id = Number(router.params.id ?? 0)
|
||||
|
||||
const [detail, setDetail] = useState<PaymentDetail | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getPaymentDetailApi(id)
|
||||
setDetail(res.data)
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail()
|
||||
}, [loadDetail])
|
||||
|
||||
/** 预览凭证图片 */
|
||||
const previewVoucher = useCallback((current: string) => {
|
||||
const urls = (detail?.payment.voucher_urls ?? []).map(resolveFileUrl)
|
||||
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])
|
||||
|
||||
/** 在线支付待支付 → 主动查询网关同步结果(已支付则后端立即结账),随后刷新详情 */
|
||||
const handleSync = useCallback(async () => {
|
||||
if (!detail || syncing) return
|
||||
setSyncing(true)
|
||||
try {
|
||||
const res = await queryOnlinePaymentApi(detail.payment.payment_no)
|
||||
if (res.data.status === 1) {
|
||||
Taro.showToast({ title: '支付成功', icon: 'success' })
|
||||
loadDetail()
|
||||
} else if (res.data.status === 2) {
|
||||
Taro.showToast({ title: '支付失败,账单已释放', icon: 'none' })
|
||||
loadDetail()
|
||||
} else {
|
||||
Taro.showToast({ title: '暂未查询到支付结果,请稍后再试', icon: 'none' })
|
||||
}
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}, [detail, syncing, loadDetail])
|
||||
|
||||
if (loading && !detail) {
|
||||
return <View className='pay-detail'><Empty description='加载中...' /></View>
|
||||
}
|
||||
if (!detail) {
|
||||
return <View className='pay-detail'><Empty description='支付记录不存在' /></View>
|
||||
}
|
||||
|
||||
const { payment, bills } = detail
|
||||
const vouchers = payment.voucher_urls.map(resolveFileUrl)
|
||||
/** 在线支付单(旺铺网关):状态语义与线下凭证单不同,无凭证 */
|
||||
const isOnline = payment.pay_type === 2
|
||||
|
||||
return (
|
||||
<View className={`pay-detail ${payment.status === 2 ? 'pay-detail--reject' : ''}`}>
|
||||
{/* ===== 支付信息 ===== */}
|
||||
<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}`}>
|
||||
{getPayStatusName(payment)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className='pay-card__amount'>¥{payment.amount}</Text>
|
||||
{payment.status === 0 && !isOnline && (
|
||||
<Text className='pay-card__tip'>付款申请已提交,商家审核通过后账单将置为已支付</Text>
|
||||
)}
|
||||
{payment.status === 0 && isOnline && (
|
||||
<Text className='pay-card__tip'>
|
||||
账单已锁定,等待支付结果确认;如已完成支付,可点击下方「刷新支付结果」
|
||||
</Text>
|
||||
)}
|
||||
{payment.status === 2 && (
|
||||
<Text className='pay-card__tip pay-card__tip--reject'>
|
||||
{isOnline
|
||||
? '支付失败,账单已释放,可重新发起付款'
|
||||
: `审核未通过${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>
|
||||
)}
|
||||
{isOnline && payment.paid_at && (
|
||||
<View className='pay-card__row'>
|
||||
<Text className='pay-card__label'>支付时间</Text>
|
||||
<Text className='pay-card__value'>{payment.paid_at}</Text>
|
||||
</View>
|
||||
)}
|
||||
{isOnline && payment.trade_no && (
|
||||
<View className='pay-card__row'>
|
||||
<Text className='pay-card__label'>交易单号</Text>
|
||||
<Text className='pay-card__value'>{payment.trade_no}</Text>
|
||||
</View>
|
||||
)}
|
||||
{payment.remark && (
|
||||
<View className='pay-card__row'>
|
||||
<Text className='pay-card__label'>付款备注</Text>
|
||||
<Text className='pay-card__value'>{payment.remark}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* ===== 汇款凭证(在线支付单无凭证) ===== */}
|
||||
{!isOnline && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ===== 在线支付待支付 → 主动同步支付结果 ===== */}
|
||||
{isOnline && payment.status === 0 && (
|
||||
<View className='pay-bar'>
|
||||
<View className='pay-bar__btn' onClick={handleSync}>
|
||||
{syncing ? '查询中...' : '刷新支付结果'}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '支付记录',
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
.payment-page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding: 20rpx 24rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.status-scroll {
|
||||
white-space: nowrap;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
padding: 12rpx 28rpx;
|
||||
margin-right: 16rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #fff;
|
||||
font-size: 26rpx;
|
||||
color: #646566;
|
||||
|
||||
&.active {
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-empty {
|
||||
padding-top: 120rpx;
|
||||
|
||||
&__btn {
|
||||
margin-top: 24rpx;
|
||||
padding: 14rpx 60rpx;
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.payment-loading {
|
||||
padding: 30rpx 0;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
// ===== 支付记录单项 =====
|
||||
.payment-item {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__no {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__status {
|
||||
font-size: 24rpx;
|
||||
|
||||
// 0 待审核 / 1 已通过 / 2 已拒绝
|
||||
&--0 { color: #ff976a; }
|
||||
&--1 { color: #07c160; }
|
||||
&--2 { color: #ee0a24; }
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-top: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__method {
|
||||
font-size: 24rpx;
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
&__date {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
&__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 32rpx;
|
||||
color: #323233;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__bills {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__reject {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
background: #fff5f5;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #ee0a24;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import { Empty } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import { getPaymentListApi, getPayStatusName, PAY_METHOD_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}`}>
|
||||
{getPayStatusName(record)}
|
||||
</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.pay_type !== 2 && !!record.audit_remark && (
|
||||
<Text className='payment-item__reject'>拒绝原因:{record.audit_remark}</Text>
|
||||
)}
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
|
||||
{loggedIn && finished && records.length > 0 && (
|
||||
<View className='payment-loading'><Text>没有更多了</Text></View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '账单付款',
|
||||
})
|
||||
@@ -0,0 +1,323 @@
|
||||
.pay-page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding: 20rpx 24rpx 160rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
.pay-section {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
&__extra {
|
||||
font-size: 26rpx;
|
||||
color: #ee0a24;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-empty {
|
||||
padding: 40rpx 0;
|
||||
|
||||
&__btn {
|
||||
margin-top: 24rpx;
|
||||
padding: 14rpx 60rpx;
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
border-radius: 999rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.pay-loading {
|
||||
padding: 24rpx 0 8rpx;
|
||||
text-align: center;
|
||||
font-size: 24rpx;
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
// ===== 账单选择行 =====
|
||||
.pay-bill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1rpx solid #f2f3f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__check {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #c8c9cc;
|
||||
margin-right: 20rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.on {
|
||||
background: #ee0a24;
|
||||
border-color: #ee0a24;
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__no {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 30rpx;
|
||||
color: #ee0a24;
|
||||
font-weight: 600;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 支付方式 =====
|
||||
.pay-methods {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.pay-method {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
|
||||
&.active {
|
||||
.pay-method__label {
|
||||
color: #ee0a24;
|
||||
}
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin-top: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__radio {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border-radius: 50%;
|
||||
border: 2rpx solid #c8c9cc;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.on {
|
||||
background: #ee0a24;
|
||||
border-color: #ee0a24;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16rpx 0 8rpx;
|
||||
}
|
||||
|
||||
&__qrcode {
|
||||
width: 360rpx;
|
||||
height: 360rpx;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
&__qrcode-tip {
|
||||
margin-top: 16rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__bank {
|
||||
width: 100%;
|
||||
font-size: 26rpx;
|
||||
color: #323233;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12rpx;
|
||||
padding: 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__copy {
|
||||
margin-top: 16rpx;
|
||||
padding: 8rpx 40rpx;
|
||||
border: 1rpx solid #ee0a24;
|
||||
border-radius: 999rpx;
|
||||
color: #ee0a24;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
display: block;
|
||||
padding: 24rpx 0 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #969799;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 汇款凭证 =====
|
||||
.pay-vouchers {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.pay-voucher {
|
||||
position: relative;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
margin: 0 16rpx 16rpx 0;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
|
||||
&__img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
&__del {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border-radius: 0 0 0 12rpx;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&--add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx dashed #dcdee0;
|
||||
background: #fafafa;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__add-text {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 备注 =====
|
||||
.pay-remark {
|
||||
width: 100%;
|
||||
height: 140rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 16rpx;
|
||||
background: #f7f8fa;
|
||||
border-radius: 12rpx;
|
||||
font-size: 26rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// ===== 提交栏 =====
|
||||
.pay-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
&__count {
|
||||
font-size: 26rpx;
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
margin-left: 16rpx;
|
||||
font-size: 36rpx;
|
||||
color: #ee0a24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
padding: 14rpx 48rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
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 { createOnlinePaymentApi, createPaymentApi, getPaymentConfigApi, queryOnlinePaymentApi } 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
|
||||
|
||||
/** 在线支付(调起 wx.requestPayment)仅微信小程序支持 */
|
||||
const IS_WEAPP = process.env.TARO_ENV === 'weapp'
|
||||
|
||||
/** H5 端处于微信内置浏览器时,可走公众号网页授权 + JSAPI 在线支付 */
|
||||
const IS_H5_WECHAT =
|
||||
process.env.TARO_ENV === 'h5' &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
/micromessenger/i.test(navigator.userAgent)
|
||||
|
||||
/** 在线支付(旺铺网关 JSAPI)是否可用:小程序 / 微信内 H5 */
|
||||
const ONLINE_PAY_AVAILABLE = IS_WEAPP || IS_H5_WECHAT
|
||||
|
||||
/** H5 公众号支付草稿存储 key(授权跳转前暂存账单选择,回跳后恢复) */
|
||||
const H5_PAY_DRAFT_KEY = 'h5_online_pay_draft'
|
||||
|
||||
/** H5 公众号支付草稿(授权回跳页面重载,勾选状态经 sessionStorage 恢复) */
|
||||
interface H5PayDraft {
|
||||
bill_ids: number[]
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 调起公众号 JSAPI 支付(WeixinJSBridge 未注入时等待 WeixinJSBridgeReady 事件)
|
||||
* resolve: 'ok' 支付成功 / 'cancel' 用户取消 / 'fail' 调起失败
|
||||
*/
|
||||
function invokeWechatJsapiPay(payParams: Record<string, any>): Promise<'ok' | 'cancel' | 'fail'> {
|
||||
return new Promise(resolve => {
|
||||
const invoke = () => {
|
||||
;(window as any).WeixinJSBridge.invoke(
|
||||
'getBrandWCPayRequest',
|
||||
{
|
||||
appId: String(payParams.appId || ''),
|
||||
timeStamp: String(payParams.timeStamp || ''),
|
||||
nonceStr: String(payParams.nonceStr || ''),
|
||||
package: String(payParams.package || ''),
|
||||
signType: String(payParams.signType || 'RSA'),
|
||||
paySign: String(payParams.paySign || ''),
|
||||
},
|
||||
(res: any) => {
|
||||
const msg: string = res?.err_msg || ''
|
||||
if (msg === 'get_brand_wcpay_request:ok') resolve('ok')
|
||||
else if (msg === 'get_brand_wcpay_request:cancel') resolve('cancel')
|
||||
else resolve('fail')
|
||||
},
|
||||
)
|
||||
}
|
||||
if ((window as any).WeixinJSBridge) {
|
||||
invoke()
|
||||
} else {
|
||||
document.addEventListener('WeixinJSBridgeReady', invoke, { once: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 支付方式选项(在线支付仅小程序/微信内 H5 展示,排在最前) */
|
||||
const PAY_METHODS: Array<{ value: PayMethod; label: string; icon: string; desc: string }> = [
|
||||
...(ONLINE_PAY_AVAILABLE
|
||||
? [
|
||||
{
|
||||
value: 4 as PayMethod,
|
||||
label: '微信在线支付',
|
||||
icon: 'wechat',
|
||||
desc: IS_WEAPP ? '小程序内直接付款,免上传凭证' : '微信内直接付款,免上传凭证',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ value: 1, label: '微信支付', icon: 'wechat', desc: '扫码完成转账' },
|
||||
{ value: 2, label: '支付宝', icon: 'alipay', desc: '扫码完成转账' },
|
||||
{ value: 3, label: '对公汇款', icon: 'credit-pay', desc: '银行转账至对公账户' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 发起付款页(合并付款)
|
||||
*/
|
||||
export default function PaymentPage() {
|
||||
const token = useAuthStore(s => s.token)
|
||||
const loggedIn = !!token
|
||||
|
||||
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>(ONLINE_PAY_AVAILABLE ? 4 : 1)
|
||||
const [vouchers, setVouchers] = useState<UploadedFile[]>([])
|
||||
const [remark, setRemark] = useState('')
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
/** 在线支付(旺铺网关 JSAPI):免凭证,调起微信支付 */
|
||||
const isOnline = payMethod === 4
|
||||
|
||||
/** 拉取可付款账单(首次加载应用路由预选) */
|
||||
const loadBills = useCallback(
|
||||
async (pageNum: number, reset: boolean) => {
|
||||
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)
|
||||
setSelectedIds(prev => Array.from(new Set([...prev, ...data.map(i => i.id)])))
|
||||
} 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 handleVoucherSubmit = 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])
|
||||
|
||||
/**
|
||||
* 在线支付:wx.login 取 code → 后端经旺铺网关下单 → 调起微信支付 → 主动查询同步结果
|
||||
* 无论支付成功/取消都跳转支付详情(待支付单可在详情页刷新同步结果)
|
||||
*/
|
||||
const handleOnlinePay = useCallback(async () => {
|
||||
if (submitting) return
|
||||
if (!IS_WEAPP) {
|
||||
Taro.showToast({ title: '请在微信小程序中使用在线支付', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (selectedIds.length === 0) {
|
||||
Taro.showToast({ title: '请选择要付款的账单', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 1. 获取微信登录凭证(后端换付款人 openid)
|
||||
const { code } = await Taro.login()
|
||||
if (!code) {
|
||||
Taro.showToast({ title: '微信登录失败,请稍后重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
// 2. 后端下单(创建支付单并锁定账单)
|
||||
const res = await createOnlinePaymentApi({
|
||||
bill_ids: selectedIds,
|
||||
code,
|
||||
remark: remark.trim() || undefined,
|
||||
})
|
||||
const { id, payment_no, pay_params } = res.data
|
||||
// 3. 调起微信支付(pay_params 为网关透传的调起参数)
|
||||
try {
|
||||
await Taro.requestPayment({
|
||||
timeStamp: String(pay_params.timeStamp || ''),
|
||||
nonceStr: String(pay_params.nonceStr || ''),
|
||||
package: String(pay_params.package || ''),
|
||||
signType: (pay_params.signType || 'RSA') as 'MD5' | 'HMAC-SHA256' | 'RSA',
|
||||
paySign: String(pay_params.paySign || ''),
|
||||
})
|
||||
} catch (e: any) {
|
||||
// 用户取消或调起失败:账单仍锁定在支付单中,进详情页可刷新同步/稍后处理
|
||||
const errMsg = e?.errMsg || ''
|
||||
Taro.showToast({
|
||||
title: errMsg.includes('cancel') ? '已取消支付' : '支付调起失败,请稍后重试',
|
||||
icon: 'none',
|
||||
})
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
|
||||
}, 800)
|
||||
return
|
||||
}
|
||||
// 4. 主动查询同步结果(网关后台通知延迟/丢失时的兜底结账)
|
||||
let paid = false
|
||||
try {
|
||||
const q = await queryOnlinePaymentApi(payment_no)
|
||||
paid = q.data.status === 1
|
||||
} catch {
|
||||
// 查询失败不阻断,进详情页可手动刷新
|
||||
}
|
||||
Taro.showToast({ title: paid ? '支付成功' : '支付结果确认中', icon: paid ? 'success' : 'none' })
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
|
||||
}, 800)
|
||||
} catch {
|
||||
// 下单失败:账单可能已被其他端付款/锁定,刷新列表
|
||||
loadBills(1, true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [submitting, selectedIds, remark, loadBills])
|
||||
|
||||
/**
|
||||
* H5 公众号支付主流程(授权回跳后执行):
|
||||
* 授权 code 下单(scene=mp,后端换付款人 openid)→ WeixinJSBridge 调起支付 → 主动查询同步结果
|
||||
* 与小程序端一致:无论成功/取消都跳转支付详情(待支付单可在详情页刷新同步结果)
|
||||
*/
|
||||
const runH5OnlinePay = useCallback(
|
||||
async (billIds: number[], code: string, remarkText?: string) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await createOnlinePaymentApi({
|
||||
bill_ids: billIds,
|
||||
code,
|
||||
scene: 'mp',
|
||||
remark: remarkText,
|
||||
})
|
||||
const { id, payment_no, pay_params } = res.data
|
||||
const result = await invokeWechatJsapiPay(pay_params)
|
||||
if (result !== 'ok') {
|
||||
// 用户取消或调起失败:账单仍锁定在支付单中,进详情页可刷新同步/稍后处理
|
||||
Taro.showToast({
|
||||
title: result === 'cancel' ? '已取消支付' : '支付调起失败,请稍后重试',
|
||||
icon: 'none',
|
||||
})
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
|
||||
}, 800)
|
||||
return
|
||||
}
|
||||
// 主动查询同步结果(网关后台通知延迟/丢失时的兜底结账)
|
||||
let paid = false
|
||||
try {
|
||||
const q = await queryOnlinePaymentApi(payment_no)
|
||||
paid = q.data.status === 1
|
||||
} catch {
|
||||
// 查询失败不阻断,进详情页可手动刷新
|
||||
}
|
||||
Taro.showToast({ title: paid ? '支付成功' : '支付结果确认中', icon: paid ? 'success' : 'none' })
|
||||
setTimeout(() => {
|
||||
Taro.redirectTo({ url: `/pages/payment-detail/index?id=${id}` })
|
||||
}, 800)
|
||||
} catch {
|
||||
// 下单失败:账单可能已被其他端付款/锁定,刷新列表
|
||||
loadBills(1, true)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[loadBills],
|
||||
)
|
||||
|
||||
/** H5 公众号支付:处理微信授权回跳(URL 携带 code 且本地存在支付草稿时自动继续支付) */
|
||||
const h5CallbackRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!IS_H5_WECHAT || h5CallbackRef.current || !loggedIn) return
|
||||
const code = new URLSearchParams(window.location.search).get('code')
|
||||
if (!code) return
|
||||
// 清理地址栏授权参数,避免刷新/分享带出已失效的 code
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
let draft: H5PayDraft | null = null
|
||||
try {
|
||||
draft = JSON.parse(window.sessionStorage.getItem(H5_PAY_DRAFT_KEY) || 'null')
|
||||
window.sessionStorage.removeItem(H5_PAY_DRAFT_KEY)
|
||||
} catch {
|
||||
draft = null
|
||||
}
|
||||
if (!draft || !Array.isArray(draft.bill_ids) || draft.bill_ids.length === 0) return
|
||||
h5CallbackRef.current = true
|
||||
setSelectedIds(draft.bill_ids)
|
||||
if (draft.remark) setRemark(draft.remark)
|
||||
runH5OnlinePay(draft.bill_ids, code, draft.remark)
|
||||
}, [loggedIn, runH5OnlinePay])
|
||||
|
||||
/**
|
||||
* H5 公众号支付入口:暂存支付草稿 → 跳转微信网页授权(snsapi_base 静默授权)
|
||||
* 授权后回跳本页携带 code,由上方 effect 恢复草稿并继续支付
|
||||
*/
|
||||
const handleH5OnlinePay = useCallback(async () => {
|
||||
if (submitting) return
|
||||
if (selectedIds.length === 0) {
|
||||
Taro.showToast({ title: '请选择要付款的账单', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// mp_appid 可能尚未加载完成,兜底重新拉取
|
||||
let appid = config?.mp_appid
|
||||
if (!appid) {
|
||||
const res = await getPaymentConfigApi()
|
||||
setConfig(res.data)
|
||||
appid = res.data.mp_appid
|
||||
}
|
||||
if (!appid) {
|
||||
Taro.showToast({ title: '公众号支付暂未开通,请选择其他支付方式', icon: 'none' })
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
const draft: H5PayDraft = { bill_ids: selectedIds, remark: remark.trim() || undefined }
|
||||
window.sessionStorage.setItem(H5_PAY_DRAFT_KEY, JSON.stringify(draft))
|
||||
const redirectUri = encodeURIComponent(window.location.origin + window.location.pathname)
|
||||
window.location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=snsapi_base#wechat_redirect`
|
||||
} catch {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}, [submitting, selectedIds, remark, config])
|
||||
|
||||
/** 提交入口:按支付方式与端分发(小程序 wx.requestPayment / H5 公众号 JSAPI / 线下凭证) */
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isOnline) {
|
||||
if (IS_H5_WECHAT) {
|
||||
handleH5OnlinePay()
|
||||
} else {
|
||||
handleOnlinePay()
|
||||
}
|
||||
} else {
|
||||
handleVoucherSubmit()
|
||||
}
|
||||
}, [isOnline, handleOnlinePay, handleH5OnlinePay, handleVoucherSubmit])
|
||||
|
||||
/** 当前支付方式的收款展示 */
|
||||
const renderMethodContent = () => {
|
||||
if (isOnline) {
|
||||
return (
|
||||
<Text className='pay-method__empty'>
|
||||
确认支付后将调起微信支付,支付成功后账单自动结清
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
if (payMethod === 3) {
|
||||
return config?.bank_info ? (
|
||||
<View className='pay-method__content'>
|
||||
<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>
|
||||
|
||||
{/* ========== 汇款凭证(在线支付免凭证) ========== */}
|
||||
{!isOnline && (
|
||||
<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={isOnline ? '可填写付款说明' : '如:汇款人姓名、转账时间等'}
|
||||
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 ? (isOnline ? '支付中...' : '提交中...') : isOnline ? '立即支付' : '提交付款'}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '注册',
|
||||
navigationBarTitleText: '隐私政策',
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
/* ========================================
|
||||
隐私政策页面(与用户协议共用样式)
|
||||
======================================== */
|
||||
|
||||
.privacy-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.privacy-scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.privacy-content {
|
||||
padding: 32px 40px 80px;
|
||||
|
||||
.doc-title {
|
||||
display: block;
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.doc-updated {
|
||||
display: block;
|
||||
font-size: 24px;
|
||||
color: #969799;
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.doc-p {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
color: #323233;
|
||||
line-height: 1.8;
|
||||
margin-bottom: 24px;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.doc-h2 {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin: 48px 0 16px;
|
||||
}
|
||||
|
||||
.doc-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import './index.less'
|
||||
|
||||
/**
|
||||
* 隐私政策
|
||||
* 静态政策文本页,由登录页/设置页进入
|
||||
*/
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<View className='privacy-page'>
|
||||
<ScrollView scrollY className='privacy-scroll'>
|
||||
<View className='privacy-content'>
|
||||
<Text className='doc-title'>隐私政策</Text>
|
||||
<Text className='doc-updated'>更新日期:2026年8月21日 生效日期:2026年8月21日</Text>
|
||||
|
||||
<Text className='doc-p'>
|
||||
「订货采购」小程序(以下简称“本小程序”)由平台运营方(以下简称“我们”)运营。我们深知个人信息对您的重要性,并会尽全力保护您的个人信息安全可靠。我们致力于维持您对我们的信任,恪守以下原则保护您的个人信息:权责一致原则、目的明确原则、选择同意原则、最小必要原则、确保安全原则、主体参与原则、公开透明原则等。
|
||||
</Text>
|
||||
<Text className='doc-p doc-bold'>
|
||||
请您在使用本小程序前,仔细阅读并充分理解本政策全部内容。您勾选“我已阅读并同意”并点击登录,即表示您同意我们按照本政策收集、使用、存储和共享您的相关信息。若您不同意本政策的任何内容,您可以选择不使用本小程序。
|
||||
</Text>
|
||||
|
||||
<Text className='doc-h2'>一、我们收集的信息及用途</Text>
|
||||
<Text className='doc-p'>为向您提供订货采购相关服务,我们会在以下场景收集和使用您的信息:</Text>
|
||||
<Text className='doc-p'>1.1 账号登录信息:当您使用账号密码登录时,我们会收集您的登录账号、密码(加密传输与存储),用于验证您的身份并完成登录。</Text>
|
||||
<Text className='doc-p'>1.2 门店与联系人信息:为完成订单配送与结算,我们会使用商家在后台录入的门店名称、门店地址、联系人、联系电话等信息,用于订单配送、账单对账及售后服务。</Text>
|
||||
<Text className='doc-p'>1.3 交易信息:当您下单、付款时,我们会收集您的订单信息(商品、数量、金额)、账单信息、付款记录等,用于订单履行、对账结算与售后处理。</Text>
|
||||
<Text className='doc-p'>1.4 设备与日志信息:为保障服务安全稳定运行,我们可能会收集您的设备型号、操作系统、网络状态、操作日志等信息,用于故障排查、安全风控与服务优化。</Text>
|
||||
|
||||
<Text className='doc-h2'>二、我们如何使用信息</Text>
|
||||
<Text className='doc-p'>2.1 我们仅将收集的信息用于实现本小程序的核心功能,包括:身份验证、商品展示与下单、订单管理、账单对账、付款结算、消息通知、客户服务等。</Text>
|
||||
<Text className='doc-p'>2.2 我们不会将您的个人信息用于与上述功能无关的用途;如超出原目的使用您的信息,我们会再次征得您的明示同意。</Text>
|
||||
|
||||
<Text className='doc-h2'>三、信息的共享、转让与公开披露</Text>
|
||||
<Text className='doc-p'>3.1 我们不会向任何无关第三方出售您的个人信息。</Text>
|
||||
<Text className='doc-p'>3.2 为完成订单履约,我们会将配送所需的信息(门店名称、地址、联系人、电话、订单明细)提供给为您提供商品的商家及配送服务方。</Text>
|
||||
<Text className='doc-p'>3.3 基于法律规定、司法机关或行政机关的合法要求,我们可能会披露您的相关信息。</Text>
|
||||
|
||||
<Text className='doc-h2'>四、信息的存储与保护</Text>
|
||||
<Text className='doc-p'>4.1 您的个人信息存储于中华人民共和国境内。我们仅在为您提供服务所必需的期间内保留您的信息。</Text>
|
||||
<Text className='doc-p'>4.2 我们采用加密传输、访问控制等安全技术和管理措施保护您的信息,防止信息遭到未经授权的访问、披露、使用或损毁。</Text>
|
||||
<Text className='doc-p'>4.3 请您妥善保管账号密码。如发生个人信息安全事件,我们将按照法律法规要求及时告知您并向主管部门报告。</Text>
|
||||
|
||||
<Text className='doc-h2'>五、您的权利</Text>
|
||||
<Text className='doc-p'>5.1 查询与更正:您可在小程序内查询您的订单、账单、付款记录及门店信息;如信息有误,可联系客服更正。</Text>
|
||||
<Text className='doc-p'>5.2 密码管理:您可通过“我的-设置-修改密码”功能自行修改登录密码。</Text>
|
||||
<Text className='doc-p'>5.3 账号注销:如您与商家的合作关系终止,可申请注销账号;账号注销后,我们将删除或匿名化处理您的个人信息,法律法规另有规定的除外。</Text>
|
||||
<Text className='doc-p'>5.4 撤回同意:您可以通过停止使用本小程序的方式撤回对您个人信息收集使用的授权。</Text>
|
||||
|
||||
<Text className='doc-h2'>六、未成年人保护</Text>
|
||||
<Text className='doc-p'>本小程序面向从事经营活动的门店用户,不面向未成年人提供服务。如您为未成年人,请勿使用本小程序。</Text>
|
||||
|
||||
<Text className='doc-h2'>七、本政策的更新</Text>
|
||||
<Text className='doc-p'>我们可能适时修订本政策。政策更新后,我们会在本页面公示更新内容。若您不同意更新后的政策,应停止使用本小程序;继续使用即视为您接受更新后的政策。</Text>
|
||||
|
||||
<Text className='doc-h2'>八、联系我们</Text>
|
||||
<Text className='doc-p'>如您对本政策或您的个人信息处理有任何疑问、意见或投诉,可通过小程序内“消息”页面或商家提供的客服渠道与我们联系,我们将尽快予以答复。</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '商品详情',
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
&__name {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
font-size: 34rpx;
|
||||
color: #323233;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
&__spec {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 26rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
&__tag {
|
||||
margin: 0 12rpx 12rpx 0;
|
||||
padding: 6rpx 16rpx;
|
||||
background: #f7f8fa;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #646566;
|
||||
}
|
||||
|
||||
&__section {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
&__content {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
line-height: 1.7;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 底部加购栏 =====
|
||||
.goods-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 99;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&__hint {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 26rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__btn {
|
||||
margin-left: 24rpx;
|
||||
padding: 16rpx 48rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
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 {formatRetailPrice, formatSpec, 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>
|
||||
)}
|
||||
</View>
|
||||
<Text className='goods-card__name'>{product.name}</Text>
|
||||
<Text className='goods-card__spec'>
|
||||
{formatSpec(product.spec, product.unit)}{' '}
|
||||
{product.price !== null && <>
|
||||
单价:{formatRetailPrice(product.price, product.spec)} {product.price_unit}
|
||||
</>}
|
||||
</Text>
|
||||
<View className='goods-card__meta'>
|
||||
{!!product.shelf_life && product.shelf_life > 0 && (
|
||||
<Text className='goods-card__tag'>保质期 {product.shelf_life} 天</Text>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -92,8 +92,9 @@
|
||||
.product-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 20rpx 20rpx 40rpx;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
// 底部留白避免最后一行被购物车悬浮球遮挡
|
||||
padding: 20rpx 20rpx 20rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -110,8 +111,8 @@
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||
|
||||
&__image {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #f2f3f5;
|
||||
flex-shrink: 0;
|
||||
@@ -135,7 +136,6 @@
|
||||
}
|
||||
|
||||
&__spec {
|
||||
margin-top: 10rpx;
|
||||
font-size: 24rpx;
|
||||
color: #969799;
|
||||
}
|
||||
@@ -160,8 +160,8 @@
|
||||
}
|
||||
|
||||
&__add {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
border-radius: 50%;
|
||||
background: #ee0a24;
|
||||
display: flex;
|
||||
|
||||
+63
-84
@@ -1,13 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow, useReachBottom } from '@tarojs/taro'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import { Button, Empty, Popup, Search, Stepper } from '@antmjs/vantui'
|
||||
import { Empty, Search } from '@antmjs/vantui'
|
||||
import useCartStore from '@/stores/cart/useCartStore'
|
||||
import { getCategoriesApi, getProductListApi } from '@/services/product'
|
||||
import type { ProductListParams } from '@/services/product'
|
||||
import { getProductCover } from '@/types/product'
|
||||
import type { Category, Product } from '@/types/product'
|
||||
import type { Category, Product, ProductCartPatch } from '@/types/product'
|
||||
import CartBall from '@/components/CartBall'
|
||||
import CartStepper from '@/components/CartStepper'
|
||||
import {formatRetailPrice, formatSpec} from '@/utils/format'
|
||||
import './index.less'
|
||||
import CustomTabBar from "@/components/CustomTabBar";
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
/** 存储 key:首页点击分类/搜索跳转时经本地存储传参(switchTab 无法带参) */
|
||||
@@ -16,6 +20,7 @@ const PENDING_KEYWORD_KEY = 'product_keyword'
|
||||
|
||||
export default function ProductPage() {
|
||||
const addItem = useCartStore(s => s.addItem)
|
||||
const setSummary = useCartStore(s => s.setSummary)
|
||||
|
||||
/** 分类树 */
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
@@ -38,12 +43,6 @@ export default function ProductPage() {
|
||||
/** 是否有请求进行中(仅用于避免"加载更多"并发) */
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
/** 加购弹层 */
|
||||
const [showPopup, setShowPopup] = useState(false)
|
||||
const [current, setCurrent] = useState<Product | null>(null)
|
||||
const [qty, setQty] = useState(1)
|
||||
const addingRef = useRef(false)
|
||||
|
||||
/** 当前选中二级分类所属的一级分类ID(用于父级高亮) */
|
||||
const activeParentId = useMemo(() => {
|
||||
if (activeId == null) return null
|
||||
@@ -68,10 +67,12 @@ export default function ProductPage() {
|
||||
if (keyword) params.keyword = keyword
|
||||
const res = await getProductListApi(params)
|
||||
if (seq !== reqSeqRef.current) return // 已有更新的请求,丢弃本次响应
|
||||
const { data, total: totalCount } = res.data
|
||||
const { data, total: totalCount, cart } = res.data
|
||||
setProducts(prev => (reset ? data : [...prev, ...data]))
|
||||
setPage(pageNum)
|
||||
setFinished(pageNum * PAGE_SIZE >= totalCount)
|
||||
// 列表响应附带悬浮球汇总(旧版本后端可能未返回)
|
||||
if (cart) setSummary(cart)
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
} finally {
|
||||
@@ -81,7 +82,7 @@ export default function ProductPage() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[effectiveCategoryId, searchKey],
|
||||
[effectiveCategoryId, searchKey, setSummary],
|
||||
)
|
||||
|
||||
/** 分类/搜索词变化时重新加载第一页(首屏由 useDidShow 触发,跳过首次执行) */
|
||||
@@ -147,11 +148,12 @@ export default function ProductPage() {
|
||||
fetchList(1, true, pendingKeyword ?? undefined)
|
||||
})
|
||||
|
||||
useReachBottom(() => {
|
||||
/** 右侧列表触底加载(页面为固定布局不滚动,由 ScrollView 触发) */
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!finished) {
|
||||
fetchList(page + 1, false)
|
||||
}
|
||||
})
|
||||
}, [finished, page, fetchList])
|
||||
|
||||
/** 点击一级分类:有子分类仅展开/收起(不可选中),无子分类则选中 */
|
||||
const handleTopTap = useCallback((cat: Category) => {
|
||||
@@ -183,27 +185,26 @@ export default function ProductPage() {
|
||||
setSearchKey('')
|
||||
}, [])
|
||||
|
||||
/** 打开加购弹层 */
|
||||
const handleAddTap = useCallback((product: Product) => {
|
||||
setCurrent(product)
|
||||
setQty(1)
|
||||
setShowPopup(true)
|
||||
/** 行内加减购确认后回写列表项的购物车字段 */
|
||||
const handleRowSync = useCallback((productId: number, patch: ProductCartPatch) => {
|
||||
setProducts(prev => prev.map(p => (p.id === productId ? { ...p, ...patch } : p)))
|
||||
}, [])
|
||||
|
||||
/** 确认加购 */
|
||||
const handleConfirmAdd = useCallback(async () => {
|
||||
if (!current || addingRef.current) return
|
||||
addingRef.current = true
|
||||
/** 跳转商品详情 */
|
||||
const goDetail = useCallback((id: number) => {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` })
|
||||
}, [])
|
||||
|
||||
/** 确认加购(用返回的购物车行回写列表项,行内随即展示加减器) */
|
||||
const handleConfirmAdd = useCallback(async (product: Product) => {
|
||||
try {
|
||||
await addItem(current.id, qty)
|
||||
Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||
setShowPopup(false)
|
||||
const res = await addItem(product.id, 1)
|
||||
handleRowSync(product.id, { cart_id: res.id, cart_quantity: res.quantity })
|
||||
// Taro.showToast({ title: '已加入购物车', icon: 'success' })
|
||||
} catch {
|
||||
// 错误(未设等级价/数量上限)已由 request 层 toast
|
||||
} finally {
|
||||
addingRef.current = false
|
||||
|
||||
}
|
||||
}, [current, qty, addItem])
|
||||
}, [addItem, handleRowSync])
|
||||
|
||||
return (
|
||||
<View className='product-page'>
|
||||
@@ -267,14 +268,19 @@ export default function ProductPage() {
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
{/* ========== 右侧商品列表 ========== */}
|
||||
<View className='product-main'>
|
||||
{/* ========== 右侧商品列表(ScrollView 滚动 + 触底加载) ========== */}
|
||||
<ScrollView
|
||||
scrollY
|
||||
className='product-main'
|
||||
lowerThreshold={80}
|
||||
onScrollToLower={handleLoadMore}
|
||||
>
|
||||
{/* 商品列表 */}
|
||||
{products.length === 0 && !loading ? (
|
||||
<Empty description='暂无商品' className='product-empty' />
|
||||
) : (
|
||||
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'}
|
||||
@@ -283,16 +289,34 @@ export default function ProductPage() {
|
||||
/>
|
||||
<View className='product-item__info'>
|
||||
<Text className='product-item__name'>{product.name}</Text>
|
||||
<Text className='product-item__spec'>{product.spec} / {product.unit}</Text>
|
||||
<Text className='product-item__spec'>
|
||||
{formatSpec(product.spec, product.unit)}{' '}
|
||||
<View>
|
||||
{product.price !== null && <>
|
||||
单价:{formatRetailPrice(product.price, product.spec)} {product.price_unit}
|
||||
</>}
|
||||
</View>
|
||||
</Text>
|
||||
<View className='product-item__bottom'>
|
||||
{product.price !== null ? (
|
||||
<Text className='product-item__price'>¥{product.price}</Text>
|
||||
) : (
|
||||
<Text className='product-item__price product-item__price--none'>价格待定</Text>
|
||||
)}
|
||||
<View className='product-item__add' onClick={() => handleAddTap(product)}>
|
||||
{/* 已加购展示行内加减器,否则展示加购按钮(点击开弹层选数量) */}
|
||||
{Number(product.cart_quantity ?? 0) > 0 ? (
|
||||
<CartStepper product={product} onSync={handleRowSync} />
|
||||
) : (
|
||||
<View
|
||||
className='product-item__add'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
handleConfirmAdd(product)
|
||||
}}
|
||||
>
|
||||
<Text className='product-item__add-icon'>+</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -304,58 +328,13 @@ export default function ProductPage() {
|
||||
{finished && products.length > 0 && (
|
||||
<View className='product-loading'><Text>没有更多了</Text></View>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ height: 68 }}></View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* ========== 加购弹层 ========== */}
|
||||
<Popup
|
||||
show={showPopup}
|
||||
position='bottom'
|
||||
round
|
||||
closeable
|
||||
closeOnClickOverlay
|
||||
safeAreaInsetBottom
|
||||
onClose={() => setShowPopup(false)}
|
||||
>
|
||||
{current && (
|
||||
<View className='add-popup'>
|
||||
<View className='add-popup__product'>
|
||||
<Image
|
||||
className='add-popup__image'
|
||||
src={getProductCover(current) || 'https://img.yzcdn.cn/vant/cat.jpeg'}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
<View className='add-popup__info'>
|
||||
<Text className='add-popup__name'>{current.name}</Text>
|
||||
<Text className='add-popup__spec'>{current.spec} / {current.unit}</Text>
|
||||
{current.price !== null ? (
|
||||
<Text className='add-popup__price'>¥{current.price}</Text>
|
||||
) : (
|
||||
<Text className='add-popup__price add-popup__price--none'>价格待定</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View className='add-popup__row'>
|
||||
<Text className='add-popup__label'>购买数量</Text>
|
||||
<Stepper
|
||||
value={qty}
|
||||
min={1}
|
||||
max={99999999.99}
|
||||
onChange={e => setQty(Number(e.detail))}
|
||||
/>
|
||||
</View>
|
||||
<Button
|
||||
type='danger'
|
||||
block
|
||||
round
|
||||
className='add-popup__submit'
|
||||
onClick={handleConfirmAdd}
|
||||
>
|
||||
加入购物车
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
</Popup>
|
||||
{/* ========== 购物车悬浮球 ========== */}
|
||||
<CartBall />
|
||||
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -144,6 +144,53 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 运营报表入口 =====
|
||||
.report-entry {
|
||||
margin-top: 12rpx;
|
||||
border-top: 1rpx solid #f2f3f5;
|
||||
padding-top: 20rpx;
|
||||
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;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
font-size: 32rpx;
|
||||
color: #c8c9cc;
|
||||
line-height: 1;
|
||||
margin-left: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 账单入口 =====
|
||||
.bill-entry {
|
||||
display: flex;
|
||||
|
||||
+40
-28
@@ -8,8 +8,8 @@ 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'
|
||||
import CustomTabBar from "@/components/CustomTabBar";
|
||||
|
||||
/** 菜单项(订单/账单入口已由上方专区承载,后续单独页面开发时在此追加) */
|
||||
const MENU_ITEMS = [
|
||||
@@ -38,9 +38,9 @@ export default function ProfilePage() {
|
||||
|
||||
const loggedIn = !!token && !!user
|
||||
|
||||
/** 功能菜单(门店账号追加「门店信息」入口) */
|
||||
/** 功能菜单(登录门店可用:门店信息 / 支付记录 / 修改密码) */
|
||||
const menuItems = useMemo(() => {
|
||||
if (!user?.store) return MENU_ITEMS
|
||||
if (!loggedIn) return MENU_ITEMS
|
||||
return [
|
||||
{
|
||||
key: 'store-info',
|
||||
@@ -48,9 +48,21 @@ 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' }),
|
||||
},
|
||||
{
|
||||
key: 'change-password',
|
||||
label: '修改密码',
|
||||
icon: 'lock',
|
||||
onClick: () => Taro.navigateTo({ url: '/pages/change-password/index' }),
|
||||
},
|
||||
...MENU_ITEMS,
|
||||
]
|
||||
}, [user?.store])
|
||||
}, [loggedIn])
|
||||
|
||||
useDidShow(() => {
|
||||
if (!loggedIn) return
|
||||
@@ -64,18 +76,16 @@ export default function ProfilePage() {
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
/** 身份标签 */
|
||||
const getTypeLabel = useCallback((type: UserType): string => {
|
||||
if (type === 1) return '门店'
|
||||
if (type === 2) return '供应商'
|
||||
return '待绑定'
|
||||
}, [])
|
||||
|
||||
/** 订单总汇 → 订单列表页(按状态) */
|
||||
const handleOrderNav = useCallback((status?: number) => {
|
||||
Taro.navigateTo({ url: `/pages/order-list/index?status=${status ?? 'all'}` })
|
||||
}, [])
|
||||
|
||||
/** 运营报表入口 → 运营报表页 */
|
||||
const goReport = useCallback(() => {
|
||||
Taro.navigateTo({ url: '/pages/report/index' })
|
||||
}, [])
|
||||
|
||||
/** 账单入口 → 账单列表页 */
|
||||
const goBill = useCallback(() => {
|
||||
Taro.navigateTo({ url: '/pages/bill/index' })
|
||||
@@ -124,29 +134,18 @@ export default function ProfilePage() {
|
||||
/>
|
||||
) : (
|
||||
<View className='profile-card__avatar profile-card__avatar--text'>
|
||||
{user?.nickname?.[0] || '用'}
|
||||
{user?.name?.[0] || '店'}
|
||||
</View>
|
||||
)}
|
||||
<View className='profile-card__info'>
|
||||
<Text className='profile-card__name'>{user?.nickname}</Text>
|
||||
<Text className='profile-card__desc'>{user?.phone || '未绑定手机号'}</Text>
|
||||
<Text className='profile-card__name'>{user?.name}</Text>
|
||||
<Text className='profile-card__desc'>{user?.phone || '未设置联系电话'}</Text>
|
||||
</View>
|
||||
{user?.type === 0 && (
|
||||
<View className='profile-card__btn' onClick={goLogin}>绑定手机号</View>
|
||||
)}
|
||||
</View>
|
||||
<View className='profile-card__identity'>
|
||||
{user?.store ? (
|
||||
<>
|
||||
<Text className='profile-card__tag'>门店 · {user.store.name}</Text>
|
||||
{user.store.level && (
|
||||
<Text className='profile-card__tag profile-card__tag--level'>{user.store.level.name}</Text>
|
||||
)}
|
||||
</>
|
||||
) : user?.supplier ? (
|
||||
<Text className='profile-card__tag'>供应商 · {user.supplier.name}</Text>
|
||||
) : (
|
||||
<Text className='profile-card__tag'>{getTypeLabel(user?.type ?? 0)},联系客服或绑定手机号</Text>
|
||||
<Text className='profile-card__tag'>门店编码 · {user?.code}</Text>
|
||||
{user?.level && (
|
||||
<Text className='profile-card__tag profile-card__tag--level'>{user.level.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
@@ -169,6 +168,17 @@ export default function ProfilePage() {
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* 运营报表入口 */}
|
||||
<View className='report-entry' onClick={goReport}>
|
||||
<View className='report-entry__icon'>
|
||||
<Icon name='bar-chart-o' size={28} color='#ee0a24' />
|
||||
</View>
|
||||
<View className='report-entry__info'>
|
||||
<Text className='report-entry__title'>运营报表</Text>
|
||||
<Text className='report-entry__desc'>按周/月统计采购金额与单品占比</Text>
|
||||
</View>
|
||||
<Text className='report-entry__arrow'>›</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ========== 我的账单 ========== */}
|
||||
@@ -220,6 +230,8 @@ export default function ProfilePage() {
|
||||
<Text>退出登录</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{process.env.TARO_ENV === 'h5' && <CustomTabBar />}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
/* ========================================
|
||||
注册页面
|
||||
======================================== */
|
||||
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ========== 内容区域 ========== */
|
||||
.register-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80px 60px 0;
|
||||
}
|
||||
|
||||
/* ========== 品牌区域 ========== */
|
||||
.register-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 60px;
|
||||
|
||||
.logo-wrapper {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24px;
|
||||
box-shadow: 0 8px 32px rgba(25, 137, 250, 0.3);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 80px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-size: 44px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.app-slogan {
|
||||
font-size: 28px;
|
||||
color: #969799;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 注册表单 ========== */
|
||||
.register-form {
|
||||
width: 100%;
|
||||
background: #f7f8fa;
|
||||
border-radius: 24px;
|
||||
padding: 0 32px;
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 112px;
|
||||
border-bottom: 1px solid #ebedf0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
width: 160px;
|
||||
font-size: 30px;
|
||||
color: #323233;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: 30px;
|
||||
color: #323233;
|
||||
}
|
||||
|
||||
.form-input-placeholder {
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
/* 手机号已授权状态 */
|
||||
.form-phone-ok {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.form-phone-ok__text {
|
||||
font-size: 30px;
|
||||
color: #07c160;
|
||||
}
|
||||
}
|
||||
|
||||
/* 手机号授权按钮(微信原生 Button 需重置样式) */
|
||||
.phone-auth-btn {
|
||||
flex: 1;
|
||||
height: 64px;
|
||||
line-height: 64px;
|
||||
background: #e8f7ef;
|
||||
color: #07c160;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 32px;
|
||||
text-align: center;
|
||||
padding: 0 32px;
|
||||
|
||||
/* 重置微信 Button 默认样式 */
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 注册操作区 ========== */
|
||||
.register-actions {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
line-height: 96px;
|
||||
background: linear-gradient(160deg, #1989fa 0%, #07c160 100%);
|
||||
color: #fff;
|
||||
font-size: 34px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: 48px;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
box-shadow: 0 6px 24px rgba(25, 137, 250, 0.35);
|
||||
transition: opacity 0.2s;
|
||||
|
||||
/* 重置微信 Button 默认样式 */
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.register-btn--loading {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* ========== 去登录入口 ========== */
|
||||
.register-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
|
||||
.switch-text {
|
||||
font-size: 28px;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.switch-link {
|
||||
font-size: 28px;
|
||||
color: #1989fa;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========== 协议文字 ========== */
|
||||
.register-agreement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 32px;
|
||||
line-height: 1.6;
|
||||
|
||||
.agree-text {
|
||||
font-size: 24px;
|
||||
color: #c8c9cc;
|
||||
}
|
||||
|
||||
.agree-link {
|
||||
font-size: 24px;
|
||||
color: #1989fa;
|
||||
}
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { View, Text, Button, Input } from '@tarojs/components'
|
||||
import CustomNavBar from '@/components/NavBar'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import './index.less'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const register = useAuthStore(s => s.register)
|
||||
const isLoggedIn = useAuthStore(s => !!s.token && !!s.user)
|
||||
|
||||
/** 门店编码(后台门店管理维护) */
|
||||
const [storeCode, setStoreCode] = useState('')
|
||||
/** 微信手机号授权得到的 code */
|
||||
const [phoneCode, setPhoneCode] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const isWeb = Taro.getEnv() === Taro.ENV_TYPE.WEB
|
||||
|
||||
/** 返回上一页(无页面栈时回首页) */
|
||||
const goBack = useCallback(() => {
|
||||
const pages = Taro.getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack()
|
||||
} else {
|
||||
Taro.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 前往登录页 */
|
||||
const goLogin = useCallback(() => {
|
||||
const pages = Taro.getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack()
|
||||
} else {
|
||||
Taro.navigateTo({ url: '/pages/login/index' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 已注册成功(登录态就绪)→ 自动返回 */
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) goBack()
|
||||
}, [isLoggedIn, goBack])
|
||||
|
||||
/** 发起注册:wx.login 换 code → POST /mini/auth/register */
|
||||
const doRegister = useCallback(
|
||||
async (phoneCodeValue: string) => {
|
||||
if (submitting) return
|
||||
if (isWeb) {
|
||||
Taro.showToast({ title: '请在微信小程序中注册', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const code = storeCode.trim()
|
||||
if (!code) {
|
||||
Taro.showToast({ title: '请填写门店编码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await Taro.login()
|
||||
if (!res.code) {
|
||||
Taro.showToast({ title: '获取登录凭证失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
await register({ code: res.code, phoneCode: phoneCodeValue, storeCode: code })
|
||||
// 注册成功后由 effect 自动返回
|
||||
} catch (e: any) {
|
||||
// 该微信已注册:引导前往登录(其余错误已由 request 层提示)
|
||||
if (typeof e?.msg === 'string' && e.msg.includes('已经注册')) {
|
||||
Taro.showModal({
|
||||
title: '提示',
|
||||
content: '该微信已经注册,请直接登录',
|
||||
confirmText: '去登录',
|
||||
cancelText: '取消',
|
||||
success: res => {
|
||||
if (res.confirm) goLogin()
|
||||
},
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
},
|
||||
[register, submitting, isWeb, storeCode, goLogin],
|
||||
)
|
||||
|
||||
/** 微信手机号授权(openType getPhoneNumber) */
|
||||
const handleGetPhoneNumber = useCallback(
|
||||
(e: any) => {
|
||||
if (isWeb) {
|
||||
Taro.showToast({ title: '请在微信小程序中授权手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const detail = e.detail || {}
|
||||
|
||||
// 用户拒绝授权
|
||||
if (detail.errMsg && !detail.errMsg.includes(':ok')) {
|
||||
Taro.showToast({ title: '需要授权手机号才能注册', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!detail.code) {
|
||||
Taro.showToast({ title: '未获取到手机号授权凭证', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
setPhoneCode(detail.code)
|
||||
if (storeCode.trim()) {
|
||||
// 门店编码已填 → 直接发起注册
|
||||
doRegister(detail.code)
|
||||
} else {
|
||||
Taro.showToast({ title: '手机号已授权,请填写门店编码', icon: 'none' })
|
||||
}
|
||||
},
|
||||
[isWeb, storeCode, doRegister],
|
||||
)
|
||||
|
||||
/** 点击注册按钮(门店编码已填 + 手机号已授权) */
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!phoneCode) {
|
||||
Taro.showToast({ title: '请先授权手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!storeCode.trim()) {
|
||||
Taro.showToast({ title: '请填写门店编码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
doRegister(phoneCode)
|
||||
}, [phoneCode, storeCode, doRegister])
|
||||
|
||||
/** 查看用户协议 */
|
||||
const handleShowAgreement = useCallback(() => {
|
||||
Taro.showToast({ title: '用户协议即将上线', icon: 'none' })
|
||||
}, [])
|
||||
|
||||
/** 查看隐私政策 */
|
||||
const handleShowPrivacy = useCallback(() => {
|
||||
Taro.showToast({ title: '隐私政策即将上线', icon: 'none' })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View className='register-page'>
|
||||
{/* ========== 导航栏 ========== */}
|
||||
<CustomNavBar title='注册' />
|
||||
|
||||
{/* ========== 内容区域 ========== */}
|
||||
<View className='register-content'>
|
||||
{/* 品牌区域 */}
|
||||
<View className='register-brand'>
|
||||
<View className='logo-wrapper'>
|
||||
<Text className='logo-text'>订</Text>
|
||||
</View>
|
||||
<Text className='app-name'>订货采购</Text>
|
||||
<Text className='app-slogan'>注册即绑定门店,开启订货之旅</Text>
|
||||
</View>
|
||||
|
||||
{/* 注册表单 */}
|
||||
<View className='register-form'>
|
||||
{/* 门店编码 */}
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>门店编码</Text>
|
||||
<Input
|
||||
className='form-input'
|
||||
type='text'
|
||||
value={storeCode}
|
||||
placeholder='请输入门店编码(门店管理员提供)'
|
||||
placeholderClass='form-input-placeholder'
|
||||
onInput={e => setStoreCode(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 手机号授权 */}
|
||||
<View className='form-item'>
|
||||
<Text className='form-label'>手机号</Text>
|
||||
{phoneCode ? (
|
||||
<View className='form-phone-ok'>
|
||||
<Text className='form-phone-ok__text'>已授权</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Button
|
||||
className='phone-auth-btn'
|
||||
openType='getPhoneNumber'
|
||||
onGetPhoneNumber={handleGetPhoneNumber}
|
||||
>
|
||||
微信手机号授权
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 注册操作 */}
|
||||
<View className='register-actions'>
|
||||
<Button
|
||||
className={`register-btn ${submitting ? 'register-btn--loading' : ''}`}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? '注册中...' : '注 册'}
|
||||
</Button>
|
||||
|
||||
{/* 已注册用户入口 */}
|
||||
<View className='register-switch' onClick={goLogin}>
|
||||
<Text className='switch-text'>已有账号?</Text>
|
||||
<Text className='switch-link'>去登录</Text>
|
||||
</View>
|
||||
|
||||
<View className='register-agreement'>
|
||||
<Text className='agree-text'>注册即代表同意</Text>
|
||||
<Text className='agree-link' onClick={handleShowAgreement}>
|
||||
《用户协议》
|
||||
</Text>
|
||||
<Text className='agree-text'>和</Text>
|
||||
<Text className='agree-link' onClick={handleShowPrivacy}>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '运营报表',
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
.report-page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
padding: 20rpx 24rpx 60rpx;
|
||||
box-sizing: border-box;
|
||||
|
||||
// ===== 周期切换 =====
|
||||
.period-bar {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.period-chip {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14rpx 0;
|
||||
border-radius: 999rpx;
|
||||
background: #fff;
|
||||
font-size: 26rpx;
|
||||
color: #323233;
|
||||
|
||||
&.active {
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 汇总卡片 =====
|
||||
.report-summary {
|
||||
margin-top: 20rpx;
|
||||
background: linear-gradient(135deg, #ee0a24, #ff4d4f);
|
||||
border-radius: 20rpx;
|
||||
padding: 32rpx 28rpx;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
&__label {
|
||||
font-size: 24rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
margin-top: 12rpx;
|
||||
font-size: 56rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__range {
|
||||
margin-top: 12rpx;
|
||||
font-size: 22rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-top: 28rpx;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
border-top: 1rpx solid rgba(255, 255, 255, 0.25);
|
||||
padding-top: 24rpx;
|
||||
}
|
||||
|
||||
&__meta-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__meta-value {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__meta-label {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 单品排行 =====
|
||||
.report-list {
|
||||
margin-top: 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 24rpx 28rpx 8rpx;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
}
|
||||
}
|
||||
|
||||
.report-item {
|
||||
display: flex;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1rpx solid #f2f3f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&__rank {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #f2f3f5;
|
||||
color: #969799;
|
||||
font-size: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4rpx;
|
||||
|
||||
&--top {
|
||||
background: #fff0f0;
|
||||
color: #ee0a24;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__name {
|
||||
font-size: 28rpx;
|
||||
color: #323233;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__amount {
|
||||
font-size: 30rpx;
|
||||
color: #ee0a24;
|
||||
font-weight: 600;
|
||||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__spec {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #969799;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__percent {
|
||||
margin-top: 8rpx;
|
||||
font-size: 24rpx;
|
||||
color: #323233;
|
||||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
margin-top: 14rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #f2f3f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__bar-inner {
|
||||
height: 100%;
|
||||
border-radius: 999rpx;
|
||||
background: linear-gradient(90deg, #ff8a8f, #ee0a24);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 空态 / 加载中 =====
|
||||
.report-empty {
|
||||
margin-top: 60rpx;
|
||||
|
||||
&__btn {
|
||||
margin-top: 20rpx;
|
||||
font-size: 26rpx;
|
||||
padding: 14rpx 48rpx;
|
||||
border-radius: 999rpx;
|
||||
background: #ee0a24;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.report-loading {
|
||||
padding: 60rpx 0;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
color: #969799;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import Taro, { useDidShow } from '@tarojs/taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Calendar, Empty } from '@antmjs/vantui'
|
||||
import useAuthStore from '@/stores/auth/useAuthStore'
|
||||
import { getPurchaseReportApi } from '@/services/report'
|
||||
import type { PurchaseReport, PurchaseReportParams, ReportPreset } from '@/services/report'
|
||||
import './index.less'
|
||||
|
||||
/** 周期选项 key(custom 为前端伪预设:选中自定义区间后生效) */
|
||||
type PeriodKey = ReportPreset
|
||||
|
||||
/** 周期切换 chips */
|
||||
const PERIOD_TABS: Array<{ key: PeriodKey; label: string }> = [
|
||||
{ key: 'week', label: '本周' },
|
||||
{ key: 'last_week', label: '上周' },
|
||||
{ key: 'month', label: '本月' },
|
||||
{ key: 'last_month', label: '上月' },
|
||||
{ key: 'custom', label: '自定义' },
|
||||
]
|
||||
|
||||
/** 自定义区间可选范围:2020-01-01 ~ 今天(进行中的周期由后端封顶今天) */
|
||||
const MIN_DATE = new Date(2020, 0, 1).getTime()
|
||||
const MAX_DATE = Date.now()
|
||||
|
||||
/** Date → Y-m-d */
|
||||
function formatDate(d: Date): string {
|
||||
const m = `${d.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${d.getDate()}`.padStart(2, '0')
|
||||
return `${d.getFullYear()}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营报表页
|
||||
* 按周期(本周/上周/本月/上月/自定义区间)统计门店采购总金额与单品累计金额占比;
|
||||
* 数据为下单快照口径,已取消/已删除订单不计入
|
||||
*/
|
||||
export default function ReportPage() {
|
||||
const token = useAuthStore(s => s.token)
|
||||
|
||||
const [period, setPeriod] = useState<PeriodKey>('month')
|
||||
/** 自定义区间(period=custom 时使用) */
|
||||
const [range, setRange] = useState<{ start: string; end: string } | null>(null)
|
||||
const [report, setReport] = useState<PurchaseReport | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showCalendar, setShowCalendar] = useState(false)
|
||||
const loadingRef = useRef(false)
|
||||
|
||||
const loggedIn = !!token
|
||||
|
||||
/** 拉取报表 */
|
||||
const load = useCallback(
|
||||
async (params: PurchaseReportParams) => {
|
||||
if (!loggedIn || loadingRef.current) return
|
||||
loadingRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getPurchaseReportApi(params)
|
||||
setReport(res.data)
|
||||
} catch {
|
||||
// 错误已由 request 层 toast
|
||||
} finally {
|
||||
loadingRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[loggedIn],
|
||||
)
|
||||
|
||||
useDidShow(() => {
|
||||
load(
|
||||
period === 'custom' && range
|
||||
? { start_date: range.start, end_date: range.end }
|
||||
: { preset: period === 'custom' ? 'month' : period },
|
||||
)
|
||||
})
|
||||
|
||||
/** 切换周期;自定义打开日历选择区间 */
|
||||
const handlePeriodTap = useCallback(
|
||||
(key: PeriodKey) => {
|
||||
if (key === 'custom') {
|
||||
setShowCalendar(true)
|
||||
return
|
||||
}
|
||||
if (key === period) return
|
||||
setPeriod(key)
|
||||
load({ preset: key })
|
||||
},
|
||||
[period, load],
|
||||
)
|
||||
|
||||
/** 日历确认区间 → 自定义区间查询(优先于 preset) */
|
||||
const handleCalendarConfirm = useCallback(
|
||||
(e: { detail: { value: Date | Date[] } }) => {
|
||||
const value = Array.isArray(e.detail.value) ? e.detail.value : [e.detail.value]
|
||||
const [start, end] = value
|
||||
if (!start || !end) return
|
||||
const next = { start: formatDate(start), end: formatDate(end) }
|
||||
setRange(next)
|
||||
setPeriod('custom')
|
||||
setShowCalendar(false)
|
||||
load({ start_date: next.start, end_date: next.end })
|
||||
},
|
||||
[load],
|
||||
)
|
||||
|
||||
const goLogin = useCallback(() => {
|
||||
Taro.navigateTo({ url: '/pages/login/index' })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View className='report-page'>
|
||||
{/* ========== 周期切换 ========== */}
|
||||
<View className='period-bar'>
|
||||
{PERIOD_TABS.map(tab => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`period-chip ${period === tab.key ? 'active' : ''}`}
|
||||
onClick={() => handlePeriodTap(tab.key)}
|
||||
>
|
||||
<Text>{tab.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{!loggedIn ? (
|
||||
<Empty description='登录后查看运营报表' className='report-empty'>
|
||||
<View className='report-empty__btn' onClick={goLogin}>去登录</View>
|
||||
</Empty>
|
||||
) : (
|
||||
<>
|
||||
{/* ========== 汇总卡片 ========== */}
|
||||
{report && (
|
||||
<View className='report-summary'>
|
||||
<Text className='report-summary__label'>采购总金额(元)</Text>
|
||||
<Text className='report-summary__amount'>¥{report.total_amount}</Text>
|
||||
<Text className='report-summary__range'>
|
||||
{report.start_date} ~ {report.end_date}
|
||||
</Text>
|
||||
<View className='report-summary__meta'>
|
||||
<View className='report-summary__meta-item'>
|
||||
<Text className='report-summary__meta-value'>{report.order_count}</Text>
|
||||
<Text className='report-summary__meta-label'>订货单数</Text>
|
||||
</View>
|
||||
<View className='report-summary__meta-item'>
|
||||
<Text className='report-summary__meta-value'>{report.item_count}</Text>
|
||||
<Text className='report-summary__meta-label'>单品数</Text>
|
||||
</View>
|
||||
<View className='report-summary__meta-item'>
|
||||
<Text className='report-summary__meta-value'>{report.total_quantity}</Text>
|
||||
<Text className='report-summary__meta-label'>订货总量</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ========== 单品排行 ========== */}
|
||||
{report && report.items.length > 0 && (
|
||||
<View className='report-list'>
|
||||
<View className='report-list__header'>
|
||||
<Text className='report-list__title'>单品采购排行</Text>
|
||||
<Text className='report-list__desc'>共 {report.item_count} 种,按金额降序</Text>
|
||||
</View>
|
||||
{report.items.map((item, index) => (
|
||||
<View key={item.product_id} className='report-item'>
|
||||
<View className={`report-item__rank ${index < 3 ? 'report-item__rank--top' : ''}`}>
|
||||
{index + 1}
|
||||
</View>
|
||||
<View className='report-item__main'>
|
||||
<View className='report-item__row'>
|
||||
<Text className='report-item__name'>{item.product_name}</Text>
|
||||
<Text className='report-item__amount'>¥{item.amount}</Text>
|
||||
</View>
|
||||
<View className='report-item__row'>
|
||||
<Text className='report-item__spec'>
|
||||
{item.product_spec ? `${item.product_spec}` : ''}{item.unit}
|
||||
</Text>
|
||||
</View>
|
||||
<View className='report-item__row'>
|
||||
<Text className='report-item__spec'>
|
||||
累计 {item.quantity}
|
||||
{parseFloat(item.weight) > 0 ? ` · 重量 ${item.weight}` : ''}
|
||||
</Text>
|
||||
<Text className='report-item__percent'>{item.percent}%</Text>
|
||||
</View>
|
||||
<View className='report-item__bar'>
|
||||
<View
|
||||
className='report-item__bar-inner'
|
||||
style={{ width: `${Math.min(Math.max(item.percent, 0), 100)}%` }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* ========== 空态 / 加载中 ========== */}
|
||||
{(!report || report.items.length === 0) && (
|
||||
loading ? (
|
||||
<View className='report-loading'><Text>加载中...</Text></View>
|
||||
) : (
|
||||
<Empty description='该时间段暂无采购数据' className='report-empty' />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ========== 自定义区间日历 ========== */}
|
||||
<Calendar
|
||||
show={showCalendar}
|
||||
type='range'
|
||||
allowSameDay
|
||||
firstDayOfWeek={1}
|
||||
minDate={MIN_DATE}
|
||||
maxDate={MAX_DATE}
|
||||
color='#ee0a24'
|
||||
title='选择统计区间'
|
||||
defaultDate={range ? [new Date(range.start).getTime(), new Date(range.end).getTime()] : undefined}
|
||||
onClose={() => setShowCalendar(false)}
|
||||
onConfirm={handleCalendarConfirm}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -21,11 +21,11 @@ export default function SettingsPage() {
|
||||
<Text className='setting-cell__label'>清除缓存</Text>
|
||||
<Text className='setting-cell__value'>›</Text>
|
||||
</View>
|
||||
<View className='setting-cell' onClick={() => handlePlaceholder('用户协议')}>
|
||||
<Text className='setting-cell__label'>用户协议</Text>
|
||||
<View className='setting-cell' onClick={() => Taro.navigateTo({ url: '/pages/agreement/index' })}>
|
||||
<Text className='setting-cell__label'>用户服务协议</Text>
|
||||
<Text className='setting-cell__value'>›</Text>
|
||||
</View>
|
||||
<View className='setting-cell' onClick={() => handlePlaceholder('隐私政策')}>
|
||||
<View className='setting-cell' onClick={() => Taro.navigateTo({ url: '/pages/privacy/index' })}>
|
||||
<Text className='setting-cell__label'>隐私政策</Text>
|
||||
<Text className='setting-cell__value'>›</Text>
|
||||
</View>
|
||||
|
||||
+27
-24
@@ -1,39 +1,42 @@
|
||||
import { get, post } from '@/utils/request'
|
||||
import { get, post, put } from '@/utils/request'
|
||||
import type { User } from '@/types/user'
|
||||
|
||||
/** 微信登录参数 */
|
||||
export interface WxLoginParams {
|
||||
/** wx.login 的临时凭证 */
|
||||
code: string
|
||||
/** 账号密码登录参数 */
|
||||
export interface LoginParams {
|
||||
/** 登录账号(商家后台分配,4~20 位) */
|
||||
username: string
|
||||
/** 登录密码 */
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 微信注册参数 */
|
||||
export interface RegisterParams {
|
||||
/** wx.login 的临时凭证 */
|
||||
code: string
|
||||
/** wx.getPhoneNumber 授权得到的 code */
|
||||
phoneCode: string
|
||||
/** 门店编码(后台门店管理维护) */
|
||||
storeCode: string
|
||||
}
|
||||
|
||||
/** 登录 / 注册返回 */
|
||||
/** 登录返回 */
|
||||
export interface AuthResult {
|
||||
token: string
|
||||
/** 门店即用户(扁平结构) */
|
||||
user: User
|
||||
}
|
||||
|
||||
/** 微信登录(仅已注册用户可登录):POST /mini/auth/login */
|
||||
export function wxLoginApi(params: WxLoginParams) {
|
||||
/** 修改密码参数 */
|
||||
export interface ChangePasswordParams {
|
||||
/** 原密码 */
|
||||
oldPassword: string
|
||||
/** 新密码(6~20 位) */
|
||||
newPassword: string
|
||||
/** 确认新密码(须与 newPassword 一致) */
|
||||
rePassword: string
|
||||
}
|
||||
|
||||
/** 账号密码登录:POST /mini/auth/login */
|
||||
export function loginApi(params: LoginParams) {
|
||||
return post<AuthResult>('/mini/auth/login', params)
|
||||
}
|
||||
|
||||
/** 微信注册(code 换 openid + phoneCode 换手机号 + storeCode 绑定门店):POST /mini/auth/register */
|
||||
export function registerApi(params: RegisterParams) {
|
||||
return post<AuthResult>('/mini/auth/register', params)
|
||||
}
|
||||
|
||||
/** 当前用户信息(含门店客户等级):GET /mini/auth/info */
|
||||
/** 当前门店信息(含客户等级):GET /mini/auth/info */
|
||||
export function getUserInfoApi() {
|
||||
return get<User>('/mini/auth/info')
|
||||
}
|
||||
|
||||
/** 修改密码(成功后现有 token 仍有效):PUT /mini/auth/password */
|
||||
export function changePasswordApi(params: ChangePasswordParams) {
|
||||
return put<null>('/mini/auth/password', params)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -44,6 +44,8 @@ export interface Bill {
|
||||
settlement_date: string
|
||||
/** 付款时间(已支付时非空) */
|
||||
paid_at: string | null
|
||||
/** 售后金额 */
|
||||
after_sale: string
|
||||
/** 付款备注 */
|
||||
pay_remark: string
|
||||
/** 账单备注 */
|
||||
@@ -70,6 +72,10 @@ export interface BillItem {
|
||||
weight: string
|
||||
/** 合计金额 */
|
||||
amount: string
|
||||
/** 商品首图 URL(无图为空字符串) */
|
||||
image: string
|
||||
price_unit: string
|
||||
spec: string
|
||||
}
|
||||
|
||||
/** 账单关联订单 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { del, get, post, put } from '@/utils/request'
|
||||
import type { CartData } from '@/types/cart'
|
||||
import type { CartData, CartSummary } from '@/types/cart'
|
||||
|
||||
/** 加购 / 改数量返回 */
|
||||
export interface CartMutationResult {
|
||||
@@ -17,6 +17,11 @@ export function getCartApi() {
|
||||
return get<CartData>('/mini/cart')
|
||||
}
|
||||
|
||||
/** 轻量汇总(悬浮球单独刷新用;必须登录,未登录 401):GET /mini/cart/summary */
|
||||
export function getCartSummaryApi() {
|
||||
return get<CartSummary>('/mini/cart/summary')
|
||||
}
|
||||
|
||||
/** 修改数量:PUT /mini/cart/{id} */
|
||||
export function updateCartItemApi(id: number, quantity: number) {
|
||||
return put<CartMutationResult>(`/mini/cart/${id}`, { quantity })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { get } from '@/utils/request'
|
||||
import type { CartSummary } from '@/types/cart'
|
||||
|
||||
/** 首页轮播图项 */
|
||||
export interface HomeBanner {
|
||||
@@ -36,6 +37,8 @@ export interface HomeConfig {
|
||||
banners: HomeBanner[]
|
||||
navs: HomeNav[]
|
||||
promos: HomePromo[]
|
||||
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
|
||||
cart?: CartSummary
|
||||
}
|
||||
|
||||
/** 首页配置(轮播图 + 宫格导航 + 促销卡片):GET /mini/home */
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { get, post } from '@/utils/request'
|
||||
import type { PaginatedData } from '@/types/api'
|
||||
|
||||
/** 支付方式:1 微信 / 2 支付宝 / 3 对公汇款 / 4 旺铺支付(小程序在线支付) */
|
||||
export type PayMethod = 1 | 2 | 3 | 4
|
||||
|
||||
export const PAY_METHOD_NAMES: Record<PayMethod, string> = {
|
||||
1: '微信支付',
|
||||
2: '支付宝',
|
||||
3: '对公汇款',
|
||||
4: '微信在线支付',
|
||||
}
|
||||
|
||||
/** 支付类型:1 线下凭证支付 / 2 在线支付(旧数据可能缺省,缺省按线下处理) */
|
||||
export type PayType = 1 | 2
|
||||
|
||||
/** 支付状态:0 待审核 / 1 已通过 / 2 已拒绝(线下凭证支付单语义) */
|
||||
export type PayStatus = 0 | 1 | 2
|
||||
|
||||
export const PAY_STATUS_NAMES: Record<PayStatus, string> = {
|
||||
0: '待审核',
|
||||
1: '已通过',
|
||||
2: '已拒绝',
|
||||
}
|
||||
|
||||
/** 在线支付状态:0 待支付 / 1 支付成功 / 2 支付失败(与线下同字段,按 pay_type 区分语义) */
|
||||
export type OnlinePayStatus = 0 | 1 | 2
|
||||
|
||||
export const ONLINE_PAY_STATUS_NAMES: Record<OnlinePayStatus, string> = {
|
||||
0: '待支付',
|
||||
1: '支付成功',
|
||||
2: '支付失败',
|
||||
}
|
||||
|
||||
/** 支付单状态展示名(在线支付单与线下凭证单同字段不同语义,按 pay_type 取名) */
|
||||
export function getPayStatusName(payment: { status: PayStatus; pay_type?: PayType }): string {
|
||||
return payment.pay_type === 2 ? ONLINE_PAY_STATUS_NAMES[payment.status] : PAY_STATUS_NAMES[payment.status]
|
||||
}
|
||||
|
||||
/** 支付配置(付款页展示,图片为解析后的预览地址,可能为空串) */
|
||||
export interface PaymentConfig {
|
||||
wechat_qrcode: string
|
||||
alipay_qrcode: string
|
||||
bank_info: string
|
||||
/** 公众号 appid(H5 网页授权取 code 拼授权链接用,配置了公众号支付才返回) */
|
||||
mp_appid?: string
|
||||
}
|
||||
|
||||
/** 支付记录(列表行与详情的 payment 字段一致) */
|
||||
export interface Payment {
|
||||
id: number
|
||||
/** 支付编号(ZF 前缀) */
|
||||
payment_no: string
|
||||
store_id: number
|
||||
user_id: number
|
||||
/** 合并付款总金额 */
|
||||
amount: string
|
||||
/** 支付类型:1 线下凭证 / 2 在线支付(旺铺网关) */
|
||||
pay_type?: PayType
|
||||
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
|
||||
/** 在线支付成功时间(在线支付单非空) */
|
||||
paid_at?: string | null
|
||||
/** 网关交易号(在线支付单非空) */
|
||||
trade_no?: 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}`)
|
||||
}
|
||||
|
||||
/** 在线支付下单返回(pay_params 为旺铺网关透传的调起参数:小程序给 wx.requestPayment,H5 公众号给 getBrandWCPayRequest,以网关实际返回为准) */
|
||||
export interface OnlinePaymentCreateResult {
|
||||
id: number
|
||||
/** 支付单号(ZF 前缀,= 上送网关的商户订单号 mer_order_id),查询/对账用 */
|
||||
payment_no: string
|
||||
/** 应付金额(= 所选账单总额合计,元) */
|
||||
amount: string
|
||||
pay_params: {
|
||||
appId?: string
|
||||
timeStamp?: string
|
||||
nonceStr?: string
|
||||
package?: string
|
||||
signType?: string
|
||||
paySign?: string
|
||||
[key: string]: any
|
||||
}
|
||||
}
|
||||
|
||||
/** 在线支付结果查询返回 */
|
||||
export interface OnlinePaymentQueryResult {
|
||||
payment_no: string
|
||||
/** 0 待支付 / 1 支付成功(账单已置已支付)/ 2 支付失败(账单已释放) */
|
||||
status: OnlinePayStatus
|
||||
status_name: string
|
||||
paid_at: string | null
|
||||
trade_no: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起在线支付(合并账单下单):POST /mini/payment/online
|
||||
* code:小程序传 wx.login() 登录凭证(不传 scene);
|
||||
* H5 公众号传网页授权回调 code,需带 scene: 'mp'(后端按场景换付款人 openid)
|
||||
*/
|
||||
export function createOnlinePaymentApi(data: {
|
||||
bill_ids: number[]
|
||||
code: string
|
||||
scene?: 'mp'
|
||||
remark?: string
|
||||
}) {
|
||||
return post<OnlinePaymentCreateResult>('/mini/payment/online', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动查询在线支付结果(网关后台通知延迟/丢失时的兜底):GET /mini/payment/online/{payment_no}/query
|
||||
* 网关返回已支付则立即结账(与后台通知同一幂等逻辑)
|
||||
*/
|
||||
export function queryOnlinePaymentApi(paymentNo: string) {
|
||||
return get<OnlinePaymentQueryResult>(`/mini/payment/online/${paymentNo}/query`)
|
||||
}
|
||||
+15
-3
@@ -1,5 +1,6 @@
|
||||
import { get } from '@/utils/request'
|
||||
import type { PaginatedData } from '@/types/api'
|
||||
import type { CartSummary } from '@/types/cart'
|
||||
import type { Category, Product } from '@/types/product'
|
||||
|
||||
/** 商品分类树(仅含上架商品的分类及其祖先):GET /mini/product/categories */
|
||||
@@ -17,7 +18,18 @@ export interface ProductListParams {
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/** 商品列表(当前门店等级实际价):GET /mini/product/list */
|
||||
export function getProductListApi(params: ProductListParams = {}) {
|
||||
return get<PaginatedData<Product>>('/mini/product/list', { data: params })
|
||||
/** 商品列表响应(分页 + 购物车悬浮球汇总) */
|
||||
export interface ProductListData extends PaginatedData<Product> {
|
||||
/** 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空) */
|
||||
cart?: CartSummary
|
||||
}
|
||||
|
||||
/** 商品列表(当前门店等级实际价 + 行内购物车字段):GET /mini/product/list */
|
||||
export function getProductListApi(params: ProductListParams = {}) {
|
||||
return get<ProductListData>('/mini/product/list', { data: params })
|
||||
}
|
||||
|
||||
/** 商品详情(免登录;未登录/未绑店/未设等级 price=null;下架或不存在业务报错):GET /mini/product/{id} */
|
||||
export function getProductDetailApi(id: number) {
|
||||
return get<Product>(`/mini/product/${id}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { get } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 统计周期预设:
|
||||
* week 本周 / last_week 上周 / month 本月 / last_month 上月;
|
||||
* custom 仅出现在响应中(传 start_date + end_date 自定义区间时生效,优先于 preset)
|
||||
*/
|
||||
export type ReportPreset = 'week' | 'last_week' | 'month' | 'last_month' | 'custom'
|
||||
|
||||
/** 单品累计行(按金额降序) */
|
||||
export interface PurchaseReportItem {
|
||||
product_id: number
|
||||
/** 品名(下单时快照) */
|
||||
product_name: string
|
||||
/** 规格/包规(快照) */
|
||||
product_spec: string
|
||||
/** 计价单位(快照) */
|
||||
unit: string
|
||||
/** 周期内累计订货量 */
|
||||
quantity: number
|
||||
/** 周期内累计重量(3 位小数,未称重为 0.000) */
|
||||
weight: string
|
||||
/** 周期内累计采购金额(元,2 位小数字符串) */
|
||||
amount: string
|
||||
/** 金额占比(%,1 位小数;如 8.8 表示 8.8%) */
|
||||
percent: number
|
||||
}
|
||||
|
||||
/** 采购运营报表 */
|
||||
export interface PurchaseReport {
|
||||
/** 实际生效的周期预设 */
|
||||
preset: ReportPreset
|
||||
/** 实际统计开始日期(Y-m-d,进行中的周期封顶为今天) */
|
||||
start_date: string
|
||||
/** 实际统计结束日期(Y-m-d) */
|
||||
end_date: string
|
||||
/** 周期内采购总金额(元,2 位小数字符串) */
|
||||
total_amount: string
|
||||
/** 周期内订货总量(各单品数量之和) */
|
||||
total_quantity: number
|
||||
/** 周期内有效订货单数 */
|
||||
order_count: number
|
||||
/** 单品个数(= items 长度) */
|
||||
item_count: number
|
||||
/** 单品累计列表,按金额降序 */
|
||||
items: PurchaseReportItem[]
|
||||
}
|
||||
|
||||
/** 报表查询参数:自定义区间(start_date + end_date 需成对)优先于 preset,preset 缺省为 month */
|
||||
export interface PurchaseReportParams {
|
||||
preset?: Exclude<ReportPreset, 'custom'>
|
||||
/** 自定义开始日期(Y-m-d) */
|
||||
start_date?: string
|
||||
/** 自定义结束日期(Y-m-d),不得早于 start_date */
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
/** 采购运营报表:GET /mini/report/purchase(仅当前门店自身数据) */
|
||||
export function getPurchaseReportApi(params: PurchaseReportParams = {}) {
|
||||
return get<PurchaseReport>('/mini/report/purchase', { data: params })
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { get } from '@/utils/request'
|
||||
import type { PaginatedData } from '@/types/api'
|
||||
import type { CartSummary } from '@/types/cart'
|
||||
import type { Product } from '@/types/product'
|
||||
|
||||
/** 特价推荐列表参数 */
|
||||
export interface SpecialListParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 特价推荐列表响应(分页 + 购物车悬浮球汇总)。
|
||||
* 行结构与 /mini/product/list 一致:商品基础字段 + price + cart_id/cart_quantity
|
||||
*/
|
||||
export interface SpecialListData extends PaginatedData<Product> {
|
||||
/**
|
||||
* 购物车悬浮球汇总(未登录返回零值结构;旧版本后端可能未返回,调用方判空)。
|
||||
* 接口文档示例为 count/quantity/amount 简写,与 /mini/home 等接口的 total_* 命名不一致,
|
||||
* 统一经 normalizeSpecialCart 转换后再写入 store
|
||||
*/
|
||||
cart?: CartSummary | { count: number; quantity: string; amount: string }
|
||||
}
|
||||
|
||||
/** 特价推荐商品列表(免登录;携带门店 token 时返回等级价与购物车字段):GET /mini/special/list */
|
||||
export function getSpecialListApi(params: SpecialListParams = {}) {
|
||||
return get<SpecialListData>('/mini/special/list', { data: params })
|
||||
}
|
||||
|
||||
/** 特价推荐响应附带的悬浮球汇总归一化(兼容 count/quantity/amount 与 total_* 两种命名) */
|
||||
export function normalizeSpecialCart(cart: SpecialListData['cart']): CartSummary | null {
|
||||
if (!cart) return null
|
||||
const raw = cart as Record<string, unknown>
|
||||
const count = raw.total_count ?? raw.count
|
||||
const quantity = raw.total_quantity ?? raw.quantity
|
||||
const amount = raw.total_amount ?? raw.amount
|
||||
if (count == null || quantity == null || amount == null) return null
|
||||
return {
|
||||
total_count: Number(count),
|
||||
total_quantity: String(quantity),
|
||||
total_amount: String(amount),
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { registerApi, wxLoginApi } from '@/services/auth'
|
||||
import type { RegisterParams, WxLoginParams } from '@/services/auth'
|
||||
import { loginApi } from '@/services/auth'
|
||||
import type { LoginParams } from '@/services/auth'
|
||||
import type { User } from '@/types/user'
|
||||
|
||||
/** 存储 key */
|
||||
@@ -26,7 +26,7 @@ function loadFromStorage(): { user: User | null; token: string | null } {
|
||||
return { user: null, token: null }
|
||||
}
|
||||
|
||||
/** 登录 / 注册成功后持久化 token 与用户信息 */
|
||||
/** 登录成功后持久化 token 与门店信息 */
|
||||
function persistAuth(token: string, user: User): void {
|
||||
try {
|
||||
Taro.setStorageSync(STORAGE_KEYS.TOKEN, token)
|
||||
@@ -40,11 +40,10 @@ interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
loading: boolean
|
||||
login: (params: WxLoginParams) => Promise<void>
|
||||
/** 微信注册(手机号授权 + 门店编码绑定门店) */
|
||||
register: (params: RegisterParams) => Promise<void>
|
||||
/** 账号密码登录(门店账号由商家后台分配) */
|
||||
login: (params: LoginParams) => Promise<void>
|
||||
logout: () => void
|
||||
/** 更新用户信息(用于编辑资料后同步 store) */
|
||||
/** 更新门店信息(用于编辑资料后同步 store) */
|
||||
setUser: (user: User) => void
|
||||
}
|
||||
|
||||
@@ -57,17 +56,9 @@ const useAuthStore = create<AuthState>((set) => {
|
||||
token: initial.token,
|
||||
loading: !!(initial.token && initial.user), // 已恢复则立即 ready
|
||||
|
||||
/** 登录(仅已注册用户可登录,未注册由页面引导去注册) */
|
||||
login: async (params: WxLoginParams) => {
|
||||
const res = await wxLoginApi(params)
|
||||
const { token, user } = res.data
|
||||
set({ user, token })
|
||||
persistAuth(token, user)
|
||||
},
|
||||
|
||||
/** 注册:POST /mini/auth/register */
|
||||
register: async (params: RegisterParams) => {
|
||||
const res = await registerApi(params)
|
||||
/** 账号密码登录:POST /mini/auth/login */
|
||||
login: async (params: LoginParams) => {
|
||||
const res = await loginApi(params)
|
||||
const { token, user } = res.data
|
||||
set({ user, token })
|
||||
persistAuth(token, user)
|
||||
|
||||
@@ -5,13 +5,26 @@ import {
|
||||
clearCartApi,
|
||||
deleteCartItemApi,
|
||||
getCartApi,
|
||||
getCartSummaryApi,
|
||||
updateCartItemApi,
|
||||
} from '@/services/cart'
|
||||
import type { CartItem } from '@/types/cart'
|
||||
import type { CartMutationResult } from '@/services/cart'
|
||||
import { getToken } from '@/utils/request'
|
||||
import type { CartItem, CartSummary } from '@/types/cart'
|
||||
|
||||
/** 存储 key */
|
||||
const STORAGE_KEY = 'cart_data'
|
||||
|
||||
/** 汇总请求序号(并发时仅采用最后一次响应) */
|
||||
let summarySeq = 0
|
||||
/** 汇总防抖校准定时器(列表加减停止 800ms 后整体拉取一次,以服务端为准) */
|
||||
let summaryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** 数值 → 2 位小数字符串(与服务端金额/数量口径一致) */
|
||||
function to2(n: number): string {
|
||||
return (Math.round(n * 100) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
/** 持久化的购物车快照(服务端为准,此处仅作展示缓存) */
|
||||
interface StoredCart {
|
||||
items: CartItem[]
|
||||
@@ -46,8 +59,8 @@ interface CartState {
|
||||
loading: boolean
|
||||
/** 拉取购物车(以服务端为准,金额一律服务端重算) */
|
||||
fetchCart: () => Promise<void>
|
||||
/** 加购 */
|
||||
addItem: (productId: number, quantity: number) => Promise<void>
|
||||
/** 加购(返回合并后的购物车行,供列表页回写 cart_id/cart_quantity) */
|
||||
addItem: (productId: number, quantity: number) => Promise<CartMutationResult>
|
||||
/** 修改数量 */
|
||||
updateQuantity: (id: number, quantity: number) => Promise<void>
|
||||
/** 删除单项 */
|
||||
@@ -56,6 +69,12 @@ interface CartState {
|
||||
clearCart: () => Promise<void>
|
||||
/** 下单成功后本地清空(不请求接口) */
|
||||
clearLocal: () => void
|
||||
/** 用接口附带的汇总块(首页/商品列表响应的 cart 字段)直接更新悬浮球 */
|
||||
setSummary: (summary: CartSummary) => void
|
||||
/** 拉取轻量汇总(需登录;未登录跳过,避免 401 跳转) */
|
||||
fetchSummary: () => Promise<void>
|
||||
/** 列表行内加减后本地增减悬浮球(乐观展示),并防抖调 fetchSummary 校准 */
|
||||
applyDelta: (delta: { quantity: number; amount: number; count?: number }) => void
|
||||
}
|
||||
|
||||
/** 空的购物车快照 */
|
||||
@@ -80,6 +99,18 @@ const useCartStore = create<CartState>((set, get) => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入悬浮球汇总并持久化(列表项快照保持不变) */
|
||||
const applySummary = (summary: CartSummary) => {
|
||||
const next = {
|
||||
items: get().items,
|
||||
totalCount: summary.total_count,
|
||||
totalQuantity: summary.total_quantity,
|
||||
totalAmount: summary.total_amount,
|
||||
}
|
||||
set(next)
|
||||
persist(next)
|
||||
}
|
||||
|
||||
return {
|
||||
...EMPTY_SNAPSHOT,
|
||||
items: cached?.items ?? [],
|
||||
@@ -111,8 +142,9 @@ const useCartStore = create<CartState>((set, get) => {
|
||||
|
||||
/** 加购:服务端校验上架与等级价,成功后重新同步 */
|
||||
addItem: async (productId, quantity) => {
|
||||
await addCartApi({ product_id: productId, quantity })
|
||||
const res = await addCartApi({ product_id: productId, quantity })
|
||||
await get().fetchCart()
|
||||
return res.data
|
||||
},
|
||||
|
||||
/** 修改数量 */
|
||||
@@ -139,6 +171,36 @@ const useCartStore = create<CartState>((set, get) => {
|
||||
set(EMPTY_SNAPSHOT)
|
||||
persist(EMPTY_SNAPSHOT)
|
||||
},
|
||||
|
||||
/** 写入接口附带的汇总块(首页/商品列表) */
|
||||
setSummary: (summary) => {
|
||||
applySummary(summary)
|
||||
},
|
||||
|
||||
/** 拉取轻量汇总(并发时仅采用最后一次响应) */
|
||||
fetchSummary: async () => {
|
||||
// 未登录无汇总(接口固定 401,会触发清理登录态),直接跳过
|
||||
if (!getToken()) return
|
||||
const seq = ++summarySeq
|
||||
const res = await getCartSummaryApi()
|
||||
if (seq !== summarySeq) return // 已有更新的请求,丢弃本次响应
|
||||
applySummary(res.data)
|
||||
},
|
||||
|
||||
/** 列表加减后的本地增减:即时反馈,防抖后以服务端汇总校准 */
|
||||
applyDelta: ({ quantity, amount, count = 0 }) => {
|
||||
const s = get()
|
||||
applySummary({
|
||||
total_count: Math.max(0, s.totalCount + count),
|
||||
total_quantity: to2(Math.max(0, Number(s.totalQuantity) + quantity)),
|
||||
total_amount: to2(Math.max(0, Number(s.totalAmount) + amount)),
|
||||
})
|
||||
if (summaryTimer) clearTimeout(summaryTimer)
|
||||
summaryTimer = setTimeout(() => {
|
||||
summaryTimer = null
|
||||
get().fetchSummary().catch(() => {})
|
||||
}, 800)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface CartItem {
|
||||
amount: string | null
|
||||
/** 1 可购 / 0 商品下架、缺失或未设等级价 */
|
||||
status: number
|
||||
price_unit: string
|
||||
}
|
||||
|
||||
/** 购物车列表数据 */
|
||||
@@ -27,3 +28,16 @@ export interface CartData {
|
||||
/** 可购项总金额 */
|
||||
total_amount: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 购物车悬浮球汇总(/mini/home、/mini/product/list 响应附带;
|
||||
* GET /mini/cart/summary 同构。未登录时列表/首页返回零值结构)
|
||||
*/
|
||||
export interface CartSummary {
|
||||
/** 商品种数(全部行数,含已下架项) */
|
||||
total_count: number
|
||||
/** 总数量(仅可购项,2 位小数字符串) */
|
||||
total_quantity: string
|
||||
/** 总金额(仅可购项,元,2 位小数字符串) */
|
||||
total_amount: string
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/** 订单列表行(状态名/可取消/商品预览均由后端给出,直接展示) */
|
||||
@@ -81,6 +85,7 @@ export interface OrderItem {
|
||||
product_name: string
|
||||
product_spec: string
|
||||
unit: string
|
||||
price_unit: string
|
||||
/** 下单时门店等级实际价快照 */
|
||||
price: string
|
||||
quantity: number
|
||||
|
||||
+23
-7
@@ -1,3 +1,5 @@
|
||||
import { resolveFileUrl } from '@/utils/format'
|
||||
|
||||
/** 商品分类节点(children 递归;叶子分类无 children 字段) */
|
||||
export interface Category {
|
||||
id: number
|
||||
@@ -6,10 +8,11 @@ export interface Category {
|
||||
children?: Category[]
|
||||
}
|
||||
|
||||
/** 商品图片 */
|
||||
/** 商品图片(SysFile 序列化,含预览地址与文件地址) */
|
||||
export interface ProductImage {
|
||||
id: number
|
||||
file_url: string
|
||||
preview_url: string
|
||||
}
|
||||
|
||||
/** 商品 */
|
||||
@@ -22,20 +25,33 @@ export interface Product {
|
||||
unit: string
|
||||
/** 商品图文详情(HTML) */
|
||||
content: string
|
||||
/** 当前门店等级的实际销售价(未设等级价为 null) */
|
||||
/** 当前门店等级的实际销售价(未登录/未绑店/未设等级为 null) */
|
||||
price: string | null
|
||||
price_unit: 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
|
||||
/** 该商品对应的购物车行 ID(不在购物车/未登录为 0;列表加减、删除时需要) */
|
||||
cart_id?: number
|
||||
/** 购物车中该商品数量(2 位小数字符串;不在购物车/未登录为 "0.00") */
|
||||
cart_quantity?: string
|
||||
}
|
||||
|
||||
/** 商品行购物车字段回写(行内加减购确认后更新列表项) */
|
||||
export interface ProductCartPatch {
|
||||
cart_id: number
|
||||
cart_quantity: string
|
||||
}
|
||||
|
||||
/** 商品首图地址 */
|
||||
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)
|
||||
}
|
||||
|
||||
+20
-41
@@ -4,54 +4,33 @@ export interface StoreLevel {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 门店信息 */
|
||||
export interface StoreInfo {
|
||||
id: number
|
||||
name: string
|
||||
/** 客户等级(level_id > 0 才可展示价格) */
|
||||
level: StoreLevel | null
|
||||
}
|
||||
|
||||
/** 供应商信息 */
|
||||
export interface SupplierInfo {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
/** 用户类型:0 待绑定 / 1 门店 / 2 供应商 */
|
||||
export type UserType = 0 | 1 | 2
|
||||
|
||||
export const USER_TYPE_MAP: Record<UserType, string> = {
|
||||
0: '待绑定',
|
||||
1: '门店',
|
||||
2: '供应商',
|
||||
}
|
||||
|
||||
/** 用户信息(user 表实际返回字段) */
|
||||
/**
|
||||
* 登录门店信息(门店即用户)
|
||||
* 用户表与门店表已合并:登录 / auth/info 返回的 user 就是门店本身(扁平结构)
|
||||
*/
|
||||
export interface User {
|
||||
id: number
|
||||
/** 用户名(注册时生成 wx_xxxx) */
|
||||
/** 门店名称 */
|
||||
name: string
|
||||
/** 门店编码(后台分配) */
|
||||
code: string
|
||||
/** 登录账号(后台分配,4~20 位) */
|
||||
username: string
|
||||
/** 昵称(注册默认「微信用户」) */
|
||||
nickname: string
|
||||
/** 头像(可能为空) */
|
||||
avatar: string
|
||||
/** 手机号(未绑定为空) */
|
||||
level_id: number
|
||||
/** 客户等级(level_id > 0 才可展示价格) */
|
||||
level: StoreLevel | null
|
||||
/** 联系人 */
|
||||
contact: string
|
||||
/** 联系电话 */
|
||||
phone: string
|
||||
/** 绑定门店ID(0 未绑定) */
|
||||
store_id: number
|
||||
/** 地址 */
|
||||
address: string
|
||||
/** 回款周期天数 */
|
||||
payment_cycle_days: number
|
||||
/** 1 正常 / 0 停用 */
|
||||
status: number
|
||||
/** 微信标识 */
|
||||
openid: string
|
||||
unionid: string
|
||||
email: string
|
||||
last_login_at: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
/** 兼容旧 /mini/auth/info 返回(身份类型) */
|
||||
type?: UserType
|
||||
/** 兼容旧 /mini/auth/info 返回(门店信息) */
|
||||
store?: StoreInfo | null
|
||||
/** 兼容旧 /mini/auth/info 返回(供应商信息) */
|
||||
supplier?: SupplierInfo | null
|
||||
}
|
||||
|
||||
+44
-3
@@ -40,11 +40,52 @@ 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 商品规格展示:包规与单位直接拼接
|
||||
* spec=20、unit=斤/箱 → 20斤/箱
|
||||
*/
|
||||
export function formatSpec(spec?: string | number | null, unit?: string | null): string {
|
||||
const s = spec === null || spec === undefined ? '' : String(spec).trim()
|
||||
const u = (unit ?? '').trim()
|
||||
return `${s}${u}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 零售价 = 售价 ÷ 包规(如 30¥/箱 ÷ 20斤/箱 = 2¥/斤)
|
||||
* 保留两位小数并去掉尾零(2 → "2",2.50 → "2.5")
|
||||
* 售价为空、包规非数字或 ≤0 时返回 null(不展示零售价)
|
||||
*/
|
||||
export function formatRetailPrice(price?: string | number | null, spec?: string | number | null): string | null {
|
||||
if (price === null || price === undefined || price === '') return null
|
||||
const p = Number(price)
|
||||
const s = Number(spec)
|
||||
if (!Number.isFinite(p) || !Number.isFinite(s) || s <= 0) return null
|
||||
return String(Math.round((p / s) * 100) / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* 数量展示:保留两位小数并去掉尾零("2.50" → "2.5","3.00" → "3")
|
||||
* 用于悬浮球徽标、行内加减器等窄空间;非法值按 0 处理
|
||||
*/
|
||||
export function formatQuantity(value?: string | number | null): string {
|
||||
if (value === null || value === undefined || value === '') return '0'
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
return String(Math.round(n * 100) / 100)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ const LOGIN_PATH = '/pages/login/index'
|
||||
/** 默认请求超时(ms) */
|
||||
const DEFAULT_TIMEOUT = 15000
|
||||
/** 接口根地址(uploadFile 等原生请求同样使用) */
|
||||
export const BASE_URL = "http://localhost:8000/index.php"
|
||||
// export const BASE_URL = "http://localhost:8000"
|
||||
export const BASE_URL = "https://purchase.henanklkj.com/index.php"
|
||||
|
||||
/**
|
||||
* HTTP 状态码 → 错误提示映射
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { BASE_URL, getToken } from '@/utils/request'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
/** 上传结果(/mini/upload 返回) */
|
||||
export interface UploadedFile {
|
||||
/** 文件 ID(提交业务接口时使用的 voucher_ids 元素) */
|
||||
id: number
|
||||
/** 预览地址 */
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传单张图片到 /mini/upload(凭证等场景,≤5MB)
|
||||
* uploadFile 不受 request 层封装(multipart),此处自行解析统一响应结构并 toast
|
||||
*/
|
||||
export function uploadImage(filePath: string): Promise<UploadedFile> {
|
||||
const token = getToken()
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.uploadFile({
|
||||
url: `${BASE_URL}/mini/upload`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
header: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
success(res) {
|
||||
let body: ApiResponse<UploadedFile> | null = null
|
||||
try {
|
||||
body = JSON.parse(res.data)
|
||||
} catch {
|
||||
// 非 JSON 响应(网关错误页等)
|
||||
}
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && body?.success) {
|
||||
resolve(body.data)
|
||||
return
|
||||
}
|
||||
const msg = body?.msg || `上传失败(${res.statusCode})`
|
||||
Taro.showToast({ title: msg, icon: 'none' })
|
||||
reject(new Error(msg))
|
||||
},
|
||||
fail(err) {
|
||||
Taro.showToast({ title: '上传失败,请检查网络', icon: 'none' })
|
||||
reject(err)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择并上传图片:一次选择 count 张,逐张上传,全部成功才返回
|
||||
*/
|
||||
export async function chooseAndUploadImages(count: number): Promise<UploadedFile[]> {
|
||||
const res = await Taro.chooseImage({ count, sizeType: ['compressed'] })
|
||||
const files: UploadedFile[] = []
|
||||
for (const path of res.tempFilePaths) {
|
||||
files.push(await uploadImage(path))
|
||||
}
|
||||
return files
|
||||
}
|
||||
Reference in New Issue
Block a user