Compare commits
181 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ee1a3f4b6c | |||
| 52a7d3789d | |||
| 98d52ff173 | |||
| 797b20979d | |||
| b1511ecb92 | |||
| fed8ff3d3a | |||
| 30f8d649cb | |||
| f4da71e952 | |||
| d7241d54b1 | |||
| 282da24bc4 | |||
| 5e0691ab35 | |||
| 5935024ea8 | |||
| 7304c7a8e1 | |||
| b90a4e9f8f | |||
| a3f17cbf94 | |||
| 6973e3d7f5 | |||
| 114d77ca48 | |||
| a144a35c3d | |||
| 7e12eb3ca6 | |||
| 608e3e5ec5 | |||
| d98b0aefb0 | |||
| 012626f6bd | |||
| 22cd00da42 | |||
| 3004a65b35 | |||
| f9161368e1 | |||
| 8a061f085f | |||
| b20e566a65 | |||
| 7d2e4ef57e | |||
| 4d434c9c67 | |||
| 73fd27bdf6 | |||
| 62e61a9e63 | |||
| cd9696bc1c | |||
| a01217539c | |||
| b5d4f8b27c | |||
| 26334ed072 | |||
| 9a80f0f8d6 | |||
| fc2e5b65de | |||
| e8b585e623 | |||
| ee493823bf | |||
| 5702266276 | |||
| 8b5bb6a981 | |||
| 0a36188b35 | |||
| a8a37fbc4a | |||
| 6cf66c722c | |||
| e34416a9b3 | |||
| 4db21cf1a1 | |||
| 2f633b307c | |||
| 62353a6800 | |||
| 6c44e9c56f | |||
| 233ed0af3b | |||
| 905e145af3 | |||
| 7dd5fdfb12 | |||
| 48d6900ab6 | |||
| 5ca9aa00aa | |||
| c0d04ee500 | |||
| 3ac008a217 | |||
| cd1e9ae488 | |||
| 18147e157f | |||
| f7c94b4f16 | |||
| cc750dc1d6 | |||
| 984dec79ee | |||
| f84614bbb4 | |||
| c0ce494732 | |||
| 69e201f927 | |||
| ecd9973d8f | |||
| bbd7e52e70 | |||
| 1c91e6bae9 | |||
| 029fd690a5 | |||
| 0bfc19b1a4 | |||
| bafb342600 | |||
| bac5c7ad80 | |||
| c05cd0e96d | |||
| 8de41796fb | |||
| 4b3b1f76ae | |||
| 75d820b574 | |||
| 0f48d7abda | |||
| 4a0e18472b | |||
| 491f03e825 | |||
| 88053353ab | |||
| ceacce9ed5 | |||
| 3913bf3bf3 | |||
| 478291e2b3 | |||
| 6754caf076 | |||
| 92c3175acf | |||
| 46361ec713 | |||
| 5586ed90cd | |||
| 4cec5b1fe5 | |||
| 07630c9046 | |||
| 4488b0c006 | |||
| 99c74f20d2 | |||
| a598d1cd30 | |||
| da6a38daa4 | |||
| c5926bd593 | |||
| df546ff39f | |||
| d6d4a77473 | |||
| 0518976953 | |||
| 8f83624ffe | |||
| 7c6dae472c | |||
| 209db59422 | |||
| 21d44026d9 | |||
| 3b192b0b88 | |||
| 1b3a7f3a36 | |||
| 34a1897c09 | |||
| 36017a19b1 | |||
| 9c7550a1c4 | |||
| fe1c6c8158 | |||
| acc841bb21 | |||
| 61a99aeea9 | |||
| 76375eff83 | |||
| 713dce52b4 | |||
| e41bf838d4 | |||
| 0692bebf07 | |||
| b19f875e7b | |||
| 090002b6c0 | |||
| 8726cf14e3 | |||
| 7298b1a5d5 | |||
| 1401f1dcd2 | |||
| 155426b669 | |||
| b4d6f2bd91 | |||
| 029c17347e | |||
| b626db5d84 | |||
| d98ef6b808 | |||
| d13a6ee3cd | |||
| 10fa361983 | |||
| 1a615ffe93 | |||
| 0269802e59 | |||
| e4e9eb2169 | |||
| b5fb9a0bb2 | |||
| 9d800a4cbd | |||
| 2a9493165b | |||
| b32c68d56b | |||
| 6816835fb8 | |||
| 571959900b | |||
| 7bc710f139 | |||
| 397bdad73f | |||
| 83b1b0e5f6 | |||
| 91fed31b10 | |||
| 1933b9b608 | |||
| a2abbb6169 | |||
| 53a79d1255 | |||
| 0181af09c5 | |||
| 9cd0c9508a | |||
| 4f363fa9e7 | |||
| 291137a15d | |||
| e2a5f21f9d | |||
| 3ef4432b2f | |||
| 33b868dfac | |||
| 5c4ad1d8db | |||
| a346fae7a4 | |||
| 37bc297d1a | |||
| bc1616c3f7 | |||
| 46a200e0ba | |||
| 429eaabe6b | |||
| 607a0224cd | |||
| 53e0db6a98 | |||
| 456b3af461 | |||
| 1f7ecffe4c | |||
| 8df0a2bea7 | |||
| 672a995c59 | |||
| 69f52921a7 | |||
| 9cb88dc181 | |||
| 345d5db9c9 | |||
| 7c9240fc84 | |||
| 2610288df0 | |||
| 62652cd072 | |||
| 9d49ed5e70 | |||
| bdbd135318 | |||
| 8ca893efab | |||
| d0ab838da6 | |||
| 50ab0f1748 | |||
| fa6fb80d19 | |||
| bd1930f6b1 | |||
| dc4295d1bd | |||
| 256526c5ec | |||
| 1c4b986924 | |||
| bf501d2374 | |||
| 442500b1a1 | |||
| 490c26a7e1 | |||
| b40eb83f27 | |||
| f80a0f2c27 | |||
| 528669f662 |
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
description: 杜康好客全局约束 — Monorepo 布局、文档优先级、2 人 OWNER 边界
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 杜康好客 · 核心规则
|
|
||||||
|
|
||||||
## Monorepo 布局
|
|
||||||
|
|
||||||
- `apps/` — preV1:`h5-user`、`h5-shop`、`h5-partner`、`admin-web`
|
|
||||||
- `packages/` — `shared-types`、`domain`、`shared-ui`
|
|
||||||
- `server/dukang-api/` — NestJS 单体 API
|
|
||||||
- `pages/` — UI 原型(**只读参照**)
|
|
||||||
|
|
||||||
> V2 目标为 Taro 四端(`mini-user` 等),编码以 **当前实际目录** 为准。
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
- **V3.0 业务事实源**:`杜康好客-v3-PRD.md`
|
|
||||||
- V3 实现验收:`杜康好客-v3编码手册.md`
|
|
||||||
- V2 蓝图:`杜康好客-V2编码手册.md`(与 V3 冲突时 V3 PRD 优先)
|
|
||||||
- preV1 裁剪:`杜康好客-preV1编码手册.md`
|
|
||||||
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
|
|
||||||
|
|
||||||
**禁止**:编码时修改业务规则;需求变更只改 `杜康好客-v3-PRD.md`。
|
|
||||||
|
|
||||||
## 边界(2 人团队)
|
|
||||||
|
|
||||||
> **临时(2026-07)**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
|
|
||||||
|
|
||||||
| 负责人 | 路径 |
|
|
||||||
|--------|------|
|
|
||||||
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
|
|
||||||
| ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
|
|
||||||
|
|
||||||
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
|
|
||||||
- `apps/*` 禁止 import `server/*` 或其他 app
|
|
||||||
- Mock 只在 `integrations/*`,不在业务 Service 散落
|
|
||||||
|
|
||||||
## 共享契约
|
|
||||||
|
|
||||||
- 枚举/DTO → `packages/shared-types`
|
|
||||||
- 纯规则 → `packages/domain`(无 IO)
|
|
||||||
|
|
||||||
## 核心业务常量
|
|
||||||
|
|
||||||
- 权益额 = `benefit_amount ?? price`
|
|
||||||
- 核销:直接核销 `0 < amount ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额`;Redis 码 3 分钟
|
|
||||||
- 订单 Tab(V3.0):`待付款 | 已付款 | 已完成`
|
|
||||||
- API:`/api/v1`,响应 `{ code, message, data }`
|
|
||||||
|
|
||||||
## 提交
|
|
||||||
|
|
||||||
- Conventional Commits:`feat(trade):` 等
|
|
||||||
- 不提交 `.env`
|
|
||||||
@@ -39,6 +39,9 @@ src/
|
|||||||
|
|
||||||
## UI 约束
|
## UI 约束
|
||||||
|
|
||||||
|
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
|
||||||
|
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`(Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||||
|
|
||||||
- C 端订单 **5 Tab**(含 pending_ship)
|
- C 端订单 **5 Tab**(含 pending_ship)
|
||||||
- 门店列表仅 `OPEN` 状态
|
- 门店列表仅 `OPEN` 状态
|
||||||
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
||||||
|
|||||||
@@ -1,33 +1,11 @@
|
|||||||
---
|
---
|
||||||
description: Git 提交与 PR 协作规范
|
description: 杜康提交 scope 与发版默认(生产)
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Git 规范
|
# Git / 发版(杜康专属)
|
||||||
|
|
||||||
## 提交格式
|
- Conventional Commits,scope = 端或模块:`feat(trade):`、`fix(redeem):`
|
||||||
|
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
|
||||||
Conventional Commits,scope = 端或模块名:
|
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
|
||||||
|
- Prisma 迁移须 Review
|
||||||
```
|
|
||||||
feat(trade): add order preview API
|
|
||||||
fix(redeem): validate redeem amount against balance
|
|
||||||
chore(shared-types): add OrderStatus enum
|
|
||||||
```
|
|
||||||
|
|
||||||
## 禁止提交
|
|
||||||
|
|
||||||
- `.env`、`.env.local` 等敏感配置
|
|
||||||
- `node_modules/`、`dist/`(已在 .gitignore)
|
|
||||||
|
|
||||||
## PR 与 Review
|
|
||||||
|
|
||||||
- feature 分支 + 小步 PR
|
|
||||||
- 跨模块改动需相关双 Owner Review
|
|
||||||
- Prisma 迁移必须多 Owner Review
|
|
||||||
|
|
||||||
## Agent 行为
|
|
||||||
|
|
||||||
- **仅用户明确要求时** 才执行 git commit / push
|
|
||||||
- 不 amend 已推送的 commit,不 force push main/master/dev
|
|
||||||
- 发版:`dev`→测试(staging),`main`→生产;用户只说「发布/发版」默认发**生产**(见 dukang-release skill);明确说「发测试」才发 staging
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
description: 小程序子页不要画与原生导航栏重复的二级 title,标题只用 navigationBarTitleText
|
||||||
|
globs: apps/mini-user/**/*.{tsx,ts}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# mini-user · 小程序导航标题
|
||||||
|
|
||||||
|
微信小程序已有原生导航栏。页面内再画一层 `SubPageHeader` / 自定义 title,会与原生标题叠成**二级 title**。
|
||||||
|
|
||||||
|
## weapp
|
||||||
|
|
||||||
|
- 页面标题只走 `index.config.ts` 的 `navigationBarTitleText`
|
||||||
|
- **不要**设 `navigationStyle: 'custom'`(除非该页需要完全自定义导航,如滚动透明顶栏、商品详情)
|
||||||
|
- **不要**在页面内再渲染与原生标题重复的 `SubPageHeader`
|
||||||
|
- H5 没有原生导航栏:可保留 `SubPageHeader`(仅返回键;H5 实现已隐藏 title 文案)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ weapp 用原生标题;H5 保留返回栏
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '申请发票',
|
||||||
|
});
|
||||||
|
|
||||||
|
{process.env.TARO_ENV === 'h5' ? (
|
||||||
|
<SubPageHeader title="申请发票" onBack={...} />
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 未配页标题 + 再画一层 SubPageHeader(weapp 会双标题)
|
||||||
|
<SubPageHeader title="申请发票" />
|
||||||
|
```
|
||||||
|
|
||||||
|
弹层/区块标题(如「编辑发票抬头」)不是导航二级 title,可保留。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
|
||||||
|
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# mini-user · 微信 open-type 硬规则
|
||||||
|
|
||||||
|
## 禁止(必踩坑)
|
||||||
|
|
||||||
|
**现象**:`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
|
||||||
|
|
||||||
|
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
|
||||||
|
<View className="mask" onClick={close}>
|
||||||
|
<View className="sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 正确写法
|
||||||
|
|
||||||
|
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅
|
||||||
|
<View className="mask">
|
||||||
|
<View className="backdrop" onClick={close} />
|
||||||
|
<View className="sheet">
|
||||||
|
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
|
||||||
|
...
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 附加
|
||||||
|
|
||||||
|
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
|
||||||
|
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
|
||||||
|
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
|
||||||
|
|
||||||
|
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
|
||||||
@@ -21,7 +21,7 @@ disable-model-invocation: true
|
|||||||
## 会话启动(按序)
|
## 会话启动(按序)
|
||||||
|
|
||||||
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`;preV1 → preV1 手册;V2 蓝图 → V2 手册
|
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`;preV1 → preV1 手册;V2 蓝图 → V2 手册
|
||||||
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 表 → **只改本 OWNER 路径**
|
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 段 + 对应 `apps/` 或 `server/dukang-api/AGENTS.md` → **只改本 OWNER 路径**
|
||||||
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
|
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
|
||||||
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
|
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
|
||||||
5. 编码前过一遍下方 Checklist
|
5. 编码前过一遍下方 Checklist
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
||||||
|
|
||||||
Vite 代理:`/api` → `localhost:3000`。
|
Vite 代理:`/api` → `localhost:3010`(可用 `VITE_API_TARGET` 覆盖)。
|
||||||
|
|
||||||
## 共享包
|
## 共享包
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: >-
|
|||||||
MOCK_SMS/MOCK_PAY/MOCK_DELIVERY flags, Seed data, and V2 upgrade path.
|
MOCK_SMS/MOCK_PAY/MOCK_DELIVERY flags, Seed data, and V2 upgrade path.
|
||||||
Use when working on Mock providers, feature flags, preV1 task cards P1-*,
|
Use when working on Mock providers, feature flags, preV1 task cards P1-*,
|
||||||
or smoke tests — not for V2 WeChat/payment production integration.
|
or smoke tests — not for V2 WeChat/payment production integration.
|
||||||
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# 杜康好客 · preV1 Mock Skill
|
# 杜康好客 · preV1 Mock Skill
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ description: >-
|
|||||||
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
|
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
|
||||||
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
|
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
|
||||||
or @杜康发布 / @dukang-release.
|
or @杜康发布 / @dukang-release.
|
||||||
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# 杜康发布
|
# 杜康发布
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ description: >-
|
|||||||
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
|
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
|
||||||
verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003,
|
verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003,
|
||||||
or asks to start/implement a milestone card from the coding manuals.
|
or asks to start/implement a milestone card from the coding manuals.
|
||||||
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# 杜康好客 · 任务卡 Skill
|
# 杜康好客 · 任务卡 Skill
|
||||||
|
|||||||
@@ -21,4 +21,8 @@ coverage/
|
|||||||
server/dukang-api/prisma/migrations/
|
server/dukang-api/prisma/migrations/
|
||||||
debug_v3.xlsx
|
debug_v3.xlsx
|
||||||
~$debug_v3.xlsx
|
~$debug_v3.xlsx
|
||||||
|
server/dukang-api/assets/fonts/*.ttf
|
||||||
|
server/dukang-api/assets/fonts/*.ttc
|
||||||
|
server/dukang-api/assets/fonts/*.otf
|
||||||
deploy/auto-release.env
|
deploy/auto-release.env
|
||||||
|
deploy/backups/
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 2026-08-12 工作记录
|
||||||
|
|
||||||
|
## 杜康好客 · 三项功能需求落地(dev_jacy 分支)
|
||||||
|
|
||||||
|
### 需求1:管理端用户列表增加好客权益金额列
|
||||||
|
- 后端 `server/dukang-api/src/modules/ops/admin-users.service.ts`:
|
||||||
|
- 新增常量 `BENEFIT_STAT_STATUSES = ['ACTIVE','USED_UP']`、`UserBenefitStat` 类型、`EMPTY_BENEFIT_STAT`、`toAmount()`。
|
||||||
|
- `list()` / `detail()` 均调用新增私有方法 `loadBenefitStats(userIds)`,用 `benefitCoupon.groupBy({ by:['userId'], where:{status:{in:[...BENEFIT_STAT_STATUSES]}}, _sum:{totalAmount,usedAmount,balance} })` 批量聚合,避免 N+1。
|
||||||
|
- `mapAdminUserRow` 增加 `benefit` 参数并展开到返回对象。统计口径与 C 端 `listCoupons` 一致(排除 VOID)。
|
||||||
|
- 前端 `apps/admin-web/src/lib/api.ts`:`AdminUserRow` 增加 `benefitTotalAmount?/benefitUsedAmount?/benefitBalance?: number`。
|
||||||
|
- 前端 `apps/admin-web/src/pages/UsersPage.tsx`:新增「剩余权益/已用权益/累计权益」三列(右对齐、可排序,scroll x 改为 1830),详情抽屉 Descriptions 增加「好客权益」项。
|
||||||
|
|
||||||
|
### 需求2:管理端门店入驻合同支持多图上传
|
||||||
|
- 新建共享工具 `server/dukang-api/src/common/store-media/contract-urls.util.ts`:`MAX_CONTRACT_FILES=20`、`normalizeContractUrls(urls?, legacy?)`、`contractMediaType(url)`(.pdf→FILE 否则 IMAGE)。
|
||||||
|
- 后端 `admin-stores.service.ts`:import 上述工具;create(645 行)/ update(451 行)路径由单条 `contractUrl` 改为遍历建立多条 `bizType:'CONTRACT'` 记录(`sortOrder:i`、`mediaType` 按扩展名判定)。
|
||||||
|
- DTO `admin-mutate.dto.ts`:`contractUrl?:string` 标记 `@deprecated`,新增 `contractUrls?:string[]`(`@IsArray @IsString({each:true}) @ArrayMaxSize(20)`)。
|
||||||
|
- 前端 `apps/admin-web/src/lib/storeCreate.ts`:`StoreCreateForm.contractUrl?:string` → `contractUrls?:string[]`。
|
||||||
|
- `MultiImageUpload.tsx`:新增 `isPdf()`、`FilePdfOutlined`、PDF 缩略图卡片、`buttonText` prop。
|
||||||
|
- `StoresPage.tsx`:编辑抽屉 + 创建 step1 表单均改用 `MultiImageUpload`(name=contractUrls, maxCount=20, accept=image/*,.pdf, buttonText="批量上传合同"),回显/保存 payload 改为 `contractUrls:string[]`。
|
||||||
|
|
||||||
|
### 需求3:合伙人 H5 门店入驻合同支持多图上传
|
||||||
|
|
||||||
|
## 发布流水线(同一会话,22:45 后)
|
||||||
|
|
||||||
|
完整跑通 dukang-release 标准流程:`dev_jacy` → push → merge `dev` → merge `main` → 发生产。
|
||||||
|
- 提交 `8805335` feat(ops,store,partner): 用户权益列 + 门店合同多图上传(13 文件,356+/59-)
|
||||||
|
- `dev_jacy` push → `origin/dev_jacy`(8805335)
|
||||||
|
- merge `dev_jacy`→`dev` → `491f03e`,push `origin/dev`
|
||||||
|
- merge `dev`→`main` → `4a0e184`,push `origin/main`
|
||||||
|
- `deploy/deploy-prod.sh -- --skip-db`(无 Prisma schema 变更,故 --skip-db)→ `DEPLOY_EXIT=0`,`==> 发版完成 (production)`
|
||||||
|
- 生产 PM2 全 online:`dukang-api`(8090) / admin-web / h5-partner / h5-shop / h5-user;健康检查 `api:200` `mini-user(h5):200`;版本记录 commit=4a0e184
|
||||||
|
|
||||||
|
### 踩坑/注意
|
||||||
|
- `git checkout main` 时本机工作树出现 `server/dukang-api` 大量 `D`(误删)假象:实为 sandbox 下 checkout 未刷新工作树,非仓库损坏。用 `git checkout -f main` 重新填充即恢复;`origin/main` 始终正确(ls-remote 核对 4a0e184)。
|
||||||
|
- 提交/合并后 `git status` 报 "ahead of origin by N" 为本地 tracking ref 陈旧,非真实分歧(ls-remote 已核对远程三分支均含本次提交)。
|
||||||
|
- 仅提交 13 个需求文件 + 新建 `server/dukang-api/src/common/store-media/`,已排除 `.workbuddy/` 与 `fix_acl.ps1`。
|
||||||
|
- 发布命令在 sandbox 下直接运行 SSH 部署成功(未强制禁用沙箱)。
|
||||||
|
- `apps/h5-partner/src/lib/storeDraft.ts`:`StoreDraftForm.contractUrl:string` → `contractUrls:string[]`;`defaultStoreForm` 改为 `contractUrls:[]`;新增 `MAX_CONTRACT_COUNT=20`;`normalizeStoreDraftForm` 新增 `contractUrls` 计算(兼容旧 `contractUrl` 迁移为数组,去重限 20);`validateStoreStep2` 参数/校验改为 `contractUrls`,不足 1 张返回「请上传签约合同」。
|
||||||
|
- `MultiOssUploadField.tsx`:新增 `accept?`、`unit?` props,`isPdf()`,PDF 缩略图卡片。
|
||||||
|
- `StoreCreatePage.tsx`:签约合同区块 `OssUploadField` → `MultiOssUploadField`(value=`form.contractUrls ?? []`, maxCount=MAX_CONTRACT_COUNT, unit="个", accept="image/*,.pdf");提交体 `contractUrl` → `contractUrls`,新增 `const contractUrls = Array.from(new Set(...)).slice(0, MAX_CONTRACT_COUNT)`;envPhotoUrls slice 上限由 3 改为 20。修正了渲染块误用未定义变量 `contractUrls`(已改为从 form 读取)。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- 后端 `npx tsc --noEmit`(server/dukang-api)通过,exit 0。
|
||||||
|
- admin-web / h5-partner 使用 `git stash` 基线对比法:本次改动**未引入任何新类型错误**(现存错误均为预存在基线问题:css module 声明、`import.meta.env`、过期 config 类型、TabLayout `end`、staff `staffRole` 等)。
|
||||||
|
- 向后兼容:旧 `contractUrl` 字段保留并标记 deprecated,H5 草稿旧数据自动迁移;避免破坏现有 API 客户端。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 2026-08-16
|
||||||
|
|
||||||
|
## 新增:线上库同步本地 CLI 工具
|
||||||
|
- 文件:`deploy/sync-prod-db-to-local.sh`(新建,未提交)
|
||||||
|
- 用途:将生产库 `dukang_prod`(阿里云 RDS `rm-bp139andp1aa385i9.mysql.rds.aliyuncs.com:3306`,用户 `dukangadmin`)同步到本地 Docker MySQL `dukang_haoke`(localhost:6016 / 容器内 `dukang-v1-mysql`,root/root)。
|
||||||
|
- 原理:SSH 隧道借道 `dukang-server`(`-L 6018:rds:3306`)→ 本地无 mysql 客户端,故在 `dukang-v1-mysql` 容器内跑 mysqldump/mysql,经 `host.docker.internal:TUNNEL_PORT` 连隧道;管道直导,不落临时文件。
|
||||||
|
- 安全:默认先 `docker exec mysqldump` 备份本地到 `deploy/backups/`;需 `--yes` 跳过交互确认;`--dry-run` 只读远端 DATABASE_URL 并打印计划。
|
||||||
|
- 远端凭据从 `/opt/dukang/server/dukang-api/.env.production` 的 DATABASE_URL 运行时解析(密码经 `MYSQL_PWD` 环境变量传入,避免命令行暴露)。
|
||||||
|
- 已知限制:不支持密码含 `@`;prod DB 名与本地不同(dukang_prod→dukang_haoke)靠 mysqldump 不带 `--databases` 实现库名映射。
|
||||||
|
- 状态:仅跑通 `--dry-run`,未执行真实同步(待用户确认,因会覆盖本地库并拉取生产数据)。
|
||||||
|
|
||||||
|
## 修复:首次同步失败的根因
|
||||||
|
- 失败现象:`mysqldump` 退出码 2,提示 `本地库可能处于不一致状态`(工具自身的失败兜底提示)。
|
||||||
|
- 真实根因有两条:
|
||||||
|
1. **密码解析错误**:生产 DB 密码含 `@` 字符(`passHasAt=true`)。原 bash 解析用「第一个 @」切分 userinfo/host,把密码里 `@` 之后的部分截断,导致 Access denied。
|
||||||
|
修复:改为在服务端用 `new URL()` 解析,base64 回传各字段(user/host/port/db/password),本地 `base64 -d` 还原,彻底免疫 `@`/`:`/`$` 等特殊字符。服务端 auth 测试 `authTestStatus=0` 验证密码正确。
|
||||||
|
2. **权限不足**:`dukangadmin`(应用库用户)无 `RELOAD`/`FLUSH_TABLES` 权限。`--single-transaction` 仍会触发全局 `FLUSH TABLES` → Access denied。
|
||||||
|
修复:`mysqldump` 改用 `--lock-tables=0`(不发起 FTWRL)+ `--set-gtid-purged=OFF`(RDS 启用 GTID,否则本地恢复会报 GTID_PURGED 错误),并保留 `--skip-routines --skip-events --column-statistics=0 --no-tablespaces --add-drop-table --skip-triggers --no-create-db`。
|
||||||
|
- 验证:结构导出(--no-data)在 4 组 flag 中仅 `--lock-tables=0` / `--skip-lock-tables`(不含 --single-transaction)退出 0。
|
||||||
|
- 现工具已修正并实际执行同步(--yes,先自动备份本地到 deploy/backups/)。
|
||||||
|
- 注意:`--lock-tables=0` 无事务一致性快照;生产库在线写入期间导出可能有极小不一致,但恢复时 mysqldump 自带 `FOREIGN_KEY_CHECKS=0`,_dev 库可接受。若需完全一致快照,可给 dukangadmin 授 RELOAD 后用 --single-transaction。
|
||||||
|
|
||||||
|
## 修改:套餐审核导航样式恢复一致
|
||||||
|
- 文件:`apps/admin-web/src/layouts/AdminLayout.tsx` 的 `attachPackageAuditBadge`(`/store-package-audits` 项)。
|
||||||
|
- 诉求演变:用户先要求「文字恒为白色」→ 后改为「不要单独处理,保持与子菜单一致,只是多一个待审徽章图标」。
|
||||||
|
- 最终实现:label 改为 `<span style={{display:'inline-flex',gap:6}}>套餐审核{pendingCount>0 && <Badge count size="small"/>}</span>`。文字无强制颜色,随菜单主题继承 normal/hover/selected 颜色;徽章仅 pendingCount>0 时作为图标提示,不参与着色。
|
||||||
|
|
||||||
|
## 发布流水线(本次收尾,含之前未上线的 8+1 文件)
|
||||||
|
- 背景:此前「联系电话脱敏+套餐多行输入+同步工具」等 9 文件 commit `8de4179` 已 push dev_jacy/dev/main,但**生产发布失败**(deploy.env 丢失导致 `未配置 DEPLOY_HOST`)。
|
||||||
|
- 修复发布:`deploy.env` 是 gitignore 的本地密钥文件,已丢失不可找回;改用 SSH 别名 `dukang-server`(~/.ssh/config:HostName 47.110.129.57 / User root / IdentityFile pem)覆盖:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`。
|
||||||
|
- 关键排查:`origin/*` 本地缓存 ref 多次陈旧(误报 ahead/behind),必须以 `git ls-remote` 为准。本次真实远端:dev_jacy=8de4179、dev=c05cd0e、main=bac5c7a,与本地一致,无丢失。
|
||||||
|
- 本次提交:`bafb342` fix(admin) 套餐审核样式 → push dev_jacy → merge dev(`0bfc19b`) → merge main(`029fd69`) → push。
|
||||||
|
- 发布结果:`DEPLOY_EXIT=0`,`==> 发版完成 (production)`,健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `029fd69`。
|
||||||
|
- 提示:以后若想恢复标准 `bash deploy/deploy-prod.sh`(不带 --host),需重建 `deploy/deploy.env`(从 `deploy.env.example` 复制,填入 DEPLOY_HOST=47.110.129.57 等;该文件 gitignore,勿提交)。
|
||||||
|
|
||||||
|
## 排查:h5-partner 本地 /login 调 /partner/me 报 401
|
||||||
|
- 现象:`http://localhost:5176/login` 启动后控制台报 `GET /api/v1/partner/me 401 (Unauthorized)`。
|
||||||
|
- 结论:**不是 bug,是登录页的「过期 token 探测」**。链路:Vite 代理 `/api`→`localhost:3010`(正常);后端返回 `{"code":401,"message":"Missing token"}`(说明后端在线、代理通)。`ensureSession()`(api.ts:244)只在 localStorage 有 token 时才打 `/partner/me`;无 token 直接返回未登录。所以 401 来自之前测试残留的失效 token → 前端静默清除并展示登录表单,不崩、不弹错。刷新页面(token 已清)即不再出现。
|
||||||
|
- 本地登录方式(关键,README 已过期):
|
||||||
|
- `MOCK_SMS=true`(.env),短信走 mock,不连运营商;固定码 `MOCK_SMS_FIXED_CODE` 默认 `999888`(README 写的 123456 已不准)。
|
||||||
|
- README 的测试号 `13700000001` **不在本地同步库**;本地 `partner_account` 共 9 个,可用真实号如 `18049821889`(Jacy) / `13073729990`(queen)(均 ACTIVE)。
|
||||||
|
- 流程:填真实合伙人手机号 → 获取验证码 → **验证码打印在 `pnpm dev:api` 后端终端**(`Mock SMS → 180****8889 code=XXXXXX`)→ 填码登录。
|
||||||
|
- 注意 h5-partner 默认 5175,本次因 5175 被占自动落到 5176,代理是路径匹配不受影响。
|
||||||
|
|
||||||
|
## 发布:v3.4.17 门店套餐审核快捷入口 上生产
|
||||||
|
- 提交 `1c91e6b`(dev_jacy 上,含后端 admin-stores.service.ts + AdminStorePackagesSection/StorePackageAuditsPage/StoresPage 三前端 + 开发计划文档)。
|
||||||
|
- 流水线:直接快进推送(避免 git checkout 工作树不刷新):`git push origin dev_jacy` + `dev_jacy:dev` + `dev_jacy:main`,三分支统一到 `1c91e6b`;本地 `git branch -f dev/main origin/*` 同步。
|
||||||
|
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(后端仅给列表响应加字段,无 schema 变更,故 skip-db)。
|
||||||
|
- 结果:`DEPLOY_EXIT=0`,新 tag `v3.4.17`,全部应用构建通过,PM2 重启 dukang-api/h5-shop/h5-partner/admin-web(另拉起 h5-user),健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `1c91e6b`。
|
||||||
|
|
||||||
|
## 发布:v3.4.18 套餐变更对比逐字段列出变动明细
|
||||||
|
- 需求:套餐变更对比「将变动的地方详细列出来」(之前只给「新增/删除/变更/未变」粗粒度标记,不列具体字段)。
|
||||||
|
- 改动仅在 `apps/admin-web/src/pages/StorePackageAuditsPage.tsx`:
|
||||||
|
- 新增 `fieldChanges(live, proposed)`:逐字段比对 价格/套餐名称/菜品内容/可用时间/其他说明/图片,产出 `label+旧值→新值` 明细。
|
||||||
|
- `diffPackages` 给「变更」行附 `changes`。
|
||||||
|
- `PackageDetailCard` 在「待审核」卡片底部渲染「变更明细」块(旧值删除线 → 新值加粗,橙色块)。
|
||||||
|
- 抽屉顶部加「新增 X · 删除 Y · 变更 Z · 未变 W」汇总条。
|
||||||
|
- 提交 `bbd7e52`(feat(admin) 套餐变更对比逐字段列出变动明细),快进推送 dev_jacy/dev/main。
|
||||||
|
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(无 schema 变更)。
|
||||||
|
- 结果:构建通过、PM2 重启 admin-web 等、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `bbd7e52`(无精确 tag,记 tag=-,无害)。
|
||||||
|
- 已知局限:配对按「套餐名称」(空则索引) 位置匹配;若套餐被**改名**,会误判为「删除旧名+新增新名」而非「变更」,故名称变更不会出现在逐字段明细里。
|
||||||
|
|
||||||
|
## 优化:套餐变更对比·逐字差异 + 文档并入 v3.4.17
|
||||||
|
- 需求:变动明细要能**定位到文本中具体哪些字变了(逐字检查)**,且把开发计划文档合并到 v3.4.17 文档里(不再单独留 v3.4.18 文档)。
|
||||||
|
- 代码改动(仍仅 `StorePackageAuditsPage.tsx`):
|
||||||
|
- `diffText(a,b)`:LCS 动态规划 (`dp[i][j]`)+回溯,产出 `equal/delete/insert` 段落并合并相邻同类型;`O(|a|·|b|)`,字段长度可控。
|
||||||
|
- `TextDiff({oldText,newText})`:两行渲染——「原:」红删线标被删字、「新:」绿标新增字。
|
||||||
|
- `FieldChange` 增加 `kind: 'text'|'value'`:价格/图片走 `旧→新`;套餐名称/菜品内容/可用时间/其他说明走 `TextDiff` 逐字高亮。
|
||||||
|
- `杜康好客-v3.4.17-门店套餐审核快捷入口.md` 新增第 F 节「套餐变更对比·逐字段/逐字明细」,并把原先 v3.4.18 的字段级说明并入,删去独立 v3.4.18 文档计划。
|
||||||
|
- 提交 `ecd9973`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`。
|
||||||
|
- 结果:构建通过、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `ecd9973`(tag=-,无害)。
|
||||||
|
- 验证要点:开任一「变更」套餐的待审核卡片 → 底部「变更明细」→ 菜品/说明等文本以红删+绿增逐字呈现。
|
||||||
|
|
||||||
|
## 优化:h5-partner 门店详情·门店套餐「编辑套餐」按钮
|
||||||
|
- 需求:按钮**缩小、靠最右侧、保持美观**。根因:`.partner-btn-outline` 是整行大按钮(`flex:1; height:48px; font-size:16px`),放在卡片标题右侧会被拉伸成大块。
|
||||||
|
- 改动:`apps/h5-partner/src/styles.css` 新增 `.partner-btn-outline--compact`(`flex:none; height:30px; padding:0 14px; font-size:13px; font-weight:500; border-radius:var(--radius-sm); color:var(--color-primary)`,hover 用 `color-mix` 8% 底色);`StoreDetailPage.tsx:288` 的「编辑套餐」按钮加该类(父级 `space-between` 已保证贴右)。
|
||||||
|
- 提交 `69e201f`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`。
|
||||||
|
- 结果:构建通过、PM2 重启 h5-partner、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `69e201f`(tag=-,无害)。
|
||||||
|
- 注意:`StoreDetailPage.tsx:311` 有个历史遗留 TS 错误(`status === 'CLOSED'` 类型无重叠),与本改动无关,未动。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 2026-08-17 工作日志
|
||||||
|
|
||||||
|
## 杜康好客 v3.4.18 门店体验与登录态优化(含 F 项)
|
||||||
|
|
||||||
|
### 已实现并发布
|
||||||
|
- **A** mini-user 门店座机脱敏:改 `apps/mini-user/src/lib/phone.ts` 的 `maskPhone` 座机分支,中间四位 `****`(保留区号+首2+末2)。
|
||||||
|
- **B** 套餐详情图 1/5 计数 + 自动轮播:`ProductCarousel.tsx` detail 变体加 autoplay(3.5s) + 右下角 `1/N` 计数。
|
||||||
|
- **C** 合伙人暂停禁止登录:`auth.service.ts` 三处非 ACTIVE 抛「该账号已暂停使用,请联系客服人员」;`h5-partner/LoginPage.tsx` 同步文案;复用 `DISABLED` 枚举,无迁移。
|
||||||
|
- **D** 门店端提现/筛选栏留白对齐:`h5-shop/styles.css` 补 `--space-page`。
|
||||||
|
- **E** 休息中核销→是否开启营业:`PhoneRedeemPage`/`RedeemConfirmPage` 弹「是否开启营业?」,确认调 `PUT /shop/store/status` 开张后继续。
|
||||||
|
- **F(本次新增)** 门店套餐图片自适应完整显示:`ProductCarousel` 新增 `imageFit="adaptive"`(`mode="widthFix"` + 按真实比例动态算 Swiper 高度),`product-detail.css` 加 `.detail-carousel-wrap--adaptive` 覆盖固定 1:1 裁剪;仅 `store-package-detail` 使用。根因:原 `.detail-carousel-wrap` 固定 `aspect-ratio:1` + `overflow:hidden` + `aspectFill` 裁掉非正方形图。
|
||||||
|
|
||||||
|
### 发布状态
|
||||||
|
- 文档:`杜康好客-v3.4.18-门店体验与登录态优化.md`(含 A–F 范围、实现位置、验收清单)。
|
||||||
|
- git:本地 `dev_jacy` 提交 v3.4.18 各功能 + F 修复;已 `git push origin dev_jacy:main`(干净快进 `984dec7..18147e1`)。
|
||||||
|
- 生产部署:后台运行 `deploy/deploy-prod.sh --host dukang-server`(/opt/dukang, main)。SSH 配置主机 `dukang-server` 即生产机;`deploy.env` 缺失,用 `--host` 覆盖。
|
||||||
|
|
||||||
|
### 注意
|
||||||
|
- 本次 git 发布前曾遇 origin/main 比本地新、working tree 异常删除 503 文件(已 `reset --hard HEAD` 还原,提交完好);最终 fetch 后确认本地领先可快进,安全发布。
|
||||||
|
- 四端 tsc 仅余项目既有环境类报错(CSS 导入/ImportMeta.env),本次改动文件无新增类型错误。
|
||||||
|
|
||||||
|
### F 项方案 A 改写(22:18)
|
||||||
|
- 放弃手算高度逻辑(SelectorQuery + onLoad 测自然宽高),改用 swiper 原生 `autoHeight`:海报有多高轮播就有多高,无裁切。
|
||||||
|
- `ProductCarousel.tsx`:删 `wrapWidth`/`imgHeights`/`handleImageLoad`/`swiperStyle`,adaptive 模式加 `autoHeight={isAdaptive}`,image 保持 `mode="widthFix"`。
|
||||||
|
- `product-detail.css`:`.detail-carousel-wrap--adaptive` 覆盖(删 `aspect-ratio:1`/`overflow:hidden`),并给 `.detail-carousel`/`.detail-carousel-item`/`.detail-carousel-image` 在 adaptive 下 `height:auto`。
|
||||||
|
- 已提交 `cd1e9ae` 并 `git push origin dev_jacy:main`(干净快进 `18147e1..cd1e9ae`)。
|
||||||
|
- 重要:F 是原生微信小程序端改动,服务器部署只覆盖 H5-user 构建;真机小程序用户需经「微信开发者工具上传 + 微信审核」才能生效。
|
||||||
|
|
||||||
|
### E 项缺陷修复(22:46 测试不通过)
|
||||||
|
- **根因**:门店非 OPEN 时,后端 `redeem.service.ts:loadOpenStoreAccount`(previewRedeem/confirmRedeem 共用)抛「门店未营业」,导致 `RedeemConfirmPage` 的 `preview` 拉取失败 → `preview` 为 null → 「确认核销」按钮 `disabled={!preview}` → 点击不了 → 开张弹窗永远不触发。手机号路径因弹窗在「发送验证码」时触发故未暴露。
|
||||||
|
- **修复**:`RedeemConfirmPage` 把 preview 拉取抽成 `loadPreview()`;确认按钮改为 `disabled={loading || (!preview && !storeClosed)}`(闭店时仍可点→弹框);`openStoreAndContinue` 开张成功后 `await loadPreview()` 再 `doConfirm()`。
|
||||||
|
- 弹窗文案对齐需求:「门店目前休息中无法核销,是否开启营业」(两页 modal title=门店休息中 / desc=该句;内联提示改为「门店目前休息中无法核销,开启营业后可继续」)。
|
||||||
|
- 提交 `3ac008a`,`git push origin dev_jacy:main`(快进 `cd1e9ae..3ac008a`),生产重新部署中(deploy-prod.sh --host dukang-server)。
|
||||||
|
- 注意:后端 `updateShopStatus` 对 CLOSED(永久关闭) 门店拒绝开张(抛「门店已永久关闭...」),属预期;需求针对 闭店/休息中(PAUSED) 场景,OK。
|
||||||
|
|
||||||
|
### E 项方案二次修正(23:00 用户纠正:前置到点击扫码)
|
||||||
|
- **用户新要求**:门店 PAUSED 时,**点击「扫码核销」按钮那一刻就弹窗**,而不是扫完码进确认页才弹。
|
||||||
|
- **改动**:`apps/h5-shop/src/pages/HomePage.tsx`(门店端首页)。
|
||||||
|
- 复用 dashboard 已加载的 `status`(`dash?.store?.status`,L384 `status = String(store?.status||'')`)。
|
||||||
|
- `handleScan()` 开头:`if (status !== 'OPEN') { setShowOpenModal(true); return; }` —— 不进入 isWechatEnv/scanQrCode 流程。
|
||||||
|
- 新增 `openStoreAndContinue()`:`PUT /shop/store/status {OPEN}`,成功后 `loadDashboard()` 刷新状态(开关/扫码按钮同步),用户可再次点击扫码。
|
||||||
|
- 弹窗复用全局 `.shop-redeem-modal` 样式(与确认页一致),文案「门店目前休息中无法核销,是否开启营业?」。
|
||||||
|
- `RedeemConfirmPage` 的弹窗保留为带码直入确认页的兜底。
|
||||||
|
- 提交 `c0d04ee`,`git push origin dev_jacy:main`(快进 `3ac008a..c0d04ee`),生产重新部署中(deploy-prod.sh --host dukang-server)。文档 E 节已同步更新方案描述。
|
||||||
|
|
||||||
|
### C 项缺陷修复(合伙人端 DISABLED 仍登录 - 微信路径)
|
||||||
|
- **现象**:手机号(短信)路径已正确拦截(login 前先 `POST /partner/auth/phone/check` 校验非 ACTIVE 即拒),但**微信 OAuth 路径**报错被吞 → 用户无任何提示。
|
||||||
|
- **根因**:`apps/h5-partner/src/contexts/PartnerSessionContext.tsx` L118 `catch { stripOAuthParamsFromLocation(); }` 把微信回调里 `processPartnerWechatOAuthCallback` 抛出的「该账号已暂停使用」错误吞掉。
|
||||||
|
- **修复**:`catch (e) { stripOAuthParamsFromLocation(); toastError(e); }`(`toastError` 从 `@/lib/toast` 引入),让微信登录被暂停时同样弹「该账号已暂停使用,请联系客服人员」。
|
||||||
|
- 提交 `5ca9aa0`,`git push origin dev_jacy:main`(快进 `c0d04ee..5ca9aa0`)。
|
||||||
|
- 生产部署完成(deploy-prod.sh --host dukang-server, 38s):dukang-api 200、各 h5/partner 服务 online。mini-user(h5) 健康检查 FAIL 与本次无关(非 v3.4.18 改动范围,本次改 native 小程序 + h5-shop + h5-partner)。
|
||||||
|
- **验收状态**:A/B/C/D/E/F 全部提交并发布生产;C 项的短信 + 微信两条路径拦截逻辑均已落地,建议真机/微信环境复测一次确认 toast 文案展示。
|
||||||
|
|
||||||
|
### C 项双拦截增强(23:37 用户要求“两个都要拦”)
|
||||||
|
- **澄清**:city 合伙人有两个独立状态字段,别混:
|
||||||
|
- `partner_account.status`(AccountStatus: ACTIVE/DISABLED)= **登录账号启用态**,feature 原拦截查的就是它(子账号自身)。
|
||||||
|
- `partner_account.bindingStatus`(CityPartnerStatus: ACTIVE/PAUSED)= **城市合伙人绑定态**,主账号为准;子账号取父主账号的此字段。
|
||||||
|
- **需求**:子账号 `status=DISABLED` 与 主账号 `bindingStatus=PAUSED` **都要拦**。
|
||||||
|
- **实现**:`auth.service.ts` 新增统一闸门 `assertPartnerAccountActive(account)`(L313),同时拦上述两种;替换三处旧校验:`assertPartnerAccountByPhone`(L334) / `loginPartner`(L1086) / `loginPartnerWechat`(L1748)。
|
||||||
|
- 自身非 ACTIVE → 「该账号已暂停使用,请联系客服人员」
|
||||||
|
- 主账号 bindingStatus 非 ACTIVE → 「该合伙人合作已暂停,请联系客服人员」
|
||||||
|
- **文档**:`杜康好客-v3.4.18-...md` C 节重写为双拦截;顶部范围表 + 验收清单同步。
|
||||||
|
- 提交 `48d6900`,`git push origin dev_jacy:main`(快进 `5ca9aa0..48d6900`),生产部署完成(task I2mWSU, 37s):commit=48d6900、api 200、各服务 online。
|
||||||
|
- **生产实测双拦截均生效**:
|
||||||
|
- `status=DISABLED`(id=7 临时翻转→测试→已还原 ACTIVE):`phone/check` 返回 400「该账号已暂停使用,请联系客服人员」;还原后 201 ok:true。
|
||||||
|
- `bindingStatus=PAUSED`(18049821889 等 3 个主账号):`phone/check` 返回 400「该合伙人合作已暂停,请联系客服人员」(修复前为 200 ok:true)。
|
||||||
|
- 库内 status!=ACTIVE 数量=0;binding_status!=ACTIVE 数量=3(均为主账号 PAUSED)。
|
||||||
|
- **遗留未提交文件(非本次改动,需用户决策)**:
|
||||||
|
- `apps/h5-shop/src/styles.css`:`shop-records-status-chips` 由 `padding:0` → `padding-bottom:10px`(D 项后续微调,未提交)。
|
||||||
|
- `apps/mini-user/src/styles/product-detail.css`:工作区版本把方案A(`aspect-ratio:auto`/`height:auto`)改回固定 4:3(`aspect-ratio:1.33`/`height:100%`),与已上线方案A矛盾,疑似另一轮实验性改动。已与用户确认前**暂不提交**。
|
||||||
@@ -1,133 +1,37 @@
|
|||||||
# 杜康好客 · AGENTS.md
|
# 杜康好客 · AGENTS.md
|
||||||
|
|
||||||
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。
|
> AI 编码入口。人类读 [`agent.md`](./agent.md)。启动、端口、测试账号见 [`README.md`](./README.md)。
|
||||||
|
|
||||||
## 项目概览
|
杜康酒业 O2O:**购酒 → 发券 → 门店核销**。Monorepo + NestJS 单体(`/api/v1`,`{ code, message, data }`)。
|
||||||
|
|
||||||
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `apps/` | `h5-user` · `h5-shop` · `h5-partner` · `admin-web` · `mini-user` |
|
||||||
|
| `packages/` | `shared-types`(枚举/DTO)· `domain`(纯规则,无 IO)· `shared-ui` |
|
||||||
|
| `server/dukang-api/` | 唯一 API |
|
||||||
|
| `pages/` | UI 原型,只读 |
|
||||||
|
|
||||||
| 阶段 | 手册 | 说明 |
|
前端:[`apps/AGENTS.md`](./apps/AGENTS.md)。后端模块/表 OWNER:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)。
|
||||||
|------|------|------|
|
|
||||||
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
|
|
||||||
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
|
|
||||||
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
|
|
||||||
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
|
|
||||||
|
|
||||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
## 文档(冲突时)
|
||||||
|
|
||||||
## 启动命令
|
1. `杜康好客-v3-PRD.md` — **唯一需求事实源**(编码时禁止改业务规则;需求变更只改它)
|
||||||
|
2. `杜康好客-v3-现状对照.md` — 进度
|
||||||
```bash
|
3. `杜康好客-v3编码手册.md` — 验收、核销、分工
|
||||||
# 基础设施
|
4. `conventions.md` — 协作边界
|
||||||
cd deploy && docker compose up -d # dukang-v1:MySQL :6016 Redis :6017
|
5. V2 手册 §四–§六 — 架构/DB/API **参考**(与 V3 冲突则忽略)
|
||||||
|
|
||||||
# 依赖与数据库
|
|
||||||
pnpm install
|
|
||||||
cp server/dukang-api/.env.example server/dukang-api/.env
|
|
||||||
pnpm db:generate && pnpm db:validate
|
|
||||||
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
|
|
||||||
|
|
||||||
# 开发(分终端)
|
|
||||||
pnpm dev:api # http://localhost:3000/api/v1
|
|
||||||
pnpm dev:user # :5173
|
|
||||||
pnpm dev:shop # :5174
|
|
||||||
pnpm dev:partner # :5175
|
|
||||||
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
|
|
||||||
|
|
||||||
# 验证
|
|
||||||
node scripts/smoke-v3.mjs
|
|
||||||
node scripts/smoke-prev1.mjs
|
|
||||||
pnpm lint && pnpm test
|
|
||||||
```
|
|
||||||
|
|
||||||
Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
|
|
||||||
|
|
||||||
> **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
|
|
||||||
|
|
||||||
## 文档优先级(冲突时)
|
|
||||||
|
|
||||||
1. `杜康好客-v3-PRD.md` — **唯一需求事实源**
|
|
||||||
2. `杜康好客-v3-现状对照.md` — 实现进度审计
|
|
||||||
3. `杜康好客-v3编码手册.md` — 实现验收、核销、分工
|
|
||||||
4. `conventions.md` — 协作与模块边界
|
|
||||||
5. V2 手册 §四 §五 §六 — 仅作架构/DB/API **参考**(与 V3 冲突时忽略)
|
|
||||||
6. `pages/{user,shop,partner,hq}/` — UI 参照
|
6. `pages/{user,shop,partner,hq}/` — UI 参照
|
||||||
|
|
||||||
> **V2 / preV1 手册不再作为需求依据。**
|
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 过时文档;`apps/*` import `server/*`;跨模块 `prisma` 写他人表。V2 / preV1 手册不再作需求依据。
|
||||||
|
|
||||||
## 团队与 OWNER 边界(2 人)
|
## OWNER(2026-07 起暂由 jacy 代管全部)
|
||||||
|
|
||||||
| 负责人 | Git 账号 | 职责 |
|
`jacy-dukang` 可改 `apps/*`、`packages/*`、全部后端模块。逻辑边界仍按 A/B/C/D,便于子 Agent 隔离。Prisma 迁移由 jacy 主导。
|
||||||
|--------|----------|------|
|
|
||||||
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
|
|
||||||
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管**) |
|
|
||||||
|
|
||||||
> **临时分工(2026-07)**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
|
- 跨模块只 inject 对方 **exported Service**
|
||||||
|
- Mock 只在 `integrations/*`;微信/配送回调只进 `callbacks/`
|
||||||
|
|
||||||
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下:
|
## 不可偏离
|
||||||
|
|
||||||
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|
|
||||||
|------------|--------|----------|-------------|
|
|
||||||
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
|
|
||||||
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
|
|
||||||
|
|
||||||
**Prisma 迁移**:jacy-dukang 主导 Review;涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review)。
|
|
||||||
|
|
||||||
### 跨模块规则(R1–R8 摘要)
|
|
||||||
|
|
||||||
- 只 inject 对方 Module **exports 的 Service**,禁止 `prisma.xxx` 写他人表
|
|
||||||
- `apps/*` 禁止 import `server/*` 源码;只走 HTTP + `packages/shared-types`
|
|
||||||
- 业务纯规则 → `packages/domain`;枚举/DTO → `packages/shared-types`
|
|
||||||
- 微信/配送回调入口 **仅** `callbacks/`;Mock 实现 **仅** `integrations/*`
|
|
||||||
|
|
||||||
## Cursor 规范
|
|
||||||
|
|
||||||
| 类型 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Rules | `.cursor/rules/` | 自动加载的编码规范 |
|
|
||||||
| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
|
|
||||||
| V3.0 需求 Skill | `.cursor/skills/dukang-v3/` | @dukang-v3(PRD、Wave、REQ/ACC) |
|
|
||||||
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
|
|
||||||
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
|
|
||||||
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
|
|
||||||
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
|
|
||||||
|
|
||||||
## 子 Agent(按职责选用)
|
|
||||||
|
|
||||||
在 Cursor Agent 输入 `/` 选择:
|
|
||||||
|
|
||||||
| 子 Agent | 负责人 | 适用场景 |
|
|
||||||
|----------|--------|----------|
|
|
||||||
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
|
|
||||||
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
|
|
||||||
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
|
|
||||||
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
|
|
||||||
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
|
|
||||||
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
|
|
||||||
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
|
|
||||||
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
|
|
||||||
|
|
||||||
## Skills(按需 @)
|
|
||||||
|
|
||||||
| Skill | 何时用 |
|
|
||||||
|-------|--------|
|
|
||||||
| `dukang-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
|
|
||||||
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
|
|
||||||
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
|
|
||||||
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
|
|
||||||
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
|
|
||||||
| `dukang-release`(杜康发布) | `dev_jacy`→`dev` 合并 + `deploy.sh` 发版 |
|
|
||||||
|
|
||||||
## 快速指令
|
|
||||||
|
|
||||||
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
|
||||||
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
|
|
||||||
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
|
|
||||||
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
|
|
||||||
- PR 前审查:`/boundary-reviewer`
|
|
||||||
- 原型参照:`pages/{user,shop,partner}/`(只读)
|
|
||||||
|
|
||||||
## 核心业务常量(不可偏离)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
权益额 = benefit_amount ?? price
|
权益额 = benefit_amount ?? price
|
||||||
@@ -137,34 +41,10 @@ C 端门店仅 status=OPEN
|
|||||||
订单 Tab:待付款 | 已付款 | 已完成
|
订单 Tab:待付款 | 已付款 | 已完成
|
||||||
```
|
```
|
||||||
|
|
||||||
## 环境与发版
|
微信:iOS H5 登录后禁止仅 SPA 跳转再扫码(`packages/weixin-sdk/GOTCHAS.md`)。小程序 `open-type` 按钮祖先禁止 `stopPropagation`;weapp 标题只用 `navigationBarTitleText`。`h5-partner` 与 `admin-web` 同端口 5175,勿同时开。
|
||||||
|
|
||||||
| 环境 | 分支 | 目录 | 端口 | 域名 |
|
## 按需加载
|
||||||
|------|------|------|------|------|
|
|
||||||
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
|
|
||||||
| **staging 测试** | `dev` | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
|
||||||
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
|
||||||
|
|
||||||
- 日常发布 → `deploy/deploy-staging.sh`(`dev`)
|
- 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产)
|
||||||
- 生产发布 → `deploy/deploy-prod.sh`(`main`,验收后)
|
- 子 Agent:`/owner-a-user-trade` 等(见 `.cursor/agents/`)
|
||||||
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example`
|
- DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
|
||||||
- 详情:`@dukang-release` / `.cursor/skills/dukang-release/SKILL.md`
|
|
||||||
|
|
||||||
## 提交与 PR
|
|
||||||
|
|
||||||
- Conventional Commits:`feat(trade):`、`fix(redeem):`;scope = 端或模块
|
|
||||||
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
|
|
||||||
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
|
|
||||||
- 不提交 `.env`、`dist/`、`node_modules/`
|
|
||||||
|
|
||||||
## 完成定义(DoD)
|
|
||||||
|
|
||||||
- [ ] 任务卡验收项全部满足
|
|
||||||
- [ ] 未跨模块直写 Prisma 表
|
|
||||||
- [ ] 枚举/DTO 在 `shared-types`;纯规则在 `domain`
|
|
||||||
- [ ] `pnpm lint` 无新增错误;相关 domain 单测通过
|
|
||||||
|
|
||||||
## 嵌套指引
|
|
||||||
|
|
||||||
- 后端细节:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)
|
|
||||||
- 前端四 App:[`apps/AGENTS.md`](./apps/AGENTS.md)
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
|
|||||||
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||||
- UI 共享组件优先 `@dukang/shared-ui`
|
- UI 共享组件优先 `@dukang/shared-ui`
|
||||||
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
||||||
|
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||||
|
|
||||||
## 新页面 workflow
|
## 新页面 workflow
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.5.1",
|
"@ant-design/icons": "^5.5.1",
|
||||||
|
"@dukang/domain": "workspace:*",
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
|
"@dukang/shared-ui": "workspace:*",
|
||||||
"antd": "^5.22.0",
|
"antd": "^5.22.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import OrdersPage from './pages/OrdersPage';
|
import OrdersPage from './pages/OrdersPage';
|
||||||
|
import BigScreenPage from './pages/BigScreenPage';
|
||||||
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
||||||
import StoresPage from './pages/StoresPage';
|
import StoresPage from './pages/StoresPage';
|
||||||
import StoreRatingsPage from './pages/StoreRatingsPage';
|
import StoreRatingsPage from './pages/StoreRatingsPage';
|
||||||
@@ -46,6 +47,7 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
|
|||||||
import WechatBindingsPage from './pages/WechatBindingsPage';
|
import WechatBindingsPage from './pages/WechatBindingsPage';
|
||||||
import HqPermissionsPage from './pages/HqPermissionsPage';
|
import HqPermissionsPage from './pages/HqPermissionsPage';
|
||||||
import SystemSettingsPage from './pages/SystemSettingsPage';
|
import SystemSettingsPage from './pages/SystemSettingsPage';
|
||||||
|
import TestWhitelistPage from './pages/TestWhitelistPage';
|
||||||
import WecomBotsPage from './pages/WecomBotsPage';
|
import WecomBotsPage from './pages/WecomBotsPage';
|
||||||
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||||
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||||
@@ -64,6 +66,14 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/orders/big-screen"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<BigScreenPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
@@ -136,6 +146,7 @@ export default function App() {
|
|||||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||||
|
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
||||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
+757
-16
@@ -16,37 +16,109 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 后台内容行:不换行 + 超出省略
|
* 后台表格:不换行、完整展示(v3.5.9 去掉省略号)
|
||||||
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
|
* 描述列表仍省略,避免详情抽屉被长字段撑破
|
||||||
*/
|
*/
|
||||||
.admin-layout .ant-table-cell,
|
.admin-layout .ant-table-cell,
|
||||||
.ant-drawer .ant-table-cell,
|
.ant-drawer .ant-table-cell,
|
||||||
.ant-modal .ant-table-cell,
|
.ant-modal .ant-table-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-layout .ant-descriptions-item-content,
|
.admin-layout .ant-descriptions-item-content,
|
||||||
.ant-drawer .ant-descriptions-item-content,
|
.ant-drawer .ant-descriptions-item-content,
|
||||||
.ant-modal .ant-descriptions-item-content {
|
.ant-modal .ant-descriptions-item-content {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-layout .ant-descriptions-item-content,
|
|
||||||
.ant-drawer .ant-descriptions-item-content,
|
|
||||||
.ant-modal .ant-descriptions-item-content {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表头同样不换行 */
|
/* 表头不换行 */
|
||||||
.admin-layout .ant-table-thead > tr > th,
|
.admin-layout .ant-table-thead > tr > th,
|
||||||
.ant-drawer .ant-table-thead > tr > th,
|
.ant-drawer .ant-table-thead > tr > th,
|
||||||
.ant-modal .ant-table-thead > tr > th {
|
.ant-modal .ant-table-thead > tr > th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
text-overflow: ellipsis;
|
text-overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 操作列、含交互控件的不截断 */
|
/* 拖表头分割线改列宽 */
|
||||||
|
.admin-layout .ant-table-thead > tr > th.admin-th-resizable {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-col-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 3;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-col-resize-handle:hover,
|
||||||
|
.admin-col-resizing .admin-col-resize-handle {
|
||||||
|
background: rgba(22, 119, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.admin-col-resizing,
|
||||||
|
body.admin-col-resizing * {
|
||||||
|
cursor: col-resize !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表页顶栏:标题左、主操作右、列设置最右 */
|
||||||
|
.admin-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-header-left {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-header-desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-settings-slot {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: auto;
|
||||||
|
order: 99;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-settings-btn.ant-btn {
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-settings-btn.ant-btn:hover {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作列保持可见 */
|
||||||
.admin-layout .ant-table-cell:has(.ant-btn),
|
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||||
.ant-modal .ant-table-cell:has(.ant-btn),
|
.ant-modal .ant-table-cell:has(.ant-btn),
|
||||||
@@ -71,10 +143,9 @@ body,
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格单元格双行信息合并为单行省略 */
|
/* 表格单元格主/副信息同一行 */
|
||||||
.admin-cell-line {
|
.admin-cell-line {
|
||||||
display: block;
|
display: inline;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-cell-line-secondary {
|
.admin-cell-line-secondary {
|
||||||
@@ -82,7 +153,677 @@ body,
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-primary-link {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-primary-link:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table-nowrap .ant-table-cell,
|
.admin-table-nowrap .ant-table-cell,
|
||||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-col {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v3.5.1 #1 订单大屏:发布会现场 */
|
||||||
|
html:has(.big-screen-page),
|
||||||
|
body:has(.big-screen-page),
|
||||||
|
#root:has(.big-screen-page) {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-page {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: radial-gradient(ellipse at 50% 18%, #163a6b 0%, #0b1e3a 42%, #061224 100%);
|
||||||
|
color: #d6e8ff;
|
||||||
|
padding: 20px 48px 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-stars {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1px 1px at 8% 18%, rgba(255, 255, 255, 0.35), transparent),
|
||||||
|
radial-gradient(1px 1px at 22% 72%, rgba(160, 210, 255, 0.28), transparent),
|
||||||
|
radial-gradient(1.5px 1.5px at 78% 24%, rgba(255, 255, 255, 0.22), transparent),
|
||||||
|
radial-gradient(1px 1px at 91% 68%, rgba(160, 210, 255, 0.3), transparent),
|
||||||
|
radial-gradient(1px 1px at 46% 88%, rgba(255, 255, 255, 0.18), transparent),
|
||||||
|
radial-gradient(1.5px 1.5px at 61% 12%, rgba(160, 210, 255, 0.25), transparent);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-frame {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner {
|
||||||
|
position: absolute;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(105, 192, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner--tl {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner--tr {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner--bl {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner--br {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba(105, 192, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(200, 220, 245, 0.55);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-live-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-live-label {
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgba(230, 244, 255, 0.88);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 12px 4px 10px;
|
||||||
|
border: 1px solid rgba(64, 169, 255, 0.7);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: rgba(8, 28, 56, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ff4d4f;
|
||||||
|
box-shadow: 0 0 8px #ff4d4f;
|
||||||
|
animation: big-screen-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-hero {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-clock {
|
||||||
|
font-size: 84px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 10px;
|
||||||
|
color: #9fd3ff;
|
||||||
|
text-shadow: 0 0 28px rgba(105, 192, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-date {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: rgba(180, 210, 240, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-list {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-list-head,
|
||||||
|
.big-screen-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 18%) minmax(0, 1fr) minmax(140px, 16%) minmax(160px, 18%);
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-list-body {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-height: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-track {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-track.is-rolling {
|
||||||
|
animation: big-screen-marquee-up var(--marquee-ms, 20s) linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-track.is-paused {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-marquee-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 68px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 0 28px 0 40px;
|
||||||
|
background: rgba(18, 48, 88, 0.45);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #e8f4ff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row.is-latest {
|
||||||
|
border-color: rgba(64, 169, 255, 0.85);
|
||||||
|
box-shadow: 0 0 16px rgba(24, 144, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t1 {
|
||||||
|
background: rgba(22, 54, 98, 0.48);
|
||||||
|
border-color: rgba(105, 192, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t1 .big-screen-amount {
|
||||||
|
color: #e6f4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t2.is-latest {
|
||||||
|
border-color: rgba(255, 229, 143, 0.95);
|
||||||
|
box-shadow: 0 0 18px rgba(255, 229, 143, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t3.is-latest {
|
||||||
|
border-color: #ffd666;
|
||||||
|
box-shadow: 0 0 22px rgba(250, 173, 20, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t2 {
|
||||||
|
background: rgba(64, 48, 8, 0.42);
|
||||||
|
border-color: rgba(255, 229, 143, 0.45);
|
||||||
|
color: #fff7d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t2 .big-screen-amount {
|
||||||
|
color: #ffe58f;
|
||||||
|
text-shadow: 0 0 10px rgba(255, 229, 143, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t3 {
|
||||||
|
background: rgba(72, 48, 0, 0.5);
|
||||||
|
border-color: rgba(250, 173, 20, 0.75);
|
||||||
|
color: #ffe7a3;
|
||||||
|
box-shadow: 0 0 18px rgba(250, 173, 20, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t3 .big-screen-amount {
|
||||||
|
color: #ffd666;
|
||||||
|
text-shadow: 0 0 14px rgba(255, 214, 102, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row-mark {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
width: 10px;
|
||||||
|
height: 22px;
|
||||||
|
margin-top: -11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #40a9ff;
|
||||||
|
box-shadow: 0 0 10px #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t2 .big-screen-row-mark {
|
||||||
|
background: #ffe58f;
|
||||||
|
box-shadow: 0 0 10px #ffe58f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t3 .big-screen-row-mark {
|
||||||
|
background: #ffd666;
|
||||||
|
box-shadow: 0 0 12px #ffd666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-amount--6d {
|
||||||
|
font-size: 0.86em;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-items {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-time,
|
||||||
|
.big-screen-phone {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-empty {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
/* 全屏高度黄金分割点(≈0.382),略偏上 */
|
||||||
|
top: 38.2%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(145, 190, 230, 0.42);
|
||||||
|
padding: 0 40px;
|
||||||
|
font-size: 60px;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(4, 12, 28, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-shock {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255, 214, 102, 0.85);
|
||||||
|
animation: big-screen-shock 1.1s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-shock {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.2);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(28);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-sweep {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
115deg,
|
||||||
|
transparent 38%,
|
||||||
|
rgba(255, 245, 200, 0.22) 50%,
|
||||||
|
transparent 62%
|
||||||
|
);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: big-screen-sweep 1.4s ease-out 0.15s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-sweep--alt {
|
||||||
|
animation-delay: 0.55s;
|
||||||
|
background: linear-gradient(
|
||||||
|
65deg,
|
||||||
|
transparent 38%,
|
||||||
|
rgba(255, 214, 102, 0.2) 50%,
|
||||||
|
transparent 62%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-shock--late {
|
||||||
|
animation-delay: 0.35s;
|
||||||
|
border-color: rgba(255, 236, 179, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-sweep {
|
||||||
|
from {
|
||||||
|
background-position: 120% 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -40% 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
min-width: min(420px, 92vw);
|
||||||
|
max-width: min(920px, 92vw);
|
||||||
|
padding: 28px 40px 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(8, 22, 48, 0.82);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t1 {
|
||||||
|
border: 2px solid rgba(105, 192, 255, 0.85);
|
||||||
|
box-shadow: 0 0 32px rgba(24, 144, 255, 0.45);
|
||||||
|
animation: big-screen-card-in-t1 0.55s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t2 {
|
||||||
|
border: 2px solid rgba(255, 229, 143, 0.95);
|
||||||
|
box-shadow: 0 0 40px rgba(255, 214, 102, 0.5);
|
||||||
|
animation: big-screen-card-in-t2 0.6s cubic-bezier(0.16, 1.2, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t3 {
|
||||||
|
min-width: min(560px, 94vw);
|
||||||
|
padding: 36px 48px 40px;
|
||||||
|
border: 3px solid #ffd666;
|
||||||
|
box-shadow:
|
||||||
|
0 0 28px rgba(255, 214, 102, 0.85),
|
||||||
|
0 0 80px rgba(250, 173, 20, 0.45);
|
||||||
|
animation: big-screen-card-in-t3 0.7s cubic-bezier(0.12, 1.4, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-card-in-t1 {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(80px) scale(0.92);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-card-in-t2 {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.6);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-card-in-t3 {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.4) rotate(-4deg);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: scale(1.12) rotate(1deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) rotate(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-kicker {
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
color: rgba(230, 244, 255, 0.7);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t2 .big-screen-fx-kicker,
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-kicker {
|
||||||
|
color: #ffe58f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-amount {
|
||||||
|
font-size: clamp(44px, 8.5vw, 64px);
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #e6f7ff;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-amount--6d {
|
||||||
|
font-size: clamp(36px, 7vw, 52px) !important;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t1 .big-screen-fx-amount {
|
||||||
|
color: #91d5ff;
|
||||||
|
text-shadow: 0 0 18px rgba(105, 192, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t2 .big-screen-fx-amount {
|
||||||
|
color: #ffe58f;
|
||||||
|
text-shadow: 0 0 20px rgba(255, 229, 143, 0.7);
|
||||||
|
animation: big-screen-amount-pop 0.8s ease-out 0.15s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
||||||
|
font-size: clamp(52px, 10vw, 84px);
|
||||||
|
color: #ffd666;
|
||||||
|
text-shadow:
|
||||||
|
0 0 12px #ffd666,
|
||||||
|
0 0 36px rgba(250, 173, 20, 0.8);
|
||||||
|
animation: big-screen-amount-flash 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
|
||||||
|
font-size: clamp(44px, 8.5vw, 68px) !important;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-amount-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-amount-flash {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-items {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 28px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(210, 228, 250, 0.75);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.big-screen-clock {
|
||||||
|
font-size: 56px;
|
||||||
|
}
|
||||||
|
.big-screen-row {
|
||||||
|
grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.big-screen-amount--6d {
|
||||||
|
font-size: 0.82em;
|
||||||
|
}
|
||||||
|
.big-screen-fx-amount {
|
||||||
|
font-size: clamp(36px, 8vw, 44px);
|
||||||
|
}
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
||||||
|
font-size: clamp(40px, 9vw, 56px);
|
||||||
|
}
|
||||||
|
.big-screen-fx-amount--6d {
|
||||||
|
font-size: clamp(32px, 6.5vw, 40px) !important;
|
||||||
|
}
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
|
||||||
|
font-size: clamp(34px, 7vw, 48px) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,12 +1,10 @@
|
|||||||
import { Typography } from 'antd';
|
|
||||||
|
|
||||||
type AdminCellLineProps = {
|
type AdminCellLineProps = {
|
||||||
primary?: string | null;
|
primary?: string | null;
|
||||||
secondary?: string | null;
|
secondary?: string | null;
|
||||||
separator?: string;
|
separator?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
|
/** 表格单元格:主/副信息同一行完整展示 */
|
||||||
export function AdminCellLine({
|
export function AdminCellLine({
|
||||||
primary,
|
primary,
|
||||||
secondary,
|
secondary,
|
||||||
@@ -14,14 +12,9 @@ export function AdminCellLine({
|
|||||||
}: AdminCellLineProps) {
|
}: AdminCellLineProps) {
|
||||||
const main = primary?.trim() || '—';
|
const main = primary?.trim() || '—';
|
||||||
const sub = secondary?.trim();
|
const sub = secondary?.trim();
|
||||||
const full = sub ? `${main}${separator}${sub}` : main;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Typography.Text
|
<span className="admin-cell-line">
|
||||||
className="admin-cell-line"
|
|
||||||
ellipsis={{ tooltip: full }}
|
|
||||||
style={{ maxWidth: '100%' }}
|
|
||||||
>
|
|
||||||
{main}
|
{main}
|
||||||
{sub ? (
|
{sub ? (
|
||||||
<span className="admin-cell-line-secondary">
|
<span className="admin-cell-line-secondary">
|
||||||
@@ -29,6 +22,6 @@ export function AdminCellLine({
|
|||||||
{sub}
|
{sub}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Typography.Text>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Checkbox, Modal, Space, Typography } from 'antd';
|
||||||
|
import { HolderOutlined } from '@ant-design/icons';
|
||||||
|
import type { ListColumnSettingItem } from '../lib/list-column-prefs';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
items: ListColumnSettingItem[];
|
||||||
|
saving?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onSave: (items: ListColumnSettingItem[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminListColumnsModal({ open, items, saving, onCancel, onReset, onSave }: Props) {
|
||||||
|
const [draft, setDraft] = useState<ListColumnSettingItem[]>(items);
|
||||||
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setDraft(items);
|
||||||
|
}, [open, items]);
|
||||||
|
|
||||||
|
function move(from: number, to: number) {
|
||||||
|
if (to < 0 || to >= draft.length) return;
|
||||||
|
const next = [...draft];
|
||||||
|
const [row] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, row);
|
||||||
|
setDraft(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="列设置"
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Button onClick={onReset}>重置默认</Button>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onCancel}>取消</Button>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => onSave(draft)}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||||
|
勾选显示列,拖拽或上下箭头调整顺序。序号与操作列固定,不在此列表中。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<div>
|
||||||
|
{draft.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragKey(item.key)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => {
|
||||||
|
if (!dragKey || dragKey === item.key) return;
|
||||||
|
const from = draft.findIndex((r) => r.key === dragKey);
|
||||||
|
if (from >= 0) move(from, index);
|
||||||
|
setDragKey(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '6px 0',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
cursor: 'move',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HolderOutlined style={{ color: '#999' }} />
|
||||||
|
<Checkbox
|
||||||
|
checked={item.visible}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...draft];
|
||||||
|
next[index] = { ...item, visible: e.target.checked };
|
||||||
|
setDraft(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Checkbox>
|
||||||
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
|
<Button type="link" size="small" disabled={index === 0} onClick={() => move(index, index - 1)}>
|
||||||
|
上移
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
disabled={index === draft.length - 1}
|
||||||
|
onClick={() => move(index, index + 1)}
|
||||||
|
>
|
||||||
|
下移
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Space, Typography } from 'antd';
|
||||||
|
|
||||||
|
/** 列表页顶栏:标题在左,主操作在右,列设置永远最右 */
|
||||||
|
export function AdminListHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
settings,
|
||||||
|
}: {
|
||||||
|
title?: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
settings?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="admin-list-header">
|
||||||
|
<div className="admin-list-header-left">
|
||||||
|
{title == null || title === '' ? null : typeof title === 'string' || typeof title === 'number' ? (
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
{title}
|
||||||
|
</Typography.Title>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
{description ? (
|
||||||
|
<div className="admin-list-header-desc">{description}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="admin-list-header-right">
|
||||||
|
{actions ? <Space wrap>{actions}</Space> : null}
|
||||||
|
{settings}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { MouseEvent, ReactNode } from 'react';
|
||||||
|
|
||||||
|
/** HQ 主列表主展示列:下划线,点击进入编辑或详情 */
|
||||||
|
export function AdminPrimaryLink({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
onClick: (e?: MouseEvent) => void;
|
||||||
|
}) {
|
||||||
|
const empty = children == null || children === '';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-primary-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{empty ? '—' : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,253 +1,381 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
||||||
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
import type { StorePackageItemDto } from '@dukang/shared-types';
|
import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
|
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import OssUpload from './OssUpload';
|
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
|
import PackageImagesUpload from './PackageImagesUpload';
|
||||||
|
|
||||||
type PackageRow = StorePackageItemDto;
|
type PackageRow = StorePackageItemDto;
|
||||||
|
|
||||||
|
export type AdminStorePackagesHandle = {
|
||||||
|
/** 套餐已加载时写入;加载中则跳过,避免空数据覆盖线上套餐 */
|
||||||
|
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
function emptyRow(index = 0): PackageRow {
|
function emptyRow(index = 0): PackageRow {
|
||||||
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', imageUrl: '', sortOrder: index };
|
return {
|
||||||
|
name: '',
|
||||||
|
price: '0',
|
||||||
|
dishes: '',
|
||||||
|
usableTime: '',
|
||||||
|
otherNotes: '',
|
||||||
|
imageUrl: '',
|
||||||
|
imageUrls: [],
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
|
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
|
||||||
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
function AdminStorePackagesSection({ storeId }, ref) {
|
||||||
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||||
const [saving, setSaving] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||||
|
const itemsRef = useRef(items);
|
||||||
|
const loadingRef = useRef(loading);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
itemsRef.current = items;
|
||||||
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
|
}, [items]);
|
||||||
.then((data) => {
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadingRef.current = loading;
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setPendingRequest(null);
|
||||||
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
|
.then((data) => {
|
||||||
|
setPendingRequest(data.pendingRequest ?? null);
|
||||||
|
setItems(
|
||||||
|
data.live?.length
|
||||||
|
? data.live.map((p, i) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
// 在审核页完成审核后,自动刷新本页「有待审核套餐」提醒
|
||||||
|
useEffect(() => {
|
||||||
|
const onChanged = () => {
|
||||||
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
|
.then((data) => setPendingRequest(data.pendingRequest ?? null))
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
function goAudit() {
|
||||||
|
if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingReminder =
|
||||||
|
pendingRequest && pendingRequest.status === 'PENDING' ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="该门店有待审核套餐"
|
||||||
|
description={
|
||||||
|
<Space>
|
||||||
|
<Button size="small" type="primary" onClick={goAudit}>
|
||||||
|
审核
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={goAudit}>
|
||||||
|
对比
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||||
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||||
|
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
const run = () => {
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
||||||
|
return next.length ? next : [];
|
||||||
|
});
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next: Record<number, boolean> = {};
|
||||||
|
Object.entries(prev).forEach(([k, v]) => {
|
||||||
|
const i = Number(k);
|
||||||
|
if (i < index) next[i] = v;
|
||||||
|
else if (i > index) next[i - 1] = v;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (items.length === 1) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '清空门店套餐',
|
||||||
|
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
|
||||||
|
okText: '确认删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: run,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollapse(index: number) {
|
||||||
|
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(opts?: { quiet?: boolean }) {
|
||||||
|
const currentItems = itemsRef.current;
|
||||||
|
const filled = currentItems
|
||||||
|
.map((item, index) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(item);
|
||||||
|
return {
|
||||||
|
name: item.name.trim(),
|
||||||
|
price: item.price.trim(),
|
||||||
|
dishes: item.dishes.trim(),
|
||||||
|
usableTime: item.usableTime?.trim() || null,
|
||||||
|
otherNotes: item.otherNotes?.trim() || null,
|
||||||
|
imageUrl: imageUrls[0] ?? null,
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
|
||||||
|
.filter((item) => {
|
||||||
|
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
|
||||||
|
const hasImages = item.imageUrls.length > 0;
|
||||||
|
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
|
||||||
|
return hasText || hasImages || hasNonZeroPrice;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < filled.length; i++) {
|
||||||
|
const item = filled[i];
|
||||||
|
if (!item.name) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
if (!item.dishes) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
const price = Number(item.price);
|
||||||
|
if (!Number.isFinite(price) || price < 0) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!opts?.quiet) message.success('套餐已保存并生效');
|
||||||
setItems(
|
setItems(
|
||||||
data.live?.length
|
data.live?.length
|
||||||
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
|
? data.live.map((p, i) => {
|
||||||
: [emptyRow()],
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
);
|
);
|
||||||
})
|
} catch (e) {
|
||||||
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
.finally(() => setLoading(false));
|
throw e;
|
||||||
}, [storeId]);
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
function updateAt(index: number, patch: Partial<PackageRow>) {
|
|
||||||
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRow() {
|
|
||||||
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
|
||||||
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeAt(index: number) {
|
|
||||||
const run = () => {
|
|
||||||
setItems((prev) => {
|
|
||||||
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
|
||||||
return next.length ? next : [];
|
|
||||||
});
|
|
||||||
setCollapsed((prev) => {
|
|
||||||
const next: Record<number, boolean> = {};
|
|
||||||
Object.entries(prev).forEach(([k, v]) => {
|
|
||||||
const i = Number(k);
|
|
||||||
if (i < index) next[i] = v;
|
|
||||||
else if (i > index) next[i - 1] = v;
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
if (items.length === 1) {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '清空门店套餐',
|
|
||||||
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
|
|
||||||
okText: '确认删除',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: run,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
function toggleCollapse(index: number) {
|
|
||||||
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
const filled = items
|
|
||||||
.map((item, index) => ({
|
|
||||||
name: item.name.trim(),
|
|
||||||
price: item.price.trim(),
|
|
||||||
dishes: item.dishes.trim(),
|
|
||||||
usableTime: item.usableTime?.trim() || null,
|
|
||||||
otherNotes: item.otherNotes?.trim() || null,
|
|
||||||
imageUrl: item.imageUrl?.trim() || null,
|
|
||||||
sortOrder: index,
|
|
||||||
}))
|
|
||||||
.filter((item) => item.name || item.dishes || item.price);
|
|
||||||
|
|
||||||
for (let i = 0; i < filled.length; i++) {
|
|
||||||
const item = filled[i];
|
|
||||||
if (!item.name) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!item.dishes) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const price = Number(item.price);
|
|
||||||
if (!Number.isFinite(price) || price < 0) {
|
|
||||||
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
useImperativeHandle(ref, () => ({
|
||||||
try {
|
saveIfLoaded: async (opts) => {
|
||||||
await request(`/admin/stores/${storeId}/packages`, {
|
if (loadingRef.current) return { skipped: true };
|
||||||
method: 'PUT',
|
await save(opts);
|
||||||
body: JSON.stringify({
|
return { skipped: false };
|
||||||
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
},
|
||||||
}),
|
}));
|
||||||
});
|
|
||||||
message.success('套餐已保存并生效');
|
if (loading) {
|
||||||
} catch (e) {
|
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
if (!items.length) {
|
||||||
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
return (
|
||||||
}
|
<Form layout="vertical" requiredMark={false}>
|
||||||
|
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
||||||
|
{pendingReminder}
|
||||||
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||||
|
添加套餐
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||||
|
保存套餐
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!items.length) {
|
|
||||||
return (
|
return (
|
||||||
<Form layout="vertical" requiredMark={false}>
|
<Form layout="vertical" requiredMark={false}>
|
||||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
{pendingReminder}
|
||||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
<Alert
|
||||||
添加套餐
|
type="info"
|
||||||
</Button>
|
showIcon
|
||||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
style={{ marginBottom: 16 }}
|
||||||
|
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isCollapsed = !!collapsed[index];
|
||||||
|
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
onClick={() => toggleCollapse(index)}
|
||||||
|
style={{ paddingLeft: 0, height: 'auto' }}
|
||||||
|
>
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography.Title>
|
||||||
|
</Button>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{!isCollapsed ? (
|
||||||
|
<>
|
||||||
|
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="如:套餐A"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonAfter="元"
|
||||||
|
placeholder="198"
|
||||||
|
value={item.price === '' ? undefined : Number(item.price)}
|
||||||
|
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||||
|
value={item.dishes}
|
||||||
|
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="节假日除外"
|
||||||
|
value={item.usableTime || ''}
|
||||||
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
||||||
|
<PackageImagesUpload
|
||||||
|
value={normalizeStorePackageImageUrls(item)}
|
||||||
|
onChange={(imageUrls) =>
|
||||||
|
updateAt(index, {
|
||||||
|
imageUrls,
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="不可叠加"
|
||||||
|
value={item.otherNotes || ''}
|
||||||
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||||
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||||
|
添加套餐
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
上传套餐图后请点右上角「保存修改」(会连同套餐一起保存),或点下方「保存套餐」。仅上传不保存,刷新会丢失。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||||
保存套餐
|
保存套餐
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return ( <Form layout="vertical" requiredMark={false}>
|
export default AdminStorePackagesSection;
|
||||||
<Alert
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
style={{ marginBottom: 16 }}
|
|
||||||
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isCollapsed = !!collapsed[index];
|
|
||||||
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
marginBottom: 16,
|
|
||||||
padding: 16,
|
|
||||||
border: '1px solid #f0f0f0',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: '#fafafa',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
|
||||||
onClick={() => toggleCollapse(index)}
|
|
||||||
style={{ paddingLeft: 0, height: 'auto' }}
|
|
||||||
>
|
|
||||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
|
||||||
{displayName}
|
|
||||||
</Typography.Title>
|
|
||||||
</Button>
|
|
||||||
{items.length > 0 ? (
|
|
||||||
<Button type="link" danger onClick={() => removeAt(index)}>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
) : null} </Space>
|
|
||||||
|
|
||||||
{!isCollapsed ? (
|
|
||||||
<>
|
|
||||||
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
|
||||||
<Input
|
|
||||||
placeholder="如:套餐A"
|
|
||||||
value={item.name}
|
|
||||||
onChange={(e) => updateAt(index, { name: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
precision={2}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
addonAfter="元"
|
|
||||||
placeholder="198"
|
|
||||||
value={item.price === '' ? undefined : Number(item.price)}
|
|
||||||
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
placeholder="红烧肉、红烧鱼、油焖茄子"
|
|
||||||
value={item.dishes}
|
|
||||||
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
|
||||||
<Input
|
|
||||||
placeholder="节假日除外"
|
|
||||||
value={item.usableTime || ''}
|
|
||||||
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
|
||||||
<OssUpload
|
|
||||||
bizType="STORE_PACKAGE"
|
|
||||||
mediaType="IMAGE"
|
|
||||||
value={item.imageUrl || undefined}
|
|
||||||
onChange={(url) => updateAt(index, { imageUrl: url })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="其他说明" style={{ marginBottom: 0 }}> <Input
|
|
||||||
placeholder="不可叠加"
|
|
||||||
value={item.otherNotes || ''}
|
|
||||||
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
|
||||||
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
|
||||||
添加套餐
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
|
||||||
修改后点击下方按钮保存,C 端将立即展示生效套餐。
|
|
||||||
</Typography.Paragraph>
|
|
||||||
|
|
||||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
|
||||||
保存套餐
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,459 +1,458 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
CITY_PARTNER_SCOPE_LABELS,
|
CITY_PARTNER_SCOPE_LABELS,
|
||||||
CITY_PARTNER_STATUS_LABELS,
|
CITY_PARTNER_STATUS_LABELS,
|
||||||
CityPartnerScopeType,
|
CityPartnerScopeType,
|
||||||
CityPartnerStatus,
|
CityPartnerStatus,
|
||||||
PARTNER_PERMISSION_KEYS,
|
PARTNER_PERMISSION_KEYS,
|
||||||
PARTNER_PERMISSION_LABELS,
|
PARTNER_PERMISSION_LABELS,
|
||||||
PARTNER_STAFF_ROLE_LABELS,
|
PARTNER_STAFF_ROLE_LABELS,
|
||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
|
||||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||||
|
|
||||||
type PartnerRow = {
|
type PartnerRow = {
|
||||||
id: string;
|
id: string;
|
||||||
companyName: string;
|
companyName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
name: string;
|
name: string;
|
||||||
scopeType?: string;
|
scopeType?: string;
|
||||||
districtCodes?: string[] | null;
|
districtCodes?: string[] | null;
|
||||||
orderCommissionRate?: number;
|
orderCommissionRate?: number;
|
||||||
redeemCommissionRate?: number;
|
redeemCommissionRate?: number;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerDetail = PartnerRow & {
|
type PartnerDetail = PartnerRow & {
|
||||||
address?: string;
|
address?: string;
|
||||||
districtCodes?: string[] | null;
|
districtCodes?: string[] | null;
|
||||||
bindingStatus?: string;
|
bindingStatus?: string;
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
bankAccountNo?: string | null;
|
bankAccountNo?: string | null;
|
||||||
bankBranch?: string | null;
|
bankBranch?: string | null;
|
||||||
managedWarehouseId?: string | null;
|
managedWarehouseId?: string | null;
|
||||||
managedWarehouseName?: string | null;
|
managedWarehouseName?: string | null;
|
||||||
contactPhone?: string | null;
|
contactPhone?: string | null;
|
||||||
children?: Array<{
|
children?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
name: string;
|
name: string;
|
||||||
staffRole?: string;
|
staffRole?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
cityId: string;
|
cityId: string;
|
||||||
cityCode?: string;
|
cityCode?: string;
|
||||||
maxPartnerCommissionRate?: number;
|
maxPartnerCommissionRate?: number;
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||||
value: k,
|
value: k,
|
||||||
label: PARTNER_PERMISSION_LABELS[k],
|
label: PARTNER_PERMISSION_LABELS[k],
|
||||||
}));
|
}));
|
||||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||||
.filter(([value]) => value !== 'PARTNER')
|
.filter(([value]) => value !== 'PARTNER')
|
||||||
.map(([value, label]) => ({ value, label }));
|
.map(([value, label]) => ({ value, label }));
|
||||||
|
|
||||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||||
if (!values?.length) return [];
|
if (!values?.length) return [];
|
||||||
if (Array.isArray(values[0])) {
|
if (Array.isArray(values[0])) {
|
||||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||||
}
|
}
|
||||||
return values as string[];
|
return values as string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function commissionSumError(
|
function commissionSumError(
|
||||||
orderPercent: number,
|
orderPercent: number,
|
||||||
redeemPercent: number,
|
redeemPercent: number,
|
||||||
maxRate: number,
|
maxRate: number,
|
||||||
): string | null {
|
): string | null {
|
||||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||||
const max = Number(maxRate);
|
const max = Number(maxRate);
|
||||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||||
if (sum > max + 1e-9) {
|
if (sum > max + 1e-9) {
|
||||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatApiError(err: unknown): string | null {
|
function formatApiError(err: unknown): string | null {
|
||||||
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
if (err && typeof err === 'object' && 'errorFields' in err) return null;
|
||||||
if (!(err instanceof Error)) return '操作失败';
|
if (!(err instanceof Error)) return '操作失败';
|
||||||
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
return err.message.replace(/\b(\d{6})\b/g, (code) => {
|
||||||
const label = districtCodeLabel(code);
|
const label = districtCodeLabel(code);
|
||||||
return label !== code ? `${label}(${code})` : code;
|
return label !== code ? `${label}(${code})` : code;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CityPartnersPanel({
|
export default function CityPartnersPanel({
|
||||||
cityId,
|
cityId,
|
||||||
cityCode,
|
cityCode,
|
||||||
maxPartnerCommissionRate = 0.05,
|
maxPartnerCommissionRate = 0.05,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
const [subEditForm] = Form.useForm();
|
const [subEditForm] = Form.useForm();
|
||||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [subOpen, setSubOpen] = useState(false);
|
const [subOpen, setSubOpen] = useState(false);
|
||||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||||
|
|
||||||
const loadPartners = useCallback(async () => {
|
const loadPartners = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||||
setPartners(res.items);
|
setPartners(res.items);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [cityId]);
|
}, [cityId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadPartners();
|
void loadPartners();
|
||||||
}, [loadPartners]);
|
}, [loadPartners]);
|
||||||
|
|
||||||
async function openPartner(id: string) {
|
async function openPartner(id: string) {
|
||||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||||
setDetail(d);
|
setDetail(d);
|
||||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
name: d.name,
|
name: d.name,
|
||||||
phone: d.phone,
|
phone: d.phone,
|
||||||
companyName: d.companyName,
|
companyName: d.companyName,
|
||||||
contactPhone: d.contactPhone ?? d.phone,
|
contactPhone: d.contactPhone ?? d.phone,
|
||||||
address: d.address ?? '',
|
address: d.address ?? '',
|
||||||
scopeType: d.scopeType,
|
scopeType: d.scopeType,
|
||||||
districtCodes: d.districtCodes ?? [],
|
districtCodes: d.districtCodes ?? [],
|
||||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||||
bankAccountName: d.bankAccountName ?? '',
|
bankAccountName: d.bankAccountName ?? '',
|
||||||
bankAccountNo: d.bankAccountNo ?? '',
|
bankAccountNo: d.bankAccountNo ?? '',
|
||||||
bankBranch: d.bankBranch ?? '',
|
bankBranch: d.bankBranch ?? '',
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function savePartner() {
|
async function savePartner() {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
try {
|
try {
|
||||||
const v = await editForm.validateFields();
|
const v = await editForm.validateFields();
|
||||||
const err = commissionSumError(
|
const err = commissionSumError(
|
||||||
Number(v.orderCommissionRate ?? 0),
|
Number(v.orderCommissionRate ?? 0),
|
||||||
Number(v.redeemCommissionRate ?? 0),
|
Number(v.redeemCommissionRate ?? 0),
|
||||||
maxPartnerCommissionRate,
|
maxPartnerCommissionRate,
|
||||||
);
|
);
|
||||||
if (err) {
|
if (err) {
|
||||||
message.error(err);
|
message.error(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await request(`/admin/partners/${detail.id}`, {
|
await request(`/admin/partners/${detail.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...v,
|
...v,
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
void loadPartners();
|
void loadPartners();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = formatApiError(e);
|
const msg = formatApiError(e);
|
||||||
if (msg) message.error(msg);
|
if (msg) message.error(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSubAccount(subId: string) {
|
async function deleteSubAccount(subId: string) {
|
||||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||||
message.success('子账号已删除');
|
message.success('子账号已删除');
|
||||||
if (detail) void openPartner(detail.id);
|
if (detail) void openPartner(detail.id);
|
||||||
void loadPartners();
|
void loadPartners();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<PartnerRow> = [
|
const columns: ColumnsType<PartnerRow> = [
|
||||||
{
|
{
|
||||||
title: '区县',
|
title: '区县',
|
||||||
dataIndex: 'districtCodes',
|
dataIndex: 'districtCodes',
|
||||||
width: 160,
|
width: 160,
|
||||||
ellipsis: true,
|
|
||||||
render: (codes: string[] | null | undefined, row) =>
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
},
|
},
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
{ title: '公司名', dataIndex: 'companyName' },
|
||||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
dataIndex: 'scopeType',
|
dataIndex: 'scopeType',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '佣金',
|
title: '佣金',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||||
},
|
},
|
||||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||||
管理
|
管理
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
cityId,
|
cityId,
|
||||||
orderCommissionRate: 0,
|
orderCommissionRate: 0,
|
||||||
redeemCommissionRate: 3,
|
redeemCommissionRate: 3,
|
||||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||||
});
|
});
|
||||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
新建合伙人
|
新建合伙人
|
||||||
</Button>
|
</Button>
|
||||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="城市合伙人"
|
title="城市合伙人"
|
||||||
width={640}
|
width={640}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'info',
|
key: 'info',
|
||||||
label: '主账号',
|
label: '主账号',
|
||||||
children: (
|
children: (
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||||
<Select options={BINDING_OPTIONS} />
|
<Select options={BINDING_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="districtCodes"
|
name="districtCodes"
|
||||||
label="区县"
|
label="区县"
|
||||||
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
>
|
>
|
||||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Space style={{ width: '100%' }} size="large">
|
<Space style={{ width: '100%' }} size="large">
|
||||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Form.Item label="管仓仓库">
|
<Form.Item label="管仓仓库">
|
||||||
<Input
|
<Input
|
||||||
disabled
|
disabled
|
||||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'staff',
|
key: 'staff',
|
||||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||||
children: (
|
children: (
|
||||||
<PartnerSubAccountList
|
<PartnerSubAccountList
|
||||||
subs={detail.children ?? []}
|
subs={detail.children ?? []}
|
||||||
onAdd={() => {
|
onAdd={() => {
|
||||||
subForm.resetFields();
|
subForm.resetFields();
|
||||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
|
||||||
setSubOpen(true);
|
setSubOpen(true);
|
||||||
}}
|
}}
|
||||||
onEdit={(row) => {
|
onEdit={(row) => {
|
||||||
setSubEditId(row.id);
|
setSubEditId(row.id);
|
||||||
subEditForm.setFieldsValue({
|
subEditForm.setFieldsValue({
|
||||||
name: row.name,
|
name: row.name,
|
||||||
phone: row.phone,
|
phone: row.phone,
|
||||||
staffRole: row.staffRole ?? 'INTERNAL',
|
staffRole: row.staffRole ?? 'INTERNAL',
|
||||||
permissions: row.permissions ?? [],
|
permissions: row.permissions ?? [],
|
||||||
status: row.status,
|
status: row.status,
|
||||||
});
|
});
|
||||||
setSubEditOpen(true);
|
setSubEditOpen(true);
|
||||||
}}
|
}}
|
||||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||||
try {
|
try {
|
||||||
const v = await createForm.validateFields();
|
const v = await createForm.validateFields();
|
||||||
const err = commissionSumError(
|
const err = commissionSumError(
|
||||||
Number(v.orderCommissionRate ?? 0),
|
Number(v.orderCommissionRate ?? 0),
|
||||||
Number(v.redeemCommissionRate ?? 3),
|
Number(v.redeemCommissionRate ?? 3),
|
||||||
maxPartnerCommissionRate,
|
maxPartnerCommissionRate,
|
||||||
);
|
);
|
||||||
if (err) {
|
if (err) {
|
||||||
message.error(err);
|
message.error(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await request('/admin/partners', {
|
await request('/admin/partners', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
...v,
|
...v,
|
||||||
cityId,
|
cityId,
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
message.success('已创建');
|
message.success('已创建');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
void loadPartners();
|
void loadPartners();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = formatApiError(e);
|
const msg = formatApiError(e);
|
||||||
if (msg) message.error(msg);
|
if (msg) message.error(msg);
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<Form form={createForm} layout="vertical">
|
<Form form={createForm} layout="vertical">
|
||||||
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
|
||||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="districtCodes"
|
name="districtCodes"
|
||||||
label="区县"
|
label="区县"
|
||||||
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
|
||||||
>
|
>
|
||||||
<CityDistrictMultiSelect cityCode={cityCode} />
|
<CityDistrictMultiSelect cityCode={cityCode} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
<Space style={{ width: '100%' }} size="large">
|
<Space style={{ width: '100%' }} size="large">
|
||||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const v = await subForm.validateFields();
|
const v = await subForm.validateFields();
|
||||||
await request('/admin/partner-accounts', {
|
await request('/admin/partner-accounts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||||
});
|
});
|
||||||
message.success('已创建');
|
message.success('已创建');
|
||||||
setSubOpen(false);
|
setSubOpen(false);
|
||||||
void openPartner(detail.id);
|
void openPartner(detail.id);
|
||||||
void loadPartners();
|
void loadPartners();
|
||||||
onChanged?.();
|
onChanged?.();
|
||||||
}}>
|
}}>
|
||||||
<Form form={subForm} layout="vertical">
|
<Form form={subForm} layout="vertical">
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||||
<Select options={STAFF_ROLE_OPTIONS} />
|
<Select options={STAFF_ROLE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="permissions" label="权限">
|
<Form.Item name="permissions" label="权限">
|
||||||
<Checkbox.Group options={PERM_OPTIONS} />
|
<Checkbox.Group options={PERM_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
||||||
if (!subEditId || !detail) return;
|
if (!subEditId || !detail) return;
|
||||||
const v = await subEditForm.validateFields();
|
const v = await subEditForm.validateFields();
|
||||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
setSubEditOpen(false);
|
setSubEditOpen(false);
|
||||||
void openPartner(detail.id);
|
void openPartner(detail.id);
|
||||||
}}>
|
}}>
|
||||||
<Form form={subEditForm} layout="vertical">
|
<Form form={subEditForm} layout="vertical">
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="permissions" label="权限">
|
<Form.Item name="permissions" label="权限">
|
||||||
<Checkbox.Group options={PERM_OPTIONS} />
|
<Checkbox.Group options={PERM_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button, Form, Space, Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
import MultiImageUpload from './MultiImageUpload';
|
||||||
import OssUpload from './OssUpload';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -8,42 +7,35 @@ type Props = {
|
|||||||
bizType?: string;
|
bizType?: string;
|
||||||
/** 最多可添加张数;不传则不限制 */
|
/** 最多可添加张数;不传则不限制 */
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
|
/** Form.Item 注入 */
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品详情/轮播等多图列表。
|
||||||
|
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
|
||||||
|
*/
|
||||||
export default function DetailImageUrlList({
|
export default function DetailImageUrlList({
|
||||||
name = 'detailImageUrls',
|
|
||||||
label,
|
label,
|
||||||
bizType = 'DETAIL',
|
bizType = 'DETAIL',
|
||||||
maxCount,
|
maxCount,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{maxCount != null && (
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
{label}:支持批量上传{maxCount != null ? `,最多 ${maxCount} 张` : ''}
|
||||||
最多 {maxCount} 张{label}
|
</Typography.Text>
|
||||||
</Typography.Text>
|
<MultiImageUpload
|
||||||
)}
|
bizType={bizType}
|
||||||
<Form.List name={name}>
|
mediaType="IMAGE"
|
||||||
{(fields, { add, remove }) => (
|
maxCount={maxCount}
|
||||||
<>
|
value={value}
|
||||||
{fields.map((field) => (
|
onChange={onChange}
|
||||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
tip={`支持一次选择多张${label}`}
|
||||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
/>
|
||||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
|
||||||
</Form.Item>
|
|
||||||
{fields.length > 1 && (
|
|
||||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
))}
|
|
||||||
{(!maxCount || fields.length < maxCount) && (
|
|
||||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
|
||||||
添加{label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Image, Space, Typography, Upload, message } from 'antd';
|
||||||
|
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadProps } from 'antd';
|
||||||
|
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
|
bizType: string;
|
||||||
|
mediaType?: OssMediaType;
|
||||||
|
/** 最多张数;不传则不限制 */
|
||||||
|
maxCount?: number;
|
||||||
|
tip?: string;
|
||||||
|
accept?: string;
|
||||||
|
/** 上传按钮文案,默认「批量上传图片」 */
|
||||||
|
buttonText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUrls(value?: string[]) {
|
||||||
|
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(url: string) {
|
||||||
|
return /\.pdf(\?|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
|
||||||
|
* Form.Item 直接绑定 string[]。
|
||||||
|
*/
|
||||||
|
export default function MultiImageUpload({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
bizType,
|
||||||
|
mediaType = 'IMAGE',
|
||||||
|
maxCount,
|
||||||
|
tip,
|
||||||
|
accept = 'image/*',
|
||||||
|
buttonText = '批量上传图片',
|
||||||
|
}: Props) {
|
||||||
|
const urls = normalizeUrls(value);
|
||||||
|
const urlsRef = useRef(urls);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const batchBuf = useRef<File[]>([]);
|
||||||
|
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const uploadChain = useRef(Promise.resolve());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
urlsRef.current = urls;
|
||||||
|
}, [urls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (batchTimer.current) clearTimeout(batchTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
|
||||||
|
const canAdd = remaining > 0;
|
||||||
|
|
||||||
|
async function uploadBatch(files: File[]) {
|
||||||
|
const current = urlsRef.current;
|
||||||
|
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
|
||||||
|
const picked = files.slice(0, room);
|
||||||
|
if (!picked.length) {
|
||||||
|
message.warning(maxCount != null ? `最多 ${maxCount} 张` : '无法上传');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length > picked.length) {
|
||||||
|
message.warning(`已达上限,仅上传前 ${picked.length} 张`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const appended: string[] = [];
|
||||||
|
let fail = 0;
|
||||||
|
try {
|
||||||
|
for (const file of picked) {
|
||||||
|
try {
|
||||||
|
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||||
|
appended.push(result.url);
|
||||||
|
} catch {
|
||||||
|
fail += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appended.length) {
|
||||||
|
// 始终基于最新列表追加,避免并行上传互相覆盖
|
||||||
|
const next = [...urlsRef.current, ...appended];
|
||||||
|
urlsRef.current = next;
|
||||||
|
onChangeRef.current?.(next);
|
||||||
|
message.success(`成功上传 ${appended.length} 张${fail ? `,失败 ${fail} 张` : ''}`);
|
||||||
|
} else if (fail) {
|
||||||
|
message.error('上传失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueUploadBatch(files: File[]) {
|
||||||
|
uploadChain.current = uploadChain.current
|
||||||
|
.then(() => uploadBatch(files))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushBatch() {
|
||||||
|
if (batchTimer.current) {
|
||||||
|
clearTimeout(batchTimer.current);
|
||||||
|
batchTimer.current = null;
|
||||||
|
}
|
||||||
|
if (!batchBuf.current.length) return;
|
||||||
|
const files = [...batchBuf.current];
|
||||||
|
batchBuf.current = [];
|
||||||
|
enqueueUploadBatch(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||||
|
batchBuf.current.push(file as File);
|
||||||
|
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
|
||||||
|
if (batchTimer.current) clearTimeout(batchTimer.current);
|
||||||
|
batchTimer.current = setTimeout(() => {
|
||||||
|
flushBatch();
|
||||||
|
}, 80);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
const next = urlsRef.current.filter((_, i) => i !== index);
|
||||||
|
urlsRef.current = next;
|
||||||
|
onChangeRef.current?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{tip ??
|
||||||
|
(maxCount != null
|
||||||
|
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
|
||||||
|
: '支持一次选择多张批量上传')}
|
||||||
|
{maxCount != null ? `(已选 ${urls.length}/${maxCount})` : urls.length ? `(已选 ${urls.length})` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
{urls.length > 0 ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={12}>
|
||||||
|
{urls.map((url, index) => (
|
||||||
|
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
|
||||||
|
{isPdf(url) ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
|
||||||
|
<span>PDF</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => removeAt(index)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'rgba(255,255,255,0.85)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
accept={accept}
|
||||||
|
multiple
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
disabled={uploading || !canAdd}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
|
||||||
|
{canAdd ? buttonText : '已达上限'}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Empty,
|
||||||
|
Image,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Timeline,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
|
||||||
|
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
orderId: string | null;
|
||||||
|
orderNo?: string | null;
|
||||||
|
orderStatus?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function providerLabel(provider?: string | null, company?: string | null) {
|
||||||
|
if (company) return company;
|
||||||
|
if (!provider) return '—';
|
||||||
|
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
|
||||||
|
if (isXfxProviderCode(provider)) return '小飞侠';
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
|
||||||
|
return [...nodes].sort((a, b) => {
|
||||||
|
const ta = new Date(a.createTime).getTime();
|
||||||
|
const tb = new Date(b.createTime).getTime();
|
||||||
|
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||||||
|
if (Number.isNaN(ta)) return 1;
|
||||||
|
if (Number.isNaN(tb)) return -1;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderTrackDrawer({
|
||||||
|
open,
|
||||||
|
orderId,
|
||||||
|
orderNo,
|
||||||
|
orderStatus,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [track, setTrack] = useState<OrderTrackDto | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!orderId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
|
||||||
|
setTrack(data);
|
||||||
|
} catch (e) {
|
||||||
|
setTrack(null);
|
||||||
|
message.error(e instanceof Error ? e.message : '加载路由失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !orderId) {
|
||||||
|
setTrack(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
}, [open, orderId, load]);
|
||||||
|
|
||||||
|
const nodes = useMemo(
|
||||||
|
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
|
||||||
|
[track?.nodes],
|
||||||
|
);
|
||||||
|
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
||||||
|
orderStatus || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title="物流路由"
|
||||||
|
width={520}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
extra={
|
||||||
|
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{(orderNo || track?.trackingNo || track?.provider) && (
|
||||||
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
|
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
|
||||||
|
<Descriptions.Item label="配送方式">
|
||||||
|
{providerLabel(track?.provider, track?.logisticsCompany)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
|
||||||
|
{track?.manualQueryUrl ? (
|
||||||
|
<Descriptions.Item label="物流查询">
|
||||||
|
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||||
|
打开物流查询
|
||||||
|
</a>
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{track?.estimatedArrival?.arriveTime ? (
|
||||||
|
<Descriptions.Item label="预计送达">
|
||||||
|
{fmtTime(track.estimatedArrival.arriveTime)}
|
||||||
|
{track.estimatedArrival.siteName
|
||||||
|
? `(${track.estimatedArrival.siteName})`
|
||||||
|
: ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
签收照片
|
||||||
|
</Typography.Title>
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap>
|
||||||
|
{track.signPhotoUrls.map((url) => (
|
||||||
|
<Image
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
物流动态
|
||||||
|
</Typography.Title>
|
||||||
|
{nodes.length > 0 ? (
|
||||||
|
<Timeline
|
||||||
|
items={nodes.map((node, index) => {
|
||||||
|
const isLatest = index === nodes.length - 1;
|
||||||
|
return {
|
||||||
|
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
{node.statusName ? (
|
||||||
|
<Typography.Text strong style={{ display: 'block' }}>
|
||||||
|
{node.statusName}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{node.trackInfo || '—'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{fmtTime(node.createTime)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
track?.manualQueryUrl
|
||||||
|
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||||
|
: '暂无路由信息,请稍后刷新'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Image, Input, Space, Upload, message } from 'antd';
|
import { Button, Image, Input, Modal, Space, Upload, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||||
|
|
||||||
@@ -15,7 +15,13 @@ type OssUploadProps = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MAX_MB = 10;
|
function isImageUrl(url: string) {
|
||||||
|
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfUrl(url: string) {
|
||||||
|
return /\.pdf(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
export default function OssUpload({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
@@ -24,22 +30,16 @@ export default function OssUpload({
|
|||||||
bizType,
|
bizType,
|
||||||
mediaType = 'IMAGE',
|
mediaType = 'IMAGE',
|
||||||
accept,
|
accept,
|
||||||
maxSizeMb = DEFAULT_MAX_MB,
|
|
||||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||||
}: OssUploadProps) {
|
}: OssUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||||
|
|
||||||
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||||
const raw = file as File;
|
const raw = file as File;
|
||||||
if (raw.size > maxSizeMb * 1024 * 1024) {
|
|
||||||
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
|
|
||||||
message.error(err.message);
|
|
||||||
onError?.(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||||
@@ -56,6 +56,57 @@ export default function OssUpload({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filePreview =
|
||||||
|
value && mediaType === 'FILE' ? (
|
||||||
|
isImageUrl(value) ? (
|
||||||
|
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
) : isPdfUrl(value) ? (
|
||||||
|
<Space direction="vertical" size={8}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
color: '#cf1322',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePdfOutlined style={{ fontSize: 36 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF 合同</span>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
|
||||||
|
新窗口打开
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Modal
|
||||||
|
title="签约合同预览"
|
||||||
|
open={pdfPreviewOpen}
|
||||||
|
onCancel={() => setPdfPreviewOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width="90vw"
|
||||||
|
styles={{ body: { height: '75vh', padding: 0 } }}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
|
||||||
|
</Modal>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
|
||||||
|
打开已上传文件
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
{value && mediaType === 'IMAGE' && (
|
{value && mediaType === 'IMAGE' && (
|
||||||
@@ -64,6 +115,7 @@ export default function OssUpload({
|
|||||||
{value && mediaType === 'VIDEO' && (
|
{value && mediaType === 'VIDEO' && (
|
||||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||||
)}
|
)}
|
||||||
|
{filePreview}
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Upload
|
<Upload
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
import MultiImageUpload from './MultiImageUpload';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 套餐多图:批量上传,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
|
||||||
|
export default function PackageImagesUpload({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<MultiImageUpload
|
||||||
|
bizType="STORE_PACKAGE"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,92 +1,91 @@
|
|||||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
PARTNER_PERMISSION_LABELS,
|
PARTNER_PERMISSION_LABELS,
|
||||||
PARTNER_STAFF_ROLE_LABELS,
|
PARTNER_STAFF_ROLE_LABELS,
|
||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
export type PartnerSubAccountRow = {
|
export type PartnerSubAccountRow = {
|
||||||
id: string;
|
id: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
name: string;
|
name: string;
|
||||||
staffRole?: string;
|
staffRole?: string;
|
||||||
permissions?: string[];
|
permissions?: string[];
|
||||||
status: string;
|
status: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatPermissions(permissions?: string[]) {
|
function formatPermissions(permissions?: string[]) {
|
||||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
subs: PartnerSubAccountRow[];
|
subs: PartnerSubAccountRow[];
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||||
onDelete: (subId: string) => void;
|
onDelete: (subId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'staffRole',
|
dataIndex: 'staffRole',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) =>
|
render: (v) =>
|
||||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (s) => (
|
render: (s) => (
|
||||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '权限',
|
title: '权限',
|
||||||
dataIndex: 'permissions',
|
dataIndex: 'permissions',
|
||||||
ellipsis: true,
|
|
||||||
render: (p: string[] | undefined) => formatPermissions(p),
|
render: (p: string[] | undefined) => formatPermissions(p),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0}>
|
<Space size={0}>
|
||||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||||
<Button type="link" size="small" danger>
|
<Button type="link" size="small" danger>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Button size="small" type="primary" onClick={onAdd}>
|
<Button size="small" type="primary" onClick={onAdd}>
|
||||||
添加子账号
|
添加子账号
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={subs}
|
dataSource={subs}
|
||||||
locale={{ emptyText: '暂无子账号' }}
|
locale={{ emptyText: '暂无子账号' }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,616 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
||||||
|
import OssUpload from './OssUpload';
|
||||||
|
|
||||||
|
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
||||||
|
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
||||||
|
type SkuRow = {
|
||||||
|
id?: string;
|
||||||
|
specValueIds: string[];
|
||||||
|
skuCode?: string;
|
||||||
|
barcode69: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount?: number;
|
||||||
|
status: string;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
saleUnit: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
specText?: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cartesian(attrs: SpecAttr[]): string[][] {
|
||||||
|
if (!attrs.length) return [[]];
|
||||||
|
return attrs.reduce<string[][]>(
|
||||||
|
(acc, attr) => {
|
||||||
|
const next: string[][] = [];
|
||||||
|
const values = attr.values.filter((v) => v.name?.trim());
|
||||||
|
for (const prev of acc) {
|
||||||
|
for (const v of values) {
|
||||||
|
const vid = v.id || `__new__:${attr.name}:${v.name}`;
|
||||||
|
next.push([...prev, vid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next.length ? next : acc;
|
||||||
|
},
|
||||||
|
[[]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyOf(ids: string[]) {
|
||||||
|
return [...ids].sort().join('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
productId: string;
|
||||||
|
initialAttrs: SpecAttr[];
|
||||||
|
initialSkus: SkuRow[];
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||||
|
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
||||||
|
initialAttrs.length
|
||||||
|
? initialAttrs
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const [skus, setSkus] = useState<SkuRow[]>(
|
||||||
|
initialSkus.length
|
||||||
|
? initialSkus.map((s) => ({
|
||||||
|
...s,
|
||||||
|
specValueIds: s.specValueIds ?? [],
|
||||||
|
status: s.status || 'ON_SALE',
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||||
|
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||||
|
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
imageUrl: s.imageUrl ?? '',
|
||||||
|
}))
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
specValueIds: [],
|
||||||
|
barcode69: '',
|
||||||
|
price: 0,
|
||||||
|
status: 'ON_SALE',
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
imageUrl: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||||
|
|
||||||
|
function regenerateMatrix() {
|
||||||
|
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
|
||||||
|
const next: SkuRow[] = combos.map((ids, i) => {
|
||||||
|
const existing = byKey.get(keyOf(ids));
|
||||||
|
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
|
||||||
|
return {
|
||||||
|
specValueIds: ids,
|
||||||
|
barcode69: '',
|
||||||
|
price: skus[0]?.price ?? 0,
|
||||||
|
benefitAmount: skus[0]?.benefitAmount,
|
||||||
|
status: 'DRAFT',
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: i === 0,
|
||||||
|
sortOrder: i,
|
||||||
|
imageUrl: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
||||||
|
setSkus(next.length ? next : skus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSku(index: number, patch: Partial<SkuRow>) {
|
||||||
|
setSkus((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = { ...next[index], ...patch };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
|
||||||
|
const specsRes = await request<{
|
||||||
|
specAttrs: SpecAttr[];
|
||||||
|
skus: SkuRow[];
|
||||||
|
}>(`/admin/products/${productId}/specs`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
attrs: attrs.map((a, i) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name.trim(),
|
||||||
|
sortOrder: i,
|
||||||
|
values: a.values
|
||||||
|
.filter((v) => v.name.trim())
|
||||||
|
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 映射临时 id → 真实 id
|
||||||
|
const nameToId = new Map<string, string>();
|
||||||
|
for (const a of specsRes.specAttrs ?? []) {
|
||||||
|
for (const v of a.values ?? []) {
|
||||||
|
nameToId.set(`${a.name}:${v.name}`, v.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolveIds = (ids: string[]) =>
|
||||||
|
ids.map((id) => {
|
||||||
|
if (!id.startsWith('__new__:')) return id;
|
||||||
|
const [, attrName, valueName] = id.split(':');
|
||||||
|
const real = nameToId.get(`${attrName}:${valueName}`);
|
||||||
|
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
|
||||||
|
return real;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payloadSkus = skus.map((s, i) => ({
|
||||||
|
id: s.id,
|
||||||
|
specValueIds: resolveIds(s.specValueIds ?? []),
|
||||||
|
barcode69: s.barcode69.trim(),
|
||||||
|
price: s.price,
|
||||||
|
benefitAmount: s.benefitAmount,
|
||||||
|
status: s.status,
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit,
|
||||||
|
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
sortOrder: i,
|
||||||
|
imageUrl: s.imageUrl?.trim() || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const barcodes = payloadSkus.map((row) => row.barcode69);
|
||||||
|
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
|
||||||
|
if (new Set(barcodes).size !== barcodes.length) {
|
||||||
|
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
|
||||||
|
}
|
||||||
|
|
||||||
|
await request(`/admin/products/${productId}/skus`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ skus: payloadSkus }),
|
||||||
|
});
|
||||||
|
message.success('规格与 SKU 已保存');
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editing = editIndex != null ? skus[editIndex] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码,并可单独上传主图。
|
||||||
|
点「填写」在弹窗中编辑。先配置销售规格轴(如「包装」),再生成 SKU 矩阵。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Typography.Title level={5}>规格轴</Typography.Title>
|
||||||
|
{attrs.map((attr, ai) => (
|
||||||
|
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||||
|
<Space align="start" style={{ width: '100%' }} wrap>
|
||||||
|
<Input
|
||||||
|
style={{ width: 140 }}
|
||||||
|
placeholder="规格名"
|
||||||
|
value={attr.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, name: e.target.value };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<MinusCircleOutlined />}
|
||||||
|
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{attr.values.map((val, vi) => (
|
||||||
|
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="规格值"
|
||||||
|
value={val.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...attrs];
|
||||||
|
const values = [...attr.values];
|
||||||
|
values[vi] = { ...val, name: e.target.value };
|
||||||
|
next[ai] = { ...attr, values };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<MinusCircleOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加规格值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={attrs.length >= 3}
|
||||||
|
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
|
||||||
|
>
|
||||||
|
添加规格轴
|
||||||
|
</Button>
|
||||||
|
<Button onClick={regenerateMatrix}>按规格生成 SKU 矩阵</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Title level={5}>SKU 列表</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey={(_, i) => String(i)}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1240 }}
|
||||||
|
dataSource={skus}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '规格',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SKU',
|
||||||
|
width: 110,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{row.skuCode || '保存后自动生成'}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <span>69码 <Typography.Text type="danger">*</Typography.Text></span>,
|
||||||
|
width: 180,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Input
|
||||||
|
placeholder="本规格独立 69 码"
|
||||||
|
value={row.barcode69}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, barcode69: e.target.value };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '售价',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.price}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, price: Number(v) || 0 };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '权益',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.benefitAmount}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.status}
|
||||||
|
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
onChange={(status) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, status };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单位',
|
||||||
|
width: 160,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
style={{ width: 80 }}
|
||||||
|
value={row.saleUnit}
|
||||||
|
options={[
|
||||||
|
{ value: 'BOTTLE', label: '瓶' },
|
||||||
|
{ value: 'BOX', label: '箱' },
|
||||||
|
]}
|
||||||
|
onChange={(saleUnit) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = {
|
||||||
|
...row,
|
||||||
|
saleUnit,
|
||||||
|
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
|
||||||
|
};
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{row.saleUnit === 'BOX' ? (
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
style={{ width: 70 }}
|
||||||
|
value={row.bottlesPerUnit}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '履约',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="线上"
|
||||||
|
unCheckedChildren="线上"
|
||||||
|
checked={row.allowOnlinePurchase}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = {
|
||||||
|
...row,
|
||||||
|
allowOnlinePurchase: checked,
|
||||||
|
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
|
||||||
|
};
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="跨城"
|
||||||
|
unCheckedChildren="跨城"
|
||||||
|
disabled={!row.allowOnlinePurchase}
|
||||||
|
checked={row.allowCrossCityDelivery}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, allowCrossCityDelivery: checked };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="现场"
|
||||||
|
unCheckedChildren="现场"
|
||||||
|
checked={row.allowOnSitePickup}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, allowOnSitePickup: checked };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '默认',
|
||||||
|
width: 70,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Radio
|
||||||
|
checked={row.isDefault}
|
||||||
|
onChange={() => {
|
||||||
|
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主图',
|
||||||
|
width: 72,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.imageUrl ? (
|
||||||
|
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '编辑',
|
||||||
|
width: 80,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
|
||||||
|
填写
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
|
||||||
|
保存规格与 SKU
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
|
||||||
|
open={editIndex != null}
|
||||||
|
onCancel={() => setEditIndex(null)}
|
||||||
|
onOk={() => setEditIndex(null)}
|
||||||
|
okText="完成"
|
||||||
|
width={560}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{editing && editIndex != null ? (
|
||||||
|
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">SKU</Typography.Text>
|
||||||
|
<div>{editing.skuCode || '保存后自动生成'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>69 码 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
|
||||||
|
<Input
|
||||||
|
placeholder="本规格独立 69 码"
|
||||||
|
value={editing.barcode69}
|
||||||
|
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>主图</Typography.Text>
|
||||||
|
<OssUpload
|
||||||
|
bizType="COVER"
|
||||||
|
value={editing.imageUrl || ''}
|
||||||
|
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>售价</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={editing.price}
|
||||||
|
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>权益</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={editing.benefitAmount}
|
||||||
|
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>状态</Typography.Text>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={editing.status}
|
||||||
|
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
onChange={(status) => patchSku(editIndex, { status })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
style={{ width: 100 }}
|
||||||
|
value={editing.saleUnit}
|
||||||
|
options={[
|
||||||
|
{ value: 'BOTTLE', label: '瓶' },
|
||||||
|
{ value: 'BOX', label: '箱' },
|
||||||
|
]}
|
||||||
|
onChange={(saleUnit) =>
|
||||||
|
patchSku(editIndex, {
|
||||||
|
saleUnit,
|
||||||
|
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{editing.saleUnit === 'BOX' ? (
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
value={editing.bottlesPerUnit}
|
||||||
|
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="线上"
|
||||||
|
unCheckedChildren="线上"
|
||||||
|
checked={editing.allowOnlinePurchase}
|
||||||
|
onChange={(checked) =>
|
||||||
|
patchSku(editIndex, {
|
||||||
|
allowOnlinePurchase: checked,
|
||||||
|
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="跨城"
|
||||||
|
unCheckedChildren="跨城"
|
||||||
|
disabled={!editing.allowOnlinePurchase}
|
||||||
|
checked={editing.allowCrossCityDelivery}
|
||||||
|
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="现场"
|
||||||
|
unCheckedChildren="现场"
|
||||||
|
checked={editing.allowOnSitePickup}
|
||||||
|
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="默认"
|
||||||
|
unCheckedChildren="默认"
|
||||||
|
checked={editing.isDefault}
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (!checked) return;
|
||||||
|
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||||
const [addressDetail, setAddressDetail] = useState('');
|
const [addressDetail, setAddressDetail] = useState('');
|
||||||
const [productId, setProductId] = useState<string>();
|
const [productId, setProductId] = useState<string>();
|
||||||
|
const [skuId, setSkuId] = useState<string>();
|
||||||
const [quantity, setQuantity] = useState(2);
|
const [quantity, setQuantity] = useState(2);
|
||||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||||
@@ -87,9 +88,37 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
const selectedSku =
|
||||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
skuOptions.find((s) => s.id === skuId) ||
|
||||||
|
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
||||||
|
skuOptions[0];
|
||||||
|
const allowOnline = selectedSku
|
||||||
|
? selectedSku.allowOnlinePurchase !== false
|
||||||
|
: selectedProduct
|
||||||
|
? selectedProduct.allowOnlinePurchase !== false
|
||||||
|
: true;
|
||||||
|
const allowOnSite = selectedSku
|
||||||
|
? !!selectedSku.allowOnSitePickup
|
||||||
|
: !!selectedProduct?.allowOnSitePickup;
|
||||||
|
const allowCrossCity = selectedSku
|
||||||
|
? selectedSku.allowCrossCityDelivery !== false
|
||||||
|
: selectedProduct
|
||||||
|
? selectedProduct.allowCrossCityDelivery !== false
|
||||||
|
: true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !selectedProduct) return;
|
||||||
|
const def =
|
||||||
|
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
||||||
|
skuOptions.find((s) => s.isDefault) ||
|
||||||
|
skuOptions[0];
|
||||||
|
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
||||||
|
setSkuId(def.id);
|
||||||
|
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, productId, selectedProduct?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !selectedProduct) return;
|
if (!open || !selectedProduct) return;
|
||||||
@@ -115,6 +144,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
deliveryMode,
|
deliveryMode,
|
||||||
|
skuId: skuId || undefined,
|
||||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
}),
|
}),
|
||||||
@@ -124,7 +154,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
.finally(() => setPreviewLoading(false));
|
.finally(() => setPreviewLoading(false));
|
||||||
}, 300);
|
}, 300);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
|
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||||
|
|
||||||
useEffect(() => () => stopPoll(), []);
|
useEffect(() => () => stopPoll(), []);
|
||||||
|
|
||||||
@@ -135,6 +165,8 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
setRegionCodes([]);
|
setRegionCodes([]);
|
||||||
setAddressDetail('');
|
setAddressDetail('');
|
||||||
setQuantity(2);
|
setQuantity(2);
|
||||||
|
setProductId(undefined);
|
||||||
|
setSkuId(undefined);
|
||||||
setPromoCodeId(undefined);
|
setPromoCodeId(undefined);
|
||||||
setDeliveryMode('ADDRESS');
|
setDeliveryMode('ADDRESS');
|
||||||
setAutoReceive(false);
|
setAutoReceive(false);
|
||||||
@@ -202,6 +234,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
productId: productId!,
|
productId: productId!,
|
||||||
quantity,
|
quantity,
|
||||||
promoCodeId: promoCodeId || undefined,
|
promoCodeId: promoCodeId || undefined,
|
||||||
|
skuId: skuId || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
@@ -217,6 +250,12 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
||||||
});
|
});
|
||||||
|
if (pay.mode === 'mock') {
|
||||||
|
message.success(`支付成功:${order.orderNo}`);
|
||||||
|
resetForm();
|
||||||
|
onSuccess({ id: order.id, orderNo: order.orderNo });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCodeUrl(pay.codeUrl ?? null);
|
setCodeUrl(pay.codeUrl ?? null);
|
||||||
startPoll(order.id);
|
startPoll(order.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -311,7 +350,10 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
loading={loadingOptions}
|
loading={loadingOptions}
|
||||||
placeholder="选择商品"
|
placeholder="选择商品"
|
||||||
value={productId}
|
value={productId}
|
||||||
onChange={setProductId}
|
onChange={(id) => {
|
||||||
|
setProductId(id);
|
||||||
|
setSkuId(undefined);
|
||||||
|
}}
|
||||||
options={(options?.products ?? []).map((p) => ({
|
options={(options?.products ?? []).map((p) => ({
|
||||||
value: p.id,
|
value: p.id,
|
||||||
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||||
@@ -319,7 +361,25 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="数量" required>
|
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||||
|
<Form.Item label="规格" required>
|
||||||
|
<Select
|
||||||
|
placeholder="选择规格"
|
||||||
|
value={skuId}
|
||||||
|
onChange={(id) => {
|
||||||
|
setSkuId(id);
|
||||||
|
const sku = skuOptions.find((s) => s.id === id);
|
||||||
|
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
||||||
|
}}
|
||||||
|
options={skuOptions.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={1}
|
min={1}
|
||||||
value={quantity}
|
value={quantity}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { Descriptions } from 'antd';
|
||||||
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
function maskPhone(phone: string | null | undefined) {
|
||||||
|
if (!phone || phone.length < 7) return phone ?? '—';
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
/** 列表页已脱敏展示时可设为 false,详情抽屉展示完整手机号 */
|
||||||
|
maskUserPhone?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
||||||
|
const user = detail.user as
|
||||||
|
| { userNo?: string; nickname?: string | null; phone?: string | null }
|
||||||
|
| undefined;
|
||||||
|
const store = detail.store as
|
||||||
|
| {
|
||||||
|
name?: string;
|
||||||
|
cityName?: string;
|
||||||
|
address?: string;
|
||||||
|
partnerAccount?: { companyName?: string };
|
||||||
|
primaryAccount?: {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
|
||||||
|
const channel = detail.channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="核销号">{String(detail.redeemNo ?? '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="方式">{REDEEM_CHANNEL_LABELS[channel as RedeemChannel]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销额">¥{Number(detail.amount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户昵称">{user?.nickname?.trim() || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户手机">
|
||||||
|
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店">
|
||||||
|
{store?.name ?? '—'}
|
||||||
|
{store?.cityName ? `(${store.cityName})` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收款户名">
|
||||||
|
{store?.primaryAccount?.bankAccountName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收款账号">
|
||||||
|
{store?.primaryAccount?.bankAccountNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">
|
||||||
|
{store?.primaryAccount?.bankBranch || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||||
|
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
|
||||||
|
<Descriptions.Item label="券分摊">
|
||||||
|
{(
|
||||||
|
detail.allocations as Array<{
|
||||||
|
couponNo?: string;
|
||||||
|
orderNo?: string | null;
|
||||||
|
amount?: number;
|
||||||
|
}>
|
||||||
|
)
|
||||||
|
.map((a, idx) => {
|
||||||
|
const role = idx === 0 ? '主' : '次';
|
||||||
|
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
||||||
|
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
||||||
|
}`;
|
||||||
|
})
|
||||||
|
.join(';')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{detail.payout ? (
|
||||||
|
<Descriptions.Item label="门店结算单">
|
||||||
|
¥{Number((detail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||||
|
{' / '}
|
||||||
|
{String((detail.payout as { status?: string }).status ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{detail.rating ? (
|
||||||
|
<Descriptions.Item label="评价">
|
||||||
|
服务 {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||||
|
{(detail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
</Descriptions>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Image, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
||||||
|
import type {
|
||||||
|
StorePackageAuditDetailDto,
|
||||||
|
StorePackageItemDto,
|
||||||
|
StorePackageViewDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
const HQ_PACKAGE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: '待审核',
|
||||||
|
APPROVED: '已通过',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
|
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||||
|
const name = String(pkg.name ?? '').trim();
|
||||||
|
return name ? `name:${name}` : `idx:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||||
|
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||||||
|
|
||||||
|
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||||
|
for (let i = m - 1; i >= 0; i--) {
|
||||||
|
for (let j = n - 1; j >= 0; j--) {
|
||||||
|
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < m && j < n) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
raw.push({ type: 'equal', text: a[i] });
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
raw.push({ type: 'delete', text: a[i] });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
raw.push({ type: 'insert', text: b[j] });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
||||||
|
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
||||||
|
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||||
|
for (const s of raw) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && last.type === s.type) last.text += s.text;
|
||||||
|
else merged.push({ ...s });
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldChanges(
|
||||||
|
live: StorePackageItemDto | StorePackageViewDto,
|
||||||
|
proposed: StorePackageItemDto | StorePackageViewDto,
|
||||||
|
): FieldChange[] {
|
||||||
|
const changes: FieldChange[] = [];
|
||||||
|
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||||||
|
const pushText = (label: string, oldV: string, newV: string) => {
|
||||||
|
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||||||
|
};
|
||||||
|
const pushValue = (label: string, oldV: string, newV: string) => {
|
||||||
|
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
||||||
|
};
|
||||||
|
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
||||||
|
pushText('套餐名称', text(live.name), text(proposed.name));
|
||||||
|
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
||||||
|
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
||||||
|
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
||||||
|
const liveImgs = normalizeStorePackageImageUrls(live);
|
||||||
|
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
||||||
|
if (imageSignature(live) !== imageSignature(proposed)) {
|
||||||
|
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||||
|
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||||
|
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||||
|
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
||||||
|
const rows: Array<{
|
||||||
|
key: string;
|
||||||
|
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||||
|
live?: StorePackageViewDto;
|
||||||
|
proposed?: StorePackageItemDto;
|
||||||
|
changes?: FieldChange[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const l = liveMap.get(key);
|
||||||
|
const p = proposedMap.get(key);
|
||||||
|
if (l && !p) {
|
||||||
|
rows.push({ key, change: 'removed', live: l });
|
||||||
|
} else if (!l && p) {
|
||||||
|
rows.push({ key, change: 'added', proposed: p });
|
||||||
|
} else if (l && p) {
|
||||||
|
const changed =
|
||||||
|
l.price !== p.price ||
|
||||||
|
l.dishes !== p.dishes ||
|
||||||
|
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||||
|
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||||
|
imageSignature(l) !== imageSignature(p);
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
change: changed ? 'changed' : 'unchanged',
|
||||||
|
live: l,
|
||||||
|
proposed: p,
|
||||||
|
changes: changed ? fieldChanges(l, p) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_LABELS = {
|
||||||
|
added: { text: '新增', color: 'green' },
|
||||||
|
removed: { text: '删除', color: 'red' },
|
||||||
|
changed: { text: '变更', color: 'orange' },
|
||||||
|
unchanged: { text: '未变', color: 'default' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||||
|
const segs = diffText(oldText, newText);
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
|
<Typography.Text type="secondary">原:</Typography.Text>
|
||||||
|
{segs
|
||||||
|
.filter((s) => s.type !== 'insert')
|
||||||
|
.map((s, idx) =>
|
||||||
|
s.type === 'delete' ? (
|
||||||
|
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
||||||
|
{s.text || '(空)'}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
|
<Typography.Text type="secondary">新:</Typography.Text>
|
||||||
|
{segs
|
||||||
|
.filter((s) => s.type !== 'delete')
|
||||||
|
.map((s, idx) =>
|
||||||
|
s.type === 'insert' ? (
|
||||||
|
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
||||||
|
{s.text || '(空)'}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageDetailCard({
|
||||||
|
title,
|
||||||
|
pkg,
|
||||||
|
change,
|
||||||
|
changes,
|
||||||
|
imageSize = 72,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||||
|
change?: keyof typeof CHANGE_LABELS;
|
||||||
|
changes?: FieldChange[];
|
||||||
|
imageSize?: number;
|
||||||
|
}) {
|
||||||
|
const images = normalizeStorePackageImageUrls(pkg);
|
||||||
|
const meta = change ? CHANGE_LABELS[change] : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: 12,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space style={{ marginBottom: 8 }} wrap>
|
||||||
|
{title ? <Typography.Text type="secondary">{title}</Typography.Text> : null}
|
||||||
|
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<strong>{pkg.name}</strong>
|
||||||
|
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||||
|
</div>
|
||||||
|
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||||
|
{pkg.dishes || '—'}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{pkg.usableTime ? (
|
||||||
|
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 4 }}>
|
||||||
|
可用时间:{pkg.usableTime}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
{pkg.otherNotes ? (
|
||||||
|
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||||
|
其他说明:{pkg.otherNotes}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
{images.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{images.map((url) => (
|
||||||
|
<Image key={url} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||||
|
)}
|
||||||
|
{changes && changes.length ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: '#fff7e6',
|
||||||
|
border: '1px solid #ffe7ba',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||||
|
变更明细
|
||||||
|
</Typography.Text>
|
||||||
|
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||||
|
{changes.map((c) => (
|
||||||
|
<li key={c.label} style={{ marginBottom: 6 }}>
|
||||||
|
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
||||||
|
{c.kind === 'text' ? (
|
||||||
|
<TextDiff oldText={c.old} newText={c.now} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography.Text delete type="secondary">
|
||||||
|
{c.old}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary"> → </Typography.Text>
|
||||||
|
<Typography.Text strong>{c.now}</Typography.Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorePackageAuditPanelProps = {
|
||||||
|
requestId: string;
|
||||||
|
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
|
||||||
|
showActions?: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
onAudited?: () => void;
|
||||||
|
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 套餐变更对比 + 通过/驳回(审核通知页与门店详情抽屉共用) */
|
||||||
|
export default function StorePackageAuditPanel({
|
||||||
|
requestId,
|
||||||
|
showActions = true,
|
||||||
|
fullscreen = false,
|
||||||
|
onAudited,
|
||||||
|
onDetailLoaded,
|
||||||
|
}: StorePackageAuditPanelProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const [auditing, setAuditing] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${requestId}`);
|
||||||
|
setDetail(data);
|
||||||
|
onDetailLoaded?.(data);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载套餐审核详情失败');
|
||||||
|
setDetail(null);
|
||||||
|
onDetailLoaded?.(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [requestId]);
|
||||||
|
|
||||||
|
async function audit(action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||||
|
if (!detail) return;
|
||||||
|
setAuditing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/store-package-audits/${detail.id}/audit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason: reason } : { action }),
|
||||||
|
});
|
||||||
|
message.success(action === 'APPROVE' ? '套餐已通过' : '套餐已驳回');
|
||||||
|
notifyPackageAuditChanged();
|
||||||
|
onAudited?.();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setAuditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !detail) {
|
||||||
|
return <Typography.Text type="secondary">加载套餐变更…</Typography.Text>;
|
||||||
|
}
|
||||||
|
if (!detail) {
|
||||||
|
return <Typography.Text type="secondary">暂无套餐审核详情</Typography.Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffRows = diffPackages(detail.livePackages ?? [], detail.packages ?? []);
|
||||||
|
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||||
|
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||||
|
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||||
|
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||||
|
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||||
|
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }} wrap>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status}</Tag>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{showActions && detail.status === 'PENDING' ? (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" loading={auditing} onClick={() => void audit('APPROVE')}>
|
||||||
|
通过套餐
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
loading={auditing}
|
||||||
|
onClick={() => {
|
||||||
|
setRejectReason('');
|
||||||
|
setRejectOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
驳回套餐
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
{detail.rejectReason ? (
|
||||||
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="green">新增 {addedCount}</Tag>
|
||||||
|
<Tag color="red">删除 {removedCount}</Tag>
|
||||||
|
<Tag color="orange">变更 {changedCount}</Tag>
|
||||||
|
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<div className="admin-package-audit-cols">
|
||||||
|
<div className="admin-package-audit-col">
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
线上已审核套餐
|
||||||
|
</Typography.Title>
|
||||||
|
{(detail.livePackages ?? []).length ? (
|
||||||
|
(detail.livePackages ?? []).map((pkg, index) => (
|
||||||
|
<PackageDetailCard
|
||||||
|
key={`live-${packageKey(pkg, index)}`}
|
||||||
|
title={`套餐 ${index + 1}`}
|
||||||
|
pkg={pkg}
|
||||||
|
change={changeByKey.get(packageKey(pkg, index))}
|
||||||
|
imageSize={fullscreen ? 160 : 72}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-package-audit-col">
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
待审核套餐
|
||||||
|
</Typography.Title>
|
||||||
|
{(detail.packages ?? []).length ? (
|
||||||
|
(detail.packages ?? []).map((pkg, index) => (
|
||||||
|
<PackageDetailCard
|
||||||
|
key={`pending-${packageKey(pkg, index)}`}
|
||||||
|
title={`套餐 ${index + 1}`}
|
||||||
|
pkg={pkg}
|
||||||
|
change={changeByKey.get(packageKey(pkg, index))}
|
||||||
|
changes={changesByKey.get(packageKey(pkg, index))}
|
||||||
|
imageSize={fullscreen ? 160 : 72}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="驳回套餐变更"
|
||||||
|
open={rejectOpen}
|
||||||
|
confirmLoading={auditing}
|
||||||
|
onCancel={() => setRejectOpen(false)}
|
||||||
|
onOk={() => {
|
||||||
|
if (!rejectReason.trim()) {
|
||||||
|
message.warning('请填写驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={rejectReason}
|
||||||
|
placeholder="驳回原因"
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供门店详情抽屉 extra 调用 */
|
||||||
|
export async function auditStorePackageRequest(
|
||||||
|
requestId: string,
|
||||||
|
action: 'APPROVE' | 'REJECT',
|
||||||
|
rejectReason?: string,
|
||||||
|
) {
|
||||||
|
await request(`/admin/store-package-audits/${requestId}/audit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason } : { action }),
|
||||||
|
});
|
||||||
|
notifyPackageAuditChanged();
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
import { Layout, Menu, Typography, Button, Space, Badge } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
RobotOutlined,
|
RobotOutlined,
|
||||||
@@ -20,9 +20,11 @@ import {
|
|||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
ProjectOutlined,
|
ProjectOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
|
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
|
import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
@@ -60,7 +62,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
label: '门店',
|
label: '门店',
|
||||||
children: [
|
children: [
|
||||||
{ key: '/stores', label: '门店列表' },
|
{ key: '/stores', label: '门店列表' },
|
||||||
{ key: '/store-package-audits', label: '套餐审核' },
|
{ key: '/store-package-audits', label: '审核通知' },
|
||||||
{ key: '/store-ratings', label: '门店评价' },
|
{ key: '/store-ratings', label: '门店评价' },
|
||||||
{ key: '/store-categories', label: '门店分类' },
|
{ key: '/store-categories', label: '门店分类' },
|
||||||
{ key: '/store-accounts', label: '门店账户' },
|
{ key: '/store-accounts', label: '门店账户' },
|
||||||
@@ -146,6 +148,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
|
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
@@ -154,6 +157,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
if (key === 'tickets-group') {
|
if (key === 'tickets-group') {
|
||||||
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||||
}
|
}
|
||||||
|
if (key === 'stores-group') {
|
||||||
|
return hasAnyStoreMenuPermission(permissionKeys);
|
||||||
|
}
|
||||||
if (key === 'dev-plan-group') {
|
if (key === 'dev-plan-group') {
|
||||||
return permissionKeys.includes('dev_plan');
|
return permissionKeys.includes('dev_plan');
|
||||||
}
|
}
|
||||||
@@ -173,11 +179,11 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/knowledge-bases': 'knowledge_bases',
|
'/knowledge-bases': 'knowledge_bases',
|
||||||
'stores-group': 'stores',
|
'stores-group': 'stores',
|
||||||
'/stores': 'stores',
|
'/stores': 'stores',
|
||||||
'/store-package-audits': 'stores',
|
'/store-package-audits': 'store_audits',
|
||||||
'/store-ratings': 'stores',
|
'/store-ratings': 'store_ratings',
|
||||||
'/store-categories': 'stores',
|
'/store-categories': 'store_categories',
|
||||||
'/store-accounts': 'stores',
|
'/store-accounts': 'store_accounts',
|
||||||
'/store-media': 'stores',
|
'/store-media': 'store_media',
|
||||||
'partners-group': 'partners',
|
'partners-group': 'partners',
|
||||||
'/cities': 'partners',
|
'/cities': 'partners',
|
||||||
'/city-partners': 'partners',
|
'/city-partners': 'partners',
|
||||||
@@ -194,10 +200,10 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/benefit/ledgers': 'benefit',
|
'/benefit/ledgers': 'benefit',
|
||||||
'/redeem-records': 'benefit',
|
'/redeem-records': 'benefit',
|
||||||
'/redeem-pending': 'benefit',
|
'/redeem-pending': 'benefit',
|
||||||
'/redeem/debug': 'benefit',
|
'/redeem/debug': 'benefit_debug',
|
||||||
'deliveries-group': 'deliveries',
|
'deliveries-group': 'deliveries',
|
||||||
'/deliveries': 'deliveries',
|
'/deliveries': 'deliveries',
|
||||||
'/deliveries/xiaofeixia': 'deliveries',
|
'/deliveries/xiaofeixia': 'deliveries_debug',
|
||||||
'/tickets': 'tickets',
|
'/tickets': 'tickets',
|
||||||
'/tickets/support': 'tech_support',
|
'/tickets/support': 'tech_support',
|
||||||
'dev-plan-group': 'dev_plan',
|
'dev-plan-group': 'dev_plan',
|
||||||
@@ -215,6 +221,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
|||||||
'/logs/third-party': 'logs',
|
'/logs/third-party': 'logs',
|
||||||
'/logs/domain-events': 'logs',
|
'/logs/domain-events': 'logs',
|
||||||
'/hq-permissions': 'hq_permissions',
|
'/hq-permissions': 'hq_permissions',
|
||||||
|
'/test-whitelist': 'test_whitelist',
|
||||||
'/system-settings': 'system_settings_any',
|
'/system-settings': 'system_settings_any',
|
||||||
'/hq-accounts': 'hq_accounts',
|
'/hq-accounts': 'hq_accounts',
|
||||||
};
|
};
|
||||||
@@ -241,6 +248,31 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M
|
|||||||
.filter(Boolean) as MenuProps['items'];
|
.filter(Boolean) as MenuProps['items'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
|
||||||
|
if (!items) return items;
|
||||||
|
return items.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||||
|
if ('children' in item && Array.isArray(item.children)) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: attachAuditBadge(item.children as MenuProps['items'], pendingCount),
|
||||||
|
} as MenuItem;
|
||||||
|
}
|
||||||
|
if (String(item.key) === '/store-package-audits') {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
审核通知
|
||||||
|
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
} as MenuItem;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
@@ -248,11 +280,35 @@ export default function AdminLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
|
const [auditPendingCount, setAuditPendingCount] = useState(0);
|
||||||
|
|
||||||
|
function refreshAuditPendingCount() {
|
||||||
|
Promise.all([
|
||||||
|
request<{ pendingCount: number }>('/admin/store-package-audits/summary').catch(() => ({ pendingCount: 0 })),
|
||||||
|
request<{ pendingCount: number; packagePendingCount?: number }>('/admin/store-info-change-requests/summary').catch(() => ({ pendingCount: 0 })),
|
||||||
|
])
|
||||||
|
.then(([pkg, info]) => setAuditPendingCount((pkg.pendingCount ?? 0) + (info.pendingCount ?? 0)))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshAuditPendingCount();
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChanged = () => refreshAuditPendingCount();
|
||||||
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
window.addEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
window.removeEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
@@ -271,10 +327,12 @@ export default function AdminLayout() {
|
|||||||
: location.pathname;
|
: location.pathname;
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
if (!profile) return MENU_ITEMS;
|
const base =
|
||||||
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
!profile || profile.adminRole === 'SUPER_ADMIN'
|
||||||
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
? MENU_ITEMS
|
||||||
}, [profile]);
|
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||||
|
return attachAuditBadge(base, auditPendingCount);
|
||||||
|
}, [profile, auditPendingCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
@@ -359,7 +417,9 @@ export default function AdminLayout() {
|
|||||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||||
>
|
>
|
||||||
<Content style={{ margin: 24 }}>
|
<Content style={{ margin: 24 }}>
|
||||||
<Outlet />
|
<ListColumnPrefsProvider initial={profile?.listColumnPrefs}>
|
||||||
|
<Outlet />
|
||||||
|
</ListColumnPrefsProvider>
|
||||||
</Content>
|
</Content>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import type { HqListColumnKey, HqListColumnPref, HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
prefs: HqListColumnPrefsMap;
|
||||||
|
ready: boolean;
|
||||||
|
setPrefs: (next: HqListColumnPrefsMap) => void;
|
||||||
|
save: (listKey: HqListColumnKey, next: HqListColumnPref | null) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListColumnPrefsContext = createContext<Ctx>({
|
||||||
|
prefs: {},
|
||||||
|
ready: false,
|
||||||
|
setPrefs: () => {},
|
||||||
|
save: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ListColumnPrefsProvider({
|
||||||
|
initial,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initial?: HqListColumnPrefsMap;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [prefs, setPrefs] = useState<HqListColumnPrefsMap>(initial ?? {});
|
||||||
|
const [ready] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) setPrefs(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const save = useCallback(async (listKey: HqListColumnKey, next: HqListColumnPref | null) => {
|
||||||
|
const res = await request<{ listColumnPrefs: HqListColumnPrefsMap }>(
|
||||||
|
`/admin/me/list-columns/${listKey}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(
|
||||||
|
next
|
||||||
|
? { order: next.order, hidden: next.hidden, widths: next.widths }
|
||||||
|
: { reset: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setPrefs(res.listColumnPrefs ?? {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ prefs, ready, setPrefs, save }), [prefs, ready, save]);
|
||||||
|
return <ListColumnPrefsContext.Provider value={value}>{children}</ListColumnPrefsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListColumnPrefs() {
|
||||||
|
return useContext(ListColumnPrefsContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
|
||||||
|
export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed';
|
||||||
|
|
||||||
|
export function notifyPackageAuditChanged() {
|
||||||
|
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
|
||||||
|
window.dispatchEvent(new Event(AUDIT_NOTICE_CHANGED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyAuditNoticeChanged() {
|
||||||
|
notifyPackageAuditChanged();
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||||
|
|
||||||
export const apiBase = '/api/v1';
|
export const apiBase = '/api/v1';
|
||||||
export const CLIENT_APP = 'HQ_WEB';
|
export const CLIENT_APP = 'HQ_WEB';
|
||||||
|
|
||||||
@@ -8,6 +10,9 @@ export type HqProfile = {
|
|||||||
adminRole: string;
|
adminRole: string;
|
||||||
status: string;
|
status: string;
|
||||||
permissionKeys?: string[];
|
permissionKeys?: string[];
|
||||||
|
cityIds?: string[];
|
||||||
|
cityScoped?: boolean;
|
||||||
|
listColumnPrefs?: HqListColumnPrefsMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getToken() {
|
export function getToken() {
|
||||||
@@ -87,6 +92,9 @@ export type DashboardStats = {
|
|||||||
openTickets?: number;
|
openTickets?: number;
|
||||||
pendingStoreWithdrawals?: number;
|
pendingStoreWithdrawals?: number;
|
||||||
overdueStoreWithdrawals?: number;
|
overdueStoreWithdrawals?: number;
|
||||||
|
pendingStoreOnboard?: number;
|
||||||
|
pendingStorePackageAudits?: number;
|
||||||
|
pendingStoreInfoChanges?: number;
|
||||||
ordersByStatus: Array<{ status: string; count: number }>;
|
ordersByStatus: Array<{ status: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,12 +169,20 @@ export type AdminUserRow = {
|
|||||||
wxOpenId: string | null;
|
wxOpenId: string | null;
|
||||||
wechatVerified: boolean;
|
wechatVerified: boolean;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
|
hqRemark: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
sourceRefId: string | null;
|
sourceRefId: string | null;
|
||||||
sourceLabel: string | null;
|
sourceLabel: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
|
isTest?: boolean;
|
||||||
|
/** 好客权益·累计获得(含已使用,不含退款作废) */
|
||||||
|
benefitTotalAmount?: number;
|
||||||
|
/** 好客权益·已使用(已核销) */
|
||||||
|
benefitUsedAmount?: number;
|
||||||
|
/** 好客权益·剩余未使用 */
|
||||||
|
benefitBalance?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminOrderItem = {
|
export type AdminOrderItem = {
|
||||||
@@ -186,8 +202,21 @@ export type AdminOrderRow = {
|
|||||||
productName?: string;
|
productName?: string;
|
||||||
productSpec?: string;
|
productSpec?: string;
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
|
saleUnit?: string;
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
receiverPhone: string;
|
receiverPhone: string;
|
||||||
|
receiverProvince?: string;
|
||||||
|
receiverCity?: string;
|
||||||
|
receiverDistrict?: string;
|
||||||
|
receiverAddress?: string;
|
||||||
|
benefitAmount?: number;
|
||||||
|
benefitCoupon?: {
|
||||||
|
couponNo?: string;
|
||||||
|
totalAmount?: number;
|
||||||
|
usedAmount?: number;
|
||||||
|
balance?: number;
|
||||||
|
status?: string;
|
||||||
|
} | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
cityId?: string;
|
cityId?: string;
|
||||||
city?: { id: string; name: string; code: string };
|
city?: { id: string; name: string; code: string };
|
||||||
@@ -199,6 +228,7 @@ export type AdminOrderRow = {
|
|||||||
isProxyOrder?: boolean;
|
isProxyOrder?: boolean;
|
||||||
proxyPartnerName?: string | null;
|
proxyPartnerName?: string | null;
|
||||||
proxyPartnerPhone?: string | null;
|
proxyPartnerPhone?: string | null;
|
||||||
|
isTest?: boolean;
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||||
delivery?: {
|
delivery?: {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { MouseEvent, ReactNode } from 'react';
|
||||||
|
import type { ColumnType } from 'antd/es/table';
|
||||||
|
|
||||||
|
export const MIN_COL_WIDTH = 48;
|
||||||
|
export const MAX_COL_WIDTH = 960;
|
||||||
|
|
||||||
|
export function clampColWidth(n: number): number {
|
||||||
|
return Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginColumnResize(
|
||||||
|
e: MouseEvent,
|
||||||
|
startWidth: number,
|
||||||
|
onMove: (width: number) => void,
|
||||||
|
onEnd: (width: number) => void,
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const startX = e.clientX;
|
||||||
|
let latest = clampColWidth(startWidth);
|
||||||
|
let moved = false;
|
||||||
|
document.body.classList.add('admin-col-resizing');
|
||||||
|
|
||||||
|
const onMouseMove = (ev: globalThis.MouseEvent) => {
|
||||||
|
moved = true;
|
||||||
|
latest = clampColWidth(startWidth + ev.clientX - startX);
|
||||||
|
onMove(latest);
|
||||||
|
};
|
||||||
|
const onMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
document.body.classList.remove('admin-col-resizing');
|
||||||
|
if (moved) onEnd(latest);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withResizeTitle<T>(
|
||||||
|
title: ColumnType<T>['title'],
|
||||||
|
onMouseDown: (e: MouseEvent) => void,
|
||||||
|
): ColumnType<T>['title'] {
|
||||||
|
const handle: ReactNode = (
|
||||||
|
<span
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="拖动调整列宽"
|
||||||
|
className="admin-col-resize-handle"
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (typeof title === 'function') {
|
||||||
|
return (props) => (
|
||||||
|
<>
|
||||||
|
{title(props)}
|
||||||
|
{handle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{title}
|
||||||
|
{handle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
|
|||||||
MANUAL: '手动',
|
MANUAL: '手动',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
LOCAL: '同城',
|
||||||
|
CROSS_CITY: '跨城',
|
||||||
|
ON_SITE_PICKUP: '现场提货',
|
||||||
|
};
|
||||||
|
|
||||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
PENDING_SHIP: '待发货',
|
PENDING_SHIP: '待发货',
|
||||||
@@ -140,10 +146,29 @@ export function fmtTime(v?: string | null) {
|
|||||||
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
return v ? new Date(v).toLocaleString('zh-CN') : '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手机号脱敏展示:138****5678;空值返回 — */
|
/** 联系电话脱敏:手机 138****5678;座机 0379-****888;空值返回 — */
|
||||||
export function maskPhone(phone?: string | null): string {
|
export function maskPhone(phone?: string | null): string {
|
||||||
const raw = String(phone ?? '').trim();
|
const raw = String(phone ?? '').trim().replace(/\s+/g, '');
|
||||||
if (!raw) return '—';
|
if (!raw) return '—';
|
||||||
|
if (/^1[3-9]\d{9}$/.test(raw)) {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||||
|
}
|
||||||
|
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
|
||||||
|
const extMatch = raw.match(/-(\d{1,6})$/);
|
||||||
|
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
|
||||||
|
const ext = hasExt ? extMatch![1] : '';
|
||||||
|
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
|
||||||
|
const digits = main.replace(/\D/g, '');
|
||||||
|
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
|
||||||
|
const area = digits.slice(0, areaLen);
|
||||||
|
const local = digits.slice(areaLen);
|
||||||
|
const keepTail = Math.min(4, Math.max(2, local.length - 4));
|
||||||
|
const maskedLocal =
|
||||||
|
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
|
||||||
|
const joiner = main.includes('-') ? '-' : '';
|
||||||
|
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
|
||||||
|
}
|
||||||
const digits = raw.replace(/\D/g, '');
|
const digits = raw.replace(/\D/g, '');
|
||||||
if (digits.length >= 11) {
|
if (digits.length >= 11) {
|
||||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||||
|
|||||||
@@ -8,3 +8,19 @@ export function downloadExcelCsv(csv: string, filename: string) {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Download binary file from base64 payload returned by HQ export APIs. */
|
||||||
|
export function downloadBase64File(contentBase64: string, filename: string, mimeType: string) {
|
||||||
|
const binary = atob(contentBase64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { ColumnType } from 'antd/es/table';
|
||||||
|
import type { HqListColumnKey, HqListColumnPref } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export const SERIAL_COLUMN_KEY = '__serial';
|
||||||
|
export const ACTIONS_COLUMN_KEY = '__actions';
|
||||||
|
|
||||||
|
export function columnKey<T>(col: ColumnType<T>, index: number): string {
|
||||||
|
if (col.key != null && String(col.key).length) return String(col.key);
|
||||||
|
const dataIndex = col.dataIndex;
|
||||||
|
if (typeof dataIndex === 'string' && dataIndex) return dataIndex;
|
||||||
|
if (Array.isArray(dataIndex) && dataIndex.length) return dataIndex.map(String).join('.');
|
||||||
|
if (typeof col.title === 'string' && col.title) return col.title;
|
||||||
|
return `col-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActionsColumn<T>(col: ColumnType<T>, key: string): boolean {
|
||||||
|
if (key === ACTIONS_COLUMN_KEY || key === 'actions' || key === 'action') return true;
|
||||||
|
return col.title === '操作';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeColumnOrder(defaultKeys: string[], pref?: HqListColumnPref): string[] {
|
||||||
|
const known = new Set(defaultKeys);
|
||||||
|
const ordered = (pref?.order ?? []).filter((k) => known.has(k));
|
||||||
|
for (const key of defaultKeys) {
|
||||||
|
if (ordered.includes(key)) continue;
|
||||||
|
const defaultIdx = defaultKeys.indexOf(key);
|
||||||
|
let insertAt = ordered.length;
|
||||||
|
for (let i = 0; i < ordered.length; i += 1) {
|
||||||
|
if (defaultKeys.indexOf(ordered[i]) > defaultIdx) {
|
||||||
|
insertAt = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered.splice(insertAt, 0, key);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleColumnKeys(defaultKeys: string[], pref?: HqListColumnPref): string[] {
|
||||||
|
const hidden = new Set(pref?.hidden ?? []);
|
||||||
|
return mergeColumnOrder(defaultKeys, pref).filter((k) => !hidden.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyColumnPrefs<T>(
|
||||||
|
columns: ColumnType<T>[],
|
||||||
|
pref: HqListColumnPref | undefined,
|
||||||
|
): ColumnType<T>[] {
|
||||||
|
const keyed = columns.map((col, i) => {
|
||||||
|
const key = columnKey(col, i);
|
||||||
|
return { col: { ...col, key }, key, actions: isActionsColumn(col, key) };
|
||||||
|
});
|
||||||
|
const configurable = keyed.filter((c) => !c.actions);
|
||||||
|
const actions = keyed.filter((c) => c.actions);
|
||||||
|
const defaultKeys = configurable.map((c) => c.key);
|
||||||
|
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
||||||
|
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
||||||
|
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
|
||||||
|
return [
|
||||||
|
...orderedKeys
|
||||||
|
.map((k) => {
|
||||||
|
const col = byKey.get(k);
|
||||||
|
if (!col) return null;
|
||||||
|
const w = pref?.widths?.[k];
|
||||||
|
return w != null ? { ...col, width: w } : col;
|
||||||
|
})
|
||||||
|
.filter((c): c is ColumnType<T> => !!c),
|
||||||
|
...actions.map((c) => {
|
||||||
|
const w = pref?.widths?.[c.key];
|
||||||
|
return w != null ? { ...c.col, width: w } : c.col;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListColumnSettingItem = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function settingItems<T>(
|
||||||
|
columns: ColumnType<T>[],
|
||||||
|
pref?: HqListColumnPref,
|
||||||
|
): ListColumnSettingItem[] {
|
||||||
|
const keyed = columns
|
||||||
|
.map((col, i) => ({ col, key: columnKey(col, i) }))
|
||||||
|
.filter(({ col, key }) => !isActionsColumn(col, key));
|
||||||
|
const hidden = new Set(pref?.hidden ?? []);
|
||||||
|
const order = mergeColumnOrder(keyed.map((c) => c.key), pref);
|
||||||
|
const byKey = new Map(keyed.map((c) => [c.key, c.col]));
|
||||||
|
return order.map((key) => {
|
||||||
|
const col = byKey.get(key);
|
||||||
|
const title = typeof col?.title === 'string' ? col.title : key;
|
||||||
|
return { key, title, visible: !hidden.has(key) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { HqListColumnKey };
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||||
|
|
||||||
export type StoreCreateForm = {
|
export type StoreCreateForm = {
|
||||||
partnerAccountId: string;
|
partnerAccountId: string;
|
||||||
cityId: string;
|
cityId: string;
|
||||||
@@ -15,6 +17,8 @@ export type StoreCreateForm = {
|
|||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
benefitUsageRule?: string;
|
benefitUsageRule?: string;
|
||||||
|
/** 对外联系电话(店长);可与登录号不同,支持座机 */
|
||||||
|
contactPhone?: string;
|
||||||
openTime?: string;
|
openTime?: string;
|
||||||
closeTime?: string;
|
closeTime?: string;
|
||||||
openTime2?: string;
|
openTime2?: string;
|
||||||
@@ -22,11 +26,13 @@ export type StoreCreateForm = {
|
|||||||
avgPrice?: number | null;
|
avgPrice?: number | null;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
contractUrl?: string;
|
/** 签约合同,支持多张照片 / PDF */
|
||||||
|
contractUrls?: string[];
|
||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
|
sortOrder?: number;
|
||||||
visibilityWhitelistEnabled?: boolean;
|
visibilityWhitelistEnabled?: boolean;
|
||||||
visibilityPhones?: string[];
|
visibilityPhones?: string[];
|
||||||
};
|
};
|
||||||
@@ -57,6 +63,7 @@ export function validateStoreCreateStep1(
|
|||||||
| 'openTime2'
|
| 'openTime2'
|
||||||
| 'closeTime2'
|
| 'closeTime2'
|
||||||
| 'avgPrice'
|
| 'avgPrice'
|
||||||
|
| 'contactPhone'
|
||||||
>,
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||||
@@ -66,6 +73,8 @@ export function validateStoreCreateStep1(
|
|||||||
if (!form.name?.trim()) return '请填写门店名称';
|
if (!form.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||||
|
const contactPhone = form.contactPhone?.trim();
|
||||||
|
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
|
||||||
if (!form.address?.trim()) return '请填写详细地址';
|
if (!form.address?.trim()) return '请填写详细地址';
|
||||||
|
|
||||||
const openTime = String(form.openTime || '').trim();
|
const openTime = String(form.openTime || '').trim();
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
|
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
|
||||||
|
import {
|
||||||
|
compressImageFileIfNeeded,
|
||||||
|
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||||
|
formatOssMaxSizeMb,
|
||||||
|
} from '@dukang/shared-ui/compressImage';
|
||||||
|
|
||||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
@@ -9,14 +14,24 @@ export type UploadFileResult = {
|
|||||||
mock: boolean;
|
mock: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
||||||
|
if (mediaType !== 'IMAGE') return file;
|
||||||
|
const compressed = await compressImageFileIfNeeded(file);
|
||||||
|
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
||||||
|
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
||||||
|
}
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||||
export async function uploadFileToOss(
|
export async function uploadFileToOss(
|
||||||
file: File,
|
file: File,
|
||||||
options: { bizType: string; mediaType?: OssMediaType },
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
): Promise<UploadFileResult> {
|
): Promise<UploadFileResult> {
|
||||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||||
|
const prepared = await prepareUploadFile(file, mediaType);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', prepared);
|
||||||
formData.append('bizType', options.bizType);
|
formData.append('bizType', options.bizType);
|
||||||
formData.append('mediaType', mediaType);
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Button, message } from 'antd';
|
||||||
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
|
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||||
|
import type { HqListColumnKey } from '@dukang/shared-types';
|
||||||
|
import { AdminListColumnsModal } from '../components/AdminListColumnsModal';
|
||||||
|
import { useListColumnPrefs } from './ListColumnPrefsContext';
|
||||||
|
import {
|
||||||
|
ACTIONS_COLUMN_KEY,
|
||||||
|
SERIAL_COLUMN_KEY,
|
||||||
|
applyColumnPrefs,
|
||||||
|
columnKey,
|
||||||
|
settingItems,
|
||||||
|
type ListColumnSettingItem,
|
||||||
|
} from './list-column-prefs';
|
||||||
|
import { beginColumnResize, withResizeTitle } from './column-resize';
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAdminListColumns<T>(
|
||||||
|
listKey: HqListColumnKey,
|
||||||
|
allColumns: ColumnsType<T>,
|
||||||
|
options: Options = {},
|
||||||
|
) {
|
||||||
|
const { page = 1, pageSize = 20 } = options;
|
||||||
|
const { prefs, save } = useListColumnPrefs();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [localWidths, setLocalWidths] = useState<Record<string, number>>({});
|
||||||
|
const pref = prefs[listKey];
|
||||||
|
const prefRef = useRef(pref);
|
||||||
|
prefRef.current = pref;
|
||||||
|
const localWidthsRef = useRef(localWidths);
|
||||||
|
localWidthsRef.current = localWidths;
|
||||||
|
const itemsRef = useRef<ListColumnSettingItem[]>([]);
|
||||||
|
|
||||||
|
const configured = useMemo(
|
||||||
|
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
||||||
|
[allColumns, pref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() => settingItems(allColumns as ColumnType<T>[], pref),
|
||||||
|
[allColumns, pref],
|
||||||
|
);
|
||||||
|
itemsRef.current = items;
|
||||||
|
|
||||||
|
const serialCol: ColumnType<T> = useMemo(
|
||||||
|
() => ({
|
||||||
|
key: SERIAL_COLUMN_KEY,
|
||||||
|
title: '序号',
|
||||||
|
width: 64,
|
||||||
|
fixed: 'left' as const,
|
||||||
|
render: (_: unknown, __: T, index: number) => (page - 1) * pageSize + index + 1,
|
||||||
|
}),
|
||||||
|
[page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistPref = useCallback(
|
||||||
|
async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
|
||||||
|
if (!next) {
|
||||||
|
setLocalWidths({});
|
||||||
|
await save(listKey, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await save(listKey, next);
|
||||||
|
},
|
||||||
|
[listKey, save],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persist = useCallback(
|
||||||
|
async (next: ListColumnSettingItem[] | null) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (!next) {
|
||||||
|
await persistPref(null);
|
||||||
|
} else {
|
||||||
|
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
|
||||||
|
await persistPref({
|
||||||
|
order: next.map((i) => i.key),
|
||||||
|
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||||
|
...(Object.keys(widths).length ? { widths } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
message.success(next ? '列设置已保存' : '已恢复默认列');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[persistPref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistWidth = useCallback(
|
||||||
|
async (key: string, width: number) => {
|
||||||
|
const current = prefRef.current;
|
||||||
|
const prevW = current?.widths?.[key];
|
||||||
|
if (prevW === width) return;
|
||||||
|
const order = current?.order?.length ? current.order : itemsRef.current.map((i) => i.key);
|
||||||
|
const hidden = current?.hidden ?? [];
|
||||||
|
const widths = { ...(current?.widths ?? {}), ...localWidthsRef.current, [key]: width };
|
||||||
|
try {
|
||||||
|
await persistPref({ order, hidden, widths });
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '列宽保存失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[persistPref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnsType<T>>(() => {
|
||||||
|
const merged: ColumnType<T>[] = [
|
||||||
|
serialCol,
|
||||||
|
...configured.map((col) =>
|
||||||
|
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
||||||
|
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
||||||
|
: col,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const widths = { ...(pref?.widths ?? {}), ...localWidths };
|
||||||
|
return merged.map((col, i) => {
|
||||||
|
const key = columnKey(col, i);
|
||||||
|
const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined);
|
||||||
|
const prevHeader = col.onHeaderCell;
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
key,
|
||||||
|
width,
|
||||||
|
title: withResizeTitle(col.title, (e) => {
|
||||||
|
const th = (e.currentTarget as HTMLElement).closest('th');
|
||||||
|
const startWidth = width ?? th?.getBoundingClientRect().width ?? 120;
|
||||||
|
beginColumnResize(
|
||||||
|
e,
|
||||||
|
startWidth,
|
||||||
|
(next) => setLocalWidths((prev) => ({ ...prev, [key]: next })),
|
||||||
|
(next) => void persistWidth(key, next),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
onHeaderCell: (column) => {
|
||||||
|
const extra = typeof prevHeader === 'function' ? prevHeader(column) : {};
|
||||||
|
return {
|
||||||
|
...extra,
|
||||||
|
className: [extra.className, 'admin-th-resizable'].filter(Boolean).join(' '),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [configured, serialCol, pref?.widths, localWidths, persistWidth]);
|
||||||
|
|
||||||
|
const settingsButton = (
|
||||||
|
<span className="admin-list-settings-slot">
|
||||||
|
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
|
||||||
|
列设置
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const settingsModal = (
|
||||||
|
<AdminListColumnsModal
|
||||||
|
open={open}
|
||||||
|
items={items}
|
||||||
|
saving={saving}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onReset={() => void persist(null)}
|
||||||
|
onSave={(draft) => void persist(draft)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { columns, settingsButton, settingsModal };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -17,8 +19,23 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
type CouponOrder = {
|
||||||
|
id?: string;
|
||||||
|
orderNo?: string;
|
||||||
|
productName?: string | null;
|
||||||
|
productSpec?: string | null;
|
||||||
|
quantity?: number | null;
|
||||||
|
saleUnit?: string | null;
|
||||||
|
deliveryType?: string | null;
|
||||||
|
payAmount?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,10 +46,43 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
sourceProduct: string;
|
sourceProduct: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user?: { userNo: string; phone: string | null };
|
user?: { id?: string; userNo: string; phone: string | null };
|
||||||
order?: { orderNo: string } | null;
|
order?: CouponOrder | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function saleUnitLabel(unit?: string | null) {
|
||||||
|
if (unit === 'BOX') return '箱';
|
||||||
|
if (unit === 'BOTTLE') return '瓶';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPayAmount(v?: number | string | null) {
|
||||||
|
if (v == null || v === '') return '';
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 来源:有订单时展示商品名、规格、数量、配送方式、金额;手动发放沿用 sourceProduct */
|
||||||
|
function formatCouponSource(row: { sourceProduct?: string; order?: CouponOrder | null }) {
|
||||||
|
const order = row.order;
|
||||||
|
if (!order?.orderNo && !order?.productName) {
|
||||||
|
return row.sourceProduct || '—';
|
||||||
|
}
|
||||||
|
const unit = saleUnitLabel(order.saleUnit);
|
||||||
|
const qty =
|
||||||
|
order.quantity != null ? `${order.quantity}${unit}` : '';
|
||||||
|
const delivery = DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '';
|
||||||
|
const amount = formatPayAmount(order.payAmount);
|
||||||
|
const parts = [
|
||||||
|
order.productName || row.sourceProduct,
|
||||||
|
order.productSpec,
|
||||||
|
qty,
|
||||||
|
delivery,
|
||||||
|
amount,
|
||||||
|
].filter((p) => p != null && String(p).trim() !== '');
|
||||||
|
return parts.join(' / ') || row.sourceProduct || '—';
|
||||||
|
}
|
||||||
|
|
||||||
type CouponRedeemRecord = {
|
type CouponRedeemRecord = {
|
||||||
id: string;
|
id: string;
|
||||||
redeemNo: string;
|
redeemNo: string;
|
||||||
@@ -57,11 +107,10 @@ type CouponRedeemSummary = {
|
|||||||
type CouponDetail = Row & {
|
type CouponDetail = Row & {
|
||||||
redeemSummary?: CouponRedeemSummary | null;
|
redeemSummary?: CouponRedeemSummary | null;
|
||||||
redeemRecords?: CouponRedeemRecord[];
|
redeemRecords?: CouponRedeemRecord[];
|
||||||
user?: { userNo?: string; phone?: string | null };
|
|
||||||
order?: { orderNo?: string } | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BenefitCouponsPage() {
|
export default function BenefitCouponsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -98,21 +147,62 @@ export default function BenefitCouponsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
{
|
||||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
title: '券号',
|
||||||
|
dataIndex: 'couponNo',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: false,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: ['user', 'userNo'],
|
||||||
|
width: 120,
|
||||||
|
ellipsis: false,
|
||||||
|
render: (v: string | undefined, row) =>
|
||||||
|
row.user?.id ? (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
v || '—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '订单',
|
title: '订单',
|
||||||
dataIndex: ['order', 'orderNo'],
|
dataIndex: ['order', 'orderNo'],
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: false,
|
ellipsis: false,
|
||||||
render: (v) => v || '—',
|
render: (v: string | undefined) =>
|
||||||
|
v ? (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'sourceProduct',
|
||||||
|
width: 360,
|
||||||
|
ellipsis: false,
|
||||||
|
render: (_: string, row) => formatCouponSource(row),
|
||||||
|
},
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -154,16 +244,20 @@ export default function BenefitCouponsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
好客权益券
|
title="好客权益券"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
actions={
|
||||||
手动发放
|
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||||
</Button>
|
手动发放
|
||||||
</div>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="couponNo" label="券号">
|
<Form.Item name="couponNo" label="券号">
|
||||||
@@ -189,7 +283,7 @@ export default function BenefitCouponsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -242,7 +336,8 @@ export default function BenefitCouponsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="权益券详情"
|
title="权益券详情"
|
||||||
width={720}
|
width={980}
|
||||||
|
styles={{ body: { paddingBottom: 24 } }}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -266,15 +361,37 @@ export default function BenefitCouponsPage() {
|
|||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="用户">
|
||||||
|
{detail.user?.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(detail.user!.id) } })}
|
||||||
|
>
|
||||||
|
{detail.user?.userNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
(detail.user?.userNo ?? '—')
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="关联订单">
|
||||||
|
{detail.order?.orderNo ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/orders?orderNo=${encodeURIComponent(detail.order!.orderNo!)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detail.order.orderNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
|
<Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
||||||
@@ -310,52 +427,53 @@ export default function BenefitCouponsPage() {
|
|||||||
size="small"
|
size="small"
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
tableLayout="fixed"
|
||||||
locale={{ emptyText: '暂无关联核销单' }}
|
locale={{ emptyText: '暂无关联核销单' }}
|
||||||
dataSource={detail.redeemRecords ?? []}
|
dataSource={detail.redeemRecords ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
{ title: '核销号', dataIndex: 'redeemNo' },
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
width: 70,
|
width: 72,
|
||||||
render: (v: string | undefined) =>
|
render: (v: string | undefined) =>
|
||||||
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '门店',
|
title: '门店',
|
||||||
dataIndex: ['store', 'name'],
|
dataIndex: ['store', 'name'],
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string | undefined, row: CouponRedeemRecord) =>
|
render: (v: string | undefined, row: CouponRedeemRecord) =>
|
||||||
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '本券分摊',
|
title: '本券分摊',
|
||||||
dataIndex: 'couponAmount',
|
dataIndex: 'couponAmount',
|
||||||
width: 95,
|
width: 96,
|
||||||
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
||||||
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '核销总额',
|
title: '核销总额',
|
||||||
dataIndex: 'amount',
|
dataIndex: 'amount',
|
||||||
width: 90,
|
width: 96,
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '结算额',
|
title: '结算额',
|
||||||
dataIndex: 'settleAmount',
|
dataIndex: 'settleAmount',
|
||||||
width: 90,
|
width: 96,
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时间',
|
title: '时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
width: 150,
|
width: 148,
|
||||||
render: (v: string) => fmtTime(v),
|
render: (v: string) => fmtTime(v),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 70,
|
width: 64,
|
||||||
render: (_: unknown, row: CouponRedeemRecord) => (
|
render: (_: unknown, row: CouponRedeemRecord) => (
|
||||||
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||||
详情
|
详情
|
||||||
@@ -374,7 +492,7 @@ export default function BenefitCouponsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="核销单详情"
|
title="核销单详情"
|
||||||
width={520}
|
width={640}
|
||||||
open={redeemDrawerOpen}
|
open={redeemDrawerOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setRedeemDrawerOpen(false);
|
setRedeemDrawerOpen(false);
|
||||||
@@ -385,79 +503,7 @@ export default function BenefitCouponsPage() {
|
|||||||
{redeemDetailLoading ? (
|
{redeemDetailLoading ? (
|
||||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
) : redeemDetail ? (
|
) : redeemDetail ? (
|
||||||
<Descriptions column={1} bordered size="small">
|
<RedeemRecordDetailDescriptions detail={redeemDetail} />
|
||||||
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="核销额">
|
|
||||||
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="结算额">
|
|
||||||
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="用户">
|
|
||||||
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
|
||||||
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
|
|
||||||
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
|
|
||||||
: ''}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="门店">
|
|
||||||
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
|
|
||||||
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
|
|
||||||
? `(${(redeemDetail.store as { cityName?: string }).cityName})`
|
|
||||||
: ''}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="门店地址">
|
|
||||||
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="合伙人">
|
|
||||||
{String(
|
|
||||||
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
|
|
||||||
?.partnerAccount?.companyName ?? '—',
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="权益券号">
|
|
||||||
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="关联订单">
|
|
||||||
{String(
|
|
||||||
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
|
|
||||||
'—',
|
|
||||||
)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
{Array.isArray(redeemDetail.allocations) &&
|
|
||||||
(redeemDetail.allocations as unknown[]).length > 0 ? (
|
|
||||||
<Descriptions.Item label="券分摊">
|
|
||||||
{(
|
|
||||||
redeemDetail.allocations as Array<{
|
|
||||||
couponNo?: string;
|
|
||||||
orderNo?: string | null;
|
|
||||||
amount?: number;
|
|
||||||
sortOrder?: number;
|
|
||||||
}>
|
|
||||||
)
|
|
||||||
.map((a, idx) => {
|
|
||||||
const role = idx === 0 ? '主' : '次';
|
|
||||||
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
|
|
||||||
a.orderNo ? `(订单 ${a.orderNo})` : ''
|
|
||||||
}`;
|
|
||||||
})
|
|
||||||
.join(';')}
|
|
||||||
</Descriptions.Item>
|
|
||||||
) : null}
|
|
||||||
{redeemDetail.payout ? (
|
|
||||||
<Descriptions.Item label="门店结算单">
|
|
||||||
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
|
||||||
{' / '}
|
|
||||||
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
|
|
||||||
</Descriptions.Item>
|
|
||||||
) : null}
|
|
||||||
{redeemDetail.rating ? (
|
|
||||||
<Descriptions.Item label="评价">
|
|
||||||
服务 {(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
|
||||||
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'} 分
|
|
||||||
</Descriptions.Item>
|
|
||||||
) : null}
|
|
||||||
</Descriptions>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
|
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
|
||||||
@@ -24,19 +27,22 @@ export default function BenefitLedgersPage() {
|
|||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
|
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
|
||||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
|
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
|
||||||
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
|
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
|
||||||
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
|
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
|
||||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
{ title: '备注', dataIndex: 'remark' },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-ledgers', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>权益流水</Typography.Title>
|
{settingsModal}
|
||||||
|
<AdminListHeader title="权益流水" settings={settingsButton} />
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
||||||
@@ -45,7 +51,7 @@ export default function BenefitLedgersPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
||||||
|
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
||||||
|
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
||||||
|
|
||||||
|
export type BigScreenOrder = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
payAmount: number;
|
||||||
|
items: string;
|
||||||
|
/** 展示用时间(付款成功时间) */
|
||||||
|
createdAt: string;
|
||||||
|
paidAt?: string;
|
||||||
|
userPhoneMasked: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AmountTier = 1 | 2 | 3;
|
||||||
|
|
||||||
|
const POLL_MS = 3000;
|
||||||
|
const ROW_MS = 2500;
|
||||||
|
/** 不足此条数时重复填充,保证滚动连贯 */
|
||||||
|
const MIN_SCROLL_ROWS = 10;
|
||||||
|
const FX_DURATION_MS = 10_000;
|
||||||
|
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||||
|
|
||||||
|
const CELEBRATE_SFX: Record<AmountTier, string> = {
|
||||||
|
1: sfxTier1,
|
||||||
|
2: sfxTier2,
|
||||||
|
3: sfxTier3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
||||||
|
let audioUnlocked = false;
|
||||||
|
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
||||||
|
|
||||||
|
function unlockCelebrateAudio() {
|
||||||
|
if (audioUnlocked || !sharedAudio) return;
|
||||||
|
sharedAudio.muted = true;
|
||||||
|
sharedAudio.src = CELEBRATE_SFX[1];
|
||||||
|
void sharedAudio
|
||||||
|
.play()
|
||||||
|
.then(() => {
|
||||||
|
sharedAudio.pause();
|
||||||
|
sharedAudio.currentTime = 0;
|
||||||
|
sharedAudio.muted = false;
|
||||||
|
audioUnlocked = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 等待下次手势 */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playCelebrateSfx(tier: AmountTier): () => void {
|
||||||
|
if (!sharedAudio) return () => undefined;
|
||||||
|
try {
|
||||||
|
sharedAudio.pause();
|
||||||
|
sharedAudio.currentTime = 0;
|
||||||
|
sharedAudio.src = CELEBRATE_SFX[tier];
|
||||||
|
sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
|
||||||
|
sharedAudio.muted = false;
|
||||||
|
void sharedAudio.play().catch(() => {
|
||||||
|
/* 未解锁时静默失败 */
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
sharedAudio.pause();
|
||||||
|
sharedAudio.currentTime = 0;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(d: Date) {
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLine(d: Date) {
|
||||||
|
return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrderTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return formatClock(new Date(iso));
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// function formatAmount(n: number): string {
|
||||||
|
// return Number(n || 0).toLocaleString('zh-CN', {
|
||||||
|
// minimumFractionDigits: 0,
|
||||||
|
// maximumFractionDigits: 0,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
function formatAmount(n: number): string {
|
||||||
|
const num = Number(n || 0);
|
||||||
|
// 先保留两位小数,再分割处理
|
||||||
|
const fixed = num.toFixed(2);
|
||||||
|
const [intStr, decStr] = fixed.split('.');
|
||||||
|
const intFormatted = Number(intStr).toLocaleString('zh-CN');
|
||||||
|
return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function amountCssClass(payAmount: number, base: string): string {
|
||||||
|
const wide = Math.floor(Math.abs(Number(payAmount || 0))) >= 100_000;
|
||||||
|
return wide ? `${base} ${base}--6d` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function amountTier(payAmount: number): AmountTier {
|
||||||
|
if (payAmount >= 1000) return 3;
|
||||||
|
if (payAmount >= 500) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveClock() {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="big-screen-hero">
|
||||||
|
<div className="big-screen-clock">{formatClock(now)}</div>
|
||||||
|
<div className="big-screen-date">{formatDateLine(now)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderRow({
|
||||||
|
order,
|
||||||
|
latest,
|
||||||
|
}: {
|
||||||
|
order: BigScreenOrder;
|
||||||
|
latest?: boolean;
|
||||||
|
}) {
|
||||||
|
const tier = amountTier(order.payAmount);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
|
||||||
|
>
|
||||||
|
{latest ? <span className="big-screen-row-mark" /> : null}
|
||||||
|
<span className={amountCssClass(order.payAmount, 'big-screen-amount')}>
|
||||||
|
¥ {formatAmount(order.payAmount)}
|
||||||
|
</span>
|
||||||
|
<span className="big-screen-items">{order.items || '—'}</span>
|
||||||
|
<span className="big-screen-time">{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
|
||||||
|
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Particle = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
rot: number;
|
||||||
|
vr: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
color: string;
|
||||||
|
life: number;
|
||||||
|
kind: 'rect' | 'ribbon' | 'spark';
|
||||||
|
rain?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_COLORS: Record<AmountTier, string[]> = {
|
||||||
|
1: ['#e6f7ff', '#91d5ff', '#40a9ff', '#ffffff', '#69c0ff'],
|
||||||
|
2: ['#fff1b8', '#ffe58f', '#ffd666', '#fffbe6', '#ffe7ba'],
|
||||||
|
3: ['#ffd666', '#faad14', '#ffec3d', '#fff1b8', '#ff4d4f', '#ff7a45', '#ffffff'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function spawnParticles(tier: AmountTier, w: number, h: number): Particle[] {
|
||||||
|
const colors = TIER_COLORS[tier];
|
||||||
|
const count = tier === 3 ? 180 : tier === 2 ? 110 : 70;
|
||||||
|
const out: Particle[] = [];
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h * 0.42;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const speed = (tier === 3 ? 8 : tier === 2 ? 6 : 4) * (0.4 + Math.random());
|
||||||
|
const kind: Particle['kind'] =
|
||||||
|
tier === 3 && Math.random() < 0.25 ? 'ribbon' : Math.random() < 0.2 ? 'spark' : 'rect';
|
||||||
|
out.push({
|
||||||
|
x: cx + (Math.random() - 0.5) * 80,
|
||||||
|
y: cy + (Math.random() - 0.5) * 40,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed - (tier === 3 ? 6 : 3),
|
||||||
|
rot: Math.random() * 360,
|
||||||
|
vr: (Math.random() - 0.5) * 18,
|
||||||
|
w: kind === 'ribbon' ? 10 + Math.random() * 16 : kind === 'spark' ? 2 : 6 + Math.random() * 8,
|
||||||
|
h: kind === 'ribbon' ? 28 + Math.random() * 24 : kind === 'spark' ? 10 + Math.random() * 8 : 4 + Math.random() * 6,
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
life: 1,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (tier >= 2) {
|
||||||
|
for (let i = 0; i < (tier === 3 ? 80 : 40); i++) {
|
||||||
|
out.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: -20 - Math.random() * 80,
|
||||||
|
vx: (Math.random() - 0.5) * 1.4,
|
||||||
|
vy: 3 + Math.random() * 5,
|
||||||
|
rot: Math.random() * 360,
|
||||||
|
vr: (Math.random() - 0.5) * 10,
|
||||||
|
w: 5 + Math.random() * 8,
|
||||||
|
h: 10 + Math.random() * 14,
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
life: 1,
|
||||||
|
kind: 'rect',
|
||||||
|
rain: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => void }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const onDoneRef = useRef(onDone);
|
||||||
|
onDoneRef.current = onDone;
|
||||||
|
const tier = amountTier(order.payAmount);
|
||||||
|
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [order.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stop = playCelebrateSfx(tier);
|
||||||
|
return stop;
|
||||||
|
}, [order.id, tier]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tier !== 3) return;
|
||||||
|
const start = performance.now();
|
||||||
|
const dur = 900;
|
||||||
|
let raf = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const p = Math.min(1, (now - start) / dur);
|
||||||
|
const eased = 1 - (1 - p) ** 3;
|
||||||
|
setDisplayAmount(Math.round(order.payAmount * eased));
|
||||||
|
if (p < 1) raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [order.payAmount, order.id, tier]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = canvas.clientWidth * devicePixelRatio;
|
||||||
|
canvas.height = canvas.clientHeight * devicePixelRatio;
|
||||||
|
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
const particles = spawnParticles(tier, canvas.clientWidth, canvas.clientHeight);
|
||||||
|
let raf = 0;
|
||||||
|
let last = performance.now();
|
||||||
|
const started = performance.now();
|
||||||
|
const gravity = tier === 3 ? 0.18 : 0.14;
|
||||||
|
const loop = (now: number) => {
|
||||||
|
const dt = Math.min(32, now - last) / 16.6;
|
||||||
|
last = now;
|
||||||
|
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
||||||
|
for (const p of particles) {
|
||||||
|
p.vy += gravity * dt;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
p.rot += p.vr * dt;
|
||||||
|
p.life -= (tier === 3 ? 0.002 : 0.003) * dt;
|
||||||
|
if (
|
||||||
|
p.rain &&
|
||||||
|
(p.y > canvas.clientHeight + 30 || p.life <= 0) &&
|
||||||
|
now - started < FX_DURATION_MS - 400
|
||||||
|
) {
|
||||||
|
p.x = Math.random() * canvas.clientWidth;
|
||||||
|
p.y = -16 - Math.random() * 60;
|
||||||
|
p.vx = (Math.random() - 0.5) * 1.4;
|
||||||
|
p.vy = 3 + Math.random() * 5;
|
||||||
|
p.life = 1;
|
||||||
|
}
|
||||||
|
if (p.life <= 0) continue;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate((p.rot * Math.PI) / 180);
|
||||||
|
ctx.globalAlpha = Math.max(0, p.life);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
if (p.kind === 'spark') {
|
||||||
|
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
const onResize = () => resize();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
};
|
||||||
|
}, [order.id, tier]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`big-screen-fx big-screen-fx--t${tier}`} role="presentation">
|
||||||
|
<canvas ref={canvasRef} className="big-screen-fx-canvas" />
|
||||||
|
{tier === 3 ? <div className="big-screen-fx-shock" /> : null}
|
||||||
|
{tier === 3 ? <div className="big-screen-fx-shock big-screen-fx-shock--late" /> : null}
|
||||||
|
{tier >= 2 ? <div className="big-screen-fx-sweep" /> : null}
|
||||||
|
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
|
||||||
|
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
|
||||||
|
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
|
||||||
|
<div className={amountCssClass(displayAmount || order.payAmount, 'big-screen-fx-amount')}>
|
||||||
|
¥ {formatAmount(displayAmount)}
|
||||||
|
</div>
|
||||||
|
<div className="big-screen-fx-items">{order.items || '—'}</div>
|
||||||
|
<div className="big-screen-fx-meta">
|
||||||
|
<span>{order.userPhoneMasked || '—'}</span>
|
||||||
|
<span>{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BigScreenPage() {
|
||||||
|
const [items, setItems] = useState<BigScreenOrder[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
||||||
|
const seenIdsRef = useRef<Set<string> | null>(null);
|
||||||
|
const celebratedIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const queueRef = useRef<BigScreenOrder[]>([]);
|
||||||
|
const celebratingRef = useRef(false);
|
||||||
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dayKeyRef = useRef('');
|
||||||
|
const [viewportH, setViewportH] = useState(0);
|
||||||
|
|
||||||
|
const playNext = useCallback(() => {
|
||||||
|
const next = queueRef.current.shift() ?? null;
|
||||||
|
celebratingRef.current = !!next;
|
||||||
|
setCelebrate(next);
|
||||||
|
setPaused(!!next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueueNew = useCallback(
|
||||||
|
(fresh: BigScreenOrder[]) => {
|
||||||
|
const toCelebrate = fresh.filter((o) => !celebratedIdsRef.current.has(o.id));
|
||||||
|
if (!toCelebrate.length) return;
|
||||||
|
for (const o of toCelebrate) celebratedIdsRef.current.add(o.id);
|
||||||
|
const ranked = [...toCelebrate].sort((a, b) => {
|
||||||
|
const td = amountTier(b.payAmount) - amountTier(a.payAmount);
|
||||||
|
if (td !== 0) return td;
|
||||||
|
return +new Date(b.paidAt ?? b.createdAt) - +new Date(a.paidAt ?? a.createdAt);
|
||||||
|
});
|
||||||
|
queueRef.current.push(...ranked);
|
||||||
|
if (!celebratingRef.current) playNext();
|
||||||
|
},
|
||||||
|
[playNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchData = useCallback(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
if (dayKeyRef.current && dayKeyRef.current !== todayKey) {
|
||||||
|
seenIdsRef.current = null;
|
||||||
|
celebratedIdsRef.current = new Set();
|
||||||
|
queueRef.current = [];
|
||||||
|
celebratingRef.current = false;
|
||||||
|
setCelebrate(null);
|
||||||
|
setPaused(false);
|
||||||
|
}
|
||||||
|
dayKeyRef.current = todayKey;
|
||||||
|
|
||||||
|
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
|
||||||
|
.then((d) => {
|
||||||
|
const list = d.items ?? [];
|
||||||
|
setItems(list);
|
||||||
|
setLoadError('');
|
||||||
|
const seen = seenIdsRef.current;
|
||||||
|
if (!seen) {
|
||||||
|
seenIdsRef.current = new Set(list.map((o) => o.id));
|
||||||
|
for (const o of list) celebratedIdsRef.current.add(o.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fresh = list.filter((o) => !seen.has(o.id));
|
||||||
|
for (const o of fresh) seen.add(o.id);
|
||||||
|
enqueueNew(fresh);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setLoadError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
});
|
||||||
|
}, [enqueueNew]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchData();
|
||||||
|
const id = setInterval(() => void fetchData(), POLL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
unlockCelebrateAudio();
|
||||||
|
const unlock = () => unlockCelebrateAudio();
|
||||||
|
window.addEventListener('pointerdown', unlock, { once: true });
|
||||||
|
window.addEventListener('keydown', unlock, { once: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', unlock);
|
||||||
|
window.removeEventListener('keydown', unlock);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const prevHtml = html.style.overflow;
|
||||||
|
const prevBody = document.body.style.overflow;
|
||||||
|
html.style.overflow = 'hidden';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
html.style.overflow = prevHtml;
|
||||||
|
document.body.style.overflow = prevBody;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setViewportH(el.clientHeight);
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unitItems = useMemo(() => {
|
||||||
|
if (!items.length) return [];
|
||||||
|
const rowH = 80;
|
||||||
|
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
|
||||||
|
const minCount = Math.max(MIN_SCROLL_ROWS, visible + 1);
|
||||||
|
const unit: BigScreenOrder[] = [];
|
||||||
|
while (unit.length < minCount) unit.push(...items);
|
||||||
|
return unit;
|
||||||
|
}, [items, viewportH]);
|
||||||
|
|
||||||
|
const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]);
|
||||||
|
const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS;
|
||||||
|
const rolling = items.length > 0 && unitItems.length > 0;
|
||||||
|
const latestId = items[0]?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="big-screen-page">
|
||||||
|
<div className="big-screen-stars" aria-hidden />
|
||||||
|
<div className="big-screen-frame" aria-hidden>
|
||||||
|
<span className="big-screen-corner big-screen-corner--tl" />
|
||||||
|
<span className="big-screen-corner big-screen-corner--tr" />
|
||||||
|
<span className="big-screen-corner big-screen-corner--bl" />
|
||||||
|
<span className="big-screen-corner big-screen-corner--br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className="big-screen-header">
|
||||||
|
<div className="big-screen-brand">
|
||||||
|
<h1 className="big-screen-title">杜康好客</h1>
|
||||||
|
<span className="big-screen-subtitle">· 发布会现场</span>
|
||||||
|
</div>
|
||||||
|
<div className="big-screen-live-wrap">
|
||||||
|
<span className="big-screen-live-label">实时成交</span>
|
||||||
|
<span className="big-screen-live">
|
||||||
|
<span className="big-screen-live-dot" />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<LiveClock />
|
||||||
|
|
||||||
|
<div className="big-screen-list">
|
||||||
|
<div className="big-screen-list-body" ref={viewportRef}>
|
||||||
|
{items.length === 0 ? null : (
|
||||||
|
<div
|
||||||
|
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
|
||||||
|
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
|
||||||
|
>
|
||||||
|
{trackItems.map((o, idx) => (
|
||||||
|
<OrderRow
|
||||||
|
key={`${o.id}-${idx}`}
|
||||||
|
order={o}
|
||||||
|
latest={idx % items.length === 0 && o.id === latestId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{celebrate ? (
|
||||||
|
<CelebrateFx
|
||||||
|
key={celebrate.id}
|
||||||
|
order={celebrate}
|
||||||
|
onDone={playNext}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,10 @@ import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/con
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import CityPartnersPanel from '../components/CityPartnersPanel';
|
import CityPartnersPanel from '../components/CityPartnersPanel';
|
||||||
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type WarehouseRow = {
|
type WarehouseRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -223,9 +227,16 @@ export default function CitiesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
{
|
||||||
|
title: '城市',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 100,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
||||||
@@ -261,7 +272,7 @@ export default function CitiesPage() {
|
|||||||
|
|
||||||
const warehouseColumns: ColumnsType<WarehouseRow> = [
|
const warehouseColumns: ColumnsType<WarehouseRow> = [
|
||||||
{ title: '仓库名', dataIndex: 'name' },
|
{ title: '仓库名', dataIndex: 'name' },
|
||||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
{ title: '地址', dataIndex: 'address' },
|
||||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||||
{
|
{
|
||||||
@@ -270,7 +281,7 @@ export default function CitiesPage() {
|
|||||||
width: 100,
|
width: 100,
|
||||||
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
|
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
|
||||||
},
|
},
|
||||||
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
|
{ title: '合伙人', dataIndex: 'partnerCompanyName', render: (v) => v || '—' },
|
||||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -311,12 +322,16 @@ export default function CitiesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('cities', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>城市</Typography.Title>
|
<AdminListHeader
|
||||||
<Button type="primary" onClick={openCreateModal}>新建城市</Button>
|
title="城市"
|
||||||
</Space>
|
settings={settingsButton}
|
||||||
|
actions={<Button type="primary" onClick={openCreateModal}>新建城市</Button>}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
||||||
@@ -325,7 +340,7 @@ export default function CitiesPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
<Table scroll={{ x: 'max-content' }} rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
|
|
||||||
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
@@ -623,9 +638,9 @@ export default function CitiesPage() {
|
|||||||
)}
|
)}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '类型', dataIndex: 'kind', width: 80 },
|
{ title: '类型', dataIndex: 'kind', width: 80 },
|
||||||
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
{ title: '名称', dataIndex: 'name' },
|
||||||
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||||
{ title: '归属', dataIndex: 'parent', ellipsis: true },
|
{ title: '归属', dataIndex: 'parent' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -640,13 +655,12 @@ export default function CitiesPage() {
|
|||||||
locale={{ emptyText: '无门店' }}
|
locale={{ emptyText: '无门店' }}
|
||||||
dataSource={deletePreview.stores}
|
dataSource={deletePreview.stores}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '门店名', dataIndex: 'name', ellipsis: true },
|
{ title: '门店名', dataIndex: 'name' },
|
||||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
width: 140,
|
width: 140,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
|
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
|||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type SubRow = PartnerSubAccountRow;
|
type SubRow = PartnerSubAccountRow;
|
||||||
|
|
||||||
@@ -59,6 +63,7 @@ type Row = {
|
|||||||
accountCount: number;
|
accountCount: number;
|
||||||
subAccounts?: SubRow[];
|
subAccounts?: SubRow[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isTest?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerDetail = Row & {
|
type PartnerDetail = Row & {
|
||||||
@@ -124,14 +129,15 @@ export default function CityPartnersPage() {
|
|||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
const [subEditForm] = Form.useForm();
|
const [subEditForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/partners',
|
'/admin/partners',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
if (filters.companyName) qs.set('companyName', String(filters.companyName));
|
||||||
if (filters.phone) qs.set('phone', filters.phone);
|
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||||
|
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -259,18 +265,34 @@ export default function CityPartnersPage() {
|
|||||||
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
|
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||||
{
|
{
|
||||||
title: '区县',
|
title: '区县',
|
||||||
dataIndex: 'districtCodes',
|
dataIndex: 'districtCodes',
|
||||||
width: 160,
|
width: 160,
|
||||||
ellipsis: true,
|
|
||||||
render: (codes: string[] | null | undefined, row) =>
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
},
|
},
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
{
|
||||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
title: '公司名',
|
||||||
|
dataIndex: 'companyName',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主账号姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 120,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
@@ -298,7 +320,6 @@ export default function CityPartnersPage() {
|
|||||||
title: '管仓仓库',
|
title: '管仓仓库',
|
||||||
dataIndex: 'managedWarehouseName',
|
dataIndex: 'managedWarehouseName',
|
||||||
width: 110,
|
width: 110,
|
||||||
ellipsis: true,
|
|
||||||
render: (v) => v || '—',
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -325,30 +346,34 @@ export default function CityPartnersPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
城市合伙人
|
title="城市合伙人"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Button
|
actions={
|
||||||
type="primary"
|
<Button
|
||||||
onClick={() => {
|
type="primary"
|
||||||
createForm.resetFields();
|
onClick={() => {
|
||||||
createForm.setFieldsValue({
|
createForm.resetFields();
|
||||||
orderCommissionRate: 0,
|
createForm.setFieldsValue({
|
||||||
redeemCommissionRate: 3,
|
orderCommissionRate: 0,
|
||||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
redeemCommissionRate: 3,
|
||||||
});
|
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
});
|
||||||
setCreateMaxRate(0.05);
|
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||||
setCreateCityCode(undefined);
|
setCreateMaxRate(0.05);
|
||||||
setCreateOpen(true);
|
setCreateCityCode(undefined);
|
||||||
}}
|
setCreateOpen(true);
|
||||||
>
|
}}
|
||||||
新建合伙人
|
>
|
||||||
</Button>
|
新建合伙人
|
||||||
</Space>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
@@ -387,6 +412,9 @@ export default function CityPartnersPage() {
|
|||||||
<Form.Item name="phone" label="手机">
|
<Form.Item name="phone" label="手机">
|
||||||
<Input allowClear placeholder="登录手机" />
|
<Input allowClear placeholder="登录手机" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
@@ -411,7 +439,7 @@ export default function CityPartnersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={toTableRows(data?.items ?? [])}
|
dataSource={toTableRows(data?.items ?? [])}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
childrenColumnName="__noTreeChildren__"
|
childrenColumnName="__noTreeChildren__"
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowRender: (record) => (
|
expandedRowRender: (record) => (
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -30,6 +29,10 @@ import {
|
|||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -70,6 +73,7 @@ function FulfillmentFields({
|
|||||||
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||||||
}) {
|
}) {
|
||||||
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -219,7 +223,7 @@ export default function CityWarehousesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '城市',
|
title: '城市',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -229,8 +233,15 @@ export default function CityWarehousesPage() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
|
{
|
||||||
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
|
title: '仓库',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '地址', dataIndex: 'address', width: 180 },
|
||||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||||
{
|
{
|
||||||
@@ -242,7 +253,6 @@ export default function CityWarehousesPage() {
|
|||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
dataIndex: 'partnerCompanyName',
|
dataIndex: 'partnerCompanyName',
|
||||||
width: 120,
|
width: 120,
|
||||||
ellipsis: true,
|
|
||||||
render: (v, row) =>
|
render: (v, row) =>
|
||||||
v && row.partnerAccountId ? (
|
v && row.partnerAccountId ? (
|
||||||
<Link to="/city-partners">{v}</Link>
|
<Link to="/city-partners">{v}</Link>
|
||||||
@@ -298,28 +308,37 @@ export default function CityWarehousesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-warehouses', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
<AdminListHeader
|
||||||
<Button
|
title="仓库"
|
||||||
type="primary"
|
settings={settingsButton}
|
||||||
onClick={() => {
|
actions={
|
||||||
createForm.resetFields();
|
<Button
|
||||||
setCreateManagerType(WarehouseManagerType.HQ);
|
type="primary"
|
||||||
setCreateCityId(undefined);
|
onClick={() => {
|
||||||
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
createForm.resetFields();
|
||||||
createForm.setFieldsValue({
|
setCreateManagerType(WarehouseManagerType.HQ);
|
||||||
managerType: WarehouseManagerType.HQ,
|
setCreateCityId(undefined);
|
||||||
status: WarehouseStatus.ACTIVE,
|
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||||||
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
createForm.setFieldsValue({
|
||||||
});
|
managerType: WarehouseManagerType.HQ,
|
||||||
setCreateOpen(true);
|
status: WarehouseStatus.ACTIVE,
|
||||||
}}
|
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||||||
>
|
});
|
||||||
新增仓库
|
setCreateOpen(true);
|
||||||
</Button>
|
}}
|
||||||
</Space>
|
>
|
||||||
|
新增仓库
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
@@ -367,7 +386,7 @@ export default function CityWarehousesPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1400 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -64,6 +64,20 @@ export default function DashboardPage() {
|
|||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
const keys = profile?.permissionKeys ?? [];
|
||||||
|
const has = (k: string) => keys.includes(k);
|
||||||
|
const cityScoped = !!profile?.cityScoped;
|
||||||
|
const canUsers = has('users');
|
||||||
|
const canOrders = has('orders');
|
||||||
|
const canStores = has('stores');
|
||||||
|
const canStoreAudits = has('store_audits');
|
||||||
|
const canPartners = has('partners');
|
||||||
|
const canPromo = has('promo_codes');
|
||||||
|
const canFinance = has('finance');
|
||||||
|
const canBenefit = has('benefit');
|
||||||
|
const canDeliveries = has('deliveries');
|
||||||
|
const canTickets = has('tickets');
|
||||||
|
const permsReady = profile !== null;
|
||||||
|
|
||||||
const [filterForm] = Form.useForm<{
|
const [filterForm] = Form.useForm<{
|
||||||
range: [Dayjs, Dayjs];
|
range: [Dayjs, Dayjs];
|
||||||
@@ -115,18 +129,26 @@ export default function DashboardPage() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
});
|
});
|
||||||
|
}, [loadVersion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return;
|
||||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
.then((res) => setCities(res.items ?? []))
|
.then((res) => setCities(res.items ?? []))
|
||||||
.catch(() => setCities([]));
|
.catch(() => setCities([]));
|
||||||
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
if ((profile.permissionKeys ?? []).includes('promo_codes')) {
|
||||||
.then((res) => setPromos(res.items ?? []))
|
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
.catch(() => setPromos([]));
|
.then((res) => setPromos(res.items ?? []))
|
||||||
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
|
.catch(() => setPromos([]));
|
||||||
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
|
}
|
||||||
)
|
if ((profile.permissionKeys ?? []).includes('partners')) {
|
||||||
.then((res) => setPartners(res.items ?? []))
|
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
|
||||||
.catch(() => setPartners([]));
|
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
|
||||||
}, [loadVersion]);
|
)
|
||||||
|
.then((res) => setPartners(res.items ?? []))
|
||||||
|
.catch(() => setPartners([]));
|
||||||
|
}
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadAnalytics(filters);
|
void loadAnalytics(filters);
|
||||||
@@ -206,9 +228,19 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
const byDateOption = useMemo<EChartsOption>(() => {
|
const byDateOption = useMemo<EChartsOption>(() => {
|
||||||
const rows = analytics?.byDate ?? [];
|
const rows = analytics?.byDate ?? [];
|
||||||
|
const legend: string[] = [];
|
||||||
|
const series: EChartsOption['series'] = [];
|
||||||
|
if (canUsers) {
|
||||||
|
legend.push('新增用户');
|
||||||
|
series.push({ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) });
|
||||||
|
}
|
||||||
|
if (canOrders) {
|
||||||
|
legend.push('订单数');
|
||||||
|
series.push({ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) });
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['新增用户', '订单数'] },
|
legend: { data: legend },
|
||||||
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -216,18 +248,25 @@ export default function DashboardPage() {
|
|||||||
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
||||||
},
|
},
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
series: [
|
series,
|
||||||
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
|
|
||||||
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}, [analytics]);
|
}, [analytics, canUsers, canOrders]);
|
||||||
|
|
||||||
const byCityOption = useMemo<EChartsOption>(() => {
|
const byCityOption = useMemo<EChartsOption>(() => {
|
||||||
const rows = analytics?.byCity ?? [];
|
const rows = analytics?.byCity ?? [];
|
||||||
|
const legend: string[] = [];
|
||||||
|
const series: EChartsOption['series'] = [];
|
||||||
|
if (canUsers) {
|
||||||
|
legend.push('用户');
|
||||||
|
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
|
||||||
|
}
|
||||||
|
if (canOrders) {
|
||||||
|
legend.push('订单');
|
||||||
|
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['用户', '订单'] },
|
legend: { data: legend },
|
||||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -235,18 +274,25 @@ export default function DashboardPage() {
|
|||||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||||
},
|
},
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
series: [
|
series,
|
||||||
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
|
||||||
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}, [analytics]);
|
}, [analytics, canUsers, canOrders]);
|
||||||
|
|
||||||
const byPromoOption = useMemo<EChartsOption>(() => {
|
const byPromoOption = useMemo<EChartsOption>(() => {
|
||||||
const rows = analytics?.byPromo ?? [];
|
const rows = analytics?.byPromo ?? [];
|
||||||
|
const legend: string[] = [];
|
||||||
|
const series: EChartsOption['series'] = [];
|
||||||
|
if (canUsers) {
|
||||||
|
legend.push('用户');
|
||||||
|
series.push({ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 });
|
||||||
|
}
|
||||||
|
if (canOrders) {
|
||||||
|
legend.push('订单');
|
||||||
|
series.push({ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 });
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['用户', '订单'] },
|
legend: { data: legend },
|
||||||
grid: { left: 48, right: 20, top: 40, bottom: 64 },
|
grid: { left: 48, right: 20, top: 40, bottom: 64 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -254,18 +300,36 @@ export default function DashboardPage() {
|
|||||||
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
||||||
},
|
},
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
series: [
|
series,
|
||||||
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
|
||||||
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}, [analytics]);
|
}, [analytics, canUsers, canOrders]);
|
||||||
|
|
||||||
const opsByDateOption = useMemo<EChartsOption>(() => {
|
const opsByDateOption = useMemo<EChartsOption>(() => {
|
||||||
const rows = analytics?.byDate ?? [];
|
const rows = analytics?.byDate ?? [];
|
||||||
|
const legend: string[] = [];
|
||||||
|
const series: NonNullable<EChartsOption['series']> = [];
|
||||||
|
if (canPartners) {
|
||||||
|
legend.push('新增合伙人');
|
||||||
|
series.push({ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) });
|
||||||
|
}
|
||||||
|
if (canStores) {
|
||||||
|
legend.push('新签门店');
|
||||||
|
series.push({ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) });
|
||||||
|
}
|
||||||
|
if (canBenefit) {
|
||||||
|
legend.push('核销笔数', '核销金额');
|
||||||
|
series.push({ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) });
|
||||||
|
series.push({
|
||||||
|
name: '核销金额',
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: rows.map((r) => r.redeemAmount),
|
||||||
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] },
|
legend: { data: legend },
|
||||||
grid: { left: 48, right: 48, top: 48, bottom: 40 },
|
grid: { left: 48, right: 48, top: 48, bottom: 40 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -276,26 +340,29 @@ export default function DashboardPage() {
|
|||||||
{ type: 'value', name: '数量', minInterval: 1 },
|
{ type: 'value', name: '数量', minInterval: 1 },
|
||||||
{ type: 'value', name: '金额', minInterval: 1 },
|
{ type: 'value', name: '金额', minInterval: 1 },
|
||||||
],
|
],
|
||||||
series: [
|
series,
|
||||||
{ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) },
|
|
||||||
{ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) },
|
|
||||||
{ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) },
|
|
||||||
{
|
|
||||||
name: '核销金额',
|
|
||||||
type: 'line',
|
|
||||||
smooth: true,
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: rows.map((r) => r.redeemAmount),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}, [analytics]);
|
}, [analytics, canPartners, canStores, canBenefit]);
|
||||||
|
|
||||||
const opsByCityOption = useMemo<EChartsOption>(() => {
|
const opsByCityOption = useMemo<EChartsOption>(() => {
|
||||||
const rows = analytics?.byCity ?? [];
|
const rows = analytics?.byCity ?? [];
|
||||||
|
const legend: string[] = [];
|
||||||
|
const series: EChartsOption['series'] = [];
|
||||||
|
if (canPartners) {
|
||||||
|
legend.push('合伙人');
|
||||||
|
series.push({ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 });
|
||||||
|
}
|
||||||
|
if (canStores) {
|
||||||
|
legend.push('门店');
|
||||||
|
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
|
||||||
|
}
|
||||||
|
if (canBenefit) {
|
||||||
|
legend.push('核销笔数');
|
||||||
|
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['合伙人', '门店', '核销笔数'] },
|
legend: { data: legend },
|
||||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -303,19 +370,31 @@ export default function DashboardPage() {
|
|||||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||||
},
|
},
|
||||||
yAxis: { type: 'value', minInterval: 1 },
|
yAxis: { type: 'value', minInterval: 1 },
|
||||||
series: [
|
series,
|
||||||
{ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 },
|
|
||||||
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
|
||||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}, [analytics]);
|
}, [analytics, canPartners, canStores, canBenefit]);
|
||||||
|
|
||||||
const opsByPartnerOption = useMemo<EChartsOption>(() => {
|
const opsByPartnerOption = useMemo<EChartsOption>(() => {
|
||||||
const rows = analytics?.byPartner ?? [];
|
const rows = analytics?.byPartner ?? [];
|
||||||
|
const legend: string[] = [];
|
||||||
|
const series: NonNullable<EChartsOption['series']> = [];
|
||||||
|
if (canStores) {
|
||||||
|
legend.push('门店');
|
||||||
|
series.push({ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 });
|
||||||
|
}
|
||||||
|
if (canBenefit) {
|
||||||
|
legend.push('核销笔数', '核销金额');
|
||||||
|
series.push({ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 });
|
||||||
|
series.push({
|
||||||
|
name: '核销金额',
|
||||||
|
type: 'line',
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: rows.map((r) => r.redeemAmount),
|
||||||
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tooltip: { trigger: 'axis' },
|
tooltip: { trigger: 'axis' },
|
||||||
legend: { data: ['门店', '核销笔数', '核销金额'] },
|
legend: { data: legend },
|
||||||
grid: { left: 48, right: 48, top: 40, bottom: 64 },
|
grid: { left: 48, right: 48, top: 40, bottom: 64 },
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
@@ -326,18 +405,9 @@ export default function DashboardPage() {
|
|||||||
{ type: 'value', name: '数量', minInterval: 1 },
|
{ type: 'value', name: '数量', minInterval: 1 },
|
||||||
{ type: 'value', name: '金额' },
|
{ type: 'value', name: '金额' },
|
||||||
],
|
],
|
||||||
series: [
|
series,
|
||||||
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
|
||||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
|
||||||
{
|
|
||||||
name: '核销金额',
|
|
||||||
type: 'line',
|
|
||||||
yAxisIndex: 1,
|
|
||||||
data: rows.map((r) => r.redeemAmount),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}, [analytics]);
|
}, [analytics, canStores, canBenefit]);
|
||||||
|
|
||||||
const redeemByCityOption = useMemo<EChartsOption>(() => {
|
const redeemByCityOption = useMemo<EChartsOption>(() => {
|
||||||
const rows = analytics?.byCity ?? [];
|
const rows = analytics?.byCity ?? [];
|
||||||
@@ -368,7 +438,9 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
<Typography.Title level={4}>
|
||||||
|
{cityScoped ? '数据概览(负责城市)' : '数据概览'}
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
{isSuperAdmin ? (
|
{isSuperAdmin ? (
|
||||||
<Card
|
<Card
|
||||||
@@ -411,46 +483,94 @@ export default function DashboardPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
{!permsReady ? (
|
||||||
<Card loading={loading}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
<Card loading />
|
||||||
</Card>
|
</Col>
|
||||||
</Col>
|
) : null}
|
||||||
<Col xs={24} sm={12} lg={6}>
|
{permsReady && canUsers ? (
|
||||||
<Card loading={loading}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
<Card loading={loading}>
|
||||||
</Card>
|
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
||||||
</Col>
|
</Card>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
</Col>
|
||||||
<Card loading={loading}>
|
) : null}
|
||||||
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
{permsReady && canUsers ? (
|
||||||
</Card>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
</Col>
|
<Card loading={loading}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
||||||
<Card loading={loading}>
|
</Card>
|
||||||
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
</Col>
|
||||||
</Card>
|
) : null}
|
||||||
</Col>
|
{permsReady && canUsers ? (
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Card loading={loading}>
|
<Card loading={loading}>
|
||||||
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
) : null}
|
||||||
<Card loading={loading}>
|
{permsReady && canOrders ? (
|
||||||
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
<Col xs={24} sm={12} lg={6}>
|
||||||
</Card>
|
<Card loading={loading}>
|
||||||
</Col>
|
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
||||||
<Col xs={24} sm={12} lg={6}>
|
</Card>
|
||||||
<Card loading={loading}>
|
</Col>
|
||||||
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
) : null}
|
||||||
</Card>
|
{permsReady && canStores ? (
|
||||||
</Col>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Card loading={loading}>
|
||||||
<Card loading={loading}>
|
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
||||||
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
</Card>
|
||||||
</Card>
|
</Col>
|
||||||
</Col>
|
) : null}
|
||||||
|
{permsReady && canStores ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading} extra={<Link to="/stores?auditStatus=PENDING">去处理</Link>}>
|
||||||
|
<Statistic
|
||||||
|
title="待入驻审核"
|
||||||
|
value={stats?.pendingStoreOnboard ?? 0}
|
||||||
|
valueStyle={{ color: (stats?.pendingStoreOnboard ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{permsReady && canStoreAudits ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading} extra={<Link to="/store-package-audits">去处理</Link>}>
|
||||||
|
<Statistic
|
||||||
|
title="待套餐/信息审核"
|
||||||
|
value={(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0)}
|
||||||
|
valueStyle={{
|
||||||
|
color:
|
||||||
|
(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0) > 0
|
||||||
|
? '#fa8c16'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{permsReady && canPartners ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{permsReady && canBenefit ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{permsReady && canDeliveries ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
@@ -478,62 +598,73 @@ export default function DashboardPage() {
|
|||||||
<Form.Item name="cityId" label="城市">
|
<Form.Item name="cityId" label="城市">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="全部开城"
|
placeholder={cityScoped ? '全部负责城市' : '全部开城'}
|
||||||
style={{ width: 160 }}
|
style={{ width: 160 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'none', label: '未选城' },
|
...(cityScoped ? [] : [{ value: 'none', label: '未选城' }]),
|
||||||
...cities.map((c) => ({ value: c.id, label: c.name })),
|
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="promoCodeId" label="推广码">
|
{canPromo ? (
|
||||||
<Select
|
<Form.Item name="promoCodeId" label="推广码">
|
||||||
allowClear
|
<Select
|
||||||
placeholder="全部来源"
|
allowClear
|
||||||
style={{ width: 200 }}
|
placeholder="全部来源"
|
||||||
options={[
|
style={{ width: 200 }}
|
||||||
{ value: 'none', label: '自然量 / 无推广码' },
|
options={[
|
||||||
...promos.map((p) => ({ value: p.id, label: `${p.name}(${p.code})` })),
|
{ value: 'none', label: '自然量 / 无推广码' },
|
||||||
]}
|
...promos.map((p) => ({ value: p.id, label: `${p.name}(${p.code})` })),
|
||||||
/>
|
]}
|
||||||
</Form.Item>
|
/>
|
||||||
<Form.Item name="partnerAccountId" label="合伙人">
|
</Form.Item>
|
||||||
<Select
|
) : null}
|
||||||
allowClear
|
{canPartners ? (
|
||||||
placeholder="全部合伙人"
|
<Form.Item name="partnerAccountId" label="合伙人">
|
||||||
style={{ width: 200 }}
|
<Select
|
||||||
options={partners.map((p) => ({
|
allowClear
|
||||||
value: p.id,
|
placeholder="全部合伙人"
|
||||||
label: p.companyName || p.name,
|
style={{ width: 200 }}
|
||||||
}))}
|
options={partners.map((p) => ({
|
||||||
/>
|
value: p.id,
|
||||||
</Form.Item>
|
label: p.companyName || p.name,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{canUsers || canOrders ? (
|
||||||
<Card
|
<Card
|
||||||
title="用户 / 订单统计"
|
title={canUsers && canOrders ? '用户 / 订单统计' : canUsers ? '用户统计' : '订单统计'}
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Link to={`/orders?${ordersDrillQs}`}>查看订单</Link>
|
{canOrders ? <Link to={`/orders?${ordersDrillQs}`}>查看订单</Link> : null}
|
||||||
<Link to="/users">查看用户</Link>
|
{canUsers ? <Link to="/users">查看用户</Link> : null}
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} sm={8}>
|
{canUsers ? (
|
||||||
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
|
<Col xs={24} sm={8}>
|
||||||
</Col>
|
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
|
||||||
<Col xs={24} sm={8}>
|
</Col>
|
||||||
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
|
) : null}
|
||||||
</Col>
|
{canOrders ? (
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={8}>
|
||||||
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
|
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{canOrders ? (
|
||||||
|
<Col xs={24} sm={8}>
|
||||||
|
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
@@ -542,7 +673,7 @@ export default function DashboardPage() {
|
|||||||
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
|
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={canPromo ? 12 : 24}>
|
||||||
<Card
|
<Card
|
||||||
type="inner"
|
type="inner"
|
||||||
title="按城市(点击柱联动筛选)"
|
title="按城市(点击柱联动筛选)"
|
||||||
@@ -562,6 +693,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
{canPromo ? (
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
<Card
|
||||||
type="inner"
|
type="inner"
|
||||||
@@ -584,42 +716,53 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canPartners || canStores || canBenefit ? (
|
||||||
<Card
|
<Card
|
||||||
title="合伙人 / 门店 / 核销统计"
|
title={[canPartners && '合伙人', canStores && '门店', canBenefit && '核销'].filter(Boolean).join(' / ') + '统计'}
|
||||||
style={{ marginBottom: 24 }}
|
style={{ marginBottom: 24 }}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Link to="/city-partners">查看合伙人</Link>
|
{canPartners ? <Link to="/city-partners">查看合伙人</Link> : null}
|
||||||
<Link to="/stores">查看门店</Link>
|
{canStores ? <Link to="/stores">查看门店</Link> : null}
|
||||||
<Link to="/redeem-records">查看核销</Link>
|
{canBenefit ? <Link to="/redeem-records">查看核销</Link> : null}
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col xs={24} sm={6}>
|
{canPartners ? (
|
||||||
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
|
<Col xs={24} sm={6}>
|
||||||
</Col>
|
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
|
||||||
<Col xs={24} sm={6}>
|
</Col>
|
||||||
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
|
) : null}
|
||||||
</Col>
|
{canStores ? (
|
||||||
<Col xs={24} sm={6}>
|
<Col xs={24} sm={6}>
|
||||||
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
|
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={6}>
|
) : null}
|
||||||
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
|
{canBenefit ? (
|
||||||
</Col>
|
<Col xs={24} sm={6}>
|
||||||
|
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{canBenefit ? (
|
||||||
|
<Col xs={24} sm={6}>
|
||||||
|
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24}>
|
<Col xs={24}>
|
||||||
<Card type="inner" title="按日趋势(合伙人 / 门店 / 核销)" loading={analyticsLoading} size="small">
|
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
|
||||||
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
|
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={canBenefit ? 12 : 24}>
|
||||||
<Card
|
<Card
|
||||||
type="inner"
|
type="inner"
|
||||||
title="按城市(点击柱联动筛选)"
|
title="按城市(点击柱联动筛选)"
|
||||||
@@ -639,6 +782,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
{canBenefit ? (
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
<Card
|
||||||
type="inner"
|
type="inner"
|
||||||
@@ -659,6 +803,8 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{canPartners && (canStores || canBenefit) ? (
|
||||||
<Col xs={24}>
|
<Col xs={24}>
|
||||||
<Card
|
<Card
|
||||||
type="inner"
|
type="inner"
|
||||||
@@ -681,10 +827,14 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canFinance || canTickets ? (
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
{canFinance ? (
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card
|
<Card
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -704,6 +854,8 @@ export default function DashboardPage() {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{canFinance ? (
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card
|
<Card
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -717,6 +869,8 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{canFinance ? (
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card
|
<Card
|
||||||
loading={loading}
|
loading={loading}
|
||||||
@@ -737,20 +891,28 @@ export default function DashboardPage() {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{canTickets ? (
|
||||||
<Col xs={24} sm={12} lg={8}>
|
<Col xs={24} sm={12} lg={8}>
|
||||||
<Card loading={loading} title="待处理工单">
|
<Card loading={loading} title="待处理工单">
|
||||||
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canUsers || canOrders ? (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={12}>
|
{canUsers ? (
|
||||||
|
<Col xs={24} lg={canOrders ? 12 : 24}>
|
||||||
<Card title="已合并访客账号" loading={loading}>
|
<Card title="已合并访客账号" loading={loading}>
|
||||||
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={12}>
|
) : null}
|
||||||
|
{canOrders ? (
|
||||||
|
<Col xs={24} lg={canUsers ? 12 : 24}>
|
||||||
<Card title="订单状态分布" loading={loading}>
|
<Card title="订单状态分布" loading={loading}>
|
||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
@@ -768,7 +930,9 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,32 @@ import {
|
|||||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
|
id: string;
|
||||||
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
|
orderId: string;
|
||||||
|
provider: string;
|
||||||
|
trackingNo: string | null;
|
||||||
|
providerOrderNo: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
/** 当次应付物流费 */
|
||||||
|
logisticsFee?: number | null;
|
||||||
|
order?: {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
receiverName: string;
|
||||||
|
receiverPhone: string;
|
||||||
|
deliveryType: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DeliveriesPage() {
|
export default function DeliveriesPage() {
|
||||||
@@ -29,38 +48,82 @@ export default function DeliveriesPage() {
|
|||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [trackOpen, setTrackOpen] = useState(false);
|
||||||
|
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
|
||||||
|
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
|
||||||
|
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
function openTrack(row: Row) {
|
||||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
const orderId = row.orderId || row.order?.id;
|
||||||
|
if (!orderId) {
|
||||||
|
message.warning('缺少关联订单,无法查询路由');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTrackOrderId(orderId);
|
||||||
|
setTrackOrderNo(row.order?.orderNo ?? null);
|
||||||
|
setTrackOrderStatus(row.order?.status ?? null);
|
||||||
|
setTrackOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<Row> = [
|
||||||
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: ['order', 'orderNo'],
|
||||||
|
width: 170,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '运费',
|
||||||
|
dataIndex: 'logisticsFee',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
||||||
|
},
|
||||||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||||
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
||||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 80,
|
title: '操作', width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Space size={0}>
|
||||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||||||
setDetail(d);
|
<Button type="link" size="small" onClick={async () => {
|
||||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||||
setDrawerOpen(true);
|
setDetail(d);
|
||||||
}}>编辑</Button>
|
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}>编辑</Button>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
{settingsModal}
|
||||||
|
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -81,15 +144,31 @@ export default function DeliveriesPage() {
|
|||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运费">
|
||||||
|
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
<Button
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={() => openTrack(detail)}
|
||||||
|
>
|
||||||
|
查看路由
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
<OrderTrackDrawer
|
||||||
|
open={trackOpen}
|
||||||
|
orderId={trackOrderId}
|
||||||
|
orderNo={trackOrderNo}
|
||||||
|
orderStatus={trackOrderStatus}
|
||||||
|
onClose={() => setTrackOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Form,
|
Form,
|
||||||
|
Image,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
@@ -16,14 +17,23 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import {
|
import {
|
||||||
DEV_PLAN_TASK_STATUS_LABELS,
|
DEV_PLAN_TASK_STATUS_LABELS,
|
||||||
DEV_PLAN_TASK_TYPE_LABELS,
|
DEV_PLAN_TASK_TYPE_LABELS,
|
||||||
|
SUPPORT_TICKET_STATUS_LABELS,
|
||||||
type DevPlanTaskDto,
|
type DevPlanTaskDto,
|
||||||
|
type DevPlanTaskExportFormat,
|
||||||
type DevPlanTaskStatusDto,
|
type DevPlanTaskStatusDto,
|
||||||
type DevPlanTaskTypeDto,
|
type DevPlanTaskTypeDto,
|
||||||
type DevPlanVersionDto,
|
type DevPlanVersionDto,
|
||||||
|
type SupportTicketDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
||||||
value: v,
|
value: v,
|
||||||
@@ -50,9 +60,12 @@ export default function DevPlanTasksPage() {
|
|||||||
const [batchEditOpen, setBatchEditOpen] = useState(false);
|
const [batchEditOpen, setBatchEditOpen] = useState(false);
|
||||||
const [batchSaving, setBatchSaving] = useState(false);
|
const [batchSaving, setBatchSaving] = useState(false);
|
||||||
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
||||||
|
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
|
||||||
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
|
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [dispatching, setDispatching] = useState(false);
|
const [dispatching, setDispatching] = useState(false);
|
||||||
|
const [exportFormat, setExportFormat] = useState<DevPlanTaskExportFormat>('xlsx');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
|
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
|
||||||
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
||||||
@@ -64,6 +77,13 @@ export default function DevPlanTasksPage() {
|
|||||||
.catch(() => setVersions([]));
|
.catch(() => setVersions([]));
|
||||||
}, [batchEditOpen]);
|
}, [batchEditOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen || editing) return;
|
||||||
|
request<{ items: SupportTicketDto[] }>('/admin/support-tickets?pageSize=100')
|
||||||
|
.then((res) => setSupportTickets(res.items ?? []))
|
||||||
|
.catch(() => setSupportTickets([]));
|
||||||
|
}, [modalOpen, editing]);
|
||||||
|
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
|
||||||
'/admin/dev-plan/tasks',
|
'/admin/dev-plan/tasks',
|
||||||
() => {
|
() => {
|
||||||
@@ -78,30 +98,41 @@ export default function DevPlanTasksPage() {
|
|||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO' });
|
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined, attachmentUrls: [''] });
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(row: DevPlanTaskDto) {
|
function openEdit(row: DevPlanTaskDto) {
|
||||||
setEditing(row);
|
setEditing(row);
|
||||||
form.setFieldsValue({ content: row.content, type: row.type, status: row.status });
|
form.setFieldsValue({
|
||||||
|
content: row.content,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
||||||
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
const attachmentUrls = (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(values),
|
body: JSON.stringify({ ...values, attachmentUrls }),
|
||||||
});
|
});
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
} else {
|
} else {
|
||||||
await request('/admin/dev-plan/tasks', {
|
await request('/admin/dev-plan/tasks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ content: values.content, type: values.type }),
|
body: JSON.stringify({
|
||||||
|
content: values.content,
|
||||||
|
type: values.type,
|
||||||
|
supportTicketId: values.supportTicketId || undefined,
|
||||||
|
attachmentUrls,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
message.success('已创建');
|
message.success('已创建');
|
||||||
}
|
}
|
||||||
@@ -185,8 +216,43 @@ export default function DevPlanTasksPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<DevPlanTaskDto> = [
|
async function exportTasks(scope: 'filter' | 'selected') {
|
||||||
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const result = await request<{
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
contentBase64: string;
|
||||||
|
count: number;
|
||||||
|
}>('/admin/dev-plan/tasks/export', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
scope,
|
||||||
|
format: exportFormat,
|
||||||
|
ids: scope === 'selected' ? selectedRowKeys : undefined,
|
||||||
|
status: filters.status || undefined,
|
||||||
|
type: filters.type || undefined,
|
||||||
|
keyword: filters.keyword || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
||||||
|
message.success(`已导出 ${result.count} 条任务`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
||||||
|
{
|
||||||
|
title: '任务号',
|
||||||
|
dataIndex: 'taskNo',
|
||||||
|
width: 160,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'type',
|
dataIndex: 'type',
|
||||||
@@ -201,7 +267,23 @@ export default function DevPlanTasksPage() {
|
|||||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '内容', dataIndex: 'content', ellipsis: true },
|
{ title: '内容', dataIndex: 'content' },
|
||||||
|
{
|
||||||
|
title: '附件',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.attachmentUrls?.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space size={4}>
|
||||||
|
{row.attachmentUrls.slice(0, 3).map((url) => (
|
||||||
|
<Image key={url} src={url} width={32} height={32} style={{ objectFit: 'cover' }} />
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
||||||
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
||||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
@@ -223,24 +305,45 @@ export default function DevPlanTasksPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
开发计划 · 任务列表
|
title="开发计划 · 任务列表"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Space>
|
actions={
|
||||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
|
<>
|
||||||
批量编辑
|
<Select
|
||||||
</Button>
|
value={exportFormat}
|
||||||
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
|
style={{ width: 120 }}
|
||||||
评审
|
onChange={setExportFormat}
|
||||||
</Button>
|
options={[
|
||||||
<Button type="primary" onClick={openCreate}>
|
{ value: 'markdown', label: 'Markdown' },
|
||||||
新建任务
|
{ value: 'docx', label: 'Word' },
|
||||||
</Button>
|
{ value: 'xlsx', label: 'Excel' },
|
||||||
</Space>
|
{ value: 'pdf', label: 'PDF' },
|
||||||
</div>
|
]}
|
||||||
|
/>
|
||||||
|
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
|
||||||
|
导出已勾选
|
||||||
|
</Button>
|
||||||
|
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
|
||||||
|
导出全部筛选
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
|
||||||
|
批量编辑
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
|
||||||
|
评审
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建任务
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -273,7 +376,7 @@ export default function DevPlanTasksPage() {
|
|||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -305,7 +408,43 @@ export default function DevPlanTasksPage() {
|
|||||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
) : null}
|
) : (
|
||||||
|
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="可选:关联工单"
|
||||||
|
options={supportTickets.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.ticketNo} · ${SUPPORT_TICKET_STATUS_LABELS[t.status]} · ${t.title}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item label="附件图片">
|
||||||
|
<Form.List name="attachmentUrls">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
||||||
|
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
||||||
|
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
|
||||||
|
</Form.Item>
|
||||||
|
{fields.length > 1 ? (
|
||||||
|
<Button type="link" danger onClick={() => remove(field.name)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button type="dashed" onClick={() => add('')} block>
|
||||||
|
添加图片
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -22,6 +21,10 @@ import {
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
|
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
|
||||||
@@ -119,8 +122,10 @@ export default function DevPlanVersionsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<DevPlanVersionDto> = [
|
const baseColumns: ColumnsType<DevPlanVersionDto> = [
|
||||||
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
|
{ title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
) },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -129,7 +134,7 @@ export default function DevPlanVersionsPage() {
|
|||||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
|
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '内容', dataIndex: 'content', ellipsis: true, render: (v) => v || '—' },
|
{ title: '内容', dataIndex: 'content', render: (v) => v || '—' },
|
||||||
{ title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
{ title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||||
{ title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
{ title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||||
{ title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
{ title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||||
@@ -158,16 +163,20 @@ export default function DevPlanVersionsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-versions', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
开发计划 · 版本列表
|
title="开发计划 · 版本列表"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={openCreate}>
|
actions={
|
||||||
新建版本
|
<Button type="primary" onClick={openCreate}>
|
||||||
</Button>
|
新建版本
|
||||||
</div>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -190,7 +199,7 @@ export default function DevPlanVersionsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,9 +58,22 @@ export default function DomainEventsPage() {
|
|||||||
load();
|
load();
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
||||||
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'eventType',
|
||||||
|
width: 130,
|
||||||
|
render: (v, r) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag>{v}</Tag>
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||||
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
||||||
@@ -76,8 +92,13 @@ export default function DomainEventsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const page = Number(params.get('page') ?? 1);
|
||||||
|
const pageSize = data?.pageSize ?? 20;
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-domain-events', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="领域事件">
|
<Card title="领域事件" extra={settingsButton}>
|
||||||
|
{settingsModal}
|
||||||
<Space wrap style={{ marginBottom: 16 }}>
|
<Space wrap style={{ marginBottom: 16 }}>
|
||||||
<Select
|
<Select
|
||||||
style={{ width: 180 }}
|
style={{ width: 180 }}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -28,6 +28,10 @@ import {
|
|||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||||
value,
|
value,
|
||||||
@@ -65,7 +69,10 @@ export default function FulfillmentProvidersPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||||||
setRows(res);
|
setRows(Array.isArray(res) ? res : []);
|
||||||
|
} catch (e) {
|
||||||
|
setRows([]);
|
||||||
|
message.error(e instanceof Error ? e.message : '加载承运商失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -91,6 +98,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||||
|
deliveryHintHtml: '',
|
||||||
});
|
});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
@@ -119,6 +127,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||||
|
deliveryHintHtml: row.deliveryHintHtml || '',
|
||||||
});
|
});
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
@@ -141,6 +150,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||||
},
|
},
|
||||||
|
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||||
@@ -173,9 +183,25 @@ export default function FulfillmentProvidersPage() {
|
|||||||
void load();
|
void load();
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<FulfillmentProviderDto> = [
|
async function remove(row: FulfillmentProviderDto) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||||
{ title: '名称', dataIndex: 'name' },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'type',
|
dataIndex: 'type',
|
||||||
@@ -221,30 +247,45 @@ export default function FulfillmentProvidersPage() {
|
|||||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
<Space size={0}>
|
||||||
编辑
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
</Button>
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认删除承运商「${row.name}」?`}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void remove(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: rows.length || 20,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<div>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
title="仓配管理"
|
||||||
仓配管理
|
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Typography.Text type="secondary">
|
actions={
|
||||||
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
<Button type="primary" onClick={openCreate}>
|
||||||
</Typography.Text>
|
注册承运商
|
||||||
</div>
|
</Button>
|
||||||
<Button type="primary" onClick={openCreate}>
|
}
|
||||||
注册承运商
|
/>
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
@@ -253,7 +294,7 @@ export default function FulfillmentProvidersPage() {
|
|||||||
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
<Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={editRow ? '编辑承运商' : '注册承运商'}
|
title={editRow ? '编辑承运商' : '注册承运商'}
|
||||||
@@ -276,6 +317,17 @@ export default function FulfillmentProvidersPage() {
|
|||||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
<Select options={STATUS_OPTIONS} />
|
<Select options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="deliveryHintHtml"
|
||||||
|
label="配送信息提示"
|
||||||
|
extra="C 端同城送展示。支持 HTML:span/p/br/b/strong/i/em/font,style 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
<Divider orientation="left">物流对账</Divider>
|
<Divider orientation="left">物流对账</Divider>
|
||||||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import {
|
|||||||
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
|
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type HqProfile } from '../lib/api';
|
import { HQ_ADMIN_ROLES } from '@dukang/shared-types';
|
||||||
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { request, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,14 +22,13 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
cityIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
type CityOption = { id: string; name: string };
|
||||||
SUPER_ADMIN: '超级管理员',
|
|
||||||
OPS: '运营',
|
const ROLE_OPTIONS = HQ_ADMIN_ROLES.map((r) => ({ value: r.value, label: r.label }));
|
||||||
FINANCE: '财务',
|
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||||||
CUSTOMER_SERVICE: '客服',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HqAccountsPage() {
|
export default function HqAccountsPage() {
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
@@ -46,15 +50,52 @@ export default function HqAccountsPage() {
|
|||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
|
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
|
||||||
|
const editRole = Form.useWatch('adminRole', editForm);
|
||||||
|
const createRole = Form.useWatch('adminRole', createForm);
|
||||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
useEffect(() => {
|
||||||
{ title: '姓名', dataIndex: 'name' },
|
if (!isSuperAdmin) return;
|
||||||
|
request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<Row> = [
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) =>
|
||||||
|
isSuperAdmin ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => {
|
||||||
|
setDetail(row);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
phone: row.phone,
|
||||||
|
loginName: row.loginName,
|
||||||
|
adminRole: row.adminRole,
|
||||||
|
status: row.status,
|
||||||
|
cityIds: row.cityIds ?? [],
|
||||||
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
v
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||||
{
|
{
|
||||||
@@ -67,7 +108,18 @@ export default function HqAccountsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
|
{ title: '角色', dataIndex: 'adminRole', width: 130, render: (r) => ROLE_LABELS[r] || r },
|
||||||
|
{
|
||||||
|
title: '城市',
|
||||||
|
width: 160,
|
||||||
|
render: (_, r) => {
|
||||||
|
if (!r.cityIds?.length) return <Typography.Text type="secondary">全国</Typography.Text>;
|
||||||
|
const names = r.cityIds
|
||||||
|
.map((id) => cities.find((c) => c.id === id)?.name || id)
|
||||||
|
.join('、');
|
||||||
|
return names;
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -81,6 +133,7 @@ export default function HqAccountsPage() {
|
|||||||
loginName: row.loginName,
|
loginName: row.loginName,
|
||||||
adminRole: row.adminRole,
|
adminRole: row.adminRole,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
cityIds: row.cityIds ?? [],
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}>编辑</Button>
|
}}>编辑</Button>
|
||||||
@@ -88,43 +141,57 @@ export default function HqAccountsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('hq-accounts', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
<AdminListHeader
|
||||||
{isSuperAdmin && (
|
title="HQ 账户"
|
||||||
<Button
|
settings={settingsButton}
|
||||||
type="primary"
|
actions={
|
||||||
onClick={() => {
|
isSuperAdmin ? (
|
||||||
createForm.resetFields();
|
<Button
|
||||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' });
|
type="primary"
|
||||||
setCreateOpen(true);
|
onClick={() => {
|
||||||
}}
|
createForm.resetFields();
|
||||||
>
|
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
|
||||||
新建账户
|
setCreateOpen(true);
|
||||||
</Button>
|
}}
|
||||||
)}
|
>
|
||||||
</Space>
|
新建账户
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select allowClear style={{ width: 160 }} options={ROLE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
<Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="编辑 HQ 账户" width={460} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={async () => {
|
<Button type="primary" loading={saving} onClick={async () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const v = await editForm.validateFields();
|
try {
|
||||||
const payload = { ...v };
|
const v = await editForm.validateFields();
|
||||||
if (!payload.password) delete payload.password;
|
const payload: Record<string, unknown> = { ...v };
|
||||||
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
if (!payload.password) delete payload.password;
|
||||||
message.success('已保存');
|
if (!String(payload.loginName ?? '').trim()) delete payload.loginName;
|
||||||
setDrawerOpen(false);
|
setSaving(true);
|
||||||
void reload();
|
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
|
message.success('已保存');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message) message.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}}>保存</Button>
|
}}>保存</Button>
|
||||||
}>
|
}>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
@@ -144,7 +211,15 @@ export default function HqAccountsPage() {
|
|||||||
<Input.Password placeholder="至少 6 位" />
|
<Input.Password placeholder="至少 6 位" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="cityIds"
|
||||||
|
label="负责城市"
|
||||||
|
extra={editRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
|
||||||
|
rules={editRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
|
||||||
|
>
|
||||||
|
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
@@ -156,15 +231,19 @@ export default function HqAccountsPage() {
|
|||||||
open={createOpen}
|
open={createOpen}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
onOk={async () => {
|
onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
try {
|
||||||
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
const v = await createForm.validateFields();
|
||||||
message.success('已创建');
|
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||||
setCreateOpen(false);
|
message.success('已创建');
|
||||||
createForm.resetFields();
|
setCreateOpen(false);
|
||||||
void reload();
|
createForm.resetFields();
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message) message.error(e.message);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}>
|
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS', cityIds: [] }}>
|
||||||
<Form.Item name="credentialType" label="创建方式">
|
<Form.Item name="credentialType" label="创建方式">
|
||||||
<Radio.Group>
|
<Radio.Group>
|
||||||
<Radio value="phone">手机号账户(短信登录)</Radio>
|
<Radio value="phone">手机号账户(短信登录)</Radio>
|
||||||
@@ -173,7 +252,15 @@ export default function HqAccountsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="cityIds"
|
||||||
|
label="负责城市"
|
||||||
|
extra={createRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
|
||||||
|
rules={createRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
|
||||||
|
>
|
||||||
|
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{credentialType === 'phone' ? (
|
{credentialType === 'phone' ? (
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,12 +56,11 @@ export default function HqLogsPage() {
|
|||||||
form.setFieldsValue(filters);
|
form.setFieldsValue(filters);
|
||||||
}, [form, filters]);
|
}, [form, filters]);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作人',
|
title: '操作人',
|
||||||
width: 200,
|
width: 200,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.hqName}
|
primary={r.hqName}
|
||||||
@@ -69,7 +72,17 @@ export default function HqLogsPage() {
|
|||||||
title: '行为',
|
title: '行为',
|
||||||
dataIndex: 'actionLabel',
|
dataIndex: 'actionLabel',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
|
render: (v, r) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
const res = await request<Row>(`/admin/logs/hq/${r.id}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||||
@@ -92,12 +105,16 @@ export default function HqLogsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>HQ 操作日志</Typography.Title>
|
{settingsModal}
|
||||||
<Typography.Paragraph type="secondary">
|
<AdminListHeader
|
||||||
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
title="HQ 操作日志"
|
||||||
</Typography.Paragraph>
|
settings={settingsButton}
|
||||||
|
description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
@@ -150,7 +167,7 @@ export default function HqLogsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
HQ_ADMIN_ROLES,
|
HQ_ADMIN_ROLES,
|
||||||
HQ_PERMISSION_CATALOG,
|
HQ_PERMISSION_CATALOG,
|
||||||
|
computeHqEffectivePermissionKeys,
|
||||||
type HqPermissionKey,
|
type HqPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
@@ -27,6 +28,8 @@ type AccountPermRes = {
|
|||||||
account: AccountOption;
|
account: AccountOption;
|
||||||
permissionKeys: string[];
|
permissionKeys: string[];
|
||||||
rolePermissionKeys: string[];
|
rolePermissionKeys: string[];
|
||||||
|
grantKeys?: string[];
|
||||||
|
denyKeys?: string[];
|
||||||
userPermissionKeys: string[];
|
userPermissionKeys: string[];
|
||||||
effectivePermissionKeys: string[];
|
effectivePermissionKeys: string[];
|
||||||
};
|
};
|
||||||
@@ -46,11 +49,17 @@ function PermissionChecklist({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
|
roleKeys,
|
||||||
|
grantKeys,
|
||||||
}: {
|
}: {
|
||||||
value: string[];
|
value: string[];
|
||||||
onChange: (keys: string[]) => void;
|
onChange: (keys: string[]) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
roleKeys?: string[];
|
||||||
|
grantKeys?: string[];
|
||||||
}) {
|
}) {
|
||||||
|
const roleSet = new Set(roleKeys ?? []);
|
||||||
|
const grantSet = new Set(grantKeys ?? []);
|
||||||
return (
|
return (
|
||||||
<Checkbox.Group
|
<Checkbox.Group
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -81,6 +90,13 @@ function PermissionChecklist({
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Checkbox key={item.key} value={item.key}>
|
<Checkbox key={item.key} value={item.key}>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||||||
|
) : grantSet.has(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||||||
|
) : roleSet.has(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||||||
|
) : null}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -88,7 +104,16 @@ function PermissionChecklist({
|
|||||||
<Row gutter={[12, 10]}>
|
<Row gutter={[12, 10]}>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Col key={item.key} xs={24} sm={12} md={span}>
|
<Col key={item.key} xs={24} sm={12} md={span}>
|
||||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
<Checkbox value={item.key}>
|
||||||
|
{item.label}
|
||||||
|
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||||||
|
) : grantSet.has(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||||||
|
) : roleSet.has(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||||||
|
) : null}
|
||||||
|
</Checkbox>
|
||||||
</Col>
|
</Col>
|
||||||
))}
|
))}
|
||||||
</Row>
|
</Row>
|
||||||
@@ -100,6 +125,14 @@ function PermissionChecklist({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitAccountOverrides(roleKeys: string[], effectiveKeys: string[]) {
|
||||||
|
const roleSet = new Set(roleKeys);
|
||||||
|
const effectiveSet = new Set(effectiveKeys);
|
||||||
|
const grantKeys = effectiveKeys.filter((k) => !roleSet.has(k));
|
||||||
|
const denyKeys = roleKeys.filter((k) => !effectiveSet.has(k));
|
||||||
|
return { grantKeys, denyKeys };
|
||||||
|
}
|
||||||
|
|
||||||
export default function HqPermissionsPage() {
|
export default function HqPermissionsPage() {
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [role, setRole] = useState<string>('OPS');
|
const [role, setRole] = useState<string>('OPS');
|
||||||
@@ -111,6 +144,8 @@ export default function HqPermissionsPage() {
|
|||||||
const [accountId, setAccountId] = useState<string>();
|
const [accountId, setAccountId] = useState<string>();
|
||||||
const [accountKeys, setAccountKeys] = useState<string[]>([]);
|
const [accountKeys, setAccountKeys] = useState<string[]>([]);
|
||||||
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
|
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
|
||||||
|
const [grantKeys, setGrantKeys] = useState<string[]>([]);
|
||||||
|
const [denyKeys, setDenyKeys] = useState<string[]>([]);
|
||||||
const [accountLoading, setAccountLoading] = useState(false);
|
const [accountLoading, setAccountLoading] = useState(false);
|
||||||
const [accountSaving, setAccountSaving] = useState(false);
|
const [accountSaving, setAccountSaving] = useState(false);
|
||||||
|
|
||||||
@@ -121,8 +156,8 @@ export default function HqPermissionsPage() {
|
|||||||
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
const previewEffectiveKeys = useMemo(
|
const previewEffectiveKeys = useMemo(
|
||||||
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
() => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys),
|
||||||
[roleInheritedKeys, accountKeys],
|
[roleInheritedKeys, grantKeys, denyKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
@@ -151,8 +186,10 @@ export default function HqPermissionsPage() {
|
|||||||
setAccountLoading(true);
|
setAccountLoading(true);
|
||||||
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setAccountKeys(res.userPermissionKeys);
|
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||||||
|
setDenyKeys(res.denyKeys ?? []);
|
||||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||||
|
setAccountKeys(res.effectivePermissionKeys);
|
||||||
})
|
})
|
||||||
.finally(() => setAccountLoading(false));
|
.finally(() => setAccountLoading(false));
|
||||||
}, [isSuperAdmin, accountId]);
|
}, [isSuperAdmin, accountId]);
|
||||||
@@ -179,10 +216,12 @@ export default function HqPermissionsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ permissionKeys: accountKeys }),
|
body: JSON.stringify({ grantKeys, denyKeys }),
|
||||||
});
|
});
|
||||||
setAccountKeys(res.userPermissionKeys);
|
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||||||
|
setDenyKeys(res.denyKeys ?? []);
|
||||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||||
|
setAccountKeys(res.effectivePermissionKeys);
|
||||||
message.success('用户权限已保存');
|
message.success('用户权限已保存');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
@@ -204,8 +243,9 @@ export default function HqPermissionsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。
|
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
||||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
||||||
|
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
||||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
@@ -275,59 +315,51 @@ export default function HqPermissionsPage() {
|
|||||||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<span style={{ marginRight: 8 }}>角色继承:</span>
|
|
||||||
{selectedIsSuperAdmin ? (
|
|
||||||
<Tag color="blue">超级管理员基础权限(不含危险操作)</Tag>
|
|
||||||
) : (
|
|
||||||
<Space wrap size={[4, 4]}>
|
|
||||||
{roleInheritedKeys.map((key) => {
|
|
||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
|
||||||
return (
|
|
||||||
<Tag key={key} color="blue">
|
|
||||||
{item?.label || key}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!roleInheritedKeys.length ? (
|
|
||||||
<Typography.Text type="secondary">无</Typography.Text>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。危险操作(删除用户/订单/城市)默认不授予,需在此勾选。
|
勾选表示该账号最终拥有该权限。取消角色已有项即为撤销;勾选角色没有的项即为追加。
|
||||||
|
切换角色会清空账号级追加/撤销。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
<PermissionChecklist
|
||||||
|
value={accountKeys}
|
||||||
|
roleKeys={roleInheritedKeys}
|
||||||
|
grantKeys={grantKeys}
|
||||||
|
disabled={selectedIsSuperAdmin}
|
||||||
|
onChange={(nextEffective) => {
|
||||||
|
if (selectedIsSuperAdmin) return;
|
||||||
|
const next = splitAccountOverrides(roleInheritedKeys, nextEffective);
|
||||||
|
setGrantKeys(next.grantKeys);
|
||||||
|
setDenyKeys(next.denyKeys);
|
||||||
|
setAccountKeys(nextEffective);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedIsSuperAdmin ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
message="超级管理员基础权限不可撤销。危险操作请到下方以外的方式:当前勾选仅展示生效权限。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div style={{ marginTop: 12 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<span style={{ marginRight: 8 }}>合并生效:</span>
|
<span style={{ marginRight: 8 }}>合并生效:</span>
|
||||||
{selectedIsSuperAdmin ? (
|
<Space wrap size={[4, 4]}>
|
||||||
<Space wrap size={[4, 4]}>
|
{previewEffectiveKeys.map((key) => {
|
||||||
<Tag>基础权限(全部)</Tag>
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||||
{accountKeys.map((key) => {
|
return (
|
||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
<Tag key={key}>
|
||||||
return (
|
{item?.label || key}
|
||||||
<Tag key={key} color="orange">
|
</Tag>
|
||||||
{item?.label || key}
|
);
|
||||||
</Tag>
|
})}
|
||||||
);
|
</Space>
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
) : (
|
|
||||||
<Space wrap size={[4, 4]}>
|
|
||||||
{previewEffectiveKeys.map((key) => {
|
|
||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
|
||||||
return (
|
|
||||||
<Tag key={key}>
|
|
||||||
{item?.label || key}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
<Button
|
||||||
|
type="primary"
|
||||||
|
loading={accountSaving}
|
||||||
|
disabled={selectedIsSuperAdmin}
|
||||||
|
onClick={() => void saveAccountPermissions()}
|
||||||
|
>
|
||||||
保存用户权限
|
保存用户权限
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -10,15 +11,16 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
Upload,
|
Upload,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
|
INVOICE_CATEGORY_LABELS,
|
||||||
INVOICE_KIND_LABELS,
|
INVOICE_KIND_LABELS,
|
||||||
INVOICE_STATUS_LABELS,
|
INVOICE_STATUS_LABELS,
|
||||||
INVOICE_TITLE_TYPE_LABELS,
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceCategory,
|
||||||
type InvoiceKind,
|
type InvoiceKind,
|
||||||
type InvoiceStatus,
|
type InvoiceStatus,
|
||||||
type InvoiceTitleType,
|
type InvoiceTitleType,
|
||||||
@@ -27,6 +29,10 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { uploadFileToOss } from '../lib/upload';
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -34,6 +40,7 @@ type Row = {
|
|||||||
orderNo?: string;
|
orderNo?: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
status: InvoiceStatus;
|
status: InvoiceStatus;
|
||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
@@ -53,6 +60,7 @@ type CreateFormValues = {
|
|||||||
orderNo: string;
|
orderNo: string;
|
||||||
titleType: InvoiceTitleType;
|
titleType: InvoiceTitleType;
|
||||||
invoiceKind: InvoiceKind;
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
titleName: string;
|
titleName: string;
|
||||||
taxNo?: string;
|
taxNo?: string;
|
||||||
addressPhone?: string;
|
addressPhone?: string;
|
||||||
@@ -63,12 +71,22 @@ type CreateFormValues = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function InvoicesPage() {
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialStatus = searchParams.get('status')?.trim() || '';
|
||||||
|
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
|
||||||
|
const [filterForm] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
if (initialStatus) init.status = initialStatus;
|
||||||
|
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/invoices',
|
'/admin/invoices',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -81,12 +99,29 @@ export default function InvoicesPage() {
|
|||||||
const [createForm] = Form.useForm<CreateFormValues>();
|
const [createForm] = Form.useForm<CreateFormValues>();
|
||||||
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||||
const titleType = Form.useWatch('titleType', createForm);
|
const titleType = Form.useWatch('titleType', createForm);
|
||||||
|
const deepLinkOpenedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filterForm.setFieldsValue({
|
||||||
|
status: filters.status || undefined,
|
||||||
|
invoiceNo: filters.invoiceNo || undefined,
|
||||||
|
});
|
||||||
|
}, [filterForm, filters.invoiceNo, filters.status]);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
setDetail(await request(`/admin/invoices/${id}`));
|
setDetail(await request(`/admin/invoices/${id}`));
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
|
||||||
|
const first = data?.items?.[0];
|
||||||
|
if (first && String(first.invoiceNo) === initialInvoiceNo) {
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
void openDetail(first.id);
|
||||||
|
}
|
||||||
|
}, [data, initialInvoiceNo, loading]);
|
||||||
|
|
||||||
async function issueWithFile(file: File) {
|
async function issueWithFile(file: File) {
|
||||||
if (!detail) return false;
|
if (!detail) return false;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -128,6 +163,7 @@ export default function InvoicesPage() {
|
|||||||
orderNo: values.orderNo.trim(),
|
orderNo: values.orderNo.trim(),
|
||||||
titleType: values.titleType,
|
titleType: values.titleType,
|
||||||
invoiceKind: values.invoiceKind,
|
invoiceKind: values.invoiceKind,
|
||||||
|
invoiceCategory: values.invoiceCategory,
|
||||||
titleName: values.titleName.trim(),
|
titleName: values.titleName.trim(),
|
||||||
taxNo: values.taxNo?.trim() || undefined,
|
taxNo: values.taxNo?.trim() || undefined,
|
||||||
addressPhone: values.addressPhone?.trim() || undefined,
|
addressPhone: values.addressPhone?.trim() || undefined,
|
||||||
@@ -148,7 +184,7 @@ export default function InvoicesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||||
{
|
{
|
||||||
@@ -158,10 +194,24 @@ export default function InvoicesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '票种',
|
title: '票种',
|
||||||
width: 120,
|
width: 140,
|
||||||
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||||
},
|
},
|
||||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
{
|
||||||
|
title: '类型',
|
||||||
|
width: 88,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.invoiceCategory
|
||||||
|
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'titleName',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
width: 110,
|
width: 110,
|
||||||
@@ -186,33 +236,32 @@ export default function InvoicesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
{settingsModal}
|
||||||
style={{
|
<AdminListHeader
|
||||||
display: 'flex',
|
title="发票管理"
|
||||||
justifyContent: 'space-between',
|
settings={settingsButton}
|
||||||
alignItems: 'center',
|
actions={
|
||||||
marginBottom: 16,
|
<Button
|
||||||
}}
|
type="primary"
|
||||||
>
|
onClick={() => {
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
createForm.setFieldsValue({
|
||||||
发票管理
|
titleType: 'PERSONAL',
|
||||||
</Typography.Title>
|
invoiceKind: 'NORMAL',
|
||||||
<Button
|
invoiceCategory: 'LIQUOR',
|
||||||
type="primary"
|
});
|
||||||
onClick={() => {
|
setCreateOpen(true);
|
||||||
createForm.setFieldsValue({
|
}}
|
||||||
titleType: 'PERSONAL',
|
>
|
||||||
invoiceKind: 'NORMAL',
|
创建发票申请
|
||||||
});
|
</Button>
|
||||||
setCreateOpen(true);
|
}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
创建发票申请
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<Form
|
<Form
|
||||||
|
form={filterForm}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
onFinish={(v) => {
|
onFinish={(v) => {
|
||||||
@@ -231,6 +280,9 @@ export default function InvoicesPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="invoiceNo" label="申请单号">
|
||||||
|
<Input allowClear placeholder="发票申请单号" />
|
||||||
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
筛选
|
筛选
|
||||||
</Button>
|
</Button>
|
||||||
@@ -240,7 +292,7 @@ export default function InvoicesPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1000 }}
|
scroll={{ x: 'max-content' }}
|
||||||
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
|
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
@@ -292,6 +344,11 @@ export default function InvoicesPage() {
|
|||||||
<Descriptions.Item label="发票类型">
|
<Descriptions.Item label="发票类型">
|
||||||
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="类型">
|
||||||
|
{detail.invoiceCategory
|
||||||
|
? INVOICE_CATEGORY_LABELS[detail.invoiceCategory]
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||||
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||||
@@ -350,6 +407,18 @@ export default function InvoicesPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="invoiceCategory"
|
||||||
|
label="类型"
|
||||||
|
rules={[{ required: true, message: '请选择类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={(Object.keys(INVOICE_CATEGORY_LABELS) as InvoiceCategory[]).map((k) => ({
|
||||||
|
value: k,
|
||||||
|
label: INVOICE_CATEGORY_LABELS[k],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="titleType"
|
name="titleType"
|
||||||
label="抬头类型"
|
label="抬头类型"
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { uploadFileToOss } from '../lib/upload';
|
import { uploadFileToOss } from '../lib/upload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -124,9 +128,15 @@ export default function KnowledgeBasesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<KnowledgeBaseDto> = [
|
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
||||||
{ title: '名称', dataIndex: 'name' },
|
{
|
||||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '说明', dataIndex: 'description' },
|
||||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||||
{
|
{
|
||||||
title: '启用',
|
title: '启用',
|
||||||
@@ -184,7 +194,7 @@ export default function KnowledgeBasesPage() {
|
|||||||
|
|
||||||
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
||||||
{ title: '标题', dataIndex: 'title' },
|
{ title: '标题', dataIndex: 'title' },
|
||||||
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
|
{ title: '文件', dataIndex: 'fileName' },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -250,16 +260,16 @@ export default function KnowledgeBasesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('knowledge-bases', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
知识库
|
title="知识库"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Typography.Text type="secondary">
|
description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索"
|
||||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
/>
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ import {
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -153,15 +157,21 @@ export default function LlmConfigsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<LlmApiConfigDto> = [
|
const baseColumns: ColumnsType<LlmApiConfigDto> = [
|
||||||
{ title: '名称', dataIndex: 'name' },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '提供商',
|
title: '提供商',
|
||||||
dataIndex: 'provider',
|
dataIndex: 'provider',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||||
},
|
},
|
||||||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
{ title: '模型', dataIndex: 'modelName' },
|
||||||
{
|
{
|
||||||
title: 'Key',
|
title: 'Key',
|
||||||
dataIndex: 'apiKeyConfigured',
|
dataIndex: 'apiKeyConfigured',
|
||||||
@@ -257,16 +267,16 @@ export default function LlmConfigsPage() {
|
|||||||
|
|
||||||
const fullEdit = !editing || editing.canEditFull;
|
const fullEdit = !editing || editing.canEditFull;
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('llm-configs', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16 }} wrap>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
语言模型配置
|
title="语言模型配置"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Typography.Text type="secondary">
|
description="非超管仅可见自己创建的配置,且创建后只能改是否生效"
|
||||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
/>
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
type BillRow = {
|
type BillRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -44,6 +47,13 @@ type BillRow = {
|
|||||||
settlementMethod: string;
|
settlementMethod: string;
|
||||||
status: string;
|
status: string;
|
||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
|
paymentRef?: string | null;
|
||||||
|
fulfillmentProvider?: {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type BillItem = {
|
type BillItem = {
|
||||||
@@ -140,14 +150,30 @@ export default function LogisticsBillsPage() {
|
|||||||
}, [summaryPeriod]);
|
}, [summaryPeriod]);
|
||||||
|
|
||||||
function confirmPay(ids: string[], amountHint?: number) {
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
|
let paymentRef = '';
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认结算?',
|
title: '确认结算?',
|
||||||
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
|
content: (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
将确认 {ids.length} 笔物流对账单
|
||||||
|
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="打款凭证号(可选)"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onChange={(e) => {
|
||||||
|
paymentRef = e.target.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
okText: '确认结算',
|
okText: '确认结算',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
|
||||||
if (ids.length === 1) {
|
if (ids.length === 1) {
|
||||||
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
|
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST', body });
|
||||||
} else {
|
} else {
|
||||||
await request('/admin/logistics-bills/batch-confirm', {
|
await request('/admin/logistics-bills/batch-confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -217,11 +243,19 @@ export default function LogisticsBillsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
|
const providerBank = (r: BillRow) => r.fulfillmentProvider;
|
||||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
|
||||||
|
|
||||||
const billColumns: ColumnsType<BillRow> = [
|
const billColumns: ColumnsType<BillRow> = [
|
||||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
{
|
||||||
|
title: '账单号',
|
||||||
|
dataIndex: 'billNo',
|
||||||
|
width: 170,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '承运商',
|
title: '承运商',
|
||||||
width: 140,
|
width: 140,
|
||||||
@@ -248,6 +282,16 @@ export default function LogisticsBillsPage() {
|
|||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '收款户名',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => providerBank(r)?.bankAccountName || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收款账号',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -303,7 +347,6 @@ export default function LogisticsBillsPage() {
|
|||||||
{
|
{
|
||||||
title: '银行账户',
|
title: '银行账户',
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) =>
|
render: (_, r) =>
|
||||||
r.bankAccountName
|
r.bankAccountName
|
||||||
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
||||||
@@ -355,16 +398,19 @@ export default function LogisticsBillsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-logistics-bills', billColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
物流对账
|
title="物流对账"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Typography.Text type="secondary">
|
description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。"
|
||||||
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
/>
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
items={[
|
items={[
|
||||||
@@ -501,7 +547,7 @@ export default function LogisticsBillsPage() {
|
|||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
columns={billColumns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedKeys,
|
selectedRowKeys: selectedKeys,
|
||||||
@@ -550,7 +596,31 @@ export default function LogisticsBillsPage() {
|
|||||||
<Descriptions.Item label="结算时间">
|
<Descriptions.Item label="结算时间">
|
||||||
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
|
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="打款凭证">
|
||||||
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
{providerBank(detail) ? (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
|
收款账户
|
||||||
|
</Typography.Title>
|
||||||
|
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="户名">
|
||||||
|
{providerBank(detail)?.bankAccountName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户银行">
|
||||||
|
{providerBank(detail)?.bankName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户支行">
|
||||||
|
{providerBank(detail)?.bankBranch || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账号">
|
||||||
|
{providerBank(detail)?.bankAccountNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Col,
|
||||||
Collapse,
|
Collapse,
|
||||||
|
DatePicker,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
@@ -11,6 +14,7 @@ import {
|
|||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Radio,
|
Radio,
|
||||||
|
Row,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -19,10 +23,17 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import { ORDER_TYPE_LABELS } from '@dukang/shared-types';
|
||||||
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminOrderItem, type AdminOrderRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
import {
|
import {
|
||||||
ADMIN_OPTIONS_PAGE_SIZE,
|
ADMIN_OPTIONS_PAGE_SIZE,
|
||||||
DELIVERY_PROVIDER_LABELS,
|
DELIVERY_PROVIDER_LABELS,
|
||||||
|
DELIVERY_TYPE_LABELS,
|
||||||
ORDER_STATUS_COLORS,
|
ORDER_STATUS_COLORS,
|
||||||
ORDER_STATUS_LABELS,
|
ORDER_STATUS_LABELS,
|
||||||
ORDER_STATUS_OPERATOR_LABELS,
|
ORDER_STATUS_OPERATOR_LABELS,
|
||||||
@@ -30,6 +41,7 @@ import {
|
|||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
import { FULFILLMENT_HOLD_REASON_LABELS } from '@dukang/shared-types';
|
||||||
import ProxyOrderModal from '../components/ProxyOrderModal';
|
import ProxyOrderModal from '../components/ProxyOrderModal';
|
||||||
|
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||||
|
|
||||||
type ShipDefaults = {
|
type ShipDefaults = {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -128,6 +140,117 @@ type OrderDetail = AdminOrderRow & {
|
|||||||
|
|
||||||
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
|
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
|
||||||
|
|
||||||
|
type OrderExportScope = 'filter' | 'selected';
|
||||||
|
type OrderExportFormat = 'xlsx' | 'pdf';
|
||||||
|
|
||||||
|
type OrderExportFilters = {
|
||||||
|
orderNo?: string;
|
||||||
|
status?: string | string[];
|
||||||
|
orderType?: string;
|
||||||
|
cityId?: string;
|
||||||
|
receiverPhone?: string;
|
||||||
|
fulfillmentHold?: boolean;
|
||||||
|
excludeTest?: boolean;
|
||||||
|
deliveryType?: string;
|
||||||
|
dateRange?: [Dayjs, Dayjs];
|
||||||
|
};
|
||||||
|
|
||||||
|
type OrderExportResult = {
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
contentBase64: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORDER_TYPE_OPTIONS = Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||||
|
|
||||||
|
const DELIVERY_TYPE_OPTIONS = [
|
||||||
|
{ value: 'LOCAL', label: '同城' },
|
||||||
|
{ value: 'CROSS_CITY', label: '跨城' },
|
||||||
|
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isOnSitePickupOrder(row: Pick<AdminOrderRow, 'deliveryType' | 'receiverProvince' | 'receiverCity' | 'receiverAddress'>) {
|
||||||
|
return (
|
||||||
|
row.deliveryType === 'ON_SITE_PICKUP' ||
|
||||||
|
row.receiverAddress === '现场取货' ||
|
||||||
|
row.receiverAddress === '现场提货' ||
|
||||||
|
(row.receiverProvince === '现场' && row.receiverCity === '现场')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReceiverAddress(row: AdminOrderRow) {
|
||||||
|
if (isOnSitePickupOrder(row)) return '现场取货';
|
||||||
|
const region = [row.receiverProvince, row.receiverCity, row.receiverDistrict].filter(Boolean).join('');
|
||||||
|
const detail = row.receiverAddress || '';
|
||||||
|
if (!region) return detail;
|
||||||
|
if (!detail || detail.startsWith(region)) return detail || region;
|
||||||
|
return `${region}${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function datePresetRange(kind: 'day' | 'week' | 'month' | 'quarter' | 'year'): [Dayjs, Dayjs] {
|
||||||
|
const now = dayjs();
|
||||||
|
if (kind === 'day') return [now.startOf('day'), now.endOf('day')];
|
||||||
|
if (kind === 'week') {
|
||||||
|
const monday = now.startOf('day').subtract((now.day() + 6) % 7, 'day');
|
||||||
|
return [monday, monday.add(6, 'day').endOf('day')];
|
||||||
|
}
|
||||||
|
if (kind === 'month') return [now.startOf('month'), now.endOf('month')];
|
||||||
|
if (kind === 'quarter') {
|
||||||
|
const start = now.month(Math.floor(now.month() / 3) * 3).startOf('month');
|
||||||
|
return [start, start.add(2, 'month').endOf('month')];
|
||||||
|
}
|
||||||
|
return [now.startOf('year'), now.endOf('year')];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year'; label: string }> = [
|
||||||
|
{ key: 'day', label: '当日' },
|
||||||
|
{ key: 'week', label: '当周' },
|
||||||
|
{ key: 'month', label: '当月' },
|
||||||
|
{ key: 'quarter', label: '当季' },
|
||||||
|
{ key: 'year', label: '当年' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatBenefitBrief(row: AdminOrderRow) {
|
||||||
|
const coupon = row.benefitCoupon;
|
||||||
|
if (coupon) {
|
||||||
|
return `总额¥${Number(coupon.totalAmount ?? 0).toFixed(0)} / 已用¥${Number(coupon.usedAmount ?? 0).toFixed(0)} / 余¥${Number(coupon.balance ?? 0).toFixed(0)}`;
|
||||||
|
}
|
||||||
|
if (row.benefitAmount != null) return `¥${Number(row.benefitAmount).toFixed(0)}`;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedStatuses(status: unknown): string[] {
|
||||||
|
if (Array.isArray(status)) return status.filter((s): s is string => Boolean(s));
|
||||||
|
if (typeof status === 'string' && status) return [status];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExportPayload(
|
||||||
|
scope: OrderExportScope,
|
||||||
|
format: OrderExportFormat,
|
||||||
|
filters: OrderExportFilters,
|
||||||
|
selectedIds: string[],
|
||||||
|
) {
|
||||||
|
const payload: Record<string, unknown> = { scope, format };
|
||||||
|
if (scope === 'selected') {
|
||||||
|
payload.ids = selectedIds;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (filters.orderNo) payload.orderNo = filters.orderNo;
|
||||||
|
const statuses = selectedStatuses(filters.status);
|
||||||
|
if (statuses.length) payload.status = statuses;
|
||||||
|
if (filters.orderType) payload.orderType = filters.orderType;
|
||||||
|
if (filters.cityId) payload.cityId = filters.cityId;
|
||||||
|
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
|
||||||
|
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
|
||||||
|
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
|
||||||
|
if (filters.excludeTest) payload.excludeTest = true;
|
||||||
|
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
|
||||||
|
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
|
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
|
||||||
if (detail.items?.length) return detail.items;
|
if (detail.items?.length) return detail.items;
|
||||||
if (!detail.productName) return [];
|
if (!detail.productName) return [];
|
||||||
@@ -160,6 +283,9 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OrdersPage() {
|
export default function OrdersPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [shipForm] = Form.useForm();
|
const [shipForm] = Form.useForm();
|
||||||
const [logisticsForm] = Form.useForm();
|
const [logisticsForm] = Form.useForm();
|
||||||
@@ -170,6 +296,7 @@ export default function OrdersPage() {
|
|||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const deepLinkOpenedRef = useRef(false);
|
||||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||||
@@ -186,8 +313,12 @@ export default function OrdersPage() {
|
|||||||
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
|
||||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||||
const [proxyOpen, setProxyOpen] = useState(false);
|
const [proxyOpen, setProxyOpen] = useState(false);
|
||||||
|
const [trackOpen, setTrackOpen] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [exportFormat, setExportFormat] = useState<OrderExportFormat>('xlsx');
|
||||||
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
const canDeleteOrders = (profile?.permissionKeys ?? []).includes('orders_delete');
|
||||||
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
const canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
|
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
|
||||||
|
|
||||||
const selectedOrders = useMemo(
|
const selectedOrders = useMemo(
|
||||||
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
|
||||||
@@ -198,6 +329,21 @@ export default function OrdersPage() {
|
|||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialOrderNo) {
|
||||||
|
form.setFieldsValue({ orderNo: initialOrderNo });
|
||||||
|
}
|
||||||
|
}, [form, initialOrderNo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
|
||||||
|
const first = data?.items?.[0];
|
||||||
|
if (first && String(first.orderNo) === initialOrderNo) {
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
void openDetail(first.id);
|
||||||
|
}
|
||||||
|
}, [data, initialOrderNo, loading]);
|
||||||
|
|
||||||
async function openRedeemDetail(redeemId: string) {
|
async function openRedeemDetail(redeemId: string) {
|
||||||
setRedeemDetailLoading(true);
|
setRedeemDetailLoading(true);
|
||||||
setRedeemDrawerOpen(true);
|
setRedeemDrawerOpen(true);
|
||||||
@@ -217,18 +363,23 @@ export default function OrdersPage() {
|
|||||||
try {
|
try {
|
||||||
const values = form.getFieldsValue();
|
const values = form.getFieldsValue();
|
||||||
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
|
||||||
|
if (initialOrderNo) qs.set('orderNo', initialOrderNo);
|
||||||
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
if (values.orderNo) qs.set('orderNo', values.orderNo);
|
||||||
if (values.status) qs.set('status', values.status);
|
for (const status of selectedStatuses(values.status)) qs.append('status', status);
|
||||||
if (values.orderType) qs.set('orderType', values.orderType);
|
if (values.orderType) qs.set('orderType', values.orderType);
|
||||||
if (values.cityId) qs.set('cityId', values.cityId);
|
if (values.cityId) qs.set('cityId', values.cityId);
|
||||||
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
|
||||||
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
if (values.fulfillmentHold) qs.set('fulfillmentHold', 'true');
|
||||||
|
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||||
|
if (values.deliveryType) qs.set('deliveryType', values.deliveryType);
|
||||||
|
if (values.dateRange?.[0]) qs.set('createdFrom', values.dateRange[0].format('YYYY-MM-DD'));
|
||||||
|
if (values.dateRange?.[1]) qs.set('createdTo', values.dateRange[1].format('YYYY-MM-DD'));
|
||||||
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [form, page, pageSize]);
|
}, [form, page, pageSize, initialOrderNo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -429,69 +580,128 @@ export default function OrdersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<AdminOrderRow> = [
|
async function submitExport(scope: OrderExportScope) {
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
const values = form.getFieldsValue() as OrderExportFilters;
|
||||||
|
if (scope === 'selected' && !selectedRowKeys.length) {
|
||||||
|
message.warning('请先勾选要导出的订单');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const payload = buildExportPayload(scope, exportFormat, values, selectedRowKeys);
|
||||||
|
const result = await request<OrderExportResult>('/admin/orders/export', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
||||||
|
message.success(`已导出 ${result.count} 条订单`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<AdminOrderRow> = [
|
||||||
{
|
{
|
||||||
title: '城市',
|
title: '订单号',
|
||||||
width: 90,
|
dataIndex: 'orderNo',
|
||||||
render: (_, row) => row.city?.name || '—',
|
width: 180,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
key: 'user',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.user?.id ? (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
||||||
|
{row.user.userNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
(row.user?.userNo || '—')
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '商品',
|
title: '商品',
|
||||||
|
key: 'productName',
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: true,
|
render: (_, row) => (
|
||||||
render: (_, row) => {
|
|
||||||
const name = row.productName || '—';
|
|
||||||
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
|
|
||||||
return (
|
|
||||||
<span title={row.productSpec ? `${name}(${row.productSpec})` : name}>
|
|
||||||
{name}{qty}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
width: 140,
|
|
||||||
render: (s, row) => (
|
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
<span>{row.productName || '—'}</span>
|
||||||
{row.fulfillmentHold ? <Tag color="orange">大单待确认</Tag> : null}
|
{row.fulfillmentHold ? <Tag color="orange">大单</Tag> : null}
|
||||||
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
||||||
<Tag color="purple" title={row.proxyPartnerName || undefined}>
|
<Tag color="purple">代下单</Tag>
|
||||||
代下单
|
|
||||||
</Tag>
|
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '配送',
|
title: '规格',
|
||||||
|
dataIndex: 'productSpec',
|
||||||
|
width: 140,
|
||||||
|
render: (v: string | undefined) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
dataIndex: 'quantity',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number | undefined, row) =>
|
||||||
|
v == null ? '—' : `${v}${row.saleUnit === 'BOX' ? '箱' : '瓶'}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配送方式',
|
||||||
dataIndex: 'deliveryType',
|
dataIndex: 'deliveryType',
|
||||||
width: 90,
|
|
||||||
render: (v) =>
|
|
||||||
v === 'ON_SITE_PICKUP' ? '现场提货' : v === 'LOCAL' ? '同城' : '跨城',
|
|
||||||
},
|
|
||||||
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
|
|
||||||
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
|
|
||||||
{ title: '手机', dataIndex: 'receiverPhone', width: 120 },
|
|
||||||
{
|
|
||||||
title: '用户',
|
|
||||||
dataIndex: ['user', 'userNo'],
|
|
||||||
width: 110,
|
|
||||||
render: (_, row) => row.user?.userNo || '—',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '快递',
|
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
|
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s: string) => (
|
||||||
|
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '实付',
|
||||||
|
dataIndex: 'payAmount',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => `¥${v}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '好客权益',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) => formatBenefitBrief(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收货人',
|
||||||
|
dataIndex: 'receiverName',
|
||||||
|
width: 90,
|
||||||
|
render: (v: string | undefined) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '电话',
|
||||||
|
dataIndex: 'receiverPhone',
|
||||||
|
width: 120,
|
||||||
|
render: (v: string | undefined) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '地址',
|
||||||
|
key: 'receiverAddress',
|
||||||
|
width: 260,
|
||||||
|
render: (_, row) => formatReceiverAddress(row) || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '下单时间',
|
title: '下单时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
render: fmtTime,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -523,27 +733,28 @@ export default function OrdersPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
<AdminListHeader
|
||||||
<Space>
|
title="订单监控"
|
||||||
{canProxyOrder ? (
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
actions={
|
||||||
代下单
|
<>
|
||||||
</Button>
|
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}>大屏</Button>
|
||||||
) : null}
|
{canProxyOrder ? (
|
||||||
{canDeleteOrders ? (
|
<Button type="primary" onClick={() => setProxyOpen(true)}>
|
||||||
<Button
|
代下单
|
||||||
danger
|
</Button>
|
||||||
disabled={!selectedRowKeys.length}
|
) : null}
|
||||||
onClick={() => setBatchDeleteOpen(true)}
|
</>
|
||||||
>
|
}
|
||||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
/>
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</Space>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
{!canDeleteOrders && profile ? (
|
{!canDeleteOrders && profile ? (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -559,51 +770,117 @@ export default function OrdersPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="orderNo" label="订单号">
|
<Row gutter={[16, 8]}>
|
||||||
<Input placeholder="DK..." allowClear />
|
<Col xs={24} md={14} lg={12}>
|
||||||
</Form.Item>
|
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
|
||||||
<Form.Item name="status" label="状态">
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Form.Item name="dateRange" noStyle>
|
||||||
</Form.Item>
|
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
|
||||||
<Form.Item name="orderType" label="类型">
|
</Form.Item>
|
||||||
<Select
|
<Space size={8} wrap>
|
||||||
allowClear
|
{DATE_PRESETS.map((preset) => (
|
||||||
style={{ width: 120 }}
|
<Button
|
||||||
placeholder="全部"
|
key={preset.key}
|
||||||
options={[
|
size="small"
|
||||||
{ value: 'NORMAL', label: '普通订单' },
|
onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
|
||||||
{ value: 'PROXY', label: '代下单' },
|
>
|
||||||
]}
|
{preset.label}
|
||||||
/>
|
</Button>
|
||||||
</Form.Item>
|
))}
|
||||||
<Form.Item name="cityId" label="城市">
|
</Space>
|
||||||
<Select
|
</Space>
|
||||||
allowClear
|
</Form.Item>
|
||||||
showSearch
|
</Col>
|
||||||
optionFilterProp="label"
|
<Col xs={24} sm={12} md={8} lg={6}>
|
||||||
style={{ width: 140 }}
|
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||||
placeholder="全部"
|
<Select
|
||||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
mode="multiple"
|
||||||
/>
|
allowClear
|
||||||
</Form.Item>
|
maxTagCount="responsive"
|
||||||
<Form.Item name="receiverPhone" label="收货手机">
|
placeholder="全部"
|
||||||
<Input allowClear />
|
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
</Form.Item>
|
/>
|
||||||
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
|
</Form.Item>
|
||||||
<Select
|
</Col>
|
||||||
allowClear
|
<Col xs={12} sm={6} md={5} lg={3}>
|
||||||
style={{ width: 140 }}
|
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
|
||||||
placeholder="全部"
|
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
||||||
options={[{ value: true, label: '仅待确认大单' }]}
|
</Form.Item>
|
||||||
/>
|
</Col>
|
||||||
</Form.Item>
|
<Col xs={12} sm={6} md={5} lg={3}>
|
||||||
<Form.Item>
|
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
|
||||||
<Space>
|
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={6} md={5} lg={3}>
|
||||||
|
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="全部"
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={6} lg={6}>
|
||||||
|
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
|
||||||
|
<Input allowClear placeholder="手机号" />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col xs={12} sm={8} md={8} lg={8}>
|
||||||
|
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
|
||||||
|
<Space size={16} wrap>
|
||||||
|
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
||||||
|
<Checkbox>大单拦截</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
|
||||||
|
<Checkbox>过滤测试</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
{canExportOrders ? (
|
||||||
|
<Space size={12} wrap>
|
||||||
|
<Radio.Group
|
||||||
|
optionType="button"
|
||||||
|
buttonStyle="solid"
|
||||||
|
value={exportFormat}
|
||||||
|
onChange={(e) => setExportFormat(e.target.value)}
|
||||||
|
>
|
||||||
|
<Radio.Button value="xlsx">Excel</Radio.Button>
|
||||||
|
<Radio.Button value="pdf">PDF</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
<Button
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
loading={exporting}
|
||||||
|
onClick={() => void submitExport('selected')}
|
||||||
|
>
|
||||||
|
导出已勾选{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
<Button loading={exporting} onClick={() => void submitExport('filter')}>
|
||||||
|
导出全部筛选
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : <span />}
|
||||||
|
<Space size={12} wrap>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||||
|
{canDeleteOrders ? (
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
disabled={!selectedRowKeys.length}
|
||||||
|
onClick={() => setBatchDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
批量删除{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
@@ -611,16 +888,18 @@ export default function OrdersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1500 }}
|
scroll={{ x: 'max-content' }}
|
||||||
rowSelection={canDeleteOrders ? {
|
rowSelection={{
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
|
preserveSelectedRowKeys: true,
|
||||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
} : undefined}
|
}}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
total: data?.total ?? 0,
|
total: data?.total ?? 0,
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
|
showTotal: (total) => `共 ${total} 条`,
|
||||||
onChange: (p, ps) => {
|
onChange: (p, ps) => {
|
||||||
setPage(p);
|
setPage(p);
|
||||||
setPageSize(ps);
|
setPageSize(ps);
|
||||||
@@ -793,7 +1072,7 @@ export default function OrdersPage() {
|
|||||||
locale={{ emptyText: '暂无关联核销单' }}
|
locale={{ emptyText: '暂无关联核销单' }}
|
||||||
dataSource={detail.redeemRecords ?? []}
|
dataSource={detail.redeemRecords ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
{ title: '核销号', dataIndex: 'redeemNo', width: 160 },
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
@@ -804,7 +1083,6 @@ export default function OrdersPage() {
|
|||||||
{
|
{
|
||||||
title: '门店',
|
title: '门店',
|
||||||
dataIndex: ['store', 'name'],
|
dataIndex: ['store', 'name'],
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string | undefined, row) =>
|
render: (v: string | undefined, row) =>
|
||||||
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||||
},
|
},
|
||||||
@@ -859,7 +1137,20 @@ export default function OrdersPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
|
||||||
{detail.delivery && (
|
{detail.delivery && (
|
||||||
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
|
<Descriptions
|
||||||
|
column={1}
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<span>配送</span>
|
||||||
|
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setTrackOpen(true)}>
|
||||||
|
查看路由
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
>
|
||||||
<Descriptions.Item label="快递公司">
|
<Descriptions.Item label="快递公司">
|
||||||
{detail.delivery.logisticsCompany ||
|
{detail.delivery.logisticsCompany ||
|
||||||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
|
||||||
@@ -907,7 +1198,6 @@ export default function OrdersPage() {
|
|||||||
{
|
{
|
||||||
title: '备注',
|
title: '备注',
|
||||||
dataIndex: 'remark',
|
dataIndex: 'remark',
|
||||||
ellipsis: true,
|
|
||||||
render: (v) => v || '—',
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1202,7 +1492,6 @@ export default function OrdersPage() {
|
|||||||
{
|
{
|
||||||
title: '商品',
|
title: '商品',
|
||||||
width: 160,
|
width: 160,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, row) =>
|
render: (_, row) =>
|
||||||
row.productName
|
row.productName
|
||||||
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
|
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
|
||||||
@@ -1232,6 +1521,14 @@ export default function OrdersPage() {
|
|||||||
void openDetail(order.id);
|
void openDetail(order.id);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<OrderTrackDrawer
|
||||||
|
open={trackOpen}
|
||||||
|
orderId={detail?.id ?? null}
|
||||||
|
orderNo={detail?.orderNo}
|
||||||
|
orderStatus={detail?.status}
|
||||||
|
onClose={() => setTrackOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string };
|
type PartnerOption = { id: string; companyName: string };
|
||||||
type ParentAccount = { id: string; name: string; phone: string };
|
type ParentAccount = { id: string; name: string; phone: string };
|
||||||
@@ -169,27 +172,24 @@ export default function PartnerAccountsPage() {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<AccountTreeRow> = [
|
const baseColumns: ColumnsType<AccountTreeRow> = [
|
||||||
{
|
{
|
||||||
title: '姓名 / 类型',
|
title: '姓名 / 类型',
|
||||||
width: 200,
|
width: 200,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<AdminCellLine
|
<span className="admin-cell-line">
|
||||||
primary={row.name}
|
<AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
|
||||||
secondary={
|
<span className="admin-cell-line-secondary">
|
||||||
row.parentAccountId
|
{' · '}
|
||||||
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
{row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
|
||||||
: '主账号'
|
</span>
|
||||||
}
|
</span>
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '开城合伙人',
|
title: '开城合伙人',
|
||||||
width: 140,
|
width: 140,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, row) => row.companyName || row.parent?.name || '—',
|
render: (_, row) => row.companyName || row.parent?.name || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -254,16 +254,19 @@ export default function PartnerAccountsPage() {
|
|||||||
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('partner-accounts', baseColumns, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: treeData.length || 20,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<div>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
title="合伙人子账号"
|
||||||
<Typography.Text type="secondary">
|
settings={settingsButton}
|
||||||
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
description="主账号在「开城合伙人」创建;此处仅管理子账号树与权限"
|
||||||
</Typography.Text>
|
/>
|
||||||
</div>
|
|
||||||
</Space>
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -305,7 +308,7 @@ export default function PartnerAccountsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={treeData}
|
dataSource={treeData}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowKeys,
|
expandedRowKeys,
|
||||||
@@ -399,7 +402,7 @@ export default function PartnerAccountsPage() {
|
|||||||
label: `账单 (${detail.bills?.length ?? 0})`,
|
label: `账单 (${detail.bills?.length ?? 0})`,
|
||||||
children: (
|
children: (
|
||||||
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
|
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
|
||||||
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} />
|
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 'max-content' }} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -407,7 +410,7 @@ export default function PartnerAccountsPage() {
|
|||||||
label: `名下订单 (${detail.orders?.length ?? 0})`,
|
label: `名下订单 (${detail.orders?.length ?? 0})`,
|
||||||
children: (
|
children: (
|
||||||
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
|
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
|
||||||
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} />
|
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 'max-content' }} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]} />
|
]} />
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
@@ -20,6 +22,10 @@ import { request, type Paginated } from '../lib/api';
|
|||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,8 +37,22 @@ type Row = {
|
|||||||
periodStart: string;
|
periodStart: string;
|
||||||
periodEnd: string;
|
periodEnd: string;
|
||||||
rejectReason?: string | null;
|
rejectReason?: string | null;
|
||||||
partner?: { companyName?: string; phone?: string };
|
paymentRef?: string | null;
|
||||||
partnerAccount?: { companyName?: string; phone?: string };
|
paidAt?: string | null;
|
||||||
|
partner?: {
|
||||||
|
companyName?: string;
|
||||||
|
phone?: string;
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
};
|
||||||
|
partnerAccount?: {
|
||||||
|
companyName?: string;
|
||||||
|
phone?: string;
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
type PartnerOption = { id: string; companyName: string; phone?: string };
|
||||||
@@ -77,6 +97,8 @@ export default function PartnerBillsPage() {
|
|||||||
const [rejectForm] = Form.useForm();
|
const [rejectForm] = Form.useForm();
|
||||||
const [rejecting, setRejecting] = useState(false);
|
const [rejecting, setRejecting] = useState(false);
|
||||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||||
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -127,17 +149,30 @@ export default function PartnerBillsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function markPaid(ids: string[], amountHint?: number) {
|
function markPaid(ids: string[], amountHint?: number) {
|
||||||
|
let paymentRef = '';
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认打款?',
|
title: '确认打款?',
|
||||||
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`,
|
content: (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
将标记 {ids.length} 笔账单为已打款
|
||||||
|
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="打款凭证号(可选)"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onChange={(e) => {
|
||||||
|
paymentRef = e.target.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
okText: '确认打款',
|
okText: '确认打款',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
|
||||||
if (ids.length === 1) {
|
if (ids.length === 1) {
|
||||||
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
|
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body });
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await request('/admin/partner-bills/batch-mark-paid', {
|
await request('/admin/partner-bills/batch-mark-paid', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -182,6 +217,12 @@ export default function PartnerBillsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
const d = await request<Row>(`/admin/partner-bills/${id}`);
|
||||||
|
setDetail(d);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
async function exportExcel() {
|
async function exportExcel() {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
@@ -201,17 +242,24 @@ export default function PartnerBillsPage() {
|
|||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
||||||
|
const partnerBank = (r: Row) => r.partnerAccount ?? r.partner;
|
||||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
||||||
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
|
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
|
||||||
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
{
|
||||||
|
title: '账单号',
|
||||||
|
dataIndex: 'billNo',
|
||||||
|
width: 180,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
width: 160,
|
width: 160,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => partnerName(r),
|
render: (_, r) => partnerName(r),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -238,6 +286,16 @@ export default function PartnerBillsPage() {
|
|||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '收款户名',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收款账号',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -259,6 +317,9 @@ export default function PartnerBillsPage() {
|
|||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0} wrap>
|
<Space size={0} wrap>
|
||||||
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
|
明细
|
||||||
|
</Button>
|
||||||
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
|
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
|
||||||
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
|
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
|
||||||
发送
|
发送
|
||||||
@@ -284,16 +345,16 @@ export default function PartnerBillsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
合伙人账单
|
title="合伙人账单"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Typography.Text type="secondary">
|
description="每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款"
|
||||||
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
/>
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
@@ -395,7 +456,7 @@ export default function PartnerBillsPage() {
|
|||||||
selectedRowKeys: selectedKeys,
|
selectedRowKeys: selectedKeys,
|
||||||
onChange: setSelectedKeys,
|
onChange: setSelectedKeys,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1280 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -465,6 +526,59 @@ export default function PartnerBillsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="合伙人账单明细"
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
{detail && (
|
||||||
|
<>
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账期">
|
||||||
|
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单佣金">
|
||||||
|
¥{Number(detail.orderCommission).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销佣金">
|
||||||
|
¥{Number(detail.redeemCommission).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="应付合计">
|
||||||
|
¥{Number(detail.totalAmount).toFixed(2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={STATUS_COLORS[detail.status] || 'default'}>
|
||||||
|
{STATUS_LABELS[detail.status] || detail.status}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="打款时间">
|
||||||
|
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="打款凭证">
|
||||||
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
|
收款账户
|
||||||
|
</Typography.Title>
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="户名">
|
||||||
|
{partnerBank(detail)?.bankAccountName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账号">
|
||||||
|
{partnerBank(detail)?.bankAccountNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">
|
||||||
|
{partnerBank(detail)?.bankBranch || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import { request } from '../lib/api';
|
|||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -71,12 +75,11 @@ export default function PartnerLogsPage() {
|
|||||||
form.setFieldsValue(filters);
|
form.setFieldsValue(filters);
|
||||||
}, [form, filters]);
|
}, [form, filters]);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '合伙人',
|
title: '合伙人',
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
r.isSubAccount ? (
|
r.isSubAccount ? (
|
||||||
<Tag color="blue">子账号</Tag>
|
<Tag color="blue">子账号</Tag>
|
||||||
@@ -88,7 +91,6 @@ export default function PartnerLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '账号',
|
title: '账号',
|
||||||
width: 150,
|
width: 150,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.accountName}
|
primary={r.accountName}
|
||||||
@@ -108,7 +110,21 @@ export default function PartnerLogsPage() {
|
|||||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
{
|
||||||
|
title: '事件',
|
||||||
|
dataIndex: 'eventName',
|
||||||
|
width: 180,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/logs/partners/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '关联',
|
title: '关联',
|
||||||
width: 120,
|
width: 120,
|
||||||
@@ -116,7 +132,6 @@ export default function PartnerLogsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要',
|
title: '摘要',
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => summarizeExtra(r.extraJson),
|
render: (_, r) => summarizeExtra(r.extraJson),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -137,11 +152,12 @@ export default function PartnerLogsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
{settingsModal}
|
||||||
合伙人日志
|
<AdminListHeader title="合伙人日志" settings={settingsButton} />
|
||||||
</Typography.Title>
|
|
||||||
<Segmented
|
<Segmented
|
||||||
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
value={category}
|
value={category}
|
||||||
@@ -216,7 +232,7 @@ export default function PartnerLogsPage() {
|
|||||||
setPageSize(ps);
|
setPageSize(ps);
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
|||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -173,17 +177,22 @@ export default function PartnersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||||
{
|
{
|
||||||
title: '区县',
|
title: '区县',
|
||||||
dataIndex: 'districtCodes',
|
dataIndex: 'districtCodes',
|
||||||
width: 160,
|
width: 160,
|
||||||
ellipsis: true,
|
|
||||||
render: (codes: string[] | null | undefined, row) =>
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
},
|
},
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
{
|
||||||
|
title: '公司名',
|
||||||
|
dataIndex: 'companyName',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
@@ -205,14 +214,20 @@ export default function PartnersPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
<AdminListHeader
|
||||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
title="开城合伙人"
|
||||||
新建城市合伙人
|
settings={settingsButton}
|
||||||
</Button>
|
actions={
|
||||||
</Space>
|
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||||
|
新建城市合伙人
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ import {
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||||
PENDING: 'orange',
|
PENDING: 'orange',
|
||||||
@@ -94,13 +98,19 @@ export default function PendingRedeemPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<RedeemPendingItem> = [
|
const baseColumns: ColumnsType<RedeemPendingItem> = [
|
||||||
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
|
{
|
||||||
|
title: '待处理单号',
|
||||||
|
dataIndex: 'pendingNo',
|
||||||
|
width: 170,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '核销码 ID',
|
title: '核销码 ID',
|
||||||
dataIndex: 'redeemToken',
|
dataIndex: 'redeemToken',
|
||||||
width: 160,
|
width: 160,
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}…</Typography.Text>,
|
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}…</Typography.Text>,
|
||||||
},
|
},
|
||||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
|
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
|
||||||
@@ -132,9 +142,12 @@ export default function PendingRedeemPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>待处理核销(弱网兜底)</Typography.Title>
|
{settingsModal}
|
||||||
|
<AdminListHeader title="待处理核销(弱网兜底)" settings={settingsButton} />
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -175,7 +188,7 @@ export default function PendingRedeemPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||||
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
||||||
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = ProductDetailTemplateDto;
|
type Row = ProductDetailTemplateDto;
|
||||||
|
|
||||||
@@ -34,7 +37,7 @@ function mapToForm(d: Record<string, unknown>) {
|
|||||||
const row = d as Row;
|
const row = d as Row;
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
|
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [],
|
||||||
features: row.features?.length
|
features: row.features?.length
|
||||||
? row.features
|
? row.features
|
||||||
: [{ icon: 'star', title: '', desc: '' }],
|
: [{ icon: 'star', title: '', desc: '' }],
|
||||||
@@ -68,12 +71,14 @@ function buildPayload(v: TemplateFormValues) {
|
|||||||
function TemplateContentFields() {
|
function TemplateContentFields() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改)</Typography.Text>
|
<Typography.Text type="secondary">模板详情长图(套用商品时可逐张修改,支持批量上传)</Typography.Text>
|
||||||
<DetailImageUrlList
|
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
|
||||||
label="详情图"
|
<DetailImageUrlList
|
||||||
bizType="DETAIL_TEMPLATE"
|
label="详情图"
|
||||||
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
bizType="DETAIL_TEMPLATE"
|
||||||
/>
|
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Form.Item name="storyTitle" label="故事标题">
|
<Form.Item name="storyTitle" label="故事标题">
|
||||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||||
@@ -134,10 +139,26 @@ export default function ProductDetailTemplatesPage() {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 120 },
|
{ title: '编码', dataIndex: 'code', width: 120 },
|
||||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
{
|
||||||
{ title: '说明', dataIndex: 'description', width: 200, ellipsis: true },
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 120,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue(mapToForm(d));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '说明', dataIndex: 'description', width: 200 },
|
||||||
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
|
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
|
||||||
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
|
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
|
||||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||||
@@ -156,10 +177,17 @@ export default function ProductDetailTemplatesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('product-detail-templates', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{settingsModal}
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>商品详情模板</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>商品详情模板</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建模板</Button>
|
<Button type="primary" onClick={() => setCreateOpen(true)}>新建模板</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
@@ -173,7 +201,7 @@ export default function ProductDetailTemplatesPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
@@ -228,7 +256,7 @@ export default function ProductDetailTemplatesPage() {
|
|||||||
}} width={640}>
|
}} width={640}>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{
|
<Form form={createForm} layout="vertical" initialValues={{
|
||||||
status: 'ACTIVE', sortOrder: 0,
|
status: 'ACTIVE', sortOrder: 0,
|
||||||
detailImageUrls: [''],
|
detailImageUrls: [],
|
||||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
}}>
|
}}>
|
||||||
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
|
||||||
Switch, Table, Tabs, Tag, Typography, message,
|
Switch, Table, Tabs, Tag, Typography, message,
|
||||||
@@ -9,9 +9,15 @@ import { request } from '../lib/api';
|
|||||||
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import MultiImageUpload from '../components/MultiImageUpload';
|
||||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||||
|
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||||
import type { FormInstance } from 'antd/es/form';
|
import type { FormInstance } from 'antd/es/form';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type ProductDetailContentDto = {
|
type ProductDetailContentDto = {
|
||||||
storyTitle?: string;
|
storyTitle?: string;
|
||||||
@@ -19,6 +25,22 @@ type ProductDetailContentDto = {
|
|||||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SkuListRow = {
|
||||||
|
id: string;
|
||||||
|
skuCode: string;
|
||||||
|
barcode69: string;
|
||||||
|
specText: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount: number;
|
||||||
|
status: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
|
soldBottles?: number;
|
||||||
|
virtual?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
skuCode: string;
|
skuCode: string;
|
||||||
@@ -28,9 +50,15 @@ type Row = {
|
|||||||
aromaType: string;
|
aromaType: string;
|
||||||
spec: string;
|
spec: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
priceMin?: number;
|
||||||
|
priceMax?: number;
|
||||||
|
soldBottles?: number;
|
||||||
benefitAmount: number;
|
benefitAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
skuCount?: number;
|
||||||
|
specEnabled?: boolean;
|
||||||
|
skus?: SkuListRow[];
|
||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
allowOnlinePurchase?: boolean;
|
allowOnlinePurchase?: boolean;
|
||||||
allowCrossCityDelivery?: boolean;
|
allowCrossCityDelivery?: boolean;
|
||||||
@@ -67,21 +95,14 @@ type ProductFormValues = {
|
|||||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserPickRow = {
|
|
||||||
id: string;
|
|
||||||
phone?: string | null;
|
|
||||||
nickname?: string | null;
|
|
||||||
userNo?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function mapDetailToForm(d: Record<string, unknown>) {
|
function mapDetailToForm(d: Record<string, unknown>) {
|
||||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||||
const row = d as Row;
|
const row = d as Row;
|
||||||
return {
|
return {
|
||||||
...d,
|
...d,
|
||||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||||
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
|
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : []) as string[],
|
||||||
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
|
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : []) as string[],
|
||||||
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
allowOnlinePurchase: row.allowOnlinePurchase !== false,
|
||||||
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
|
||||||
allowOnSitePickup: !!row.allowOnSitePickup,
|
allowOnSitePickup: !!row.allowOnSitePickup,
|
||||||
@@ -112,10 +133,6 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
features: features.length ? features : undefined,
|
features: features.length ? features : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const visibilityPhones = (v.visibilityPhones ?? [])
|
|
||||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
barcode69: v.barcode69,
|
barcode69: v.barcode69,
|
||||||
name: v.name,
|
name: v.name,
|
||||||
@@ -131,7 +148,6 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
allowCrossCityDelivery:
|
allowCrossCityDelivery:
|
||||||
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
|
||||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||||
visibilityPhones,
|
|
||||||
coverUrl: v.coverUrl,
|
coverUrl: v.coverUrl,
|
||||||
carouselUrls,
|
carouselUrls,
|
||||||
detailImageUrls,
|
detailImageUrls,
|
||||||
@@ -141,25 +157,13 @@ function buildProductPayload(v: ProductFormValues) {
|
|||||||
|
|
||||||
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
||||||
return (
|
return (
|
||||||
<Form.List name={name}>
|
<Form.Item name={name} style={{ marginBottom: 0 }}>
|
||||||
{(fields, { add, remove }) => (
|
<MultiImageUpload
|
||||||
<>
|
bizType={bizType}
|
||||||
{fields.map((field) => (
|
mediaType="IMAGE"
|
||||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
tip={`${label}支持一次选择多张批量上传`}
|
||||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
/>
|
||||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
</Form.Item>
|
||||||
</Form.Item>
|
|
||||||
{fields.length > 1 && (
|
|
||||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
))}
|
|
||||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
|
||||||
添加{label}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,11 +172,13 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
|||||||
<>
|
<>
|
||||||
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL,单张最大 10MB)</Typography.Text>
|
<Typography.Text type="secondary">详情页轮播(CAROUSEL,支持批量上传,单张最大 10MB)</Typography.Text>
|
||||||
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography.Text type="secondary">详情长图(DETAIL,可逐张修改,单张最大 10MB)</Typography.Text>
|
<Typography.Text type="secondary">详情长图(DETAIL,支持批量上传,单张最大 10MB)</Typography.Text>
|
||||||
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
<Form.Item name="detailImageUrls" style={{ marginBottom: 0 }}>
|
||||||
|
<DetailImageUrlList label="详情图" bizType="DETAIL" />
|
||||||
|
</Form.Item>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Form.Item name="storyTitle" label="故事标题">
|
<Form.Item name="storyTitle" label="故事标题">
|
||||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||||
@@ -213,33 +219,6 @@ function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaTyp
|
|||||||
}
|
}
|
||||||
|
|
||||||
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
||||||
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
|
|
||||||
const [userSearching, setUserSearching] = useState(false);
|
|
||||||
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
async function searchUsers(keyword: string) {
|
|
||||||
const q = keyword.trim();
|
|
||||||
if (searchTimer.current) clearTimeout(searchTimer.current);
|
|
||||||
if (!q) {
|
|
||||||
setUserOptions([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
searchTimer.current = setTimeout(() => {
|
|
||||||
void (async () => {
|
|
||||||
setUserSearching(true);
|
|
||||||
try {
|
|
||||||
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
|
|
||||||
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
|
|
||||||
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
|
|
||||||
} catch {
|
|
||||||
setUserOptions([]);
|
|
||||||
} finally {
|
|
||||||
setUserSearching(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -248,52 +227,46 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
|
|||||||
name="visibilityWhitelistEnabled"
|
name="visibilityWhitelistEnabled"
|
||||||
label="可见白名单"
|
label="可见白名单"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
|
extra="开启后仅全局测试白名单内手机号在 C 端可见/可购,用于在线测试"
|
||||||
>
|
>
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{enabled ? (
|
{enabled ? (
|
||||||
<>
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||||
<Form.Item
|
可见手机号见白名单管理
|
||||||
name="visibilityPhones"
|
</Typography.Text>
|
||||||
label="白名单手机号"
|
|
||||||
rules={[{ required: true, message: '请至少添加一个手机号' }]}
|
|
||||||
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
mode="tags"
|
|
||||||
tokenSeparators={[',', ' ', ',', ';', ';']}
|
|
||||||
placeholder="输入手机号后回车"
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="从用户库添加">
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
filterOption={false}
|
|
||||||
placeholder="按手机号搜索用户"
|
|
||||||
loading={userSearching}
|
|
||||||
options={userOptions.map((u) => ({
|
|
||||||
value: u.phone!,
|
|
||||||
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `(${u.userNo})` : ''}`,
|
|
||||||
}))}
|
|
||||||
onSearch={searchUsers}
|
|
||||||
onSelect={(phone: string) => {
|
|
||||||
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
|
|
||||||
if (!cur.includes(phone)) {
|
|
||||||
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
|
function FulfillmentTags(row: {
|
||||||
|
allowOnlinePurchase?: boolean;
|
||||||
|
allowCrossCityDelivery?: boolean;
|
||||||
|
allowOnSitePickup?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Space size={[0, 4]} wrap>
|
||||||
|
{row.allowOnlinePurchase !== false ? <Tag color="blue">线上</Tag> : null}
|
||||||
|
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
|
||||||
|
<Tag color="cyan">跨城</Tag>
|
||||||
|
) : null}
|
||||||
|
{row.allowOnSitePickup ? <Tag color="green">现场</Tag> : null}
|
||||||
|
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag>无</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BaseInfoFields({
|
||||||
|
mode,
|
||||||
|
form,
|
||||||
|
hideFulfillment,
|
||||||
|
}: {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
form: FormInstance;
|
||||||
|
/** 多规格商品:履约只在规格 SKU 上编辑 */
|
||||||
|
hideFulfillment?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{mode === 'create' && (
|
{mode === 'create' && (
|
||||||
@@ -301,8 +274,13 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
|||||||
<Form.Item label="SKU">
|
<Form.Item label="SKU">
|
||||||
<Input disabled placeholder="保存后自动生成" />
|
<Input disabled placeholder="保存后自动生成" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Input />
|
name="barcode69"
|
||||||
|
label="69码"
|
||||||
|
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
|
||||||
|
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
|
||||||
|
>
|
||||||
|
<Input placeholder="默认规格 69 码" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
@@ -330,39 +308,47 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
|||||||
<Form.Item name="sortOrder" label="排序">
|
<Form.Item name="sortOrder" label="排序">
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
{hideFulfillment ? (
|
||||||
name="allowOnlinePurchase"
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||||
label="允许线上购买"
|
该商品有多种规格,履约(线上 / 跨城 / 现场)请到「规格与 SKU」中按规格设置。
|
||||||
valuePropName="checked"
|
</Typography.Paragraph>
|
||||||
extra="配送到址(同城)"
|
) : (
|
||||||
>
|
<>
|
||||||
<Switch
|
|
||||||
checkedChildren="开"
|
|
||||||
unCheckedChildren="关"
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
|
|
||||||
{() => (
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="allowCrossCityDelivery"
|
name="allowOnlinePurchase"
|
||||||
label="允许跨城配送"
|
label="允许线上购买"
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
extra="须先开启线上购买"
|
extra="配送到址(同城)"
|
||||||
>
|
>
|
||||||
<Switch
|
<Switch
|
||||||
checkedChildren="开"
|
checkedChildren="开"
|
||||||
unCheckedChildren="关"
|
unCheckedChildren="关"
|
||||||
disabled={!form.getFieldValue('allowOnlinePurchase')}
|
onChange={(checked) => {
|
||||||
|
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
|
||||||
</Form.Item>
|
{() => (
|
||||||
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
<Form.Item
|
||||||
<Switch checkedChildren="开" unCheckedChildren="关" />
|
name="allowCrossCityDelivery"
|
||||||
</Form.Item>
|
label="允许跨城配送"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="须先开启线上购买"
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="开"
|
||||||
|
unCheckedChildren="关"
|
||||||
|
disabled={!form.getFieldValue('allowOnlinePurchase')}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="开" unCheckedChildren="关" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<VisibilityWhitelistFields form={form} />
|
<VisibilityWhitelistFields form={form} />
|
||||||
<Form.Item name="coverUrl" label="封面">
|
<Form.Item name="coverUrl" label="封面">
|
||||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||||
@@ -405,13 +391,43 @@ export default function ProductsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = useMemo(() => [
|
const baseColumns: ColumnsType<Row> = useMemo(() => [
|
||||||
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
|
|
||||||
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
|
|
||||||
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
||||||
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
|
{ title: '品名', dataIndex: 'name', width: 200, render: (v, row) => (
|
||||||
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
<AdminPrimaryLink onClick={async () => {
|
||||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue(mapDetailToForm(d));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}>{v}</AdminPrimaryLink>
|
||||||
|
) },
|
||||||
|
{
|
||||||
|
title: '累计销售',
|
||||||
|
dataIndex: 'soldBottles',
|
||||||
|
width: 110,
|
||||||
|
render: (v: number | undefined) => `累计 ${v ?? 0} 瓶`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '售价',
|
||||||
|
key: 'priceRange',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => {
|
||||||
|
const min = row.priceMin ?? row.price;
|
||||||
|
const max = row.priceMax ?? row.price;
|
||||||
|
if (min === max) return `¥${min}`;
|
||||||
|
return `¥${min} ~ ¥${max}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '规格数',
|
||||||
|
dataIndex: 'skuCount',
|
||||||
|
width: 80,
|
||||||
|
render: (v: number | undefined, row) => {
|
||||||
|
const real = v ?? 0;
|
||||||
|
if (real > 0) return real;
|
||||||
|
return row.skus?.some((s) => s.virtual) ? 1 : 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
||||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||||
) },
|
) },
|
||||||
@@ -419,26 +435,10 @@ export default function ProductsPage() {
|
|||||||
title: '白名单',
|
title: '白名单',
|
||||||
dataIndex: 'visibilityWhitelistEnabled',
|
dataIndex: 'visibilityWhitelistEnabled',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v: boolean, row) =>
|
render: (v: boolean) =>
|
||||||
v ? <Tag color="orange">限{row.visibilityPhones?.length ?? 0}人</Tag> : <Tag>公开</Tag>,
|
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '履约',
|
|
||||||
key: 'fulfillment',
|
|
||||||
width: 160,
|
|
||||||
render: (_, row) => (
|
|
||||||
<Space size={[0, 4]} wrap>
|
|
||||||
{row.allowOnlinePurchase !== false ? <Tag color="blue">线上</Tag> : null}
|
|
||||||
{row.allowOnlinePurchase !== false && row.allowCrossCityDelivery !== false ? (
|
|
||||||
<Tag color="cyan">跨城</Tag>
|
|
||||||
) : null}
|
|
||||||
{row.allowOnSitePickup ? <Tag color="green">现场</Tag> : null}
|
|
||||||
{row.allowOnlinePurchase === false && !row.allowOnSitePickup ? <Tag>无</Tag> : null}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
|
||||||
{
|
{
|
||||||
title: '操作', width: 120,
|
title: '操作', width: 120,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
@@ -451,7 +451,7 @@ export default function ProductsPage() {
|
|||||||
}}>编辑</Button>
|
}}>编辑</Button>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="确认删除该商品?"
|
title="确认删除该商品?"
|
||||||
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
|
description={`将永久删除「${row.name}」,此操作不可恢复。`}
|
||||||
okText="确认删除"
|
okText="确认删除"
|
||||||
cancelText="取消"
|
cancelText="取消"
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
@@ -464,14 +464,62 @@ export default function ProductsPage() {
|
|||||||
},
|
},
|
||||||
], [detail, editForm]);
|
], [detail, editForm]);
|
||||||
|
|
||||||
|
const skuColumns: ColumnsType<SkuListRow> = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '规格',
|
||||||
|
dataIndex: 'specText',
|
||||||
|
width: 140,
|
||||||
|
render: (v: string, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v || '默认'}</span>
|
||||||
|
{row.isDefault ? <Tag color="blue">默认</Tag> : null}
|
||||||
|
{row.virtual ? <Tag>未建SKU</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: 'SKU', dataIndex: 'skuCode', width: 110 },
|
||||||
|
{ title: '69码', dataIndex: 'barcode69', width: 160 },
|
||||||
|
{ title: '售价', dataIndex: 'price', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{ title: '权益额', dataIndex: 'benefitAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
|
{
|
||||||
|
title: '累计销售',
|
||||||
|
dataIndex: 'soldBottles',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number | undefined) => `${v ?? 0} 瓶`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '履约',
|
||||||
|
key: 'fulfillment',
|
||||||
|
width: 160,
|
||||||
|
render: (_, row) => <FulfillmentTags {...row} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (s) => (
|
||||||
|
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], []);
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('products', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
|
<AdminListHeader
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>
|
title="商品管理"
|
||||||
</Space>
|
settings={settingsButton}
|
||||||
|
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
|
<Form.Item name="name" label="名称">
|
||||||
|
<Input allowClear placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -480,17 +528,46 @@ export default function ProductsPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
|
<Table
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
rowKey="id"
|
||||||
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
expandable={{
|
||||||
|
rowExpandable: () => true,
|
||||||
|
expandedRowRender: (row) => (
|
||||||
|
<div style={{ margin: '-8px -8px -8px 24px', padding: 12, background: '#f5f5f5', borderRadius: 6 }}>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
columns={skuColumns}
|
||||||
|
dataSource={row.skus ?? []}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
style={{ background: 'transparent' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }}
|
||||||
|
/>
|
||||||
|
<Drawer title="编辑商品" width={1100} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={detail && (
|
extra={detail && (
|
||||||
<Button type="primary" onClick={async () => {
|
<Button type="primary" onClick={async () => {
|
||||||
const v = await editForm.validateFields();
|
const v = await editForm.validateFields();
|
||||||
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
const multiSku = Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1;
|
||||||
|
if (!multiSku && !v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||||
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = buildProductPayload(v);
|
const payload = buildProductPayload(v);
|
||||||
|
if (multiSku) {
|
||||||
|
delete (payload as { allowOnlinePurchase?: boolean }).allowOnlinePurchase;
|
||||||
|
delete (payload as { allowCrossCityDelivery?: boolean }).allowCrossCityDelivery;
|
||||||
|
delete (payload as { allowOnSitePickup?: boolean }).allowOnSitePickup;
|
||||||
|
}
|
||||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
message.success('已保存');
|
message.success('已保存');
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
@@ -500,13 +577,24 @@ export default function ProductsPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
|
<Descriptions.Item label="默认 SKU">{String(detail.skuCode)}(系统生成)</Descriptions.Item>
|
||||||
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
|
<Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
|
{
|
||||||
|
key: 'base',
|
||||||
|
label: '基础信息',
|
||||||
|
children: (
|
||||||
|
<BaseInfoFields
|
||||||
|
mode="edit"
|
||||||
|
form={editForm}
|
||||||
|
hideFulfillment={Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'detail',
|
key: 'detail',
|
||||||
label: '详情页',
|
label: '详情页',
|
||||||
@@ -517,6 +605,23 @@ export default function ProductsPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'specs',
|
||||||
|
label: '规格与 SKU',
|
||||||
|
children: (
|
||||||
|
<ProductSpecsEditor
|
||||||
|
productId={String(detail.id)}
|
||||||
|
initialAttrs={(detail.specAttrs as never) ?? []}
|
||||||
|
initialSkus={(detail.skus as never) ?? []}
|
||||||
|
onSaved={async () => {
|
||||||
|
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue(mapDetailToForm(d));
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
]} />
|
]} />
|
||||||
</Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
@@ -539,7 +644,7 @@ export default function ProductsPage() {
|
|||||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||||
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
|
||||||
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
visibilityWhitelistEnabled: false, visibilityPhones: [],
|
||||||
carouselUrls: [''], detailImageUrls: [''],
|
carouselUrls: [], detailImageUrls: [],
|
||||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||||
}}>
|
}}>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = PromoCodeItem;
|
type Row = PromoCodeItem;
|
||||||
|
|
||||||
@@ -74,8 +78,15 @@ export default function PromoCodesPage() {
|
|||||||
void loadScenes();
|
void loadScenes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 160,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}`)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||||
{
|
{
|
||||||
title: '场景',
|
title: '场景',
|
||||||
@@ -160,12 +171,16 @@ export default function PromoCodesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-codes', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
<AdminListHeader
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
title="推广码管理"
|
||||||
</div>
|
settings={settingsButton}
|
||||||
|
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
@@ -194,7 +209,7 @@ export default function PromoCodesPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
|
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,30 +19,82 @@ type Row = {
|
|||||||
settleAmount: number;
|
settleAmount: number;
|
||||||
channel?: RedeemChannel;
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user?: { userNo: string; phone: string | null };
|
isTest?: boolean;
|
||||||
|
user?: { userNo: string; phone: string | null; nickname?: string | null };
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { couponNo: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function maskPhone(phone: string | null | undefined) {
|
||||||
|
if (!phone || phone.length < 7) return phone ?? '—';
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RedeemRecordsPage() {
|
export default function RedeemRecordsPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||||
|
const init: Record<string, string | boolean> = {};
|
||||||
|
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
'/admin/redeem-records',
|
'/admin/redeem-records',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
if (filters.redeemNo) qs.set('redeemNo', String(filters.redeemNo));
|
||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
if (filters.storeId) qs.set('storeId', String(filters.storeId));
|
||||||
if (filters.channel) qs.set('channel', filters.channel);
|
if (filters.channel) qs.set('channel', String(filters.channel));
|
||||||
|
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const deepLinkOpenedRef = useRef(false);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
useEffect(() => {
|
||||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
|
||||||
|
}, [form, initialRedeemNo]);
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
setDetailLoading(true);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
try {
|
||||||
|
setDetail(await request(`/admin/redeem-records/${id}`));
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
|
||||||
|
const first = data?.items?.[0];
|
||||||
|
if (first && String(first.redeemNo) === initialRedeemNo) {
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
void openDetail(first.id);
|
||||||
|
}
|
||||||
|
}, [data, initialRedeemNo, loading]);
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<Row> = [
|
||||||
|
{
|
||||||
|
title: '核销号',
|
||||||
|
dataIndex: 'redeemNo',
|
||||||
|
width: 200,
|
||||||
|
render: (v, row) => (
|
||||||
|
<span>
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
{row.isTest ? (
|
||||||
|
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||||
|
测试
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '方式',
|
title: '方式',
|
||||||
dataIndex: 'channel',
|
dataIndex: 'channel',
|
||||||
@@ -50,7 +108,19 @@ export default function RedeemRecordsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
{ title: '用户编号', dataIndex: ['user', 'userNo'], width: 110 },
|
||||||
|
{
|
||||||
|
title: '用户昵称',
|
||||||
|
dataIndex: ['user', 'nickname'],
|
||||||
|
width: 100,
|
||||||
|
render: (v: string | null | undefined) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户手机',
|
||||||
|
dataIndex: ['user', 'phone'],
|
||||||
|
width: 120,
|
||||||
|
render: (v: string | null | undefined) => maskPhone(v),
|
||||||
|
},
|
||||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
|
||||||
@@ -60,23 +130,19 @@ export default function RedeemRecordsPage() {
|
|||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
type="link"
|
|
||||||
size="small"
|
|
||||||
onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-records', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
{settingsModal}
|
||||||
|
<AdminListHeader title="核销记录" settings={settingsButton} />
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -102,6 +168,9 @@ export default function RedeemRecordsPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
查询
|
查询
|
||||||
@@ -113,7 +182,7 @@ export default function RedeemRecordsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -125,22 +194,21 @@ export default function RedeemRecordsPage() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer
|
||||||
{detail && (
|
title="核销详情"
|
||||||
<Descriptions column={1} bordered size="small">
|
width={640}
|
||||||
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
open={drawerOpen}
|
||||||
<Descriptions.Item label="方式">
|
onClose={() => {
|
||||||
{
|
setDrawerOpen(false);
|
||||||
REDEEM_CHANNEL_LABELS[
|
setDetail(null);
|
||||||
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel
|
}}
|
||||||
]
|
destroyOnClose
|
||||||
}
|
>
|
||||||
</Descriptions.Item>
|
{detailLoading ? (
|
||||||
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
) : detail ? (
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
<RedeemRecordDetailDescriptions detail={detail} />
|
||||||
</Descriptions>
|
) : null}
|
||||||
)}
|
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
fmtTime,
|
fmtTime,
|
||||||
} from '../lib/constants';
|
} from '../lib/constants';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,7 +61,7 @@ export default function ResourcesPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
{
|
{
|
||||||
title: '归属',
|
title: '归属',
|
||||||
@@ -84,8 +87,8 @@ export default function ResourcesPage() {
|
|||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
{ title: 'URL', dataIndex: 'url' },
|
||||||
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
|
{ title: 'OSS Key', dataIndex: 'ossKey', width: 160 },
|
||||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
@@ -114,16 +117,16 @@ export default function ResourcesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('resources', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
OSS 资源库
|
title="OSS 资源库"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>登记资源</Button>}
|
||||||
登记资源
|
/>
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -170,7 +173,7 @@ export default function ResourcesPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||||
|
|
||||||
@@ -15,6 +19,7 @@ type Row = {
|
|||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isTest?: boolean;
|
||||||
storeCount?: number;
|
storeCount?: number;
|
||||||
staffCount?: number;
|
staffCount?: number;
|
||||||
bankAccountName?: string | null;
|
bankAccountName?: string | null;
|
||||||
@@ -30,13 +35,14 @@ type StoreOption = { id: string; name: string };
|
|||||||
export default function StoreAccountsPage() {
|
export default function StoreAccountsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/store-accounts',
|
'/admin/store-accounts',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.phone) qs.set('phone', filters.phone);
|
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', String(filters.status));
|
||||||
|
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -72,8 +78,25 @@ export default function StoreAccountsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 120,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '绑定门店',
|
title: '绑定门店',
|
||||||
@@ -126,25 +149,27 @@ export default function StoreAccountsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Space direction="vertical" size={0}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
title="门店账户"
|
||||||
<Typography.Text type="secondary">
|
settings={settingsButton}
|
||||||
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
|
||||||
</Typography.Text>
|
actions={
|
||||||
</Space>
|
<Button
|
||||||
<Button
|
type="primary"
|
||||||
type="primary"
|
onClick={() => {
|
||||||
onClick={() => {
|
void loadStores();
|
||||||
void loadStores();
|
setCreateOpen(true);
|
||||||
setCreateOpen(true);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
新建账户
|
||||||
新建账户
|
</Button>
|
||||||
</Button>
|
}
|
||||||
</Space>
|
/>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -162,6 +187,9 @@ export default function StoreAccountsPage() {
|
|||||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table
|
<Table
|
||||||
@@ -170,7 +198,7 @@ export default function StoreAccountsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ import { request, type Paginated } from '../lib/api';
|
|||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
kind: StoreSettlementKind;
|
kind: StoreSettlementKind;
|
||||||
@@ -44,6 +48,11 @@ type Row = {
|
|||||||
settlementRate?: number | null;
|
settlementRate?: number | null;
|
||||||
payoutCount?: number | null;
|
payoutCount?: number | null;
|
||||||
store?: { id: string; name: string; cityName: string; phone?: string | null };
|
store?: { id: string; name: string; cityName: string; phone?: string | null };
|
||||||
|
bankAccount?: {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StoreOption = { id: string; name: string; phone: string };
|
type StoreOption = { id: string; name: string; phone: string };
|
||||||
@@ -55,6 +64,11 @@ const STATUS_COLORS: Record<string, string> = {
|
|||||||
REJECTED: 'red',
|
REJECTED: 'red',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function settlementStatusColor(kind: StoreSettlementKind, status: string) {
|
||||||
|
if (status === 'PAID') return kind === 'WITHDRAW' ? 'cyan' : 'green';
|
||||||
|
return STATUS_COLORS[status] || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
||||||
T1_BILL: 'blue',
|
T1_BILL: 'blue',
|
||||||
WITHDRAW: 'purple',
|
WITHDRAW: 'purple',
|
||||||
@@ -63,10 +77,15 @@ const KIND_COLORS: Record<StoreSettlementKind, string> = {
|
|||||||
export default function StoreBillsPage() {
|
export default function StoreBillsPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||||
|
const initialStoreId = searchParams.get('storeId') || '';
|
||||||
|
const initialStatus =
|
||||||
|
searchParams.get('status')?.trim() ||
|
||||||
|
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({
|
const [filters, setFilters] = useState<Record<string, string>>({
|
||||||
kind: initialKind,
|
kind: initialKind,
|
||||||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
status: initialStatus,
|
||||||
|
storeId: initialStoreId,
|
||||||
});
|
});
|
||||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
@@ -97,8 +116,9 @@ export default function StoreBillsPage() {
|
|||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
kind: filters.kind || undefined,
|
kind: filters.kind || undefined,
|
||||||
status: filters.status || undefined,
|
status: filters.status || undefined,
|
||||||
|
storeId: filters.storeId || undefined,
|
||||||
});
|
});
|
||||||
}, [filters.kind, filters.status, form]);
|
}, [filters.kind, filters.status, filters.storeId, form]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
@@ -112,14 +132,30 @@ export default function StoreBillsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function confirmPay(ids: string[], amountHint?: number) {
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
|
let paymentRef = '';
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认打款?',
|
title: '确认打款?',
|
||||||
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
content: (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
将确认 {ids.length} 笔 T+1 门店对账单
|
||||||
|
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="打款凭证号(可选)"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onChange={(e) => {
|
||||||
|
paymentRef = e.target.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
okText: '确认打款',
|
okText: '确认打款',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
|
||||||
if (ids.length === 1) {
|
if (ids.length === 1) {
|
||||||
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
|
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST', body });
|
||||||
} else {
|
} else {
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -235,23 +271,24 @@ export default function StoreBillsPage() {
|
|||||||
);
|
);
|
||||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'kind',
|
dataIndex: 'kind',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (k: StoreSettlementKind) => (
|
render: (k: StoreSettlementKind) => (
|
||||||
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
|
<Tag color={KIND_COLORS[k]} style={{ minWidth: 72, textAlign: 'center' }}>
|
||||||
|
{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}
|
||||||
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '单号',
|
title: '单号',
|
||||||
dataIndex: 'billNo',
|
dataIndex: 'billNo',
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: true,
|
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<span>{v}</span>
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -262,9 +299,21 @@ export default function StoreBillsPage() {
|
|||||||
width: 160,
|
width: 160,
|
||||||
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
||||||
},
|
},
|
||||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||||
|
{
|
||||||
|
title: '收款户名',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收款账号',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '笔数',
|
title: '笔数',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -282,7 +331,9 @@ export default function StoreBillsPage() {
|
|||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s: string, row) => (
|
render: (s: string, row) => (
|
||||||
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
|
<Tag color={settlementStatusColor(row.kind, s)}>
|
||||||
|
{storeSettlementStatusLabel(row.kind, s)}
|
||||||
|
</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -326,21 +377,28 @@ export default function StoreBillsPage() {
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
门店账单
|
title="门店账单"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Typography.Text type="secondary">
|
description={
|
||||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口
|
<>
|
||||||
</Typography.Text>
|
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
||||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
||||||
<Typography.Text type="danger">
|
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||||
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
<div>
|
||||||
</Typography.Text>
|
<Typography.Text type="danger">
|
||||||
) : null}
|
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
||||||
</Space>
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
@@ -359,6 +417,7 @@ export default function StoreBillsPage() {
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
kind: filters.kind || undefined,
|
kind: filters.kind || undefined,
|
||||||
status: filters.status || undefined,
|
status: filters.status || undefined,
|
||||||
|
storeId: filters.storeId || undefined,
|
||||||
}}
|
}}
|
||||||
onFinish={(v: {
|
onFinish={(v: {
|
||||||
kind?: string;
|
kind?: string;
|
||||||
@@ -461,7 +520,7 @@ export default function StoreBillsPage() {
|
|||||||
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
|
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1300 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -496,7 +555,32 @@ export default function StoreBillsPage() {
|
|||||||
<Descriptions.Item label="打款时间">
|
<Descriptions.Item label="打款时间">
|
||||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="打款凭证">
|
||||||
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
{(detail.storeAccount as {
|
||||||
|
bankAccountName?: string;
|
||||||
|
bankAccountNo?: string;
|
||||||
|
bankBranch?: string;
|
||||||
|
} | null) ? (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
|
收款账户
|
||||||
|
</Typography.Title>
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="户名">
|
||||||
|
{(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账号">
|
||||||
|
{(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">
|
||||||
|
{(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
核销明细
|
核销明细
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import {
|
|||||||
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { request } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type CategoryNode = {
|
type CategoryNode = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -38,7 +42,9 @@ export default function StoreCategoriesPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<FlatRow | null>(null);
|
const [editing, setEditing] = useState<FlatRow | null>(null);
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const canDelete = (profile?.permissionKeys ?? []).includes('store_categories_delete');
|
||||||
|
|
||||||
const rows = useMemo(() => flattenTree(tree), [tree]);
|
const rows = useMemo(() => flattenTree(tree), [tree]);
|
||||||
const rootOptions = useMemo(
|
const rootOptions = useMemo(
|
||||||
@@ -60,6 +66,7 @@ export default function StoreCategoriesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function openCreate(parentId?: string) {
|
function openCreate(parentId?: string) {
|
||||||
@@ -112,7 +119,7 @@ export default function StoreCategoriesPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<FlatRow> = [
|
const baseColumns: ColumnsType<FlatRow> = [
|
||||||
{
|
{
|
||||||
title: '层级',
|
title: '层级',
|
||||||
dataIndex: 'level',
|
dataIndex: 'level',
|
||||||
@@ -125,7 +132,7 @@ export default function StoreCategoriesPage() {
|
|||||||
render: (name, row) => (
|
render: (name, row) => (
|
||||||
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||||
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||||
{name}
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -141,48 +148,59 @@ export default function StoreCategoriesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 220,
|
width: canDelete ? 220 : 160,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||||
{row.level === 1 ? (
|
{row.level === 1 ? (
|
||||||
<Button type="link" size="small" onClick={() => openCreate(row.id)}>加二级</Button>
|
<Button type="link" size="small" onClick={() => openCreate(row.id)}>加二级</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Popconfirm
|
{canDelete ? (
|
||||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
<Popconfirm
|
||||||
onConfirm={async () => {
|
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||||
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
onConfirm={async () => {
|
||||||
message.success('已处理');
|
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||||
void reload();
|
message.success('已处理');
|
||||||
}}
|
void reload();
|
||||||
>
|
}}
|
||||||
<Button type="link" size="small" danger>删除</Button>
|
>
|
||||||
</Popconfirm>
|
<Button type="link" size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-categories', baseColumns, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: rows.length || 20,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Space direction="vertical" size={0}>
|
<AdminListHeader
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
title="门店分类"
|
||||||
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
settings={settingsButton}
|
||||||
</Space>
|
description="两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择"
|
||||||
<Space>
|
actions={
|
||||||
<Button
|
<>
|
||||||
onClick={async () => {
|
{canDelete ? (
|
||||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
<Button
|
||||||
message.success('已同步默认分类');
|
onClick={async () => {
|
||||||
void reload();
|
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||||
}}
|
message.success('已同步默认分类');
|
||||||
>
|
void reload();
|
||||||
同步默认分类
|
}}
|
||||||
</Button>
|
>
|
||||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
同步默认分类
|
||||||
</Space>
|
</Button>
|
||||||
</Space>
|
) : null}
|
||||||
|
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Table
|
<Table
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ import { request } from '../lib/api';
|
|||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -80,14 +84,14 @@ export default function StoreLogsPage() {
|
|||||||
form.setFieldsValue(filters);
|
form.setFieldsValue(filters);
|
||||||
}, [form, filters]);
|
}, [form, filters]);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '门店', width: 180, ellipsis: true,
|
title: '门店', width: 180,
|
||||||
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
|
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账号', width: 150, ellipsis: true,
|
title: '账号', width: 150,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.accountName}
|
primary={r.accountName}
|
||||||
@@ -101,7 +105,23 @@ export default function StoreLogsPage() {
|
|||||||
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
{
|
||||||
|
title: '事件',
|
||||||
|
dataIndex: 'eventName',
|
||||||
|
width: 160,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
const parsed = parseCompositeId(row.id);
|
||||||
|
if (!parsed) return;
|
||||||
|
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '来源', dataIndex: 'source', width: 100,
|
title: '来源', dataIndex: 'source', width: 100,
|
||||||
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
||||||
@@ -111,7 +131,7 @@ export default function StoreLogsPage() {
|
|||||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要', ellipsis: true,
|
title: '摘要',
|
||||||
render: (_, r) => summarizeExtra(r.extraJson),
|
render: (_, r) => summarizeExtra(r.extraJson),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -127,12 +147,16 @@ export default function StoreLogsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-stores', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>商户日志</Typography.Title>
|
{settingsModal}
|
||||||
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
<AdminListHeader
|
||||||
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
title="商户日志"
|
||||||
</Typography.Paragraph>
|
settings={settingsButton}
|
||||||
|
description="门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。"
|
||||||
|
/>
|
||||||
<Segmented
|
<Segmented
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
@@ -164,7 +188,7 @@ export default function StoreLogsPage() {
|
|||||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { request, type Paginated } from '../lib/api';
|
|||||||
import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
|
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
|
||||||
@@ -40,8 +44,23 @@ export default function StoreMediaPage() {
|
|||||||
setStores(res.items);
|
setStores(res.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
{
|
||||||
|
title: '门店',
|
||||||
|
dataIndex: ['store', 'name'],
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(row);
|
||||||
|
editForm.setFieldsValue(row);
|
||||||
|
setEditOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
||||||
{
|
{
|
||||||
title: '预览', dataIndex: 'url', width: 100,
|
title: '预览', dataIndex: 'url', width: 100,
|
||||||
@@ -49,7 +68,7 @@ export default function StoreMediaPage() {
|
|||||||
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
|
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
|
||||||
: <a href={url} target="_blank" rel="noreferrer">视频</a>,
|
: <a href={url} target="_blank" rel="noreferrer">视频</a>,
|
||||||
},
|
},
|
||||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
{ title: 'URL', dataIndex: 'url' },
|
||||||
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -73,12 +92,16 @@ export default function StoreMediaPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-media', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店资源</Typography.Title>
|
<AdminListHeader
|
||||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>
|
title="门店资源"
|
||||||
</Space>
|
settings={settingsButton}
|
||||||
|
actions={<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="mediaType" label="类型">
|
<Form.Item name="mediaType" label="类型">
|
||||||
@@ -86,7 +109,7 @@ export default function StoreMediaPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
const v = await createForm.validateFields();
|
||||||
|
|||||||
@@ -1,95 +1,155 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
|
Image,
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type {
|
import type {
|
||||||
StorePackageAuditDetailDto,
|
StoreInfoChangeFieldDiff,
|
||||||
|
StoreInfoChangeRequestDto,
|
||||||
|
StoreInfoChangeStatus,
|
||||||
|
StorePackageAuditSummaryDto,
|
||||||
StorePackageChangeRequestDto,
|
StorePackageChangeRequestDto,
|
||||||
StorePackageItemDto,
|
StorePackageChangeStatus,
|
||||||
StorePackageViewDto,
|
} from '@dukang/shared-types';
|
||||||
|
import {
|
||||||
|
STORE_INFO_CHANGE_STATUS_LABELS,
|
||||||
|
STORE_INFO_CHANGEABLE_FIELD_LABELS,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { STORE_PACKAGE_CHANGE_STATUS_LABELS } from '@dukang/shared-types';
|
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||||
const name = String(pkg.name ?? '').trim();
|
PENDING: '待审核',
|
||||||
return name ? `name:${name}` : `idx:${index}`;
|
APPROVED: '已通过',
|
||||||
}
|
REJECTED: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
function fmtFieldValue(field: string, v: unknown): string {
|
||||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
if (v == null || String(v).trim() === '') return '(空)';
|
||||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
||||||
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
return String(v);
|
||||||
const rows: Array<{
|
|
||||||
key: string;
|
|
||||||
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
|
||||||
live?: StorePackageViewDto;
|
|
||||||
proposed?: StorePackageItemDto;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
for (const key of keys) {
|
|
||||||
const l = liveMap.get(key);
|
|
||||||
const p = proposedMap.get(key);
|
|
||||||
if (l && !p) {
|
|
||||||
rows.push({ key, change: 'removed', live: l });
|
|
||||||
} else if (!l && p) {
|
|
||||||
rows.push({ key, change: 'added', proposed: p });
|
|
||||||
} else if (l && p) {
|
|
||||||
const changed =
|
|
||||||
l.price !== p.price ||
|
|
||||||
l.dishes !== p.dishes ||
|
|
||||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
|
||||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '');
|
|
||||||
rows.push({ key, change: changed ? 'changed' : 'unchanged', live: l, proposed: p });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return rows;
|
if (field === 'envPhotoUrls' && Array.isArray(v)) {
|
||||||
|
return `${v.length} 张`;
|
||||||
|
}
|
||||||
|
if (field === 'coverUrl') return '见对比图';
|
||||||
|
return String(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
const CHANGE_LABELS = {
|
function InfoChangeImageDiff({
|
||||||
added: { text: '新增', color: 'green' },
|
field,
|
||||||
removed: { text: '删除', color: 'red' },
|
live,
|
||||||
changed: { text: '变更', color: 'orange' },
|
proposed,
|
||||||
unchanged: { text: '未变', color: 'default' },
|
imageSize = 72,
|
||||||
} as const;
|
}: {
|
||||||
|
field: string;
|
||||||
|
live: unknown;
|
||||||
|
proposed: unknown;
|
||||||
|
imageSize?: number;
|
||||||
|
}) {
|
||||||
|
const liveUrls =
|
||||||
|
field === 'coverUrl'
|
||||||
|
? [String(live || '').trim()].filter(Boolean)
|
||||||
|
: Array.isArray(live)
|
||||||
|
? live.map((u) => String(u || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const proposedUrls =
|
||||||
|
field === 'coverUrl'
|
||||||
|
? [String(proposed || '').trim()].filter(Boolean)
|
||||||
|
: Array.isArray(proposed)
|
||||||
|
? proposed.map((u) => String(u || '').trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
export default function StorePackageAuditsPage() {
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
||||||
|
变更前
|
||||||
|
</Typography.Text>
|
||||||
|
{liveUrls.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{liveUrls.map((url) => (
|
||||||
|
<Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">(空)</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
||||||
|
变更后
|
||||||
|
</Typography.Text>
|
||||||
|
{proposedUrls.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{proposedUrls.map((url) => (
|
||||||
|
<Image key={`new-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">(空)</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoChangeAuditPanel({
|
||||||
|
initialRequestId,
|
||||||
|
}: {
|
||||||
|
initialRequestId?: string | null;
|
||||||
|
}) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
const [items, setItems] = useState<StoreInfoChangeRequestDto[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [status, setStatus] = useState<string>('PENDING');
|
const [status, setStatus] = useState<string>('PENDING');
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<(StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }) | null>(null);
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
const [rejectReason, setRejectReason] = useState('');
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [detailOpen, setDetailOpen] = useState(false);
|
const [infoFullscreen, setInfoFullscreen] = useState(false);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
|
||||||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
|
||||||
|
|
||||||
async function reload(nextPage = page, nextStatus = status) {
|
async function reload(nextPage = page, nextStatus = status) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({ page: String(nextPage), pageSize: '20' });
|
||||||
page: String(nextPage),
|
|
||||||
pageSize: '20',
|
|
||||||
});
|
|
||||||
if (nextStatus) qs.set('status', nextStatus);
|
if (nextStatus) qs.set('status', nextStatus);
|
||||||
const data = await request<Paginated<StorePackageChangeRequestDto>>(
|
const [data, summary] = await Promise.all([
|
||||||
`/admin/store-package-audits?${qs}`,
|
request<{ items: StoreInfoChangeRequestDto[]; total: number; page?: number }>(
|
||||||
);
|
`/admin/store-info-change-requests?${qs}`,
|
||||||
|
),
|
||||||
|
request<{ pendingCount: number }>('/admin/store-info-change-requests/summary'),
|
||||||
|
]);
|
||||||
setItems(data.items);
|
setItems(data.items);
|
||||||
setTotal(data.total);
|
setTotal(data.total);
|
||||||
setPage(data.page);
|
setPage(data.page ?? nextPage);
|
||||||
|
setPendingCount(summary.pendingCount ?? 0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '加载失败');
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -101,12 +161,19 @@ export default function StorePackageAuditsPage() {
|
|||||||
void reload(1, status);
|
void reload(1, status);
|
||||||
}, [status]);
|
}, [status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialRequestId) void openDetail(initialRequestId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
try {
|
try {
|
||||||
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${id}`);
|
const data = await request<StoreInfoChangeRequestDto & { diffs?: StoreInfoChangeFieldDiff[] }>(
|
||||||
|
`/admin/store-info-change-requests/${id}`,
|
||||||
|
);
|
||||||
setDetail(data);
|
setDetail(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '加载详情失败');
|
message.error(e instanceof Error ? e.message : '加载详情失败');
|
||||||
@@ -118,7 +185,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
|
|
||||||
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||||
try {
|
try {
|
||||||
await request(`/admin/store-package-audits/${id}/audit`, {
|
await request(`/admin/store-info-change-requests/${id}/audit`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(
|
body: JSON.stringify(
|
||||||
action === 'REJECT' ? { action, rejectReason: reason } : { action },
|
action === 'REJECT' ? { action, rejectReason: reason } : { action },
|
||||||
@@ -126,78 +193,43 @@ export default function StorePackageAuditsPage() {
|
|||||||
});
|
});
|
||||||
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||||
setDetailOpen(false);
|
setDetailOpen(false);
|
||||||
|
notifyPackageAuditChanged();
|
||||||
void reload(page, status);
|
void reload(page, status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '操作失败');
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
|
||||||
|
|
||||||
const diffColumns: ColumnsType<(typeof diffRows)[number]> = [
|
|
||||||
{
|
{
|
||||||
title: '变更',
|
title: '门店',
|
||||||
dataIndex: 'change',
|
dataIndex: 'storeName',
|
||||||
width: 72,
|
render: (_, row) => (
|
||||||
render: (v: keyof typeof CHANGE_LABELS) => {
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
|
||||||
const meta = CHANGE_LABELS[v];
|
{row.storeName || row.storeId}
|
||||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
</AdminPrimaryLink>
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '当前线上',
|
|
||||||
render: (_, row) =>
|
|
||||||
row.live ? (
|
|
||||||
<div>
|
|
||||||
<div><strong>{row.live.name}</strong> · ¥{row.live.price}</div>
|
|
||||||
<Typography.Text type="secondary">{row.live.dishes}</Typography.Text>
|
|
||||||
{row.live.usableTime ? (
|
|
||||||
<div><Typography.Text type="secondary">可用:{row.live.usableTime}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
{row.live.otherNotes ? (
|
|
||||||
<div><Typography.Text type="secondary">备注:{row.live.otherNotes}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '申请变更',
|
|
||||||
render: (_, row) =>
|
|
||||||
row.proposed ? (
|
|
||||||
<div>
|
|
||||||
<div><strong>{row.proposed.name}</strong> · ¥{row.proposed.price}</div>
|
|
||||||
<Typography.Text type="secondary">{row.proposed.dishes}</Typography.Text>
|
|
||||||
{row.proposed.usableTime ? (
|
|
||||||
<div><Typography.Text type="secondary">可用:{row.proposed.usableTime}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
{row.proposed.otherNotes ? (
|
|
||||||
<div><Typography.Text type="secondary">备注:{row.proposed.otherNotes}</Typography.Text></div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
|
||||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
|
||||||
{
|
|
||||||
title: '状态',
|
|
||||||
dataIndex: 'status',
|
|
||||||
render: (v: StorePackageChangeRequestDto['status']) => (
|
|
||||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '套餐数',
|
title: '状态',
|
||||||
render: (_, row) => row.packages?.length ?? 0,
|
dataIndex: 'status',
|
||||||
|
render: (v: StoreInfoChangeStatus) => (
|
||||||
|
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '变更字段',
|
||||||
|
render: (_, row) =>
|
||||||
|
row.changedFields?.length
|
||||||
|
? row.changedFields.map((f) => (
|
||||||
|
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
|
||||||
|
))
|
||||||
|
: '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '提交方',
|
title: '提交方',
|
||||||
render: (_, row) => (row.submitterType === 'PARTNER' ? '合伙人' : '门店'),
|
render: (_, row) =>
|
||||||
|
row.submitterType === 'PARTNER' ? '合伙人' : row.submitterType === 'SHOP' ? '门店' : '总部',
|
||||||
},
|
},
|
||||||
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
||||||
{
|
{
|
||||||
@@ -205,7 +237,7 @@ export default function StorePackageAuditsPage() {
|
|||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="link" onClick={() => void openDetail(row.id)}>
|
<Button type="link" onClick={() => void openDetail(row.id)}>
|
||||||
查看变更
|
查看
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === 'PENDING' ? (
|
{row.status === 'PENDING' ? (
|
||||||
<>
|
<>
|
||||||
@@ -234,11 +266,22 @@ export default function StorePackageAuditsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>套餐变更审核</Typography.Title>
|
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||||
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
<Button
|
||||||
{s ? STORE_PACKAGE_CHANGE_STATUS_LABELS[s as keyof typeof STORE_PACKAGE_CHANGE_STATUS_LABELS] : '全部'}
|
key={s || 'all'}
|
||||||
|
type={status === s ? 'primary' : 'default'}
|
||||||
|
onClick={() => setStatus(s)}
|
||||||
|
>
|
||||||
|
{s === 'PENDING' ? (
|
||||||
|
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||||
|
{STORE_INFO_CHANGE_STATUS_LABELS.PENDING}
|
||||||
|
</Badge>
|
||||||
|
) : s ? (
|
||||||
|
STORE_INFO_CHANGE_STATUS_LABELS[s]
|
||||||
|
) : (
|
||||||
|
'全部'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -256,55 +299,331 @@ export default function StorePackageAuditsPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
|
||||||
width={720}
|
width={infoFullscreen ? '100%' : 680}
|
||||||
open={detailOpen}
|
open={detailOpen}
|
||||||
onClose={() => setDetailOpen(false)}
|
onClose={() => {
|
||||||
|
setDetailOpen(false);
|
||||||
|
setInfoFullscreen(false);
|
||||||
|
}}
|
||||||
extra={
|
extra={
|
||||||
detail?.status === 'PENDING' ? (
|
<Space>
|
||||||
<Space>
|
<Button onClick={() => setInfoFullscreen((v) => !v)}>
|
||||||
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
{infoFullscreen ? '退出全屏' : '全屏查看'}
|
||||||
<Button
|
</Button>
|
||||||
danger
|
{detail?.status === 'PENDING' ? (
|
||||||
onClick={() => {
|
<>
|
||||||
setActiveId(detail.id);
|
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
||||||
setRejectReason('');
|
<Button
|
||||||
setRejectOpen(true);
|
danger
|
||||||
}}
|
onClick={() => {
|
||||||
>
|
setActiveId(detail.id);
|
||||||
驳回
|
setRejectReason('');
|
||||||
</Button>
|
setRejectOpen(true);
|
||||||
</Space>
|
}}
|
||||||
) : null
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
) : detail ? (
|
) : detail ? (
|
||||||
<>
|
<>
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
<Tag>{STORE_PACKAGE_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
<Tag>{STORE_INFO_CHANGE_STATUS_LABELS[detail.status]}</Tag>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : detail.submitterType === 'SHOP' ? '门店' : '总部'} · {fmtTime(detail.createdAt)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
{detail.rejectReason ? (
|
{detail.rejectReason ? (
|
||||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
<Typography.Paragraph type="secondary">
|
{detail.diffs && detail.diffs.length ? (
|
||||||
线上 {detail.livePackages?.length ?? 0} 条 → 申请 {detail.packages?.length ?? 0} 条
|
<Descriptions column={1} bordered size="small">
|
||||||
</Typography.Paragraph>
|
{detail.diffs.map((d) => (
|
||||||
<Table
|
<Descriptions.Item
|
||||||
size="small"
|
key={d.field}
|
||||||
rowKey="key"
|
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
|
||||||
columns={diffColumns}
|
>
|
||||||
dataSource={diffRows}
|
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
|
||||||
pagination={false}
|
<InfoChangeImageDiff
|
||||||
/>
|
field={d.field}
|
||||||
|
live={d.live}
|
||||||
|
proposed={d.proposed}
|
||||||
|
imageSize={infoFullscreen ? 160 : 72}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<Typography.Text delete type="secondary">
|
||||||
|
{fmtFieldValue(d.field, d.live)}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary"> → </Typography.Text>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{fmtFieldValue(d.field, d.proposed)}
|
||||||
|
</Typography.Text>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无变更字段明细</Typography.Text>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="驳回信息变更"
|
||||||
|
open={rejectOpen}
|
||||||
|
onCancel={() => setRejectOpen(false)}
|
||||||
|
onOk={() => {
|
||||||
|
if (!activeId) return;
|
||||||
|
if (!rejectReason.trim()) {
|
||||||
|
message.warning('请填写驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void audit(activeId, 'REJECT', rejectReason.trim());
|
||||||
|
setRejectOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={rejectReason}
|
||||||
|
placeholder="驳回原因"
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StorePackageAuditsPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [items, setItems] = useState<StorePackageChangeRequestDto[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [status, setStatus] = useState<string>('PENDING');
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
|
||||||
|
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
|
||||||
|
const [packageFullscreen, setPackageFullscreen] = useState(false);
|
||||||
|
|
||||||
|
async function reload(nextPage = page, nextStatus = status) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
page: String(nextPage),
|
||||||
|
pageSize: '20',
|
||||||
|
});
|
||||||
|
if (nextStatus) qs.set('status', nextStatus);
|
||||||
|
const [data, summary] = await Promise.all([
|
||||||
|
request<Paginated<StorePackageChangeRequestDto>>(`/admin/store-package-audits?${qs}`),
|
||||||
|
request<StorePackageAuditSummaryDto>('/admin/store-package-audits/summary'),
|
||||||
|
]);
|
||||||
|
setItems(data.items);
|
||||||
|
setTotal(data.total);
|
||||||
|
setPage(data.page);
|
||||||
|
setPendingCount(summary.pendingCount ?? 0);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload(1, status);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
||||||
|
const infoRequestId = searchParams.get('infoRequestId');
|
||||||
|
useEffect(() => {
|
||||||
|
const rid = searchParams.get('requestId');
|
||||||
|
if (rid) {
|
||||||
|
setActiveRequestId(rid);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function openDetail(id: string) {
|
||||||
|
setActiveRequestId(id);
|
||||||
|
setDrawerTitle('套餐变更详情');
|
||||||
|
setDetailOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/store-package-audits/${id}/audit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(
|
||||||
|
action === 'REJECT' ? { action, rejectReason: reason } : { action },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
message.success(action === 'APPROVE' ? '已通过' : '已驳回');
|
||||||
|
setDetailOpen(false);
|
||||||
|
notifyPackageAuditChanged();
|
||||||
|
void reload(page, status);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||||
|
{
|
||||||
|
title: '门店',
|
||||||
|
dataIndex: 'storeName',
|
||||||
|
render: (_, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
|
||||||
|
{row.storeName || row.storeId}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (v: StorePackageChangeRequestDto['status']) => (
|
||||||
|
<Tag>{HQ_PACKAGE_STATUS_LABELS[v] ?? v}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '套餐数',
|
||||||
|
render: (_, row) => row.packages?.length ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提交方',
|
||||||
|
render: (_, row) => (row.submitterType === 'PARTNER' ? '合伙人' : '门店'),
|
||||||
|
},
|
||||||
|
{ title: '提交时间', dataIndex: 'createdAt', render: (v) => fmtTime(String(v)) },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" onClick={() => openDetail(row.id)}>
|
||||||
|
查看变更
|
||||||
|
</Button>
|
||||||
|
{row.status === 'PENDING' ? (
|
||||||
|
<>
|
||||||
|
<Button type="link" onClick={() => void audit(row.id, 'APPROVE')}>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
setActiveId(row.id);
|
||||||
|
setRejectReason('');
|
||||||
|
setRejectOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
row.rejectReason || null
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-package-audits', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader title="审核通知" settings={settingsButton} />
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'package',
|
||||||
|
label: '套餐审核',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
{(['PENDING', 'APPROVED', 'REJECTED', ''] as const).map((s) => (
|
||||||
|
<Button key={s || 'all'} type={status === s ? 'primary' : 'default'} onClick={() => setStatus(s)}>
|
||||||
|
{s === 'PENDING' ? (
|
||||||
|
<Badge count={pendingCount} size="small" offset={[8, -2]}>
|
||||||
|
{HQ_PACKAGE_STATUS_LABELS.PENDING}
|
||||||
|
</Badge>
|
||||||
|
) : s ? (
|
||||||
|
HQ_PACKAGE_STATUS_LABELS[s]
|
||||||
|
) : (
|
||||||
|
'全部'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total,
|
||||||
|
pageSize: 20,
|
||||||
|
onChange: (p) => void reload(p, status),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: '信息变更',
|
||||||
|
children: <InfoChangeAuditPanel initialRequestId={infoRequestId} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={drawerTitle}
|
||||||
|
width={packageFullscreen ? '100%' : 880}
|
||||||
|
open={detailOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setDetailOpen(false);
|
||||||
|
setPackageFullscreen(false);
|
||||||
|
}}
|
||||||
|
destroyOnClose
|
||||||
|
extra={
|
||||||
|
<Button onClick={() => setPackageFullscreen((v) => !v)}>
|
||||||
|
{packageFullscreen ? '退出全屏' : '全屏查看'}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{activeRequestId ? (
|
||||||
|
<StorePackageAuditPanel
|
||||||
|
requestId={activeRequestId}
|
||||||
|
fullscreen={packageFullscreen}
|
||||||
|
onAudited={() => {
|
||||||
|
setDetailOpen(false);
|
||||||
|
void reload(page, status);
|
||||||
|
}}
|
||||||
|
onDetailLoaded={(d) => {
|
||||||
|
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="驳回套餐变更"
|
title="驳回套餐变更"
|
||||||
open={rejectOpen}
|
open={rejectOpen}
|
||||||
@@ -329,3 +648,4 @@ export default function StorePackageAuditsPage() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Button, Form, Input, Table, Typography } from 'antd';
|
import { Button, Form, Input, Select, Table, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime, ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -20,22 +24,30 @@ export default function StoreRatingsPage() {
|
|||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialStoreId = searchParams.get('storeId') || '';
|
const initialStoreId = searchParams.get('storeId') || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [cities, setCities] = useState<{ id: string; name: string }[]>([]);
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({
|
const [filters, setFilters] = useState<Record<string, string>>({
|
||||||
...(initialStoreId ? { storeId: initialStoreId } : {}),
|
...(initialStoreId ? { storeId: initialStoreId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<Paginated<{ id: string; name: string }>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||||
'/admin/store-ratings',
|
'/admin/store-ratings',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
|
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||||
{
|
{
|
||||||
title: '门店',
|
title: '门店',
|
||||||
@@ -53,11 +65,12 @@ export default function StoreRatingsPage() {
|
|||||||
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
|
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-ratings', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
{settingsModal}
|
||||||
门店评价
|
<AdminListHeader title="门店评价" settings={settingsButton} />
|
||||||
</Typography.Title>
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
@@ -66,11 +79,22 @@ export default function StoreRatingsPage() {
|
|||||||
onFinish={(v) => {
|
onFinish={(v) => {
|
||||||
setFilters({
|
setFilters({
|
||||||
storeId: v.storeId || '',
|
storeId: v.storeId || '',
|
||||||
|
cityId: v.cityId || '',
|
||||||
redeemNo: v.redeemNo || '',
|
redeemNo: v.redeemNo || '',
|
||||||
});
|
});
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Form.Item name="cityId" label="城市">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
placeholder="全部"
|
||||||
|
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="storeId" label="门店ID">
|
<Form.Item name="storeId" label="门店ID">
|
||||||
<Input allowClear placeholder="storeId" />
|
<Input allowClear placeholder="storeId" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/
|
|||||||
import { request, type Paginated } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -147,14 +151,14 @@ export default function StoreWithdrawalsPage() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '提现单号',
|
title: '提现单号',
|
||||||
dataIndex: 'withdrawNo',
|
dataIndex: 'withdrawNo',
|
||||||
width: 180,
|
width: 180,
|
||||||
render: (v, row) => (
|
render: (v, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link>
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
@@ -229,11 +233,12 @@ export default function StoreWithdrawalsPage() {
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-withdrawals', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
{settingsModal}
|
||||||
门店提现审
|
<AdminListHeader title="门店提现审" settings={settingsButton} />
|
||||||
</Typography.Title>
|
|
||||||
{overdueSummary ? (
|
{overdueSummary ? (
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
待审 {overdueSummary.pendingCount} 笔
|
待审 {overdueSummary.pendingCount} 笔
|
||||||
@@ -299,7 +304,7 @@ export default function StoreWithdrawalsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -30,6 +30,7 @@ import {
|
|||||||
DEV_PLAN_TASK_TYPE_LABELS,
|
DEV_PLAN_TASK_TYPE_LABELS,
|
||||||
SUPPORT_TICKET_STATUS_LABELS,
|
SUPPORT_TICKET_STATUS_LABELS,
|
||||||
SUPPORT_TICKET_TYPE_LABELS,
|
SUPPORT_TICKET_TYPE_LABELS,
|
||||||
|
SUPPORT_TICKET_PRIORITY_LABELS,
|
||||||
mapSupportTicketTypeToDevPlanTask,
|
mapSupportTicketTypeToDevPlanTask,
|
||||||
type BatchReviewPreviewItem,
|
type BatchReviewPreviewItem,
|
||||||
type BatchReviewPreviewResponse,
|
type BatchReviewPreviewResponse,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
type DevPlanVersionDto,
|
type DevPlanVersionDto,
|
||||||
type SupportTicketDto,
|
type SupportTicketDto,
|
||||||
type SupportTicketLinkedTaskDto,
|
type SupportTicketLinkedTaskDto,
|
||||||
|
type SupportTicketPriorityDto,
|
||||||
type SupportTicketStatusDto,
|
type SupportTicketStatusDto,
|
||||||
type SupportTicketTypeDto,
|
type SupportTicketTypeDto,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
@@ -44,6 +46,9 @@ import { request, type HqProfile } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
||||||
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
|
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
|
||||||
@@ -54,6 +59,7 @@ const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
|
|||||||
DEVELOPING: 'blue',
|
DEVELOPING: 'blue',
|
||||||
TESTING: 'purple',
|
TESTING: 'purple',
|
||||||
PASSED: 'green',
|
PASSED: 'green',
|
||||||
|
PUBLISHED: 'cyan',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
|
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
|
||||||
@@ -64,6 +70,17 @@ const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTick
|
|||||||
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
|
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const PRIORITY_COLOR: Record<SupportTicketPriorityDto, string> = {
|
||||||
|
LOW: 'default',
|
||||||
|
NORMAL: 'blue',
|
||||||
|
HIGH: 'orange',
|
||||||
|
URGENT: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_OPTIONS = (Object.keys(SUPPORT_TICKET_PRIORITY_LABELS) as SupportTicketPriorityDto[]).map(
|
||||||
|
(value) => ({ value, label: SUPPORT_TICKET_PRIORITY_LABELS[value] }),
|
||||||
|
);
|
||||||
|
|
||||||
const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map(
|
const TASK_TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map(
|
||||||
(v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }),
|
(v) => ({ value: v, label: DEV_PLAN_TASK_TYPE_LABELS[v] }),
|
||||||
);
|
);
|
||||||
@@ -80,6 +97,7 @@ export default function SupportTicketsPage() {
|
|||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.priority) qs.set('priority', filters.priority);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -154,6 +172,7 @@ export default function SupportTicketsPage() {
|
|||||||
title: values.title.trim(),
|
title: values.title.trim(),
|
||||||
content: values.content?.trim() || undefined,
|
content: values.content?.trim() || undefined,
|
||||||
remark: values.remark?.trim() || undefined,
|
remark: values.remark?.trim() || undefined,
|
||||||
|
priority: values.priority,
|
||||||
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
|
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -202,8 +221,13 @@ export default function SupportTicketsPage() {
|
|||||||
|
|
||||||
setReviewOpen(false);
|
setReviewOpen(false);
|
||||||
const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500);
|
const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500);
|
||||||
|
const ticketUrls = (detail!.attachmentUrls ?? []).filter(Boolean);
|
||||||
tasksForm.setFieldsValue({
|
tasksForm.setFieldsValue({
|
||||||
tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }],
|
tasks: [{
|
||||||
|
content: summary || detail!.title,
|
||||||
|
type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType),
|
||||||
|
attachmentUrls: ticketUrls,
|
||||||
|
}],
|
||||||
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
|
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
|
||||||
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
|
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
|
||||||
});
|
});
|
||||||
@@ -217,6 +241,7 @@ export default function SupportTicketsPage() {
|
|||||||
title: detail.title,
|
title: detail.title,
|
||||||
content: detail.content || '',
|
content: detail.content || '',
|
||||||
remark: detail.remark || '',
|
remark: detail.remark || '',
|
||||||
|
priority: detail.priority ?? 'NORMAL',
|
||||||
attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''],
|
attachmentUrls: detail.attachmentUrls?.length ? detail.attachmentUrls : [''],
|
||||||
});
|
});
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
@@ -233,6 +258,7 @@ export default function SupportTicketsPage() {
|
|||||||
title: values.title.trim(),
|
title: values.title.trim(),
|
||||||
content: values.content?.trim() || undefined,
|
content: values.content?.trim() || undefined,
|
||||||
remark: values.remark?.trim() || undefined,
|
remark: values.remark?.trim() || undefined,
|
||||||
|
priority: values.priority,
|
||||||
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
|
attachmentUrls: (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -346,7 +372,11 @@ export default function SupportTicketsPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
decision: 'APPROVE',
|
decision: 'APPROVE',
|
||||||
note: note?.trim() || undefined,
|
note: note?.trim() || undefined,
|
||||||
tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })),
|
tasks: values.tasks.map((t) => ({
|
||||||
|
content: t.content.trim(),
|
||||||
|
type: t.type,
|
||||||
|
attachmentUrls: t.attachmentUrls?.length ? t.attachmentUrls : detail!.attachmentUrls,
|
||||||
|
})),
|
||||||
dispatchToWecom: !!values.dispatchToWecom,
|
dispatchToWecom: !!values.dispatchToWecom,
|
||||||
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
|
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
@@ -443,7 +473,7 @@ export default function SupportTicketsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<TicketRow> = [
|
const baseColumns: ColumnsType<TicketRow> = [
|
||||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
@@ -451,15 +481,38 @@ export default function SupportTicketsPage() {
|
|||||||
width: 90,
|
width: 90,
|
||||||
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
|
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '优先级',
|
||||||
|
dataIndex: 'priority',
|
||||||
|
width: 90,
|
||||||
|
render: (p: SupportTicketPriorityDto) => (
|
||||||
|
<Tag color={PRIORITY_COLOR[p] ?? 'default'}>
|
||||||
|
{SUPPORT_TICKET_PRIORITY_LABELS[p] ?? p ?? '普通'}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (s: SupportTicketStatusDto) => (
|
render: (s: SupportTicketStatusDto, row) => (
|
||||||
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
<Space size={4} direction="vertical" style={{ lineHeight: 1.2 }}>
|
||||||
|
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
|
||||||
|
{row.releasedVersionNo ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
|
{row.releasedVersionNo}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(String(row.id))}>{v}</AdminPrimaryLink>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
|
||||||
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -502,8 +555,11 @@ export default function SupportTicketsPage() {
|
|||||||
return null;
|
return null;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('support-tickets', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{settingsModal}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -515,6 +571,7 @@ export default function SupportTicketsPage() {
|
|||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
技术支持
|
技术支持
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Space>
|
<Space>
|
||||||
{isSuperAdmin ? (
|
{isSuperAdmin ? (
|
||||||
<>
|
<>
|
||||||
@@ -562,6 +619,9 @@ export default function SupportTicketsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="priority" label="优先级">
|
||||||
|
<Select allowClear style={{ width: 120 }} options={PRIORITY_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
筛选
|
筛选
|
||||||
</Button>
|
</Button>
|
||||||
@@ -572,16 +632,12 @@ export default function SupportTicketsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 'max-content' }}
|
||||||
rowSelection={
|
rowSelection={
|
||||||
isSuperAdmin
|
isSuperAdmin
|
||||||
? {
|
? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
onChange: (keys, rows) => {
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
const pending = rows.filter((r) => r.status === 'PENDING_REVIEW').map((r) => String(r.id));
|
|
||||||
setSelectedRowKeys(pending.length === rows.length ? (keys as string[]) : pending);
|
|
||||||
},
|
|
||||||
getCheckboxProps: (row) => ({ disabled: row.status !== 'PENDING_REVIEW' }),
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -607,6 +663,9 @@ export default function SupportTicketsPage() {
|
|||||||
{detail.ticketNo}
|
{detail.ticketNo}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
|
<Tag>{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}</Tag>
|
||||||
|
<Tag color={PRIORITY_COLOR[detail.priority ?? 'NORMAL']}>
|
||||||
|
{SUPPORT_TICKET_PRIORITY_LABELS[detail.priority ?? 'NORMAL']}
|
||||||
|
</Tag>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -637,6 +696,11 @@ export default function SupportTicketsPage() {
|
|||||||
{detail.status === 'PASSED' && (
|
{detail.status === 'PASSED' && (
|
||||||
<Typography.Text type="success">此工单已测试通过</Typography.Text>
|
<Typography.Text type="success">此工单已测试通过</Typography.Text>
|
||||||
)}
|
)}
|
||||||
|
{detail.status === 'PUBLISHED' && (
|
||||||
|
<Typography.Text type="success">
|
||||||
|
已发布{detail.releasedVersionNo ? ` · ${detail.releasedVersionNo}` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
创建于 {fmtTime(detail.createdAt)}
|
创建于 {fmtTime(detail.createdAt)}
|
||||||
@@ -660,6 +724,7 @@ export default function SupportTicketsPage() {
|
|||||||
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
|
{ key: 'DEVELOPING' as SupportTicketStatusDto, label: '开发中', time: detail.reviewedAt },
|
||||||
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
|
{ key: 'TESTING' as SupportTicketStatusDto, label: '测试中', time: null },
|
||||||
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
|
{ key: 'PASSED' as SupportTicketStatusDto, label: '已通过', time: detail.completedAt },
|
||||||
|
{ key: 'PUBLISHED' as SupportTicketStatusDto, label: '已发布', time: detail.publishedAt },
|
||||||
]
|
]
|
||||||
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]),
|
: [{ key: 'REJECTED' as SupportTicketStatusDto, label: '已驳回', time: detail.reviewedAt }]),
|
||||||
];
|
];
|
||||||
@@ -865,10 +930,13 @@ export default function SupportTicketsPage() {
|
|||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
|
<Modal title="创建技术支持工单" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={() => void submitCreate()} confirmLoading={creating} destroyOnClose okText="提交">
|
||||||
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', attachmentUrls: [''] }}>
|
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG', priority: 'NORMAL', attachmentUrls: [''] }}>
|
||||||
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
|
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
|
||||||
<Select options={TYPE_OPTIONS} />
|
<Select options={TYPE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
|
||||||
|
<Select options={PRIORITY_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||||
<Input maxLength={128} showCount />
|
<Input maxLength={128} showCount />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -972,6 +1040,9 @@ export default function SupportTicketsPage() {
|
|||||||
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
|
<Form.Item name="ticketType" label="类型" rules={[{ required: true }]}>
|
||||||
<Select options={TYPE_OPTIONS} />
|
<Select options={TYPE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="priority" label="优先级" rules={[{ required: true }]}>
|
||||||
|
<Select options={PRIORITY_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
|
||||||
<Input maxLength={128} showCount />
|
<Input maxLength={128} showCount />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -249,15 +249,70 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
const collapseItems = useMemo(() => {
|
const collapseItems = useMemo(() => {
|
||||||
if (!meta) return [];
|
if (!meta) return [];
|
||||||
return meta.groups.map((group) => ({
|
const SHARE_SUBGROUP_LABELS: Record<string, string> = {
|
||||||
key: group.key,
|
global: '全局',
|
||||||
label: group.label,
|
home: '首页',
|
||||||
forceRender: true,
|
stores: '门店列表',
|
||||||
children: (
|
storeDetail: '门店详情',
|
||||||
<div style={{ maxWidth: 720 }}>
|
benefit: '权益页',
|
||||||
{meta.fields
|
mine: '我的',
|
||||||
.filter((f) => f.group === group.key)
|
productDetail: '商品详情',
|
||||||
.map((f) =>
|
orderDetail: '订单详情',
|
||||||
|
};
|
||||||
|
const SHARE_SUBGROUP_ORDER = [
|
||||||
|
'global',
|
||||||
|
'home',
|
||||||
|
'stores',
|
||||||
|
'storeDetail',
|
||||||
|
'benefit',
|
||||||
|
'mine',
|
||||||
|
'productDetail',
|
||||||
|
'orderDetail',
|
||||||
|
];
|
||||||
|
|
||||||
|
return meta.groups.map((group) => {
|
||||||
|
const fields = meta.fields.filter((f) => f.group === group.key);
|
||||||
|
const hasSubgroups = fields.some((f) => f.subgroup);
|
||||||
|
|
||||||
|
let children: ReactNode;
|
||||||
|
if (group.key === 'wechat_mini_share' && hasSubgroups) {
|
||||||
|
const bySub = new Map<string, SystemConfigFieldMeta[]>();
|
||||||
|
for (const f of fields) {
|
||||||
|
const sk = f.subgroup || 'global';
|
||||||
|
if (!bySub.has(sk)) bySub.set(sk, []);
|
||||||
|
bySub.get(sk)!.push(f);
|
||||||
|
}
|
||||||
|
const orderedKeys = [
|
||||||
|
...SHARE_SUBGROUP_ORDER.filter((k) => bySub.has(k)),
|
||||||
|
...[...bySub.keys()].filter((k) => !SHARE_SUBGROUP_ORDER.includes(k)),
|
||||||
|
];
|
||||||
|
children = (
|
||||||
|
<Collapse
|
||||||
|
defaultActiveKey={[]}
|
||||||
|
items={orderedKeys.map((sk) => ({
|
||||||
|
key: sk,
|
||||||
|
label: SHARE_SUBGROUP_LABELS[sk] || sk,
|
||||||
|
forceRender: true,
|
||||||
|
children: (
|
||||||
|
<div style={{ maxWidth: 720 }}>
|
||||||
|
{(bySub.get(sk) ?? []).map((f) =>
|
||||||
|
renderField(
|
||||||
|
f,
|
||||||
|
meta.configuredSecrets,
|
||||||
|
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||||
|
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||||
|
) : undefined,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
children = (
|
||||||
|
<div style={{ maxWidth: 720 }}>
|
||||||
|
{fields.map((f) =>
|
||||||
renderField(
|
renderField(
|
||||||
f,
|
f,
|
||||||
meta.configuredSecrets,
|
meta.configuredSecrets,
|
||||||
@@ -266,9 +321,17 @@ export default function SystemSettingsPage() {
|
|||||||
) : undefined,
|
) : undefined,
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
}));
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: group.key,
|
||||||
|
label: group.label,
|
||||||
|
forceRender: true,
|
||||||
|
children,
|
||||||
|
};
|
||||||
|
});
|
||||||
}, [meta, mockSmsEnabled, loading]);
|
}, [meta, mockSmsEnabled, loading]);
|
||||||
|
|
||||||
async function onSave() {
|
async function onSave() {
|
||||||
|
|||||||
@@ -0,0 +1,708 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||||
|
|
||||||
|
type PhoneRow = {
|
||||||
|
id: string;
|
||||||
|
phone: string;
|
||||||
|
note: string | null;
|
||||||
|
createdByHqId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccountType = 'user' | 'store_account' | 'partner' | 'store' | 'order';
|
||||||
|
|
||||||
|
type LinkedPayload = {
|
||||||
|
phone: PhoneRow;
|
||||||
|
users: Array<{ id: string; userNo: string; phone: string | null; nickname: string | null; isTest: boolean; status: number }>;
|
||||||
|
storeAccounts: Array<{ id: string; phone: string; name: string; isTest: boolean; status: string }>;
|
||||||
|
partners: Array<{ id: string; phone: string; name: string; companyName: string | null; isTest: boolean; status: string }>;
|
||||||
|
stores: Array<{ id: string; name: string; phone: string; isTest: boolean; status: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCOUNT_TYPE_OPTIONS: { value: AccountType; label: string }[] = [
|
||||||
|
{ value: 'user', label: 'C 端用户' },
|
||||||
|
{ value: 'store_account', label: '门店账号' },
|
||||||
|
{ value: 'partner', label: '合伙人' },
|
||||||
|
{ value: 'store', label: '门店' },
|
||||||
|
{ value: 'order', label: '订单' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TestWhitelistPage() {
|
||||||
|
const [mockSms, setMockSms] = useState(false);
|
||||||
|
const [mockWechat, setMockWechat] = useState(false);
|
||||||
|
const [mockPay, setMockPay] = useState(false);
|
||||||
|
const [mockLoading, setMockLoading] = useState(true);
|
||||||
|
const [mockSaving, setMockSaving] = useState(false);
|
||||||
|
|
||||||
|
const [phoneForm] = Form.useForm();
|
||||||
|
const [phones, setPhones] = useState<Paginated<PhoneRow> | null>(null);
|
||||||
|
const [phonesLoading, setPhonesLoading] = useState(false);
|
||||||
|
const [phonePage, setPhonePage] = useState(1);
|
||||||
|
const [phonePageSize, setPhonePageSize] = useState(20);
|
||||||
|
const [phoneFilters, setPhoneFilters] = useState<{ phone?: string }>({});
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [addForm] = Form.useForm();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
const [editRow, setEditRow] = useState<PhoneRow | null>(null);
|
||||||
|
const [editForm] = Form.useForm();
|
||||||
|
const [migrating, setMigrating] = useState(false);
|
||||||
|
|
||||||
|
const [accountType, setAccountType] = useState<AccountType>('user');
|
||||||
|
const [accountPhone, setAccountPhone] = useState('');
|
||||||
|
const [accounts, setAccounts] = useState<Paginated<Record<string, unknown>> | null>(null);
|
||||||
|
const [accountsLoading, setAccountsLoading] = useState(false);
|
||||||
|
const [accountPage, setAccountPage] = useState(1);
|
||||||
|
const [accountPageSize, setAccountPageSize] = useState(20);
|
||||||
|
|
||||||
|
const [linkedOpen, setLinkedOpen] = useState(false);
|
||||||
|
const [linkedLoading, setLinkedLoading] = useState(false);
|
||||||
|
const [linked, setLinked] = useState<LinkedPayload | null>(null);
|
||||||
|
|
||||||
|
async function loadMockFlags() {
|
||||||
|
setMockLoading(true);
|
||||||
|
try {
|
||||||
|
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||||
|
'/admin/test-whitelist/mock-flags',
|
||||||
|
);
|
||||||
|
setMockSms(!!cfg.mockSms);
|
||||||
|
setMockWechat(!!cfg.mockWechat);
|
||||||
|
setMockPay(!!cfg.mockPay);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载 Mock 配置失败');
|
||||||
|
} finally {
|
||||||
|
setMockLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMockFlags(next: { MOCK_SMS?: boolean; MOCK_WECHAT?: boolean; MOCK_PAY?: boolean }) {
|
||||||
|
const body: { mockSms?: boolean; mockWechat?: boolean; mockPay?: boolean } = {};
|
||||||
|
if (next.MOCK_SMS !== undefined) body.mockSms = next.MOCK_SMS;
|
||||||
|
if (next.MOCK_WECHAT !== undefined) body.mockWechat = next.MOCK_WECHAT;
|
||||||
|
if (next.MOCK_PAY !== undefined) body.mockPay = next.MOCK_PAY;
|
||||||
|
setMockSaving(true);
|
||||||
|
try {
|
||||||
|
const cfg = await request<{ mockSms: boolean; mockWechat: boolean; mockPay: boolean }>(
|
||||||
|
'/admin/test-whitelist/mock-flags',
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setMockSms(!!cfg.mockSms);
|
||||||
|
setMockWechat(!!cfg.mockWechat);
|
||||||
|
setMockPay(!!cfg.mockPay);
|
||||||
|
message.success('已保存');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
await loadMockFlags();
|
||||||
|
} finally {
|
||||||
|
setMockSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPhones = useCallback(async () => {
|
||||||
|
setPhonesLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
page: String(phonePage),
|
||||||
|
pageSize: String(phonePageSize),
|
||||||
|
});
|
||||||
|
if (phoneFilters.phone) qs.set('phone', phoneFilters.phone);
|
||||||
|
const res = await request<Paginated<PhoneRow>>(`/admin/test-whitelist/phones?${qs}`);
|
||||||
|
setPhones(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载手机号名单失败');
|
||||||
|
} finally {
|
||||||
|
setPhonesLoading(false);
|
||||||
|
}
|
||||||
|
}, [phonePage, phonePageSize, phoneFilters]);
|
||||||
|
|
||||||
|
const loadAccounts = useCallback(async () => {
|
||||||
|
setAccountsLoading(true);
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
type: accountType,
|
||||||
|
page: String(accountPage),
|
||||||
|
pageSize: String(accountPageSize),
|
||||||
|
});
|
||||||
|
if (accountPhone.trim()) qs.set('phone', accountPhone.trim());
|
||||||
|
const res = await request<Paginated<Record<string, unknown>>>(
|
||||||
|
`/admin/test-whitelist/accounts?${qs}`,
|
||||||
|
);
|
||||||
|
setAccounts(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载测试账号失败');
|
||||||
|
} finally {
|
||||||
|
setAccountsLoading(false);
|
||||||
|
}
|
||||||
|
}, [accountType, accountPage, accountPageSize, accountPhone]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadMockFlags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadPhones();
|
||||||
|
}, [loadPhones]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAccounts();
|
||||||
|
}, [loadAccounts]);
|
||||||
|
|
||||||
|
async function onAddPhone() {
|
||||||
|
const v = await addForm.validateFields();
|
||||||
|
try {
|
||||||
|
await request('/admin/test-whitelist/phones', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: v.phone, note: v.note || undefined }),
|
||||||
|
});
|
||||||
|
message.success('已添加');
|
||||||
|
setAddOpen(false);
|
||||||
|
addForm.resetFields();
|
||||||
|
setPhonePage(1);
|
||||||
|
void loadPhones();
|
||||||
|
void loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '添加失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onEditPhone() {
|
||||||
|
if (!editRow) return;
|
||||||
|
const v = await editForm.validateFields();
|
||||||
|
try {
|
||||||
|
await request(`/admin/test-whitelist/phones/${editRow.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ note: v.note ?? null }),
|
||||||
|
});
|
||||||
|
message.success('已更新');
|
||||||
|
setEditOpen(false);
|
||||||
|
setEditRow(null);
|
||||||
|
void loadPhones();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '更新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDeletePhone(id: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/test-whitelist/phones/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void loadPhones();
|
||||||
|
void loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMigrate() {
|
||||||
|
setMigrating(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ importedCandidates: number; added: number }>(
|
||||||
|
'/admin/test-whitelist/migrate-visibility',
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
message.success(
|
||||||
|
`导入完成:候选 ${res.importedCandidates} 个,新增 ${res.added} 个`,
|
||||||
|
);
|
||||||
|
void loadPhones();
|
||||||
|
void loadAccounts();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导入失败');
|
||||||
|
} finally {
|
||||||
|
setMigrating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLinked(row: PhoneRow) {
|
||||||
|
setLinkedOpen(true);
|
||||||
|
setLinkedLoading(true);
|
||||||
|
setLinked(null);
|
||||||
|
try {
|
||||||
|
const res = await request<LinkedPayload>(`/admin/test-whitelist/phones/${row.id}/linked`);
|
||||||
|
setLinked(res);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载关联失败');
|
||||||
|
setLinkedOpen(false);
|
||||||
|
} finally {
|
||||||
|
setLinkedLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneColumns: ColumnsType<PhoneRow> = [
|
||||||
|
{
|
||||||
|
title: '手机号',
|
||||||
|
dataIndex: 'phone',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => {
|
||||||
|
setEditRow(row);
|
||||||
|
editForm.setFieldsValue({ note: row.note ?? '' });
|
||||||
|
setEditOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'note',
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 170,
|
||||||
|
render: fmtTime,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => void openLinked(row)}>
|
||||||
|
关联账号
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={() => {
|
||||||
|
setEditRow(row);
|
||||||
|
editForm.setFieldsValue({ note: row.note ?? '' });
|
||||||
|
setEditOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑备注
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认移出白名单?"
|
||||||
|
description="将同步清除该手机号关联账号的测试标记"
|
||||||
|
okText="确认"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void onDeletePhone(row.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function accountColumns(): ColumnsType<Record<string, unknown>> {
|
||||||
|
if (accountType === 'user') {
|
||||||
|
return [
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => (v as string) || '—' },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '注册', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (accountType === 'store_account') {
|
||||||
|
return [
|
||||||
|
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (accountType === 'partner') {
|
||||||
|
return [
|
||||||
|
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||||
|
{ title: '公司', dataIndex: 'companyName', render: (v) => (v as string) || '—' },
|
||||||
|
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (accountType === 'store') {
|
||||||
|
return [
|
||||||
|
{ title: '门店名', dataIndex: 'name', width: 160 },
|
||||||
|
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||||
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '创建', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 110 },
|
||||||
|
{
|
||||||
|
title: '实付',
|
||||||
|
dataIndex: 'payAmount',
|
||||||
|
width: 90,
|
||||||
|
render: (v) => `¥${v}`,
|
||||||
|
},
|
||||||
|
{ title: '收货手机', dataIndex: 'receiverPhone', width: 120 },
|
||||||
|
{
|
||||||
|
title: '用户手机',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) =>
|
||||||
|
(row.user as { phone?: string | null } | undefined)?.phone || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标记',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
{ title: '下单', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v as string) },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('test-whitelist', phoneColumns, {
|
||||||
|
page: phonePage,
|
||||||
|
pageSize: phonePageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader title="白名单管理" settings={settingsButton} />
|
||||||
|
|
||||||
|
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
Mock 开关(与系统设置同源,勾选 = 不做真实验证)
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<Checkbox
|
||||||
|
checked={mockSms}
|
||||||
|
disabled={mockSaving}
|
||||||
|
onChange={(e) => void saveMockFlags({ MOCK_SMS: e.target.checked })}
|
||||||
|
>
|
||||||
|
短信不做真实验证
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
checked={mockWechat}
|
||||||
|
disabled={mockSaving}
|
||||||
|
onChange={(e) => void saveMockFlags({ MOCK_WECHAT: e.target.checked })}
|
||||||
|
>
|
||||||
|
微信不做真实验证
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox
|
||||||
|
checked={mockPay}
|
||||||
|
disabled={mockSaving}
|
||||||
|
onChange={(e) => void saveMockFlags({ MOCK_PAY: e.target.checked })}
|
||||||
|
>
|
||||||
|
支付不做真实验证
|
||||||
|
</Checkbox>
|
||||||
|
</Space>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
|
||||||
|
配置键:{MOCK_KEYS.join(' / ')}
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'phones',
|
||||||
|
label: '手机号名单',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }} wrap>
|
||||||
|
<Form
|
||||||
|
form={phoneForm}
|
||||||
|
layout="inline"
|
||||||
|
onFinish={(v) => {
|
||||||
|
setPhoneFilters({ phone: v.phone || undefined });
|
||||||
|
setPhonePage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="phone" label="手机号">
|
||||||
|
<Input allowClear placeholder="模糊搜索" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '从可见性白名单导入',
|
||||||
|
content: '将商品/门店旧可见性手机号合并入全局名单(幂等),并同步测试标记。',
|
||||||
|
okText: '开始导入',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: () => onMigrate(),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
loading={migrating}
|
||||||
|
>
|
||||||
|
从可见性白名单导入
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={() => setAddOpen(true)}>
|
||||||
|
添加手机号
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={phonesLoading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={phones?.items ?? []}
|
||||||
|
pagination={{
|
||||||
|
current: phonePage,
|
||||||
|
pageSize: phonePageSize,
|
||||||
|
total: phones?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPhonePage(p);
|
||||||
|
setPhonePageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'accounts',
|
||||||
|
label: '测试账号记录',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Select
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={accountType}
|
||||||
|
options={ACCOUNT_TYPE_OPTIONS}
|
||||||
|
onChange={(v: AccountType) => {
|
||||||
|
setAccountType(v);
|
||||||
|
setAccountPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder="按手机号筛选"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
value={accountPhone}
|
||||||
|
onChange={(e) => setAccountPhone(e.target.value)}
|
||||||
|
onPressEnter={() => {
|
||||||
|
setAccountPage(1);
|
||||||
|
void loadAccounts();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (accountPage !== 1) setAccountPage(1);
|
||||||
|
else void loadAccounts();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={accountsLoading}
|
||||||
|
columns={accountColumns()}
|
||||||
|
dataSource={accounts?.items ?? []}
|
||||||
|
scroll={{ x: 900 }}
|
||||||
|
pagination={{
|
||||||
|
current: accountPage,
|
||||||
|
pageSize: accountPageSize,
|
||||||
|
total: accounts?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setAccountPage(p);
|
||||||
|
setAccountPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="添加白名单手机号"
|
||||||
|
open={addOpen}
|
||||||
|
onCancel={() => setAddOpen(false)}
|
||||||
|
onOk={() => void onAddPhone()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={addForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label="手机号"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请输入手机号' },
|
||||||
|
{ pattern: /^1\d{10}$/, message: '请输入 11 位手机号' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="1xxxxxxxxxx" maxLength={11} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="note" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`编辑备注 · ${editRow?.phone ?? ''}`}
|
||||||
|
open={editOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditOpen(false);
|
||||||
|
setEditRow(null);
|
||||||
|
}}
|
||||||
|
onOk={() => void onEditPhone()}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="note" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={256} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title={linked ? `关联账号 · ${linked.phone.phone}` : '关联账号'}
|
||||||
|
open={linkedOpen}
|
||||||
|
onClose={() => setLinkedOpen(false)}
|
||||||
|
width={560}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{linkedLoading ? (
|
||||||
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
|
) : linked ? (
|
||||||
|
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||||
|
<Descriptions size="small" column={1} bordered>
|
||||||
|
<Descriptions.Item label="手机号">{linked.phone.phone}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{linked.phone.note || '—'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>C 端用户({linked.users.length})</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={linked.users}
|
||||||
|
columns={[
|
||||||
|
{ title: '编号', dataIndex: 'userNo' },
|
||||||
|
{ title: '昵称', dataIndex: 'nickname', render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '测试',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>门店账号({linked.storeAccounts.length})</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={linked.storeAccounts}
|
||||||
|
columns={[
|
||||||
|
{ title: '姓名', dataIndex: 'name' },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '测试',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>合伙人({linked.partners.length})</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={linked.partners}
|
||||||
|
columns={[
|
||||||
|
{ title: '姓名', dataIndex: 'name' },
|
||||||
|
{ title: '公司', dataIndex: 'companyName', render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '测试',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={5}>门店({linked.stores.length})</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={linked.stores}
|
||||||
|
columns={[
|
||||||
|
{ title: '名称', dataIndex: 'name' },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '测试',
|
||||||
|
dataIndex: 'isTest',
|
||||||
|
width: 70,
|
||||||
|
render: (v) => (v ? <Tag color="orange">测试</Tag> : '—'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -69,7 +73,7 @@ export default function ThirdPartyLogsPage() {
|
|||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||||
{ title: '场景', dataIndex: 'scene', width: 120 },
|
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||||
@@ -87,15 +91,27 @@ export default function ThirdPartyLogsPage() {
|
|||||||
{
|
{
|
||||||
title: '错误信息',
|
title: '错误信息',
|
||||||
dataIndex: 'errorMessage',
|
dataIndex: 'errorMessage',
|
||||||
ellipsis: true,
|
|
||||||
render: (v: string | null | undefined) => v || '—',
|
render: (v: string | null | undefined) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '请求摘要',
|
title: '请求摘要',
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => summarizeJson(r.requestBody),
|
render: (_, r) => summarizeJson(r.requestBody),
|
||||||
},
|
},
|
||||||
{ title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' },
|
{
|
||||||
|
title: '外部单号',
|
||||||
|
dataIndex: 'externalNo',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/common/third-party-logs/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -114,9 +130,15 @@ export default function ThirdPartyLogsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-third-party', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>第三方日志</Typography.Title>
|
{settingsModal}
|
||||||
|
<AdminListHeader title="第三方日志" settings={settingsButton} />
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
@@ -143,7 +165,7 @@ export default function ThirdPartyLogsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -10,14 +10,24 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
import {
|
||||||
|
TICKET_STATUS_LABELS,
|
||||||
|
TICKET_TYPE_LABELS,
|
||||||
|
ticketStatusLabel,
|
||||||
|
type TicketStatusDto,
|
||||||
|
type TicketTypeDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,6 +41,21 @@ type Row = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STATUS_COLOR: Partial<Record<TicketStatusDto, string>> = {
|
||||||
|
PENDING: 'orange',
|
||||||
|
OPEN: 'blue',
|
||||||
|
COLLABORATING: 'cyan',
|
||||||
|
RESOLVED: 'green',
|
||||||
|
REJECTED: 'red',
|
||||||
|
COMPLETED: 'green',
|
||||||
|
CLOSED: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = (Object.keys(TICKET_STATUS_LABELS) as TicketStatusDto[]).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: TICKET_STATUS_LABELS[value],
|
||||||
|
}));
|
||||||
|
|
||||||
export default function TicketsPage() {
|
export default function TicketsPage() {
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
@@ -93,17 +118,38 @@ export default function TicketsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
{
|
||||||
|
title: '工单号',
|
||||||
|
dataIndex: 'ticketNo',
|
||||||
|
width: 180,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: '类型',
|
||||||
dataIndex: 'ticketType',
|
dataIndex: 'ticketType',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
||||||
},
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s: string) => (
|
||||||
|
<Tag color={STATUS_COLOR[s as TicketStatusDto] ?? 'default'}>{ticketStatusLabel(s)}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
{ title: '备注', dataIndex: 'remark' },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -125,8 +171,11 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
|
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('tickets', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{settingsModal}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -138,6 +187,7 @@ export default function TicketsPage() {
|
|||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
工单中心
|
工单中心
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
创建工单
|
创建工单
|
||||||
</Button>
|
</Button>
|
||||||
@@ -165,7 +215,7 @@ export default function TicketsPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Input allowClear placeholder="PENDING" />
|
<Select allowClear style={{ width: 140 }} placeholder="全部" options={STATUS_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
筛选
|
筛选
|
||||||
@@ -176,7 +226,7 @@ export default function TicketsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -212,7 +262,11 @@ export default function TicketsPage() {
|
|||||||
<Descriptions.Item label="类型">
|
<Descriptions.Item label="类型">
|
||||||
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
<Descriptions.Item label="状态">
|
||||||
|
<Tag color={STATUS_COLOR[detail.status as TicketStatusDto] ?? 'default'}>
|
||||||
|
{ticketStatusLabel(detail.status)}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="关联">
|
<Descriptions.Item label="关联">
|
||||||
{String(detail.refType)} #{String(detail.refId)}
|
{String(detail.refType)} #{String(detail.refId)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { request } from '../lib/api';
|
|||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -61,10 +65,10 @@ export default function UserLogsPage() {
|
|||||||
form.setFieldsValue(filters);
|
form.setFieldsValue(filters);
|
||||||
}, [form, filters]);
|
}, [form, filters]);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '用户', width: 180, ellipsis: true,
|
title: '用户', width: 180,
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.nickname || r.userNo}
|
primary={r.nickname || r.userNo}
|
||||||
@@ -78,13 +82,27 @@ export default function UserLogsPage() {
|
|||||||
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
{
|
||||||
|
title: '事件',
|
||||||
|
dataIndex: 'eventName',
|
||||||
|
width: 160,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/logs/users/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '关联', width: 120,
|
title: '关联', width: 120,
|
||||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '摘要', ellipsis: true,
|
title: '摘要',
|
||||||
render: (_, r) => summarizeExtra(r.extraJson),
|
render: (_, r) => summarizeExtra(r.extraJson),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -98,9 +116,12 @@ export default function UserLogsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-users', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>用户日志</Typography.Title>
|
{settingsModal}
|
||||||
|
<AdminListHeader title="用户日志" settings={settingsButton} />
|
||||||
<Segmented
|
<Segmented
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||||
@@ -131,7 +152,7 @@ export default function UserLogsPage() {
|
|||||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
{detail && (
|
{detail && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
|
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
|
||||||
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||||
|
|
||||||
type UserOrderRow = {
|
type UserOrderRow = {
|
||||||
@@ -30,6 +33,84 @@ type UserOrderRow = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function HqRemarkCell({
|
||||||
|
id,
|
||||||
|
hqRemark,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
hqRemark: string | null;
|
||||||
|
onSaved: (id: string, hqRemark: string | null) => void;
|
||||||
|
}) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [value, setValue] = useState(hqRemark ?? '');
|
||||||
|
const savingRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setValue(hqRemark ?? '');
|
||||||
|
}, [hqRemark, editing]);
|
||||||
|
|
||||||
|
async function commit() {
|
||||||
|
if (savingRef.current) return;
|
||||||
|
const next = value.trim() || null;
|
||||||
|
const prev = hqRemark?.trim() || null;
|
||||||
|
setEditing(false);
|
||||||
|
if (next === prev) return;
|
||||||
|
savingRef.current = true;
|
||||||
|
try {
|
||||||
|
const res = await request<{ id: string; hqRemark: string | null }>(`/admin/users/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ hqRemark: next ?? '' }),
|
||||||
|
});
|
||||||
|
onSaved(id, res.hqRemark);
|
||||||
|
message.success('备注已保存');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
setValue(hqRemark ?? '');
|
||||||
|
} finally {
|
||||||
|
savingRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
maxLength={128}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={() => void commit()}
|
||||||
|
onPressEnter={() => void commit()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape') void commit();
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
title="双击修改备注,离开后保存"
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditing(true);
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'text', display: 'inline-block', minWidth: 48 }}
|
||||||
|
>
|
||||||
|
{hqRemark || '—'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 好客权益金额展示:0 与空值统一显示占位,避免整列都是 ¥0.00 干扰 */
|
||||||
|
function fmtBenefit(v: number | null | undefined) {
|
||||||
|
const n = Number(v ?? 0);
|
||||||
|
if (!Number.isFinite(n) || n <= 0) return '—';
|
||||||
|
return `¥${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
type UserBehaviorLog = {
|
type UserBehaviorLog = {
|
||||||
id: string;
|
id: string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
@@ -112,6 +193,7 @@ export default function UsersPage() {
|
|||||||
if (values.status !== undefined && values.status !== '') {
|
if (values.status !== undefined && values.status !== '') {
|
||||||
qs.set('status', String(values.status));
|
qs.set('status', String(values.status));
|
||||||
}
|
}
|
||||||
|
if (values.excludeTest) qs.set('excludeTest', 'true');
|
||||||
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
const res = await request<Paginated<AdminUserRow>>(`/admin/users?${qs}`);
|
||||||
setData(res);
|
setData(res);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -236,14 +318,50 @@ export default function UsersPage() {
|
|||||||
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
];
|
];
|
||||||
|
|
||||||
const columns: ColumnsType<AdminUserRow> = [
|
const baseColumns: ColumnsType<AdminUserRow> = [
|
||||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
{
|
||||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
title: '用户编号',
|
||||||
|
dataIndex: 'userNo',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '昵称',
|
||||||
|
dataIndex: 'nickname',
|
||||||
|
width: 140,
|
||||||
|
render: (v: string | null, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'hqRemark',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string | null, row) => (
|
||||||
|
<HqRemarkCell
|
||||||
|
id={row.id}
|
||||||
|
hqRemark={v}
|
||||||
|
onSaved={(id, hqRemark) => {
|
||||||
|
setData((prev) =>
|
||||||
|
prev
|
||||||
|
? { ...prev, items: prev.items.map((u) => (u.id === id ? { ...u, hqRemark } : u)) }
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
setDetail((d) => (d && d.id === id ? { ...d, hqRemark } : d));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '手机',
|
title: '手机',
|
||||||
dataIndex: 'phone',
|
dataIndex: 'phone',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v) => maskPhone(v),
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '验手机',
|
title: '验手机',
|
||||||
@@ -271,7 +389,6 @@ export default function UsersPage() {
|
|||||||
title: '来源 ID',
|
title: '来源 ID',
|
||||||
dataIndex: 'sourceRefId',
|
dataIndex: 'sourceRefId',
|
||||||
width: 100,
|
width: 100,
|
||||||
ellipsis: true,
|
|
||||||
render: (v, row) => {
|
render: (v, row) => {
|
||||||
if (!v) return '—';
|
if (!v) return '—';
|
||||||
if (row.sourceType === 'PROMO_CODE') {
|
if (row.sourceType === 'PROMO_CODE') {
|
||||||
@@ -288,13 +405,11 @@ export default function UsersPage() {
|
|||||||
title: '来源标签',
|
title: '来源标签',
|
||||||
dataIndex: 'sourceLabel',
|
dataIndex: 'sourceLabel',
|
||||||
width: 120,
|
width: 120,
|
||||||
ellipsis: true,
|
|
||||||
render: (v) => v || '—',
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'deviceKey',
|
title: 'deviceKey',
|
||||||
dataIndex: 'deviceKey',
|
dataIndex: 'deviceKey',
|
||||||
ellipsis: true,
|
|
||||||
render: (v) => v || '—',
|
render: (v) => v || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -304,6 +419,39 @@ export default function UsersPage() {
|
|||||||
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
render: (v) => (v ? <Tag color="blue">已合并</Tag> : '—'),
|
||||||
},
|
},
|
||||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
|
{
|
||||||
|
title: '剩余权益',
|
||||||
|
dataIndex: 'benefitBalance',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: (a, b) => Number(a.benefitBalance ?? 0) - Number(b.benefitBalance ?? 0),
|
||||||
|
render: (v: number | undefined) =>
|
||||||
|
Number(v ?? 0) > 0 ? (
|
||||||
|
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||||
|
{fmtBenefit(v)}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">—</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已用权益',
|
||||||
|
dataIndex: 'benefitUsedAmount',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: (a, b) => Number(a.benefitUsedAmount ?? 0) - Number(b.benefitUsedAmount ?? 0),
|
||||||
|
render: (v: number | undefined) => (
|
||||||
|
<Typography.Text type="secondary">{fmtBenefit(v)}</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '累计权益',
|
||||||
|
dataIndex: 'benefitTotalAmount',
|
||||||
|
width: 110,
|
||||||
|
align: 'right',
|
||||||
|
sorter: (a, b) => Number(a.benefitTotalAmount ?? 0) - Number(b.benefitTotalAmount ?? 0),
|
||||||
|
render: (v: number | undefined) => fmtBenefit(v),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '注册时间',
|
title: '注册时间',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'createdAt',
|
||||||
@@ -326,20 +474,29 @@ export default function UsersPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('users', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
<AdminListHeader
|
||||||
{canDeleteUsers ? (
|
title="用户监控"
|
||||||
<Button
|
settings={settingsButton}
|
||||||
danger
|
actions={
|
||||||
disabled={!selectedRowKeys.length}
|
canDeleteUsers ? (
|
||||||
onClick={() => void openBatchDeleteModal()}
|
<Button
|
||||||
>
|
danger
|
||||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
disabled={!selectedRowKeys.length}
|
||||||
</Button>
|
onClick={() => void openBatchDeleteModal()}
|
||||||
) : null}
|
>
|
||||||
</Space>
|
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||||
<Form.Item name="phone" label="手机号">
|
<Form.Item name="phone" label="手机号">
|
||||||
<Input placeholder="模糊搜索" allowClear />
|
<Input placeholder="模糊搜索" allowClear />
|
||||||
@@ -362,6 +519,9 @@ export default function UsersPage() {
|
|||||||
{ value: 0, label: '停用' },
|
{ value: 0, label: '停用' },
|
||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">查询</Button>
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
@@ -375,7 +535,7 @@ export default function UsersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1500 }}
|
scroll={{ x: 'max-content' }}
|
||||||
rowSelection={canDeleteUsers ? {
|
rowSelection={canDeleteUsers ? {
|
||||||
selectedRowKeys,
|
selectedRowKeys,
|
||||||
preserveSelectedRowKeys: true,
|
preserveSelectedRowKeys: true,
|
||||||
@@ -408,6 +568,7 @@ export default function UsersPage() {
|
|||||||
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||||
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{detail.hqRemark || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
|
<Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="验手机时间">
|
<Descriptions.Item label="验手机时间">
|
||||||
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
|
||||||
@@ -447,6 +608,18 @@ export default function UsersPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
|
||||||
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
<Descriptions.Item label="订单/地址">{detail.orderCount} / {detail.addressCount}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="好客权益">
|
||||||
|
<Space size={16} wrap>
|
||||||
|
<span>
|
||||||
|
剩余{' '}
|
||||||
|
<Typography.Text strong style={{ color: '#cf1322' }}>
|
||||||
|
{fmtBenefit(detail.benefitBalance)}
|
||||||
|
</Typography.Text>
|
||||||
|
</span>
|
||||||
|
<span>已用 {fmtBenefit(detail.benefitUsedAmount)}</span>
|
||||||
|
<span>累计 {fmtBenefit(detail.benefitTotalAmount)}</span>
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="注册时间">
|
<Descriptions.Item label="注册时间">
|
||||||
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { request } from '../lib/api';
|
|||||||
import { AdminCellLine } from '../components/AdminCellLine';
|
import { AdminCellLine } from '../components/AdminCellLine';
|
||||||
import { fmtTime, maskPhone } from '../lib/constants';
|
import { fmtTime, maskPhone } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||||
|
|
||||||
@@ -90,12 +94,11 @@ export default function WechatBindingsPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<GroupRow> = [
|
const baseColumns: ColumnsType<GroupRow> = [
|
||||||
{
|
{
|
||||||
title: 'unionId',
|
title: 'unionId',
|
||||||
dataIndex: 'unionId',
|
dataIndex: 'unionId',
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: true,
|
|
||||||
render: (v) => v || <Tag>无 unionId</Tag>,
|
render: (v) => v || <Tag>无 unionId</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -119,11 +122,12 @@ export default function WechatBindingsPage() {
|
|||||||
title: '手机号',
|
title: '手机号',
|
||||||
dataIndex: 'primaryPhone',
|
dataIndex: 'primaryPhone',
|
||||||
width: 140,
|
width: 140,
|
||||||
render: (v) => maskPhone(v),
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '身份摘要',
|
title: '身份摘要',
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
|
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
|
||||||
@@ -160,7 +164,6 @@ export default function WechatBindingsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '账号',
|
title: '账号',
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.name || '—'}
|
primary={r.name || '—'}
|
||||||
@@ -172,14 +175,12 @@ export default function WechatBindingsPage() {
|
|||||||
title: '归属',
|
title: '归属',
|
||||||
dataIndex: 'refLabel',
|
dataIndex: 'refLabel',
|
||||||
width: 160,
|
width: 160,
|
||||||
ellipsis: true,
|
|
||||||
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
|
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'wxOpenId',
|
title: 'wxOpenId',
|
||||||
dataIndex: 'wxOpenId',
|
dataIndex: 'wxOpenId',
|
||||||
width: 160,
|
width: 160,
|
||||||
ellipsis: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '手机验证',
|
title: '手机验证',
|
||||||
@@ -205,12 +206,16 @@ export default function WechatBindingsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>微信绑定总览</Typography.Title>
|
{settingsModal}
|
||||||
<Typography.Paragraph type="secondary">
|
<AdminListHeader
|
||||||
按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。
|
title="微信绑定总览"
|
||||||
</Typography.Paragraph>
|
settings={settingsButton}
|
||||||
|
description="按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。"
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { useAdminList } from '../lib/useAdminList';
|
|||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
export default function WecomBotLogsPage() {
|
export default function WecomBotLogsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
@@ -24,12 +28,19 @@ export default function WecomBotLogsPage() {
|
|||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: ColumnsType<WecomBotLogDto> = [
|
const baseColumns: ColumnsType<WecomBotLogDto> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{ title: '机器人', dataIndex: 'botName', width: 120, ellipsis: true, render: (v, r) => v || r.botKey || '—' },
|
{
|
||||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120, ellipsis: true },
|
title: '机器人',
|
||||||
{ title: '动作', dataIndex: 'action', width: 160, ellipsis: true },
|
dataIndex: 'botName',
|
||||||
{ title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' },
|
width: 120,
|
||||||
|
render: (v, r) => (
|
||||||
|
<AdminPrimaryLink onClick={() => setDetail(r)}>{v || r.botKey || ''}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
||||||
|
{ title: '动作', dataIndex: 'action', width: 160 },
|
||||||
|
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '结果',
|
title: '结果',
|
||||||
dataIndex: 'success',
|
dataIndex: 'success',
|
||||||
@@ -37,7 +48,7 @@ export default function WecomBotLogsPage() {
|
|||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>,
|
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' },
|
{ title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' },
|
||||||
{ title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true },
|
{ title: '输入摘要', dataIndex: 'inputSummary' },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -49,12 +60,16 @@ export default function WecomBotLogsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-wecom-bots', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>企微机器人日志</Typography.Title>
|
{settingsModal}
|
||||||
<Typography.Paragraph type="secondary">
|
<AdminListHeader
|
||||||
记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。
|
title="企微机器人日志"
|
||||||
</Typography.Paragraph>
|
settings={settingsButton}
|
||||||
|
description="记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。"
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
@@ -96,7 +111,7 @@ export default function WecomBotLogsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ import { request } from '../lib/api';
|
|||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -280,7 +283,7 @@ export default function WecomBotsPage() {
|
|||||||
|
|
||||||
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
|
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
|
||||||
|
|
||||||
const columns: ColumnsType<WecomBotDto> = [
|
const baseColumns: ColumnsType<WecomBotDto> = [
|
||||||
{
|
{
|
||||||
title: '头像',
|
title: '头像',
|
||||||
dataIndex: 'avatarUrl',
|
dataIndex: 'avatarUrl',
|
||||||
@@ -291,18 +294,24 @@ export default function WecomBotsPage() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '角色',
|
title: '角色',
|
||||||
dataIndex: 'role',
|
dataIndex: 'role',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
|
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
|
||||||
},
|
},
|
||||||
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
|
{ title: 'BotID', dataIndex: 'botId', width: 160 },
|
||||||
{
|
{
|
||||||
title: '权限',
|
title: '权限',
|
||||||
dataIndex: 'permissions',
|
dataIndex: 'permissions',
|
||||||
ellipsis: true,
|
|
||||||
render: (perms: WecomBotPermission[]) =>
|
render: (perms: WecomBotPermission[]) =>
|
||||||
perms.map((p) => (
|
perms.map((p) => (
|
||||||
<Tag key={p} style={{ marginBottom: 2 }}>
|
<Tag key={p} style={{ marginBottom: 2 }}>
|
||||||
@@ -367,13 +376,20 @@ export default function WecomBotsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-bots', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{settingsModal}
|
||||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
企微机器人
|
企微机器人
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
{settingsButton}
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。
|
并列创建多个智能机器人,配置 BotID / Secret / 权限 / 角色 / 头像。总开关在「系统设置 → 功能开关」。
|
||||||
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
|
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
|
||||||
@@ -428,7 +444,7 @@ export default function WecomBotsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
message,
|
message,
|
||||||
@@ -21,13 +22,22 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import {
|
import {
|
||||||
WECOM_PUSH_CONDITION_GROUPS,
|
WECOM_PUSH_CONDITION_GROUPS,
|
||||||
WECOM_PUSH_CONDITION_LABELS,
|
WECOM_PUSH_CONDITION_LABELS,
|
||||||
|
WECOM_TEMPLATE_EVENT_KEYS,
|
||||||
|
WECOM_TEMPLATE_EVENT_LABELS,
|
||||||
|
WECOM_TEMPLATE_PLACEHOLDERS,
|
||||||
type WecomMessagePushDto,
|
type WecomMessagePushDto,
|
||||||
type WecomPushCondition,
|
type WecomPushCondition,
|
||||||
|
type WecomPushTemplateDto,
|
||||||
|
type WecomTemplateEventKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import OssUpload from '../components/OssUpload';
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -78,7 +88,7 @@ function WecomPushConditionPicker({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WecomMessagePushesPage() {
|
function PushRoutesTab() {
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [form] = Form.useForm<FormValues>();
|
const [form] = Form.useForm<FormValues>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -187,21 +197,20 @@ export default function WecomMessagePushesPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<WecomMessagePushDto> = [
|
const baseColumns: ColumnsType<WecomMessagePushDto> = [
|
||||||
{
|
{
|
||||||
title: '名称',
|
title: '名称',
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
render: (name: string, row) => (
|
render: (name: string, row) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
||||||
<span>{name}</span>
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Webhook',
|
title: 'Webhook',
|
||||||
dataIndex: 'webhookUrlMasked',
|
dataIndex: 'webhookUrlMasked',
|
||||||
ellipsis: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '推送条件',
|
title: '推送条件',
|
||||||
@@ -256,13 +265,23 @@ export default function WecomMessagePushesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-pushes', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
{settingsModal}
|
||||||
<Typography.Paragraph type="secondary">
|
<AdminListHeader
|
||||||
配置群机器人 Webhook 多实例,按推送条件分发运营告警、技术支持工单、开发任务派发等消息。运行时不再读取
|
settings={settingsButton}
|
||||||
.env 中的 Webhook URL。
|
description="配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。"
|
||||||
</Typography.Paragraph>
|
actions={
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建推送
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={filterForm}
|
form={filterForm}
|
||||||
@@ -297,9 +316,6 @@ export default function WecomMessagePushesPage() {
|
|||||||
>
|
>
|
||||||
重置
|
重置
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" onClick={openCreate}>
|
|
||||||
新建推送
|
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
@@ -332,10 +348,10 @@ export default function WecomMessagePushesPage() {
|
|||||||
>
|
>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||||
<Input placeholder="如:运营告警" />
|
<Input placeholder="如:业务待办通知群" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
||||||
<OssUpload />
|
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="webhookUrl"
|
name="webhookUrl"
|
||||||
@@ -400,6 +416,208 @@ export default function WecomMessagePushesPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplatesTab() {
|
||||||
|
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
|
||||||
|
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [preview, setPreview] = useState('');
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
|
||||||
|
setTemplates(list);
|
||||||
|
setEventKey((prev) => {
|
||||||
|
const current = list.find((t) => t.eventKey === prev) ?? list[0];
|
||||||
|
if (current) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
title: current.title,
|
||||||
|
body: current.body,
|
||||||
|
handleLabel: current.handleLabel,
|
||||||
|
});
|
||||||
|
return current.eventKey;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载模板失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
function selectEvent(key: WecomTemplateEventKey) {
|
||||||
|
setEventKey(key);
|
||||||
|
const row = templates.find((t) => t.eventKey === key);
|
||||||
|
if (row) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
title: row.title,
|
||||||
|
body: row.body,
|
||||||
|
handleLabel: row.handleLabel,
|
||||||
|
});
|
||||||
|
setPreview('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await request<WecomPushTemplateDto>(
|
||||||
|
`/admin/wecom-push-templates/${eventKey}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
message.success('模板已保存');
|
||||||
|
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reset() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await request<WecomPushTemplateDto>(
|
||||||
|
`/admin/wecom-push-templates/${eventKey}/reset`,
|
||||||
|
{ method: 'POST', body: '{}' },
|
||||||
|
);
|
||||||
|
message.success('已恢复默认文案');
|
||||||
|
form.setFieldsValue({
|
||||||
|
title: updated.title,
|
||||||
|
body: updated.body,
|
||||||
|
handleLabel: updated.handleLabel,
|
||||||
|
});
|
||||||
|
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
||||||
|
setPreview('');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '恢复失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSend() {
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const res = await request<{ ok: boolean; message: string; preview: string }>(
|
||||||
|
`/admin/wecom-push-templates/${eventKey}/test`,
|
||||||
|
{ method: 'POST', body: '{}' },
|
||||||
|
);
|
||||||
|
setPreview(res.preview || '');
|
||||||
|
message.success(res.message || '已发送');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '测试失败');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
每种业务事件全站一份文案,使用 {'{{orderNo}}'} 等形式占位。快链由系统注入 {'{{handleUrl}}'}
|
||||||
|
;须在「推送路由」中勾选对应条件并配置 Webhook 才会发出。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Space align="start" style={{ width: '100%' }} size={24} wrap>
|
||||||
|
<div style={{ minWidth: 200 }}>
|
||||||
|
<Typography.Text strong>事件</Typography.Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
|
||||||
|
<div key={k} style={{ marginBottom: 4 }}>
|
||||||
|
<Button
|
||||||
|
type={k === eventKey ? 'primary' : 'text'}
|
||||||
|
size="small"
|
||||||
|
onClick={() => selectEvent(k)}
|
||||||
|
block
|
||||||
|
style={{ textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
{WECOM_TEMPLATE_EVENT_LABELS[k]}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, minWidth: 360 }}>
|
||||||
|
<Form form={form} layout="vertical" disabled={loading}>
|
||||||
|
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="body"
|
||||||
|
label="正文(企微 markdown)"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="去处理" />
|
||||||
|
</Form.Item>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
|
||||||
|
<Button loading={saving}>恢复默认</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Button loading={testing} onClick={() => void testSend()}>
|
||||||
|
用示例数据测试推送
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{preview ? (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Text strong>预览</Typography.Text>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 12,
|
||||||
|
background: '#f5f5f5',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WecomMessagePushesPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
||||||
|
<Tabs
|
||||||
|
items={[
|
||||||
|
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
|
||||||
|
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import { fmtTime } from '../lib/constants';
|
|||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,6 +41,13 @@ type Row = {
|
|||||||
wineryAmount: number;
|
wineryAmount: number;
|
||||||
status: string;
|
status: string;
|
||||||
paidAt?: string | null;
|
paidAt?: string | null;
|
||||||
|
paymentRef?: string | null;
|
||||||
|
wineryBank?: {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type BillItem = {
|
type BillItem = {
|
||||||
@@ -51,13 +62,35 @@ type BillItem = {
|
|||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
UNPAID: '未打款',
|
UNPAID: '未打款',
|
||||||
PAID: '已打款',
|
PAID: '已打款',
|
||||||
|
NO_PAYMENT_NEEDED: '无需打款',
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
UNPAID: 'red',
|
UNPAID: 'red',
|
||||||
PAID: 'green',
|
PAID: 'green',
|
||||||
|
NO_PAYMENT_NEEDED: 'default',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function isZeroPayable(amount: number | string | null | undefined) {
|
||||||
|
return Number(amount ?? 0) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应付为 0 时展示「无需打款」(灰),否则按 DB 打款状态 */
|
||||||
|
function displayWineryStatus(status: string, wineryAmount: number | string) {
|
||||||
|
if (isZeroPayable(wineryAmount)) {
|
||||||
|
return { key: 'NO_PAYMENT_NEEDED', label: STATUS_LABELS.NO_PAYMENT_NEEDED, color: STATUS_COLORS.NO_PAYMENT_NEEDED };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: status,
|
||||||
|
label: STATUS_LABELS[status] || status,
|
||||||
|
color: STATUS_COLORS[status] || 'default',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canConfirmWineryPay(row: { status: string; wineryAmount: number | string }) {
|
||||||
|
return row.status === 'UNPAID' && !isZeroPayable(row.wineryAmount);
|
||||||
|
}
|
||||||
|
|
||||||
const DELIVERY_LABELS: Record<string, string> = {
|
const DELIVERY_LABELS: Record<string, string> = {
|
||||||
LOCAL: '同城',
|
LOCAL: '同城',
|
||||||
CROSS_CITY: '跨城',
|
CROSS_CITY: '跨城',
|
||||||
@@ -105,14 +138,30 @@ export default function WineryBillsPage() {
|
|||||||
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
|
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
|
||||||
|
|
||||||
function confirmPay(ids: string[], amountHint?: number) {
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
|
let paymentRef = '';
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认打款?',
|
title: '确认打款?',
|
||||||
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
content: (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
将确认 {ids.length} 笔酒厂对账单
|
||||||
|
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="打款凭证号(可选)"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onChange={(e) => {
|
||||||
|
paymentRef = e.target.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
okText: '确认打款',
|
okText: '确认打款',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
|
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
|
||||||
if (ids.length === 1) {
|
if (ids.length === 1) {
|
||||||
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
|
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST', body });
|
||||||
} else {
|
} else {
|
||||||
await request('/admin/winery-bills/batch-confirm', {
|
await request('/admin/winery-bills/batch-confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -190,8 +239,15 @@ export default function WineryBillsPage() {
|
|||||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
{
|
||||||
|
title: '账单号',
|
||||||
|
dataIndex: 'billNo',
|
||||||
|
width: 170,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '账单日',
|
title: '账单日',
|
||||||
dataIndex: 'billDate',
|
dataIndex: 'billDate',
|
||||||
@@ -212,7 +268,7 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒厂应付',
|
title: '应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
@@ -220,8 +276,11 @@ export default function WineryBillsPage() {
|
|||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 90,
|
width: 100,
|
||||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
render: (s, row) => {
|
||||||
|
const d = displayWineryStatus(s, row.wineryAmount);
|
||||||
|
return <Tag color={d.color}>{d.label}</Tag>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -232,7 +291,7 @@ export default function WineryBillsPage() {
|
|||||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||||
明细
|
明细
|
||||||
</Button>
|
</Button>
|
||||||
{row.status === 'UNPAID' && (
|
{canConfirmWineryPay(row) && (
|
||||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||||||
确认打款
|
确认打款
|
||||||
</Button>
|
</Button>
|
||||||
@@ -242,38 +301,30 @@ export default function WineryBillsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
{settingsModal}
|
||||||
style={{
|
<AdminListHeader
|
||||||
display: 'flex',
|
title="酒厂对账单"
|
||||||
justifyContent: 'space-between',
|
settings={settingsButton}
|
||||||
alignItems: 'flex-start',
|
description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
|
||||||
marginBottom: 16,
|
actions={
|
||||||
gap: 16,
|
canEditWineryBank ? (
|
||||||
}}
|
<Button type="default" onClick={() => void openBankModal()}>
|
||||||
>
|
酒厂银行账户信息配置
|
||||||
<Space direction="vertical" size={0}>
|
</Button>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
) : null
|
||||||
酒厂对账单
|
}
|
||||||
</Typography.Title>
|
/>
|
||||||
<Typography.Text type="secondary">
|
|
||||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
{canEditWineryBank ? (
|
|
||||||
<Button type="default" onClick={() => void openBankModal()}>
|
|
||||||
酒厂银行账户信息配置
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
<Space size="large" wrap>
|
<Space size="large" wrap>
|
||||||
<Statistic title="账单数" value={summary.count} />
|
<Statistic title="账单数" value={summary.count} />
|
||||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
<Statistic title="应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -296,7 +347,7 @@ export default function WineryBillsPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select
|
<Select
|
||||||
allowClear
|
allowClear
|
||||||
style={{ width: 120 }}
|
style={{ width: 130 }}
|
||||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -347,9 +398,9 @@ export default function WineryBillsPage() {
|
|||||||
rowSelection={{
|
rowSelection={{
|
||||||
selectedRowKeys: selectedKeys,
|
selectedRowKeys: selectedKeys,
|
||||||
onChange: setSelectedKeys,
|
onChange: setSelectedKeys,
|
||||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -368,10 +419,36 @@ export default function WineryBillsPage() {
|
|||||||
<Descriptions column={1} size="small" bordered>
|
<Descriptions column={1} size="small" bordered>
|
||||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
<Descriptions.Item label="应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
<Descriptions.Item label="状态">
|
||||||
|
{displayWineryStatus(detail.status, detail.wineryAmount).label}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="打款凭证">
|
||||||
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
|
{detail.wineryBank ? (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
|
收款账户(酒厂)
|
||||||
|
</Typography.Title>
|
||||||
|
<Descriptions column={1} size="small" bordered>
|
||||||
|
<Descriptions.Item label="户名">
|
||||||
|
{detail.wineryBank.bankAccountName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户银行">
|
||||||
|
{detail.wineryBank.bankName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户支行">
|
||||||
|
{detail.wineryBank.bankBranch || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账号">
|
||||||
|
{detail.wineryBank.bankAccountNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
订单明细
|
订单明细
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
@@ -381,7 +458,7 @@ export default function WineryBillsPage() {
|
|||||||
pagination={false}
|
pagination={false}
|
||||||
dataSource={detail.items ?? []}
|
dataSource={detail.items ?? []}
|
||||||
columns={[
|
columns={[
|
||||||
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
|
{ title: '订单号', dataIndex: 'orderNo' },
|
||||||
{
|
{
|
||||||
title: '配送',
|
title: '配送',
|
||||||
dataIndex: 'deliveryType',
|
dataIndex: 'deliveryType',
|
||||||
@@ -395,7 +472,7 @@ export default function WineryBillsPage() {
|
|||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '酒厂应付',
|
title: '应付',
|
||||||
dataIndex: 'wineryAmount',
|
dataIndex: 'wineryAmount',
|
||||||
width: 90,
|
width: 90,
|
||||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user