44 Commits

Author SHA1 Message Date
jacy 0b548a2764 Merge pull request 'Dev' (#49) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/49
2026-08-25 12:39:52 +08:00
jacy 30f8d649cb Merge pull request 'v3.5.9版本迭代列表优化' (#48) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/48
2026-08-25 12:39:30 +08:00
jacy f4da71e952 v3.5.9版本迭代列表优化
CI / verify (pull_request) Waiting to run
2026-08-25 12:38:21 +08:00
jacy 40e8a12596 Merge pull request 'Dev' (#47) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/47
2026-08-25 10:10:20 +08:00
jacy d7241d54b1 Merge pull request '给用户增加“备注”列' (#46) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/46
2026-08-25 10:10:07 +08:00
jacy 282da24bc4 给用户增加“备注”列
CI / verify (pull_request) Waiting to run
2026-08-25 10:09:05 +08:00
jacy 3d819dc10b Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#45) from dev into main
CI / verify (push) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/45
2026-08-25 09:23:12 +08:00
jacy 5e0691ab35 Merge pull request 'Dev jacy v3.5.8 和 v3.5.9 版本更新' (#44) from dev_jacy into dev
CI / verify (pull_request) Waiting to run
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/44
2026-08-25 09:22:48 +08:00
jacy 5935024ea8 v3.5.8和v3.5.9版本更新
CI / verify (pull_request) Waiting to run
2026-08-25 09:20:32 +08:00
jacy 7304c7a8e1 chore(ops): 增加生产库备份/回滚与按 isTest 清理脚本
只入库通用运维能力,不提交写死手机号的一次性清理脚本与 dump。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 22:03:35 +08:00
jacy 8984465893 Merge pull request 'Dev' (#43) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/43
2026-08-23 19:27:28 +08:00
jacy b90a4e9f8f Merge pull request 'v3.5.7版本迭代' (#42) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/42
2026-08-23 19:27:14 +08:00
jacy a3f17cbf94 v3.5.7版本迭代
CI / verify (pull_request) Has been cancelled
门店端UI修改
2026-08-23 19:26:12 +08:00
jacy 3f208d27a9 Merge pull request 'Dev' (#41) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/41
2026-08-23 17:34:40 +08:00
jacy 6973e3d7f5 Merge pull request '修改订单导出时间错误' (#40) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/40
2026-08-23 17:34:21 +08:00
jacy 114d77ca48 修改订单导出时间错误
CI / verify (pull_request) Has been cancelled
2026-08-23 17:33:20 +08:00
jacy fa69ced448 Merge pull request 'Dev v3.5.6' (#39) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/39
2026-08-23 16:43:51 +08:00
jacy a144a35c3d Merge pull request 'Dev jacy v3.5.6 版本迭代' (#38) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/38
2026-08-23 16:43:07 +08:00
jacy 7e12eb3ca6 v3.5.6版本迭代
CI / verify (pull_request) Has been cancelled
2026-08-23 16:41:22 +08:00
jacy 608e3e5ec5 整理发布脚本 2026-08-23 14:37:36 +08:00
jacy d98b0aefb0 整理上下文 2026-08-23 14:37:29 +08:00
jacy 18c5abea79 Merge pull request 'v3.5.5版本合并' (#37) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/37
2026-08-23 12:30:04 +08:00
jacy 012626f6bd Merge pull request 'v3.5.5版本上传' (#36) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/36
2026-08-23 12:29:33 +08:00
jacy 22cd00da42 v3.5.5版本上传
CI / verify (pull_request) Has been cancelled
2026-08-23 12:26:57 +08:00
jacy 5d4ed566de merge(dev): HQ product list as SPU with expandable SKUs
CI / verify (push) Has been cancelled
2026-08-21 23:52:41 +08:00
jacy 3004a65b35 Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev 2026-08-21 23:52:37 +08:00
jacy f9161368e1 feat(admin): show HQ product list as SPU with expandable SKUs
Group catalog rows by product, add paid bottle sales, and keep multi-SKU fulfillment on SKU rows only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 23:52:28 +08:00
jacy 8a061f085f 提交v3.5.4 版本号小程序 2026-08-21 17:49:53 +08:00
jacy b20e566a65 logo3 2026-08-21 17:39:11 +08:00
jacy 5fdddae41c merge(dev): v3.5.4 规格主图与门店使用规则
CI / verify (push) Has been cancelled
2026-08-21 17:37:20 +08:00
jacy 7d2e4ef57e merge(dev_jacy): v3.5.4 规格主图与门店使用规则 2026-08-21 17:37:09 +08:00
jacy 4d434c9c67 v3.5.4 规格主图、门店使用规则与收银台 logo
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 17:36:53 +08:00
jacy 22c0b03a47 Merge pull request 'Dev' (#35) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/35
2026-08-21 16:24:07 +08:00
jacy 73fd27bdf6 Merge pull request 'v3.5.4版本提交,修改api' (#34) from dev_jacy into dev
CI / verify (pull_request) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/34
2026-08-21 16:23:54 +08:00
jacy 62e61a9e63 v3.5.4版本提交,修改api
CI / verify (pull_request) Has been cancelled
2026-08-21 16:22:53 +08:00
jacy f2d03e2595 Merge pull request 'v3.5.4' (#33) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/33
2026-08-21 15:50:30 +08:00
jacy 0c28c204b2 Merge pull request 'Dev' (#31) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/31
2026-08-20 20:23:20 +08:00
jacy e7f49a9639 Merge pull request 'v3.5.3版本更新1' (#28) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/28
2026-08-20 18:55:18 +08:00
jacy f941dcf072 Merge pull request 'Dev' (#27) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/27
2026-08-20 15:06:24 +08:00
jacy 3382d36a6c Merge pull request 'Dev jacy 大屏优化' (#24) from dev_jacy into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/24
2026-08-20 13:43:06 +08:00
jacy 9b8e3f1347 Merge pull request 'v3.5.2 大屏调整' (#23) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/23
2026-08-20 12:01:21 +08:00
jacy 1440a59fc8 Merge pull request 'Dev' (#21) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/21
2026-08-20 00:54:54 +08:00
jacy 11659484c6 Merge pull request 'v3.5.1 版本更新' (#19) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/19
2026-08-19 16:01:31 +08:00
jacy 0fb7ab7abb Merge pull request 'v3.4.18' (#17) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/17
2026-08-17 23:45:19 +08:00
195 changed files with 10648 additions and 3433 deletions
-55
View File
@@ -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`
+6 -28
View File
@@ -1,33 +1,11 @@
---
description: Git 提交与 PR 协作规范
description: 杜康提交 scope 与发版默认(生产)
alwaysApply: true
---
# Git 规范
# Git / 发版(杜康专属)
## 提交格式
Conventional Commitsscope = 端或模块名:
```
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
- Conventional Commitsscope = 端或模块:`feat(trade):`、`fix(redeem):`
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- Prisma 迁移须 Review
+1 -1
View File
@@ -21,7 +21,7 @@ disable-model-invocation: true
## 会话启动(按序)
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 §七
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
5. 编码前过一遍下方 Checklist
+1
View File
@@ -5,6 +5,7 @@ description: >-
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-*,
or smoke tests — not for V2 WeChat/payment production integration.
disable-model-invocation: true
---
# 杜康好客 · preV1 Mock Skill
+1
View File
@@ -4,6 +4,7 @@ description: >-
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
or @杜康发布 / @dukang-release.
disable-model-invocation: true
---
# 杜康发布
+1
View File
@@ -4,6 +4,7 @@ description: >-
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,
or asks to start/implement a milestone card from the coding manuals.
disable-model-invocation: true
---
# 杜康好客 · 任务卡 Skill
+4
View File
@@ -21,4 +21,8 @@ coverage/
server/dukang-api/prisma/migrations/
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/backups/
+26 -150
View File
@@ -1,133 +1,37 @@
# 杜康好客 · 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 原型,只读 |
| 阶段 | 手册 | 说明 |
|------|------|------|
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考(**已压缩**V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
前端:[`apps/AGENTS.md`](./apps/AGENTS.md)。后端模块/表 OWNER[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)。
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
## 文档(冲突时)
## 启动命令
```bash
# 基础设施
cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017
# 依赖与数据库
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:3010/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 冲突时忽略)
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 参照
> **V2 / preV1 手册不再作需求依据。**
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 过时文档;`apps/*` import `server/*`;跨模块 `prisma` 写他人表。V2 / preV1 手册不再作需求依据。
## 团队与 OWNER 边界(2 人
## OWNER2026-07 起暂由 jacy 代管全部
| 负责人 | Git 账号 | 职责 |
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管** |
`jacy-dukang` 可改 `apps/*``packages/*`、全部后端模块。逻辑边界仍按 A/B/C/D,便于子 Agent 隔离。Prisma 迁移由 jacy 主导。
> **临时分工(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-v3PRD、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
@@ -137,38 +41,10 @@ C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
**iOS 微信 H5 JSSDK**:登录/OAuth 后禁止仅 SPA 跳转再扫码;见 `packages/weixin-sdk/GOTCHAS.md`、知识库「门店端 · 踩坑」
**微信小程序 open-type**`chooseAvatar` 等 Button 的祖先禁止 `stopPropagation`(会编成 catchtap);见知识库「C 端 · 踩坑」、`.cursor/rules/mini-user-weapp-opentype.mdc`
**微信小程序页面标题**weapp 只用原生 `navigationBarTitleText`,不要再画一层与导航栏重复的 `SubPageHeader` title;见 `.cursor/rules/mini-user-weapp-nav-title.mdc`
微信: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 `:3010` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev`
- 生产发布 → `deploy/deploy-prod.sh``main`,验收后)
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example`
- 详情:`@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)
- 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产)
- 子 Agent`/owner-a-user-trade` 等(见 `.cursor/agents/`
- DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
+60 -16
View File
@@ -16,37 +16,65 @@ body,
}
/*
* 后台内容行:不换行 + 超出省略
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
* 后台表格:不换行、完整展示(v3.5.9 去掉省略号)
* 描述列表仍省略,避免详情抽屉被长字段撑破
*/
.admin-layout .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,
.ant-drawer .ant-descriptions-item-content,
.ant-modal .ant-descriptions-item-content {
white-space: nowrap;
overflow: hidden;
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%;
}
/* 表头同样不换行 */
/* 表头不换行 */
.admin-layout .ant-table-thead > tr > th,
.ant-drawer .ant-table-thead > tr > th,
.ant-modal .ant-table-thead > tr > th {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
overflow: visible;
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-layout .ant-table-cell:has(.ant-btn),
.ant-drawer .ant-table-cell:has(.ant-btn),
.ant-modal .ant-table-cell:has(.ant-btn),
@@ -71,10 +99,9 @@ body,
max-width: none;
}
/* 表格单元格双行信息合并为单行省略 */
/* 表格单元格主/副信息同一行 */
.admin-cell-line {
display: block;
max-width: 100%;
display: inline;
}
.admin-cell-line-secondary {
@@ -82,6 +109,23 @@ body,
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-ellipsis {
white-space: nowrap;
@@ -1,12 +1,10 @@
import { Typography } from 'antd';
type AdminCellLineProps = {
primary?: string | null;
secondary?: string | null;
separator?: string;
};
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
/** 表格单元格:主/副信息同一行完整展示 */
export function AdminCellLine({
primary,
secondary,
@@ -14,14 +12,9 @@ export function AdminCellLine({
}: AdminCellLineProps) {
const main = primary?.trim() || '—';
const sub = secondary?.trim();
const full = sub ? `${main}${separator}${sub}` : main;
return (
<Typography.Text
className="admin-cell-line"
ellipsis={{ tooltip: full }}
style={{ maxWidth: '100%' }}
>
<span className="admin-cell-line">
{main}
{sub ? (
<span className="admin-cell-line-secondary">
@@ -29,6 +22,6 @@ export function AdminCellLine({
{sub}
</span>
) : 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,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,459 +1,458 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Checkbox,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = {
id: string;
companyName: string;
phone: string;
name: string;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = PartnerRow & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
contactPhone?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type Props = {
cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number;
onChanged?: () => void;
};
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 PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
function commissionSumError(
orderPercent: number,
redeemPercent: number,
maxRate: number,
): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPanel({
cityId,
cityCode,
maxPartnerCommissionRate = 0.05,
onChanged,
}: Props) {
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [partners, setPartners] = useState<PartnerRow[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const loadPartners = useCallback(async () => {
setLoading(true);
try {
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
setPartners(res.items);
} finally {
setLoading(false);
}
}, [cityId]);
useEffect(() => {
void loadPartners();
}, [loadPartners]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
async function deleteSubAccount(subId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
if (detail) void openPartner(detail.id);
void loadPartners();
onChanged?.();
}
const columns: ColumnsType<PartnerRow> = [
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 120,
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: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<>
<Button
type="primary"
style={{ marginBottom: 12 }}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({
cityId,
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateOpen(true);
}}
>
</Button>
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<Tabs
items={[
{
key: 'info',
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></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="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<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>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<Form.Item label="管仓仓库">
<Input
disabled
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
/>
</Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
),
},
{
key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(row) => {
setSubEditId(row.id);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}}
onDelete={(subId) => void deleteSubAccount(subId)}
/>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
try {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></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="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<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>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
void openPartner(detail.id);
void loadPartners();
onChanged?.();
}}>
<Form form={subForm} layout="vertical">
<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="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
if (!subEditId || !detail) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
void openPartner(detail.id);
}}>
<Form form={subEditForm} layout="vertical">
<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="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</>
);
}
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Checkbox,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = {
id: string;
companyName: string;
phone: string;
name: string;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = PartnerRow & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
contactPhone?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type Props = {
cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number;
onChanged?: () => void;
};
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 PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
function commissionSumError(
orderPercent: number,
redeemPercent: number,
maxRate: number,
): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPanel({
cityId,
cityCode,
maxPartnerCommissionRate = 0.05,
onChanged,
}: Props) {
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [partners, setPartners] = useState<PartnerRow[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const loadPartners = useCallback(async () => {
setLoading(true);
try {
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
setPartners(res.items);
} finally {
setLoading(false);
}
}, [cityId]);
useEffect(() => {
void loadPartners();
}, [loadPartners]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
async function deleteSubAccount(subId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
if (detail) void openPartner(detail.id);
void loadPartners();
onChanged?.();
}
const columns: ColumnsType<PartnerRow> = [
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName' },
{ title: '主账号', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 120,
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: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<>
<Button
type="primary"
style={{ marginBottom: 12 }}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({
cityId,
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateOpen(true);
}}
>
</Button>
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<Tabs
items={[
{
key: 'info',
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></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="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<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>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<Form.Item label="管仓仓库">
<Input
disabled
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
/>
</Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
),
},
{
key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(row) => {
setSubEditId(row.id);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}}
onDelete={(subId) => void deleteSubAccount(subId)}
/>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
try {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></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="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<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>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
void openPartner(detail.id);
void loadPartners();
onChanged?.();
}}>
<Form form={subForm} layout="vertical">
<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="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
if (!subEditId || !detail) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
void openPartner(detail.id);
}}>
<Form form={subEditForm} layout="vertical">
<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="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</>
);
@@ -1,92 +1,91 @@
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
export type PartnerSubAccountRow = {
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
};
function formatPermissions(permissions?: string[]) {
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
}
type Props = {
subs: PartnerSubAccountRow[];
onAdd: () => void;
onEdit: (sub: PartnerSubAccountRow) => void;
onDelete: (subId: string) => void;
};
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
const columns: ColumnsType<PartnerSubAccountRow> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '角色',
dataIndex: 'staffRole',
width: 90,
render: (v) =>
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '权限',
dataIndex: 'permissions',
ellipsis: true,
render: (p: string[] | undefined) => formatPermissions(p),
},
{
title: '操作',
width: 120,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => onEdit(row)}>
</Button>
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{subs.length}·
</Typography.Text>
<Button size="small" type="primary" onClick={onAdd}>
</Button>
</div>
<Table
size="small"
rowKey="id"
pagination={false}
columns={columns}
dataSource={subs}
locale={{ emptyText: '暂无子账号' }}
/>
</>
);
}
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
export type PartnerSubAccountRow = {
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
};
function formatPermissions(permissions?: string[]) {
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
}
type Props = {
subs: PartnerSubAccountRow[];
onAdd: () => void;
onEdit: (sub: PartnerSubAccountRow) => void;
onDelete: (subId: string) => void;
};
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
const columns: ColumnsType<PartnerSubAccountRow> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '角色',
dataIndex: 'staffRole',
width: 90,
render: (v) =>
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '权限',
dataIndex: 'permissions',
render: (p: string[] | undefined) => formatPermissions(p),
},
{
title: '操作',
width: 120,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => onEdit(row)}>
</Button>
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{subs.length}·
</Typography.Text>
<Button size="small" type="primary" onClick={onAdd}>
</Button>
</div>
<Table
size="small"
rowKey="id"
pagination={false}
columns={columns}
dataSource={subs}
locale={{ emptyText: '暂无子账号' }}
/>
</>
);
@@ -1,10 +1,11 @@
import { useMemo, useState } from 'react';
import {
Button, Input, InputNumber, Radio, Select, Space, Switch, Table, Typography, message,
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
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[] };
@@ -24,6 +25,7 @@ type SkuRow = {
isDefault: boolean;
sortOrder?: number;
specText?: string;
imageUrl?: string | null;
};
function cartesian(attrs: SpecAttr[]): string[][] {
@@ -73,6 +75,7 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
isDefault: !!s.isDefault,
imageUrl: s.imageUrl ?? '',
}))
: [
{
@@ -86,10 +89,12 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
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]);
@@ -111,12 +116,21 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
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 {
@@ -157,8 +171,7 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
const payloadSkus = skus.map((s, i) => ({
id: s.id,
specValueIds: resolveIds(s.specValueIds ?? []),
skuCode: s.skuCode,
barcode69: s.barcode69,
barcode69: s.barcode69.trim(),
price: s.price,
benefitAmount: s.benefitAmount,
status: s.status,
@@ -169,10 +182,13 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
isDefault: !!s.isDefault,
sortOrder: i,
imageUrl: s.imageUrl?.trim() || null,
}));
for (const row of payloadSkus) {
if (!row.barcode69?.trim()) throw new Error('请填写全部 SKU 的 69 码');
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`, {
@@ -188,10 +204,13 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
}
}
const editing = editIndex != null ? skus[editIndex] : null;
return (
<div>
<Typography.Paragraph type="secondary">
SKU SKU/
SKU DK <strong></strong> 69
SKU
</Typography.Paragraph>
<Typography.Title level={5}></Typography.Title>
@@ -272,7 +291,7 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
size="small"
rowKey={(_, i) => String(i)}
pagination={false}
scroll={{ x: 1100 }}
scroll={{ x: 1240 }}
dataSource={skus}
columns={[
{
@@ -281,10 +300,20 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
},
{
title: '69码',
width: 140,
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];
@@ -434,12 +463,154 @@ export default function ProductSpecsEditor({ productId, initialAttrs, initialSku
/>
),
},
{
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>
);
}
@@ -23,6 +23,11 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
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;
@@ -46,6 +51,15 @@ export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone =
</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 ? (
@@ -174,11 +174,13 @@ function PackageDetailCard({
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;
@@ -217,7 +219,7 @@ function PackageDetailCard({
<Image.PreviewGroup>
<Space wrap size={8}>
{images.map((url) => (
<Image key={url} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={url} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -265,6 +267,7 @@ export type StorePackageAuditPanelProps = {
requestId: string;
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
showActions?: boolean;
fullscreen?: boolean;
onAudited?: () => void;
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
};
@@ -273,6 +276,7 @@ export type StorePackageAuditPanelProps = {
export default function StorePackageAuditPanel({
requestId,
showActions = true,
fullscreen = false,
onAudited,
onDetailLoaded,
}: StorePackageAuditPanelProps) {
@@ -389,6 +393,7 @@ export default function StorePackageAuditPanel({
title={`套餐 ${index + 1}`}
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
@@ -407,6 +412,7 @@ export default function StorePackageAuditPanel({
pkg={pkg}
change={changeByKey.get(packageKey(pkg, index))}
changes={changesByKey.get(packageKey(pkg, index))}
imageSize={fullscreen ? 160 : 72}
/>
))
) : (
+15 -9
View File
@@ -20,10 +20,11 @@ import {
AccountBookOutlined,
ProjectOutlined,
} 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 { 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;
@@ -156,6 +157,9 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
if (key === 'tickets-group') {
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
}
if (key === 'stores-group') {
return hasAnyStoreMenuPermission(permissionKeys);
}
if (key === 'dev-plan-group') {
return permissionKeys.includes('dev_plan');
}
@@ -175,11 +179,11 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/knowledge-bases': 'knowledge_bases',
'stores-group': 'stores',
'/stores': 'stores',
'/store-package-audits': 'stores',
'/store-ratings': 'stores',
'/store-categories': 'stores',
'/store-accounts': 'stores',
'/store-media': 'stores',
'/store-package-audits': 'store_audits',
'/store-ratings': 'store_ratings',
'/store-categories': 'store_categories',
'/store-accounts': 'store_accounts',
'/store-media': 'store_media',
'partners-group': 'partners',
'/cities': 'partners',
'/city-partners': 'partners',
@@ -196,10 +200,10 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/benefit/ledgers': 'benefit',
'/redeem-records': 'benefit',
'/redeem-pending': 'benefit',
'/redeem/debug': 'benefit',
'/redeem/debug': 'benefit_debug',
'deliveries-group': 'deliveries',
'/deliveries': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries_debug',
'/tickets': 'tickets',
'/tickets/support': 'tech_support',
'dev-plan-group': 'dev_plan',
@@ -413,7 +417,9 @@ export default function AdminLayout() {
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
>
<Content style={{ margin: 24 }}>
<Outlet />
<ListColumnPrefsProvider initial={profile?.listColumnPrefs}>
<Outlet />
</ListColumnPrefsProvider>
</Content>
</div>
</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);
}
+22
View File
@@ -1,3 +1,5 @@
import type { HqListColumnPrefsMap } from '@dukang/shared-types';
export const apiBase = '/api/v1';
export const CLIENT_APP = 'HQ_WEB';
@@ -8,6 +10,9 @@ export type HqProfile = {
adminRole: string;
status: string;
permissionKeys?: string[];
cityIds?: string[];
cityScoped?: boolean;
listColumnPrefs?: HqListColumnPrefsMap;
};
export function getToken() {
@@ -87,6 +92,9 @@ export type DashboardStats = {
openTickets?: number;
pendingStoreWithdrawals?: number;
overdueStoreWithdrawals?: number;
pendingStoreOnboard?: number;
pendingStorePackageAudits?: number;
pendingStoreInfoChanges?: number;
ordersByStatus: Array<{ status: string; count: number }>;
};
@@ -161,6 +169,7 @@ export type AdminUserRow = {
wxOpenId: string | null;
wechatVerified: boolean;
nickname: string | null;
hqRemark: string | null;
status: number;
sourceType: string;
sourceRefId: string | null;
@@ -193,8 +202,21 @@ export type AdminOrderRow = {
productName?: string;
productSpec?: string;
quantity?: number;
saleUnit?: string;
receiverName: 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;
cityId?: string;
city?: { id: string; name: string; code: string };
+67
View File
@@ -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}
</>
);
}
+6
View File
@@ -7,6 +7,12 @@ export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
MANUAL: '手动',
};
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
ON_SITE_PICKUP: '现场提货',
};
export const ORDER_STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
+16
View File
@@ -8,3 +8,19 @@ export function downloadExcelCsv(csv: string, filename: string) {
a.click();
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 };
@@ -0,0 +1,173 @@
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 = (
<Button icon={<SettingOutlined />} onClick={() => setOpen(true)}>
</Button>
);
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 };
}
+135 -19
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Descriptions,
@@ -9,6 +10,7 @@ import {
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Typography,
@@ -17,9 +19,22 @@ import {
import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
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 { useAdminListColumns } from '../lib/useAdminListColumns';
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 = {
id: string;
@@ -30,10 +45,43 @@ type Row = {
status: string;
sourceProduct: string;
createdAt: string;
user?: { userNo: string; phone: string | null };
order?: { orderNo: string } | null;
user?: { id?: string; userNo: string; phone: 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 = {
id: string;
redeemNo: string;
@@ -58,11 +106,10 @@ type CouponRedeemSummary = {
type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[];
user?: { userNo?: string; phone?: string | null };
order?: { orderNo?: string } | null;
};
export default function BenefitCouponsPage() {
const navigate = useNavigate();
const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({});
@@ -99,21 +146,62 @@ export default function BenefitCouponsPage() {
}
}
const columns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
const baseColumns: ColumnsType<Row> = [
{
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: ['order', 'orderNo'],
width: 180,
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: 'balance', width: 80, render: (v) => `¥${v}` },
{ 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: '操作',
@@ -155,15 +243,21 @@ export default function BenefitCouponsPage() {
}
}
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" onClick={() => setGrantOpen(true)}>
</Button>
<Space>
{settingsButton}
<Button type="primary" onClick={() => setGrantOpen(true)}>
</Button>
</Space>
</div>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
@@ -190,7 +284,7 @@ export default function BenefitCouponsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
@@ -268,15 +362,37 @@ export default function BenefitCouponsPage() {
<>
<Descriptions column={1} bordered size="small">
<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.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.balance).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
<Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
@@ -316,7 +432,7 @@ export default function BenefitCouponsPage() {
locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []}
columns={[
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
{ title: '核销号', dataIndex: 'redeemNo' },
{
title: '角色',
dataIndex: 'role',
@@ -327,7 +443,7 @@ export default function BenefitCouponsPage() {
{
title: '门店',
dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row: CouponRedeemRecord) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
},
@@ -3,6 +3,8 @@ import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
type Row = {
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
@@ -24,19 +26,23 @@ export default function BenefitLedgersPage() {
[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: ['user', 'userNo'], width: 120, ellipsis: false },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '备注', dataIndex: 'remark' },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-ledgers', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<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="couponId" label="券ID"><Input allowClear /></Form.Item>
@@ -45,7 +51,7 @@ export default function BenefitLedgersPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</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); } }} />
</div>
);
+22 -9
View File
@@ -24,6 +24,9 @@ import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/con
import { useAdminList } from '../lib/useAdminList';
import CityPartnersPanel from '../components/CityPartnersPanel';
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type WarehouseRow = {
id: string;
@@ -223,9 +226,16 @@ export default function CitiesPage() {
}
};
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ 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: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
@@ -261,7 +271,7 @@ export default function CitiesPage() {
const warehouseColumns: ColumnsType<WarehouseRow> = [
{ title: '仓库名', dataIndex: 'name' },
{ title: '地址', dataIndex: 'address', ellipsis: true },
{ title: '地址', dataIndex: 'address' },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
@@ -270,7 +280,7 @@ export default function CitiesPage() {
width: 100,
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: '操作',
@@ -311,10 +321,14 @@ export default function CitiesPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('cities', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={openCreateModal}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
@@ -325,7 +339,7 @@ export default function CitiesPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</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); } }} />
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
@@ -623,9 +637,9 @@ export default function CitiesPage() {
)}
columns={[
{ title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '名称', dataIndex: 'name' },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true },
{ title: '归属', dataIndex: 'parent' },
]}
/>
</div>
@@ -640,13 +654,12 @@ export default function CitiesPage() {
locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores}
columns={[
{ title: '门店名', dataIndex: 'name', ellipsis: true },
{ title: '门店名', dataIndex: 'name' },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '合伙人',
width: 140,
ellipsis: true,
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
},
]}
+17 -6
View File
@@ -36,6 +36,9 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type SubRow = PartnerSubAccountRow;
@@ -261,22 +264,27 @@ export default function CityPartnersPage() {
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
}
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 90 },
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
{
title: '公司名',
dataIndex: 'companyName',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '主账号姓名',
dataIndex: 'name',
width: 120,
ellipsis: true,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
@@ -311,7 +319,6 @@ export default function CityPartnersPage() {
title: '管仓仓库',
dataIndex: 'managedWarehouseName',
width: 110,
ellipsis: true,
render: (v) => v || '—',
},
{
@@ -338,12 +345,16 @@ export default function CityPartnersPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Button
type="primary"
onClick={() => {
@@ -427,7 +438,7 @@ export default function CityPartnersPage() {
loading={loading}
columns={columns}
dataSource={toTableRows(data?.items ?? [])}
scroll={{ x: 1200 }}
scroll={{ x: 'max-content' }}
childrenColumnName="__noTreeChildren__"
expandable={{
expandedRowRender: (record) => (
@@ -30,6 +30,9 @@ import {
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -70,6 +73,7 @@ function FulfillmentFields({
onModeChange: (mode: WarehouseFulfillmentMode) => void;
}) {
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
return (
<>
<Form.Item
@@ -219,7 +223,7 @@ export default function CityWarehousesPage() {
}
}
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{
title: '城市',
width: 100,
@@ -229,8 +233,15 @@ export default function CityWarehousesPage() {
</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: 'contactPhone', width: 120 },
{
@@ -242,7 +253,6 @@ export default function CityWarehousesPage() {
title: '合伙人',
dataIndex: 'partnerCompanyName',
width: 120,
ellipsis: true,
render: (v, row) =>
v && row.partnerAccountId ? (
<Link to="/city-partners">{v}</Link>
@@ -298,10 +308,17 @@ export default function CityWarehousesPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-warehouses', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button
type="primary"
onClick={() => {
@@ -367,7 +384,7 @@ export default function CityWarehousesPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1400 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+322 -158
View File
@@ -64,6 +64,20 @@ export default function DashboardPage() {
const [profile, setProfile] = useState<HqProfile | null>(null);
const [deploying, setDeploying] = useState(false);
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<{
range: [Dayjs, Dayjs];
@@ -115,18 +129,26 @@ export default function DashboardPage() {
.catch(() => {
setVersionLoading(false);
});
}, [loadVersion]);
useEffect(() => {
if (!profile) return;
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => setCities([]));
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPromos(res.items ?? []))
.catch(() => setPromos([]));
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, [loadVersion]);
if ((profile.permissionKeys ?? []).includes('promo_codes')) {
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPromos(res.items ?? []))
.catch(() => setPromos([]));
}
if ((profile.permissionKeys ?? []).includes('partners')) {
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}
}, [profile]);
useEffect(() => {
void loadAnalytics(filters);
@@ -206,9 +228,19 @@ export default function DashboardPage() {
const byDateOption = useMemo<EChartsOption>(() => {
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 {
tooltip: { trigger: 'axis' },
legend: { data: ['新增用户', '订单数'] },
legend: { data: legend },
grid: { left: 40, right: 20, top: 40, bottom: 40 },
xAxis: {
type: 'category',
@@ -216,18 +248,25 @@ export default function DashboardPage() {
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
],
series,
};
}, [analytics]);
}, [analytics, canUsers, canOrders]);
const byCityOption = useMemo<EChartsOption>(() => {
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 {
tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] },
legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: {
type: 'category',
@@ -235,18 +274,25 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
series,
};
}, [analytics]);
}, [analytics, canUsers, canOrders]);
const byPromoOption = useMemo<EChartsOption>(() => {
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 {
tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] },
legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 64 },
xAxis: {
type: 'category',
@@ -254,18 +300,36 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
series,
};
}, [analytics]);
}, [analytics, canUsers, canOrders]);
const opsByDateOption = useMemo<EChartsOption>(() => {
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 {
tooltip: { trigger: 'axis' },
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] },
legend: { data: legend },
grid: { left: 48, right: 48, top: 48, bottom: 40 },
xAxis: {
type: 'category',
@@ -276,26 +340,29 @@ export default function DashboardPage() {
{ type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额', minInterval: 1 },
],
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),
},
],
series,
};
}, [analytics]);
}, [analytics, canPartners, canStores, canBenefit]);
const opsByCityOption = useMemo<EChartsOption>(() => {
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 {
tooltip: { trigger: 'axis' },
legend: { data: ['合伙人', '门店', '核销笔数'] },
legend: { data: legend },
grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: {
type: 'category',
@@ -303,19 +370,31 @@ export default function DashboardPage() {
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
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 },
],
series,
};
}, [analytics]);
}, [analytics, canPartners, canStores, canBenefit]);
const opsByPartnerOption = useMemo<EChartsOption>(() => {
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 {
tooltip: { trigger: 'axis' },
legend: { data: ['门店', '核销笔数', '核销金额'] },
legend: { data: legend },
grid: { left: 48, right: 48, top: 40, bottom: 64 },
xAxis: {
type: 'category',
@@ -326,18 +405,9 @@ export default function DashboardPage() {
{ type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额' },
],
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),
},
],
series,
};
}, [analytics]);
}, [analytics, canStores, canBenefit]);
const redeemByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
@@ -368,7 +438,9 @@ export default function DashboardPage() {
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Title level={4}>
{cityScoped ? '数据概览(负责城市)' : '数据概览'}
</Typography.Title>
{isSuperAdmin ? (
<Card
@@ -411,46 +483,94 @@ export default function DashboardPage() {
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
</Card>
</Col>
{!permsReady ? (
<Col xs={24} sm={12} lg={6}>
<Card loading />
</Col>
) : null}
{permsReady && canUsers ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
</Card>
</Col>
) : null}
{permsReady && canUsers ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
) : null}
{permsReady && canUsers ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
) : null}
{permsReady && canOrders ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
</Card>
</Col>
) : null}
{permsReady && canStores ? (
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
</Card>
</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>
<Card
@@ -478,62 +598,73 @@ export default function DashboardPage() {
<Form.Item name="cityId" label="城市">
<Select
allowClear
placeholder="全部开城"
placeholder={cityScoped ? '全部负责城市' : '全部开城'}
style={{ width: 160 }}
options={[
{ value: 'none', label: '未选城' },
...(cityScoped ? [] : [{ value: 'none', label: '未选城' }]),
...cities.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</Form.Item>
<Form.Item name="promoCodeId" label="推广码">
<Select
allowClear
placeholder="全部来源"
style={{ width: 200 }}
options={[
{ value: 'none', label: '自然量 / 无推广码' },
...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })),
]}
/>
</Form.Item>
<Form.Item name="partnerAccountId" label="合伙人">
<Select
allowClear
placeholder="全部合伙人"
style={{ width: 200 }}
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.name,
}))}
/>
</Form.Item>
{canPromo ? (
<Form.Item name="promoCodeId" label="推广码">
<Select
allowClear
placeholder="全部来源"
style={{ width: 200 }}
options={[
{ value: 'none', label: '自然量 / 无推广码' },
...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })),
]}
/>
</Form.Item>
) : null}
{canPartners ? (
<Form.Item name="partnerAccountId" label="合伙人">
<Select
allowClear
placeholder="全部合伙人"
style={{ width: 200 }}
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.name,
}))}
/>
</Form.Item>
) : null}
<Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form.Item>
</Form>
</Card>
{canUsers || canOrders ? (
<Card
title="用户 / 订单统计"
title={canUsers && canOrders ? '用户 / 订单统计' : canUsers ? '用户统计' : '订单统计'}
style={{ marginBottom: 24 }}
extra={
<Space>
<Link to={`/orders?${ordersDrillQs}`}></Link>
<Link to="/users"></Link>
{canOrders ? <Link to={`/orders?${ordersDrillQs}`}></Link> : null}
{canUsers ? <Link to="/users"></Link> : null}
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
</Col>
<Col xs={24} sm={8}>
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
</Col>
<Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
</Col>
{canUsers ? (
<Col xs={24} sm={8}>
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
</Col>
) : null}
{canOrders ? (
<Col xs={24} sm={8}>
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
</Col>
) : null}
{canOrders ? (
<Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
</Col>
) : null}
</Row>
<Row gutter={[16, 16]}>
@@ -542,7 +673,7 @@ export default function DashboardPage() {
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
</Card>
</Col>
<Col xs={24} lg={12}>
<Col xs={24} lg={canPromo ? 12 : 24}>
<Card
type="inner"
title="按城市(点击柱联动筛选)"
@@ -562,6 +693,7 @@ export default function DashboardPage() {
/>
</Card>
</Col>
{canPromo ? (
<Col xs={24} lg={12}>
<Card
type="inner"
@@ -584,42 +716,53 @@ export default function DashboardPage() {
/>
</Card>
</Col>
) : null}
</Row>
</Card>
) : null}
{canPartners || canStores || canBenefit ? (
<Card
title="合伙人 / 门店 / 核销统计"
title={[canPartners && '合伙人', canStores && '门店', canBenefit && '核销'].filter(Boolean).join(' / ') + '统计'}
style={{ marginBottom: 24 }}
extra={
<Space>
<Link to="/city-partners"></Link>
<Link to="/stores"></Link>
<Link to="/redeem-records"></Link>
{canPartners ? <Link to="/city-partners"></Link> : null}
{canStores ? <Link to="/stores"></Link> : null}
{canBenefit ? <Link to="/redeem-records"></Link> : null}
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={6}>
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
</Col>
{canPartners ? (
<Col xs={24} sm={6}>
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
</Col>
) : null}
{canStores ? (
<Col xs={24} sm={6}>
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
</Col>
) : null}
{canBenefit ? (
<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 gutter={[16, 16]}>
<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 />
</Card>
</Col>
<Col xs={24} lg={12}>
<Col xs={24} lg={canBenefit ? 12 : 24}>
<Card
type="inner"
title="按城市(点击柱联动筛选)"
@@ -639,6 +782,7 @@ export default function DashboardPage() {
/>
</Card>
</Col>
{canBenefit ? (
<Col xs={24} lg={12}>
<Card
type="inner"
@@ -659,6 +803,8 @@ export default function DashboardPage() {
/>
</Card>
</Col>
) : null}
{canPartners && (canStores || canBenefit) ? (
<Col xs={24}>
<Card
type="inner"
@@ -681,10 +827,14 @@ export default function DashboardPage() {
/>
</Card>
</Col>
) : null}
</Row>
</Card>
) : null}
{canFinance || canTickets ? (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{canFinance ? (
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
@@ -704,6 +854,8 @@ export default function DashboardPage() {
</Typography.Text>
</Card>
</Col>
) : null}
{canFinance ? (
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
@@ -717,6 +869,8 @@ export default function DashboardPage() {
/>
</Card>
</Col>
) : null}
{canFinance ? (
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
@@ -737,20 +891,28 @@ export default function DashboardPage() {
</Typography.Text>
</Card>
</Col>
) : null}
{canTickets ? (
<Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card>
</Col>
) : null}
</Row>
) : null}
{canUsers || canOrders ? (
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
{canUsers ? (
<Col xs={24} lg={canOrders ? 12 : 24}>
<Card title="已合并访客账号" loading={loading}>
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
</Card>
</Col>
<Col xs={24} lg={12}>
) : null}
{canOrders ? (
<Col xs={24} lg={canUsers ? 12 : 24}>
<Card title="订单状态分布" loading={loading}>
<Table
size="small"
@@ -768,7 +930,9 @@ export default function DashboardPage() {
/>
</Card>
</Col>
) : null}
</Row>
) : null}
</div>
);
}
+26 -3
View File
@@ -7,6 +7,9 @@ import OrderTrackDrawer from '../components/OrderTrackDrawer';
import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -59,8 +62,24 @@ export default function DeliveriesPage() {
setTrackOpen(true);
}
const columns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
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: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
@@ -83,16 +102,20 @@ export default function DeliveriesPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}>/</Typography.Title>
{settingsButton}
<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="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</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); } }} />
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={
+119 -8
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import {
Button,
Form,
Image,
Input,
Modal,
Popconfirm,
@@ -18,6 +19,7 @@ import {
DEV_PLAN_TASK_TYPE_LABELS,
SUPPORT_TICKET_STATUS_LABELS,
type DevPlanTaskDto,
type DevPlanTaskExportFormat,
type DevPlanTaskStatusDto,
type DevPlanTaskTypeDto,
type DevPlanVersionDto,
@@ -25,7 +27,12 @@ import {
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
value: v,
@@ -56,6 +63,8 @@ export default function DevPlanTasksPage() {
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
const [saving, setSaving] = useState(false);
const [dispatching, setDispatching] = useState(false);
const [exportFormat, setExportFormat] = useState<DevPlanTaskExportFormat>('xlsx');
const [exporting, setExporting] = useState(false);
const [form] = Form.useForm();
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
@@ -88,24 +97,30 @@ export default function DevPlanTasksPage() {
function openCreate() {
setEditing(null);
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined, attachmentUrls: [''] });
setModalOpen(true);
}
function openEdit(row: DevPlanTaskDto) {
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);
}
async function save() {
const values = await form.validateFields();
const attachmentUrls = (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean);
setSaving(true);
try {
if (editing) {
await request(`/admin/dev-plan/tasks/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(values),
body: JSON.stringify({ ...values, attachmentUrls }),
});
message.success('已更新');
} else {
@@ -115,6 +130,7 @@ export default function DevPlanTasksPage() {
content: values.content,
type: values.type,
supportTicketId: values.supportTicketId || undefined,
attachmentUrls,
}),
});
message.success('已创建');
@@ -199,8 +215,43 @@ export default function DevPlanTasksPage() {
}
}
const columns: ColumnsType<DevPlanTaskDto> = [
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
async function exportTasks(scope: 'filter' | 'selected') {
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: '类型',
dataIndex: 'type',
@@ -215,7 +266,23 @@ export default function DevPlanTasksPage() {
<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: 'creatorName', width: 90 },
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
@@ -237,13 +304,34 @@ export default function DevPlanTasksPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
·
</Typography.Title>
<Space>
{settingsButton}
<Space wrap>
<Select
value={exportFormat}
style={{ width: 120 }}
onChange={setExportFormat}
options={[
{ value: 'markdown', label: 'Markdown' },
{ value: 'docx', label: 'Word' },
{ value: 'xlsx', label: 'Excel' },
{ value: 'pdf', label: 'PDF' },
]}
/>
<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>
@@ -287,7 +375,7 @@ export default function DevPlanTasksPage() {
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
}}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
@@ -333,6 +421,29 @@ export default function DevPlanTasksPage() {
/>
</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>
</Modal>
@@ -22,6 +22,9 @@ import {
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
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> = [
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
const baseColumns: ColumnsType<DevPlanVersionDto> = [
{ title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
) },
{
title: '状态',
dataIndex: 'status',
@@ -129,7 +134,7 @@ export default function DevPlanVersionsPage() {
<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: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
{ title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
@@ -158,12 +163,16 @@ export default function DevPlanVersionsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-versions', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
·
</Typography.Title>
{settingsButton}
<Button type="primary" onClick={openCreate}>
</Button>
@@ -190,7 +199,7 @@ export default function DevPlanVersionsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+24 -3
View File
@@ -4,6 +4,9 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -55,9 +58,22 @@ export default function DomainEventsPage() {
load();
}, [load]);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ 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: '状态', dataIndex: 'status', width: 100 },
{ 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 (
<Card title="领域事件">
<Card title="领域事件" extra={settingsButton}>
{settingsModal}
<Space wrap style={{ marginBottom: 16 }}>
<Select
style={{ width: 180 }}
@@ -28,6 +28,9 @@ import {
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
value,
@@ -173,9 +176,15 @@ export default function FulfillmentProvidersPage() {
void load();
}
const columns: ColumnsType<FulfillmentProviderDto> = [
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 },
{ title: '名称', dataIndex: 'name' },
{
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{
title: '类型',
dataIndex: 'type',
@@ -230,13 +239,20 @@ export default function FulfillmentProvidersPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, {
page: 1,
pageSize: rows.length || 20,
});
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
</Typography.Text>
@@ -253,7 +269,7 @@ export default function FulfillmentProvidersPage() {
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
title={editRow ? '编辑承运商' : '注册承运商'}
+116 -32
View File
@@ -3,9 +3,13 @@ import {
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type HqProfile } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { HQ_ADMIN_ROLES } from '@dukang/shared-types';
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 { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -17,14 +21,13 @@ type Row = {
status: string;
lastLoginAt: string | null;
createdAt: string;
cityIds?: string[];
};
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: '超级管理员',
OPS: '运营',
FINANCE: '财务',
CUSTOMER_SERVICE: '客服',
};
type CityOption = { id: string; name: string };
const ROLE_OPTIONS = HQ_ADMIN_ROLES.map((r) => ({ value: r.value, label: r.label }));
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
export default function HqAccountsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null);
@@ -46,15 +49,52 @@ export default function HqAccountsPage() {
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = 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 editRole = Form.useWatch('adminRole', editForm);
const createRole = Form.useWatch('adminRole', createForm);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name' },
useEffect(() => {
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: 'phone', width: 130 },
{
@@ -67,7 +107,18 @@ export default function HqAccountsPage() {
</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: 'lastLoginAt', width: 160, render: fmtTime },
{
@@ -81,6 +132,7 @@ export default function HqAccountsPage() {
loginName: row.loginName,
adminRole: row.adminRole,
status: row.status,
cityIds: row.cityIds ?? [],
});
setDrawerOpen(true);
}}></Button>
@@ -88,16 +140,20 @@ export default function HqAccountsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('hq-accounts', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>HQ </Typography.Title>
{settingsButton}
{isSuperAdmin && (
<Button
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' });
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
setCreateOpen(true);
}}
>
@@ -108,23 +164,31 @@ export default function HqAccountsPage() {
<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="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><Button type="primary" htmlType="submit"></Button></Form.Item>
</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); } }} />
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
<Drawer title="编辑 HQ 账户" width={460} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={
<Button type="primary" onClick={async () => {
<Button type="primary" loading={saving} onClick={async () => {
if (!detail) return;
const v = await editForm.validateFields();
const payload = { ...v };
if (!payload.password) delete payload.password;
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
try {
const v = await editForm.validateFields();
const payload: Record<string, unknown> = { ...v };
if (!payload.password) delete payload.password;
if (!String(payload.loginName ?? '').trim()) delete payload.loginName;
setSaving(true);
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>
}>
<Form form={editForm} layout="vertical">
@@ -144,7 +208,15 @@ export default function HqAccountsPage() {
<Input.Password placeholder="至少 6 位" />
</Form.Item>
<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 name="status" label="状态">
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
@@ -156,15 +228,19 @@ export default function HqAccountsPage() {
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
try {
const v = await createForm.validateFields();
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
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="创建方式">
<Radio.Group>
<Radio value="phone"></Radio>
@@ -173,7 +249,15 @@ export default function HqAccountsPage() {
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<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>
{credentialType === 'phone' ? (
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
+20 -4
View File
@@ -9,6 +9,9 @@ import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -52,12 +55,11 @@ export default function HqLogsPage() {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作人',
width: 200,
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.hqName}
@@ -69,7 +71,17 @@ export default function HqLogsPage() {
title: '行为',
dataIndex: 'actionLabel',
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: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
@@ -92,9 +104,13 @@ export default function HqLogsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}>HQ </Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
@@ -150,7 +166,7 @@ export default function HqLogsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 900 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+86 -54
View File
@@ -17,6 +17,7 @@ import {
import {
HQ_ADMIN_ROLES,
HQ_PERMISSION_CATALOG,
computeHqEffectivePermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api';
@@ -27,6 +28,8 @@ type AccountPermRes = {
account: AccountOption;
permissionKeys: string[];
rolePermissionKeys: string[];
grantKeys?: string[];
denyKeys?: string[];
userPermissionKeys: string[];
effectivePermissionKeys: string[];
};
@@ -46,11 +49,17 @@ function PermissionChecklist({
value,
onChange,
disabled,
roleKeys,
grantKeys,
}: {
value: string[];
onChange: (keys: string[]) => void;
disabled?: boolean;
roleKeys?: string[];
grantKeys?: string[];
}) {
const roleSet = new Set(roleKeys ?? []);
const grantSet = new Set(grantKeys ?? []);
return (
<Checkbox.Group
style={{ width: '100%' }}
@@ -81,6 +90,13 @@ function PermissionChecklist({
{items.map((item) => (
<Checkbox key={item.key} 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>
))}
</Space>
@@ -88,7 +104,16 @@ function PermissionChecklist({
<Row gutter={[12, 10]}>
{items.map((item) => (
<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>
))}
</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() {
const [profile, setProfile] = useState<HqProfile | null>(null);
const [role, setRole] = useState<string>('OPS');
@@ -111,6 +144,8 @@ export default function HqPermissionsPage() {
const [accountId, setAccountId] = useState<string>();
const [accountKeys, setAccountKeys] = useState<string[]>([]);
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
const [grantKeys, setGrantKeys] = useState<string[]>([]);
const [denyKeys, setDenyKeys] = useState<string[]>([]);
const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = useState(false);
@@ -121,8 +156,8 @@ export default function HqPermissionsPage() {
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys],
() => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys),
[roleInheritedKeys, grantKeys, denyKeys],
);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
@@ -151,8 +186,10 @@ export default function HqPermissionsPage() {
setAccountLoading(true);
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
.then((res) => {
setAccountKeys(res.userPermissionKeys);
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
setDenyKeys(res.denyKeys ?? []);
setRoleInheritedKeys(res.rolePermissionKeys);
setAccountKeys(res.effectivePermissionKeys);
})
.finally(() => setAccountLoading(false));
}, [isSuperAdmin, accountId]);
@@ -179,10 +216,12 @@ export default function HqPermissionsPage() {
try {
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
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);
setAccountKeys(res.effectivePermissionKeys);
message.success('用户权限已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
@@ -204,8 +243,9 @@ export default function HqPermissionsPage() {
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
=
=
//
/
//
</Typography.Paragraph>
@@ -275,59 +315,51 @@ export default function HqPermissionsPage() {
<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>
<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 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Space wrap size={[4, 4]}>
<Tag></Tag>
{accountKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key} color="orange">
{item?.label || key}
</Tag>
);
})}
</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>
)}
<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 style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
<Button
type="primary"
loading={accountSaving}
disabled={selectedIsSuperAdmin}
onClick={() => void saveAccountPermissions()}
>
</Button>
</div>
+48 -4
View File
@@ -17,9 +17,11 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
INVOICE_CATEGORY_LABELS,
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceCategory,
type InvoiceKind,
type InvoiceStatus,
type InvoiceTitleType,
@@ -28,6 +30,9 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -35,6 +40,7 @@ type Row = {
orderNo?: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
status: InvoiceStatus;
overdue?: boolean;
@@ -54,6 +60,7 @@ type CreateFormValues = {
orderNo: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
invoiceCategory?: InvoiceCategory;
titleName: string;
taxNo?: string;
addressPhone?: string;
@@ -156,6 +163,7 @@ export default function InvoicesPage() {
orderNo: values.orderNo.trim(),
titleType: values.titleType,
invoiceKind: values.invoiceKind,
invoiceCategory: values.invoiceCategory,
titleName: values.titleName.trim(),
taxNo: values.taxNo?.trim() || undefined,
addressPhone: values.addressPhone?.trim() || undefined,
@@ -176,7 +184,7 @@ export default function InvoicesPage() {
}
}
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{
@@ -186,10 +194,24 @@ export default function InvoicesPage() {
},
{
title: '票种',
width: 120,
width: 140,
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: '状态',
width: 110,
@@ -214,8 +236,11 @@ export default function InvoicesPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<div
style={{
display: 'flex',
@@ -227,12 +252,14 @@ export default function InvoicesPage() {
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Button
type="primary"
onClick={() => {
createForm.setFieldsValue({
titleType: 'PERSONAL',
invoiceKind: 'NORMAL',
invoiceCategory: 'LIQUOR',
});
setCreateOpen(true);
}}
@@ -272,7 +299,7 @@ export default function InvoicesPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1000 }}
scroll={{ x: 'max-content' }}
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
pagination={{
current: page,
@@ -324,6 +351,11 @@ export default function InvoicesPage() {
<Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]}
</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.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
@@ -382,6 +414,18 @@ export default function InvoicesPage() {
}}
/>
</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
name="titleType"
label="抬头类型"
@@ -25,6 +25,9 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
name: string;
@@ -124,9 +127,15 @@ export default function KnowledgeBasesPage() {
}
};
const columns: ColumnsType<KnowledgeBaseDto> = [
{ title: '名称', dataIndex: 'name' },
{ title: '说明', dataIndex: 'description', ellipsis: true },
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
{
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{ title: '说明', dataIndex: 'description' },
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
{
title: '启用',
@@ -184,7 +193,7 @@ export default function KnowledgeBasesPage() {
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
{ title: '标题', dataIndex: 'title' },
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
{ title: '文件', dataIndex: 'fileName' },
{
title: '状态',
dataIndex: 'status',
@@ -250,12 +259,16 @@ export default function KnowledgeBasesPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('knowledge-bases', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
.txt/.md AI
</Typography.Text>
+16 -3
View File
@@ -25,6 +25,9 @@ import {
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
name: string;
@@ -153,15 +156,21 @@ export default function LlmConfigsPage() {
}
};
const columns: ColumnsType<LlmApiConfigDto> = [
{ title: '名称', dataIndex: 'name' },
const baseColumns: ColumnsType<LlmApiConfigDto> = [
{
title: '名称',
dataIndex: 'name',
render: (v, row) => (
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{
title: '提供商',
dataIndex: 'provider',
width: 120,
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
},
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
{ title: '模型', dataIndex: 'modelName' },
{
title: 'Key',
dataIndex: 'apiKeyConfigured',
@@ -257,12 +266,16 @@ export default function LlmConfigsPage() {
const fullEdit = !editing || editing.canEditFull;
const { columns, settingsButton, settingsModal } = useAdminListColumns('llm-configs', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
</Typography.Text>
@@ -29,6 +29,8 @@ import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type BillRow = {
id: string;
@@ -44,6 +46,13 @@ type BillRow = {
settlementMethod: string;
status: string;
paidAt?: string | null;
paymentRef?: string | null;
fulfillmentProvider?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
};
type BillItem = {
@@ -140,14 +149,30 @@ export default function LogisticsBillsPage() {
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
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: '确认结算',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
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 {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
@@ -217,11 +242,19 @@ export default function LogisticsBillsPage() {
}
const summary = data?.summary;
const providerBank = (r: BillRow) => r.fulfillmentProvider;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
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: '承运商',
width: 140,
@@ -248,6 +281,16 @@ export default function LogisticsBillsPage() {
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
render: (_, r) => providerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -303,7 +346,6 @@ export default function LogisticsBillsPage() {
{
title: '银行账户',
width: 180,
ellipsis: true,
render: (_, r) =>
r.bankAccountName
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
@@ -355,12 +397,19 @@ export default function LogisticsBillsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-logistics-bills', billColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
</Typography.Text>
@@ -501,7 +550,7 @@ export default function LogisticsBillsPage() {
<Table
rowKey="id"
loading={loading}
columns={billColumns}
columns={columns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
@@ -550,7 +599,31 @@ export default function LogisticsBillsPage() {
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</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
rowKey="id"
size="small"
+338 -110
View File
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Col,
Collapse,
DatePicker,
Descriptions,
Drawer,
Form,
@@ -12,6 +14,7 @@ import {
InputNumber,
Modal,
Radio,
Row,
Select,
Space,
Table,
@@ -20,10 +23,16 @@ import {
message,
} from 'antd';
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 { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { downloadBase64File } from '../lib/exportExcel';
import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
DELIVERY_TYPE_LABELS,
ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS,
ORDER_STATUS_OPERATOR_LABELS,
@@ -130,6 +139,110 @@ type OrderDetail = AdminOrderRow & {
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
type OrderExportScope = 'filter' | 'selected';
type OrderExportFormat = 'xlsx' | 'pdf';
type OrderExportFilters = {
orderNo?: string;
status?: 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 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;
if (filters.status) payload.status = filters.status;
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[] {
if (detail.items?.length) return detail.items;
if (!detail.productName) return [];
@@ -162,6 +275,7 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
}
export default function OrdersPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
const [form] = Form.useForm();
@@ -192,8 +306,11 @@ export default function OrdersPage() {
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
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 canProxyOrder = (profile?.permissionKeys ?? []).includes('orders');
const canExportOrders = (profile?.permissionKeys ?? []).includes('orders');
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -238,6 +355,7 @@ export default function OrdersPage() {
try {
const values = form.getFieldsValue();
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.status) qs.set('status', values.status);
if (values.orderType) qs.set('orderType', values.orderType);
@@ -245,12 +363,15 @@ export default function OrdersPage() {
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
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}`);
setData(res);
} finally {
setLoading(false);
}
}, [form, page, pageSize]);
}, [form, page, pageSize, initialOrderNo]);
useEffect(() => {
void load();
@@ -451,79 +572,128 @@ export default function OrdersPage() {
}
}
const columns: ColumnsType<AdminOrderRow> = [
async function submitExport(scope: OrderExportScope) {
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: '订单号',
dataIndex: 'orderNo',
width: 200,
width: 180,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
},
{
title: '城市',
width: 90,
render: (_, row) => row.city?.name || '—',
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: '商品',
key: 'productName',
width: 180,
ellipsis: true,
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) => (
render: (_, row) => (
<Space size={4} wrap>
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
<span>{row.productName || '—'}</span>
{row.fulfillmentHold ? <Tag color="orange"></Tag> : null}
{row.orderType === 'PROXY' || row.isProxyOrder ? (
<Tag color="purple" title={row.proxyPartnerName || undefined}>
</Tag>
<Tag color="purple"></Tag>
) : null}
</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',
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,
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: '下单时间',
dataIndex: 'createdAt',
width: 170,
render: (v) => new Date(v).toLocaleString('zh-CN'),
render: fmtTime,
},
{
title: '操作',
@@ -555,26 +725,24 @@ export default function OrdersPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, {
page,
pageSize,
});
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
{settingsModal}
<Space style={{ marginBottom: 20, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
<Space size={12}>
{settingsButton}
<Button onClick={() => window.open('/orders/big-screen', 'dukang-big-screen')}></Button>
{canProxyOrder ? (
<Button type="primary" onClick={() => setProxyOpen(true)}>
</Button>
) : null}
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
</Space>
@@ -592,54 +760,115 @@ export default function OrdersPage() {
/>
) : null}
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="orderNo" label="订单号">
<Input placeholder="DK..." allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="orderType" label="类型">
<Select
allowClear
style={{ width: 120 }}
placeholder="全部"
options={[
{ value: 'NORMAL', label: '普通订单' },
{ value: 'PROXY', label: '代下单' },
]}
/>
</Form.Item>
<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="receiverPhone" label="收货手机">
<Input allowClear />
</Form.Item>
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
<Select
allowClear
style={{ width: 140 }}
placeholder="全部"
options={[{ value: true, label: '仅待确认大单' }]}
/>
</Form.Item>
<Form.Item name="excludeTest" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item>
<Space>
<Form form={form} layout="vertical" onFinish={() => { setPage(1); void load(); }}>
<Row gutter={[16, 8]}>
<Col xs={24} md={14} lg={12}>
<Form.Item label="下单日期" style={{ marginBottom: 12 }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Form.Item name="dateRange" noStyle>
<DatePicker.RangePicker allowClear style={{ width: '100%' }} />
</Form.Item>
<Space size={8} wrap>
{DATE_PRESETS.map((preset) => (
<Button
key={preset.key}
size="small"
onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
>
{preset.label}
</Button>
))}
</Space>
</Space>
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
<Select
allowClear
placeholder="全部"
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
</Form.Item>
</Col>
<Col xs={12} sm={6} md={5} lg={3}>
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
<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 onClick={() => { form.resetFields(); setPage(1); void load(); }}></Button>
{canDeleteOrders ? (
<Button
danger
disabled={!selectedRowKeys.length}
onClick={() => setBatchDeleteOpen(true)}
>
{selectedRowKeys.length ? `${selectedRowKeys.length}` : ''}
</Button>
) : null}
</Space>
</Form.Item>
</div>
</Form>
<Table
@@ -647,16 +876,18 @@ export default function OrdersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1500 }}
rowSelection={canDeleteOrders ? {
scroll={{ x: 'max-content' }}
rowSelection={{
selectedRowKeys,
preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
} : undefined}
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (total) => `${total}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
@@ -829,7 +1060,7 @@ export default function OrdersPage() {
locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []}
columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
{ title: '核销号', dataIndex: 'redeemNo', width: 160 },
{
title: '角色',
dataIndex: 'role',
@@ -840,7 +1071,6 @@ export default function OrdersPage() {
{
title: '门店',
dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
},
@@ -956,7 +1186,6 @@ export default function OrdersPage() {
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: (v) => v || '—',
},
{
@@ -1251,7 +1480,6 @@ export default function OrdersPage() {
{
title: '商品',
width: 160,
ellipsis: true,
render: (_, row) =>
row.productName
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
@@ -5,8 +5,10 @@ import {
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 { 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 { useAdminListColumns } from '../lib/useAdminListColumns';
type PartnerOption = { id: string; companyName: string };
type ParentAccount = { id: string; name: string; phone: string };
@@ -169,27 +171,24 @@ export default function PartnerAccountsPage() {
return keys;
}
const columns: ColumnsType<AccountTreeRow> = [
const baseColumns: ColumnsType<AccountTreeRow> = [
{
title: '姓名 / 类型',
width: 200,
ellipsis: true,
render: (_, row) => (
<AdminCellLine
primary={row.name}
secondary={
row.parentAccountId
? `子账号 · ${staffRoleLabel(row.staffRole)}`
: '主账号'
}
/>
<span className="admin-cell-line">
<AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
<span className="admin-cell-line-secondary">
{' · '}
{row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
</span>
</span>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '开城合伙人',
width: 140,
ellipsis: true,
render: (_, row) => row.companyName || row.parent?.name || '—',
},
{
@@ -254,11 +253,18 @@ export default function PartnerAccountsPage() {
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('partner-accounts', baseColumns, {
page: 1,
pageSize: treeData.length || 20,
});
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
</Typography.Text>
@@ -305,7 +311,7 @@ export default function PartnerAccountsPage() {
loading={loading}
columns={columns}
dataSource={treeData}
scroll={{ x: 1200 }}
scroll={{ x: 'max-content' }}
pagination={false}
expandable={{
expandedRowKeys,
@@ -399,7 +405,7 @@ export default function PartnerAccountsPage() {
label: `账单 (${detail.bills?.length ?? 0})`,
children: (
<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 +413,7 @@ export default function PartnerAccountsPage() {
label: `名下订单 (${detail.orders?.length ?? 0})`,
children: (
<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' }} />
),
},
]} />
+128 -11
View File
@@ -3,6 +3,8 @@ import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
Modal,
@@ -20,6 +22,9 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -31,8 +36,22 @@ type Row = {
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
paymentRef?: string | null;
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 };
@@ -77,6 +96,8 @@ export default function PartnerBillsPage() {
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -127,17 +148,30 @@ export default function PartnerBillsPage() {
}
function markPaid(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
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: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, { method: 'POST', body });
} else {
await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST',
@@ -182,6 +216,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() {
setExporting(true);
try {
@@ -201,17 +241,24 @@ export default function PartnerBillsPage() {
const summary = data?.summary;
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 canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
const baseColumns: ColumnsType<Row> = [
{
title: '账单号',
dataIndex: 'billNo',
width: 180,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '合伙人',
width: 160,
ellipsis: true,
render: (_, r) => partnerName(r),
},
{
@@ -238,6 +285,16 @@ export default function PartnerBillsPage() {
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '收款户名',
width: 100,
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
},
{
title: '收款账号',
width: 140,
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
},
{
title: '状态',
dataIndex: 'status',
@@ -259,6 +316,9 @@ export default function PartnerBillsPage() {
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
@@ -284,12 +344,16 @@ export default function PartnerBillsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
1 8:00
</Typography.Text>
@@ -395,7 +459,7 @@ export default function PartnerBillsPage() {
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
}}
scroll={{ x: 1280 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
@@ -465,6 +529,59 @@ export default function PartnerBillsPage() {
</Button>
</Form>
</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>
);
}
+24 -6
View File
@@ -14,6 +14,9 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -71,12 +74,11 @@ export default function PartnerLogsPage() {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '合伙人',
width: 180,
ellipsis: true,
render: (_, r) => (
r.isSubAccount ? (
<Tag color="blue"></Tag>
@@ -88,7 +90,6 @@ export default function PartnerLogsPage() {
{
title: '账号',
width: 150,
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.accountName}
@@ -108,7 +109,21 @@ export default function PartnerLogsPage() {
<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: '关联',
width: 120,
@@ -116,7 +131,6 @@ export default function PartnerLogsPage() {
},
{
title: '摘要',
ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson),
},
{
@@ -137,11 +151,15 @@ export default function PartnerLogsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4} style={{ marginBottom: 16 }}>
</Typography.Title>
{settingsButton}
<Segmented
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category}
@@ -216,7 +234,7 @@ export default function PartnerLogsPage() {
setPageSize(ps);
},
}}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
/>
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && (
+15 -3
View File
@@ -27,6 +27,9 @@ import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -173,17 +176,22 @@ export default function PartnersPage() {
}
}
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 100 },
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
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: '管辖',
@@ -205,10 +213,14 @@ export default function PartnersPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
</Button>
+17 -4
View File
@@ -23,6 +23,9 @@ import {
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange',
@@ -94,13 +97,19 @@ export default function PendingRedeemPage() {
}
}
const columns: ColumnsType<RedeemPendingItem> = [
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
const baseColumns: ColumnsType<RedeemPendingItem> = [
{
title: '待处理单号',
dataIndex: 'pendingNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '核销码 ID',
dataIndex: 'redeemToken',
width: 160,
ellipsis: true,
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 || '—' },
@@ -132,9 +141,13 @@ export default function PendingRedeemPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form
form={form}
layout="inline"
@@ -175,7 +188,7 @@ export default function PendingRedeemPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
@@ -13,6 +13,9 @@ import { useAdminList } from '../lib/useAdminList';
import DetailImageUrlList from '../components/DetailImageUrlList';
import type { ProductDetailTemplateDto } 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;
@@ -136,10 +139,26 @@ export default function ProductDetailTemplatesPage() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ 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: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
@@ -158,10 +177,17 @@ export default function ProductDetailTemplatesPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('product-detail-templates', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
@@ -175,7 +201,7 @@ export default function ProductDetailTemplatesPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</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); } }} />
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
+225 -59
View File
@@ -14,6 +14,9 @@ import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import ProductSpecsEditor from '../components/ProductSpecsEditor';
import type { FormInstance } from 'antd/es/form';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type ProductDetailContentDto = {
storyTitle?: string;
@@ -21,6 +24,22 @@ type ProductDetailContentDto = {
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 = {
id: string;
skuCode: string;
@@ -30,9 +49,15 @@ type Row = {
aromaType: string;
spec: string;
price: number;
priceMin?: number;
priceMax?: number;
soldBottles?: number;
benefitAmount: number;
status: string;
sortOrder: number;
skuCount?: number;
specEnabled?: boolean;
skus?: SkuListRow[];
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
@@ -214,7 +239,33 @@ function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
);
}
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 (
<>
{mode === 'create' && (
@@ -222,8 +273,13 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" />
</Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
<Input />
<Form.Item
name="barcode69"
label="69码"
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
>
<Input placeholder="默认规格 69 码" />
</Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
@@ -251,39 +307,47 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="allowOnlinePurchase"
label="允许线上购买"
valuePropName="checked"
extra="配送到址(同城)"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
{() => (
{hideFulfillment ? (
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
线 / / SKU
</Typography.Paragraph>
) : (
<>
<Form.Item
name="allowCrossCityDelivery"
label="允许跨城配送"
name="allowOnlinePurchase"
label="允许线上购买"
valuePropName="checked"
extra="须先开启线上购买"
extra="配送到址(同城)"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
disabled={!form.getFieldValue('allowOnlinePurchase')}
onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/>
</Form.Item>
)}
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
{() => (
<Form.Item
name="allowCrossCityDelivery"
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} />
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
@@ -326,13 +390,43 @@ export default function ProductsPage() {
}
}
const columns: ColumnsType<Row> = useMemo(() => [
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
const baseColumns: ColumnsType<Row> = useMemo(() => [
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '品名', dataIndex: 'name', width: 200, render: (v, row) => (
<AdminPrimaryLink onClick={async () => {
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) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) },
@@ -343,23 +437,7 @@ export default function ProductsPage() {
render: (v: boolean) =>
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: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 120,
render: (_, row) => (
@@ -372,7 +450,7 @@ export default function ProductsPage() {
}}></Button>
<Popconfirm
title="确认删除该商品?"
description={`将永久删除「${row.name}${row.skuCode},此操作不可恢复。`}
description={`将永久删除「${row.name}」,此操作不可恢复。`}
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
@@ -385,14 +463,62 @@ export default function ProductsPage() {
},
], [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 (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}></Button>
</Space>
<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="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
@@ -401,17 +527,46 @@ export default function ProductsPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
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)}
<Table
rowKey="id"
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 && (
<Button type="primary" onClick={async () => {
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('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
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) });
message.success('已保存');
setDrawerOpen(false);
@@ -421,13 +576,24 @@ export default function ProductsPage() {
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="默认 SKU">{String(detail.skuCode)}</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="创建时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<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',
label: '详情页',
+17 -3
View File
@@ -14,6 +14,9 @@ import {
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = PromoCodeItem;
@@ -74,8 +77,15 @@ export default function PromoCodesPage() {
void loadScenes();
}, []);
const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
const baseColumns: ColumnsType<Row> = [
{
title: '名称',
dataIndex: 'name',
width: 160,
render: (v, row) => (
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}`)}>{v}</AdminPrimaryLink>
),
},
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
@@ -160,10 +170,14 @@ export default function PromoCodesPage() {
}
}
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-codes', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>
</div>
@@ -194,7 +208,7 @@ export default function PromoCodesPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+10 -3
View File
@@ -7,6 +7,9 @@ import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDesc
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -75,14 +78,14 @@ export default function RedeemRecordsPage() {
}
}, [data, initialRedeemNo, loading]);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{
title: '核销号',
dataIndex: 'redeemNo',
width: 200,
render: (v, row) => (
<span>
{v}
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
{row.isTest ? (
<Tag color="orange" style={{ marginLeft: 6 }}>
@@ -133,9 +136,13 @@ export default function RedeemRecordsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-records', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form
form={form}
layout="inline"
@@ -175,7 +182,7 @@ export default function RedeemRecordsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1300 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+10 -4
View File
@@ -12,6 +12,8 @@ import {
fmtTime,
} from '../lib/constants';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
type Row = {
id: string;
@@ -58,7 +60,7 @@ export default function ResourcesPage() {
void reload();
}, []);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{
title: '归属',
@@ -84,8 +86,8 @@ export default function ResourcesPage() {
</a>
),
},
{ title: 'URL', dataIndex: 'url', ellipsis: true },
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
{ title: 'URL', dataIndex: 'url' },
{ title: 'OSS Key', dataIndex: 'ossKey', width: 160 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{
title: '状态',
@@ -114,12 +116,16 @@ export default function ResourcesPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('resources', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
OSS
</Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
@@ -170,7 +176,7 @@ export default function ResourcesPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+17 -3
View File
@@ -6,6 +6,9 @@ import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
@@ -74,14 +77,21 @@ export default function StoreAccountsPage() {
}
}
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{
title: '姓名',
dataIndex: 'name',
width: 120,
render: (v, row) => (
<Space size={4}>
<span>{v}</span>
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
),
@@ -138,11 +148,15 @@ export default function StoreAccountsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
</Typography.Text>
@@ -185,7 +199,7 @@ export default function StoreAccountsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+84 -10
View File
@@ -30,6 +30,9 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
kind: StoreSettlementKind;
@@ -44,6 +47,11 @@ type Row = {
settlementRate?: number | null;
payoutCount?: number | 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 };
@@ -55,6 +63,11 @@ const STATUS_COLORS: Record<string, string> = {
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> = {
T1_BILL: 'blue',
WITHDRAW: 'purple',
@@ -118,14 +131,30 @@ export default function StoreBillsPage() {
}, []);
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
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: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
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 {
setBatchLoading(true);
try {
@@ -241,23 +270,24 @@ export default function StoreBillsPage() {
);
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{
title: '类型',
dataIndex: 'kind',
width: 100,
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: '单号',
dataIndex: 'billNo',
width: 180,
ellipsis: true,
render: (v, row) => (
<Space>
<span>{v}</span>
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
{row.overdue ? <Tag color="magenta"></Tag> : null}
</Space>
),
@@ -268,9 +298,21 @@ export default function StoreBillsPage() {
width: 160,
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', '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: '笔数',
width: 80,
@@ -288,7 +330,9 @@ export default function StoreBillsPage() {
dataIndex: 'status',
width: 100,
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>
),
},
{
@@ -332,14 +376,19 @@ export default function StoreBillsPage() {
}
| undefined;
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
T+1 沿
T+1 沿
= T+1 =
</Typography.Text>
{overdueSummary && overdueSummary.overdueCount > 0 ? (
<Typography.Text type="danger">
@@ -468,7 +517,7 @@ export default function StoreBillsPage() {
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
}),
}}
scroll={{ x: 1300 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
@@ -503,7 +552,32 @@ export default function StoreBillsPage() {
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</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>
@@ -3,7 +3,10 @@ import {
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type CategoryNode = {
id: string;
@@ -38,7 +41,9 @@ export default function StoreCategoriesPage() {
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FlatRow | null>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [form] = Form.useForm();
const canDelete = (profile?.permissionKeys ?? []).includes('store_categories_delete');
const rows = useMemo(() => flattenTree(tree), [tree]);
const rootOptions = useMemo(
@@ -60,6 +65,7 @@ export default function StoreCategoriesPage() {
useEffect(() => {
void reload();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
function openCreate(parentId?: string) {
@@ -112,7 +118,7 @@ export default function StoreCategoriesPage() {
void reload();
}
const columns: ColumnsType<FlatRow> = [
const baseColumns: ColumnsType<FlatRow> = [
{
title: '层级',
dataIndex: 'level',
@@ -125,7 +131,7 @@ export default function StoreCategoriesPage() {
render: (name, row) => (
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
{name}
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</span>
),
},
@@ -141,45 +147,56 @@ export default function StoreCategoriesPage() {
},
{
title: '操作',
width: 220,
width: canDelete ? 220 : 160,
render: (_, row) => (
<Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button>
{row.level === 1 ? (
<Button type="link" size="small" onClick={() => openCreate(row.id)}></Button>
) : null}
<Popconfirm
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
onConfirm={async () => {
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
message.success('已处理');
void reload();
}}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
{canDelete ? (
<Popconfirm
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
onConfirm={async () => {
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
message.success('已处理');
void reload();
}}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-categories', baseColumns, {
page: 1,
pageSize: rows.length || 20,
});
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Typography.Text type="secondary">/宿/ </Typography.Text>
</Space>
<Space>
<Button
onClick={async () => {
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
message.success('已同步默认分类');
void reload();
}}
>
</Button>
{canDelete ? (
<Button
onClick={async () => {
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
message.success('已同步默认分类');
void reload();
}}
>
</Button>
) : null}
<Button type="primary" onClick={() => openCreate()}></Button>
</Space>
</Space>
+29 -6
View File
@@ -12,6 +12,9 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -80,14 +83,14 @@ export default function StoreLogsPage() {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '门店', width: 180, ellipsis: true,
title: '门店', width: 180,
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
},
{
title: '账号', width: 150, ellipsis: true,
title: '账号', width: 150,
render: (_, r) => (
<AdminCellLine
primary={r.accountName}
@@ -101,7 +104,23 @@ export default function StoreLogsPage() {
<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,
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
@@ -111,7 +130,7 @@ export default function StoreLogsPage() {
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要', ellipsis: true,
title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson),
},
{
@@ -127,9 +146,13 @@ export default function StoreLogsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-stores', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
/
</Typography.Paragraph>
@@ -164,7 +187,7 @@ export default function StoreLogsPage() {
<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>
<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); } }} />
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
+26 -4
View File
@@ -7,6 +7,9 @@ import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
@@ -40,8 +43,23 @@ export default function StoreMediaPage() {
setStores(res.items);
}
const columns: ColumnsType<Row> = [
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
const baseColumns: ColumnsType<Row> = [
{
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: 'url', width: 100,
@@ -49,7 +67,7 @@ export default function StoreMediaPage() {
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
: <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: 'createdAt', width: 160, render: fmtTime },
{
@@ -73,10 +91,14 @@ export default function StoreMediaPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-media', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
@@ -86,7 +108,7 @@ export default function StoreMediaPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</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); } }} />
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
@@ -32,6 +32,8 @@ import { request, type Paginated } from '../lib/api';
import { notifyPackageAuditChanged } from '../lib/admin-events';
import { fmtTime } from '../lib/constants';
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
PENDING: '待审核',
@@ -55,10 +57,12 @@ function InfoChangeImageDiff({
field,
live,
proposed,
imageSize = 72,
}: {
field: string;
live: unknown;
proposed: unknown;
imageSize?: number;
}) {
const liveUrls =
field === 'coverUrl'
@@ -83,7 +87,7 @@ function InfoChangeImageDiff({
<Image.PreviewGroup>
<Space wrap size={8}>
{liveUrls.map((url) => (
<Image key={`live-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -99,7 +103,7 @@ function InfoChangeImageDiff({
<Image.PreviewGroup>
<Space wrap size={8}>
{proposedUrls.map((url) => (
<Image key={`new-${url}`} src={url} width={72} height={72} style={{ objectFit: 'cover', borderRadius: 4 }} />
<Image key={`new-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
))}
</Space>
</Image.PreviewGroup>
@@ -128,6 +132,7 @@ function InfoChangeAuditPanel({
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const [activeId, setActiveId] = useState<string | null>(null);
const [infoFullscreen, setInfoFullscreen] = useState(false);
async function reload(nextPage = page, nextStatus = status) {
setLoading(true);
@@ -195,7 +200,15 @@ function InfoChangeAuditPanel({
}
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
{
title: '门店',
dataIndex: 'storeName',
render: (_, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
{row.storeName || row.storeId}
</AdminPrimaryLink>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -286,25 +299,33 @@ function InfoChangeAuditPanel({
<Drawer
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
width={680}
width={infoFullscreen ? '100%' : 680}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onClose={() => {
setDetailOpen(false);
setInfoFullscreen(false);
}}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</Space>
) : null
<Space>
<Button onClick={() => setInfoFullscreen((v) => !v)}>
{infoFullscreen ? '退出全屏' : '全屏查看'}
</Button>
{detail?.status === 'PENDING' ? (
<>
<Button onClick={() => void audit(detail.id, 'APPROVE')}></Button>
<Button
danger
onClick={() => {
setActiveId(detail.id);
setRejectReason('');
setRejectOpen(true);
}}
>
</Button>
</>
) : null}
</Space>
}
>
{detailLoading ? (
@@ -328,7 +349,12 @@ function InfoChangeAuditPanel({
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
>
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
<InfoChangeImageDiff field={d.field} live={d.live} proposed={d.proposed} />
<InfoChangeImageDiff
field={d.field}
live={d.live}
proposed={d.proposed}
imageSize={infoFullscreen ? 160 : 72}
/>
) : (
<span>
<Typography.Text delete type="secondary">
@@ -388,6 +414,7 @@ export default function StorePackageAuditsPage() {
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);
@@ -452,8 +479,16 @@ export default function StorePackageAuditsPage() {
}
}
const columns: ColumnsType<StorePackageChangeRequestDto> = [
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
{
title: '门店',
dataIndex: 'storeName',
render: (_, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
{row.storeName || row.storeId}
</AdminPrimaryLink>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -502,9 +537,16 @@ export default function StorePackageAuditsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-package-audits', baseColumns, {
page,
pageSize: 20,
});
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
@@ -554,14 +596,23 @@ export default function StorePackageAuditsPage() {
<Drawer
title={drawerTitle}
width={880}
width={packageFullscreen ? '100%' : 880}
open={detailOpen}
onClose={() => setDetailOpen(false)}
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);
+30 -4
View File
@@ -1,9 +1,12 @@
import { useState } from 'react';
import { Button, Form, Input, Table, Typography } from 'antd';
import { useEffect, useState } from 'react';
import { Button, Form, Input, Select, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
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 { request, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
type Row = {
id: string;
@@ -20,22 +23,30 @@ export default function StoreRatingsPage() {
const [searchParams] = useSearchParams();
const initialStoreId = searchParams.get('storeId') || '';
const [form] = Form.useForm();
const [cities, setCities] = useState<{ id: string; name: string }[]>([]);
const [filters, setFilters] = useState<Record<string, string>>({
...(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>(
'/admin/store-ratings',
() => {
const qs = new URLSearchParams();
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
return qs;
},
[filters],
);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{
title: '门店',
@@ -53,11 +64,15 @@ export default function StoreRatingsPage() {
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-ratings', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
{settingsButton}
<Form
form={form}
layout="inline"
@@ -66,11 +81,22 @@ export default function StoreRatingsPage() {
onFinish={(v) => {
setFilters({
storeId: v.storeId || '',
cityId: v.cityId || '',
redeemNo: v.redeemNo || '',
});
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">
<Input allowClear placeholder="storeId" />
</Form.Item>
@@ -20,6 +20,9 @@ import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -147,14 +150,14 @@ export default function StoreWithdrawalsPage() {
});
}
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{
title: '提现单号',
dataIndex: 'withdrawNo',
width: 180,
render: (v, row) => (
<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}
</Space>
),
@@ -229,11 +232,15 @@ export default function StoreWithdrawalsPage() {
}
| undefined;
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-withdrawals', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
{settingsButton}
{overdueSummary ? (
<Typography.Paragraph type="secondary">
{overdueSummary.pendingCount}
@@ -299,7 +306,7 @@ export default function StoreWithdrawalsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
pagination={{
current: page,
+47 -23
View File
@@ -39,6 +39,8 @@ import {
type StoreCreateForm,
} from '../lib/storeCreate';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { resolveRegionBinding } from '../lib/china-region';
import ChinaRegionCascader from '../components/ChinaRegionCascader';
import OssUpload from '../components/OssUpload';
@@ -198,6 +200,9 @@ type StoreRow = {
pendingPackageAuditId?: string | null;
/** 该门店当前待审核信息变更的 requestId(无则为空) */
pendingInfoChangeId?: string | null;
/** 累计核销好客权益(券面额合计) */
totalRedeemedBenefitAmount?: number;
contactPhone?: string | null;
isTest?: boolean;
cityRef?: { name: string; code: string };
partner?: { id?: string; companyName?: string | null; name?: string | null; phone?: string | null };
@@ -720,47 +725,60 @@ export default function StoresPage() {
}
}
const columns: ColumnsType<StoreRow> = [
const baseColumns: ColumnsType<StoreRow> = [
{
key: 'coverUrl',
title: '封面', dataIndex: 'coverUrl', width: 72,
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
},
{
key: 'name',
title: '门店名',
dataIndex: 'name',
width: 180,
ellipsis: { showTitle: false },
render: (v: string, row) => {
const name = v || '—';
return (
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
{name}
</Typography.Text>
<Space size={4} wrap={false}>
<AdminPrimaryLink onClick={() => void openStoreDetail(row)}>{name === '—' ? '' : name}</AdminPrimaryLink>
{row.isTest ? <Tag color="orange"></Tag> : null}
</Space>
);
},
},
{
key: 'category',
title: '分类',
width: 100,
ellipsis: true,
render: (_, row) => row.category?.name || '—',
},
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
{ title: '登录号', dataIndex: 'phone', width: 120 },
{ key: 'cityName', title: '城市', dataIndex: 'cityName', width: 80 },
{ key: 'phone', title: '登录号', dataIndex: 'phone', width: 120 },
{
key: 'contactPhone',
title: '联系电话',
dataIndex: 'contactPhone',
width: 120,
render: (v: string | null | undefined, row) => v || row.phone,
},
{
key: 'status',
title: '营业状态', dataIndex: 'status', width: 90,
render: (s) => <Tag>{STORE_STATUS_LABELS[s] || s}</Tag>,
},
{
key: 'totalRedeemedBenefitAmount',
title: '累计核销好客权益',
dataIndex: 'totalRedeemedBenefitAmount',
width: 150,
align: 'right',
render: (v: number | undefined) => {
const n = Number(v ?? 0);
return n > 0 ? `¥${n.toFixed(2)}` : '—';
},
},
{
key: 'auditStatus',
title: '审核', dataIndex: 'auditStatus', width: 100,
render: (s, row) => {
const status = s || 'APPROVED';
@@ -769,7 +787,7 @@ export default function StoresPage() {
<Space direction="vertical" size={0} style={{ maxWidth: '100%' }}>
<Tag color={color}>{STORE_AUDIT_STATUS_LABELS[status] || status}</Tag>
{status === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason}
</Typography.Text>
) : null}
@@ -778,32 +796,29 @@ export default function StoresPage() {
},
},
{
key: 'partner',
title: '开城合伙人',
dataIndex: 'partner',
width: 140,
ellipsis: { showTitle: false },
render: (partner: StoreRow['partner']) => {
if (!partner) return '—';
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
return (
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
{label}
</Typography.Text>
);
return partnerOptionLabel({ id: partner.id ?? '', ...partner });
},
},
{
key: 'visibilityWhitelistEnabled',
title: '可见',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v) =>
v ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
{ title: '介绍', dataIndex: 'intro', width: 160, ellipsis: true, render: (v) => v || '—' },
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '店长', dataIndex: ['account', 'name'], width: 90, ellipsis: true, render: (v) => v || '—' },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ key: 'intro', title: '介绍', dataIndex: 'intro', width: 160, render: (v) => v || '—' },
{ key: 'sortOrder', title: '排序', dataIndex: 'sortOrder', width: 70 },
{ key: 'accountName', title: '店长', dataIndex: ['account', 'name'], width: 90, render: (v) => v || '—' },
{ key: 'createdAt', title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
key: 'actions',
title: '操作',
width: 280,
fixed: 'right',
@@ -850,14 +865,23 @@ export default function StoresPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('stores', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"> {data?.total ?? 0} </Typography.Text>
</Space>
<Button type="primary" onClick={openCreateModal}></Button>
<Space>
{settingsButton}
<Button type="primary" onClick={openCreateModal}></Button>
</Space>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
@@ -918,7 +942,7 @@ export default function StoresPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1720 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
@@ -46,6 +46,9 @@ import { request, type HqProfile } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
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 DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
@@ -218,8 +221,13 @@ export default function SupportTicketsPage() {
setReviewOpen(false);
const summary = [detail!.title, detail!.content].filter(Boolean).join('\n').slice(0, 500);
const ticketUrls = (detail!.attachmentUrls ?? []).filter(Boolean);
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',
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
});
@@ -364,7 +372,11 @@ export default function SupportTicketsPage() {
body: JSON.stringify({
decision: 'APPROVE',
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,
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
}),
@@ -461,7 +473,7 @@ export default function SupportTicketsPage() {
}
}
const columns: ColumnsType<TicketRow> = [
const baseColumns: ColumnsType<TicketRow> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
{
title: '类型',
@@ -494,7 +506,13 @@ export default function SupportTicketsPage() {
</Space>
),
},
{ title: '标题', dataIndex: 'title', ellipsis: true },
{
title: '标题',
dataIndex: 'title',
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(String(row.id))}>{v}</AdminPrimaryLink>
),
},
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
@@ -537,8 +555,11 @@ export default function SupportTicketsPage() {
return null;
})();
const { columns, settingsButton, settingsModal } = useAdminListColumns('support-tickets', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<div
style={{
display: 'flex',
@@ -550,6 +571,7 @@ export default function SupportTicketsPage() {
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Space>
{isSuperAdmin ? (
<>
@@ -610,7 +632,7 @@ export default function SupportTicketsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 900 }}
scroll={{ x: 'max-content' }}
rowSelection={
isSuperAdmin
? {
+28 -5
View File
@@ -20,6 +20,8 @@ import {
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 { AdminPrimaryLink } from '../components/AdminPrimaryLink';
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
@@ -253,11 +255,25 @@ export default function TestWhitelistPage() {
}
const phoneColumns: ColumnsType<PhoneRow> = [
{ title: '手机号', dataIndex: 'phone', width: 140 },
{
title: '手机号',
dataIndex: 'phone',
width: 140,
render: (v, row) => (
<AdminPrimaryLink
onClick={() => {
setEditRow(row);
editForm.setFieldsValue({ note: row.note ?? '' });
setEditOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '备注',
dataIndex: 'note',
ellipsis: true,
render: (v) => v || '—',
},
{
@@ -334,7 +350,7 @@ export default function TestWhitelistPage() {
if (accountType === 'partner') {
return [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
{ title: '公司', dataIndex: 'companyName', render: (v) => (v as string) || '—' },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '标记',
@@ -347,7 +363,7 @@ export default function TestWhitelistPage() {
}
if (accountType === 'store') {
return [
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '门店名', dataIndex: 'name', width: 160 },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
@@ -386,11 +402,18 @@ export default function TestWhitelistPage() {
];
}
const { columns, settingsButton, settingsModal } = useAdminListColumns('test-whitelist', phoneColumns, {
page: phonePage,
pageSize: phonePageSize,
});
return (
<div>
{settingsModal}
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
{settingsButton}
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
@@ -472,7 +495,7 @@ export default function TestWhitelistPage() {
<Table
rowKey="id"
loading={phonesLoading}
columns={phoneColumns}
columns={columns}
dataSource={phones?.items ?? []}
pagination={{
current: phonePage,
@@ -4,6 +4,9 @@ import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -69,7 +72,7 @@ export default function ThirdPartyLogsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ title: 'Provider', dataIndex: 'provider', width: 120 },
{ title: '场景', dataIndex: 'scene', width: 120 },
@@ -87,15 +90,27 @@ export default function ThirdPartyLogsPage() {
{
title: '错误信息',
dataIndex: 'errorMessage',
ellipsis: true,
render: (v: string | null | undefined) => v || '—',
},
{
title: '请求摘要',
ellipsis: true,
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: '操作',
width: 80,
@@ -114,9 +129,16 @@ export default function ThirdPartyLogsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-third-party', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Form
layout="inline"
style={{ marginBottom: 16 }}
@@ -143,7 +165,7 @@ export default function ThirdPartyLogsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+25 -4
View File
@@ -25,6 +25,9 @@ import {
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -115,8 +118,22 @@ export default function TicketsPage() {
}
}
const columns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
const baseColumns: ColumnsType<Row> = [
{
title: '工单号',
dataIndex: 'ticketNo',
width: 180,
render: (v, row) => (
<AdminPrimaryLink
onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
{v}
</AdminPrimaryLink>
),
},
{
title: '类型',
dataIndex: 'ticketType',
@@ -132,7 +149,7 @@ export default function TicketsPage() {
),
},
{ 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: '操作',
@@ -154,8 +171,11 @@ export default function TicketsPage() {
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
const { columns, settingsButton, settingsModal } = useAdminListColumns('tickets', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<div
style={{
display: 'flex',
@@ -167,6 +187,7 @@ export default function TicketsPage() {
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
@@ -205,7 +226,7 @@ export default function TicketsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 900 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+26 -5
View File
@@ -7,6 +7,9 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -61,10 +64,10 @@ export default function UserLogsPage() {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
const baseColumns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '用户', width: 180, ellipsis: true,
title: '用户', width: 180,
render: (_, r) => (
<AdminCellLine
primary={r.nickname || r.userNo}
@@ -78,13 +81,27 @@ export default function UserLogsPage() {
<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,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要', ellipsis: true,
title: '摘要',
render: (_, r) => summarizeExtra(r.extraJson),
},
{
@@ -98,9 +115,13 @@ export default function UserLogsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-users', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Segmented
style={{ marginBottom: 16 }}
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 onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</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); } }} />
<Drawer title="日志详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
+114 -8
View File
@@ -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 {
Alert,
@@ -19,6 +19,8 @@ import {
import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, resolveUserLogCategory, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = {
@@ -30,6 +32,77 @@ type UserOrderRow = {
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);
@@ -244,7 +317,7 @@ export default function UsersPage() {
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
];
const columns: ColumnsType<AdminUserRow> = [
const baseColumns: ColumnsType<AdminUserRow> = [
{
title: '用户编号',
dataIndex: 'userNo',
@@ -256,12 +329,38 @@ export default function UsersPage() {
</Space>
),
},
{ title: '昵称', dataIndex: 'nickname', width: 100 },
{
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: '手机',
dataIndex: 'phone',
width: 120,
render: (v) => maskPhone(v),
render: (v) => v || '—',
},
{
title: '验手机',
@@ -289,7 +388,6 @@ export default function UsersPage() {
title: '来源 ID',
dataIndex: 'sourceRefId',
width: 100,
ellipsis: true,
render: (v, row) => {
if (!v) return '—';
if (row.sourceType === 'PROMO_CODE') {
@@ -306,13 +404,11 @@ export default function UsersPage() {
title: '来源标签',
dataIndex: 'sourceLabel',
width: 120,
ellipsis: true,
render: (v) => v || '—',
},
{
title: 'deviceKey',
dataIndex: 'deviceKey',
ellipsis: true,
render: (v) => v || '—',
},
{
@@ -377,10 +473,18 @@ export default function UsersPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('users', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Space>
{settingsButton}
{canDeleteUsers ? (
<Button
danger
@@ -390,6 +494,7 @@ export default function UsersPage() {
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号">
@@ -429,7 +534,7 @@ export default function UsersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1830 }}
scroll={{ x: 'max-content' }}
rowSelection={canDeleteUsers ? {
selectedRowKeys,
preserveSelectedRowKeys: true,
@@ -462,6 +567,7 @@ export default function UsersPage() {
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户编号">{detail.userNo}</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="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
@@ -7,6 +7,9 @@ import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime, maskPhone } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
@@ -90,12 +93,11 @@ export default function WechatBindingsPage() {
setDrawerOpen(true);
}
const columns: ColumnsType<GroupRow> = [
const baseColumns: ColumnsType<GroupRow> = [
{
title: 'unionId',
dataIndex: 'unionId',
width: 180,
ellipsis: true,
render: (v) => v || <Tag> unionId</Tag>,
},
{
@@ -119,11 +121,12 @@ export default function WechatBindingsPage() {
title: '手机号',
dataIndex: 'primaryPhone',
width: 140,
render: (v) => maskPhone(v),
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
),
},
{
title: '身份摘要',
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
@@ -160,7 +163,6 @@ export default function WechatBindingsPage() {
},
{
title: '账号',
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.name || '—'}
@@ -172,14 +174,12 @@ export default function WechatBindingsPage() {
title: '归属',
dataIndex: 'refLabel',
width: 160,
ellipsis: true,
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
},
{
title: 'wxOpenId',
dataIndex: 'wxOpenId',
width: 160,
ellipsis: true,
},
{
title: '手机验证',
@@ -205,9 +205,13 @@ export default function WechatBindingsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary">
unionId C HQ unionId
</Typography.Paragraph>
+21 -7
View File
@@ -5,6 +5,9 @@ import { useAdminList } from '../lib/useAdminList';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import type { WecomBotLogDto } from '@dukang/shared-types';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
export default function WecomBotLogsPage() {
const [form] = Form.useForm();
@@ -24,12 +27,19 @@ export default function WecomBotLogsPage() {
[filters],
);
const columns: ColumnsType<WecomBotLogDto> = [
const baseColumns: ColumnsType<WecomBotLogDto> = [
{ 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: '动作', dataIndex: 'action', width: 160, ellipsis: true },
{ title: '权限', dataIndex: 'permission', width: 140, ellipsis: true, render: (v) => v || '—' },
{
title: '机器人',
dataIndex: 'botName',
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: '结果',
dataIndex: 'success',
@@ -37,7 +47,7 @@ export default function WecomBotLogsPage() {
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '成功' : '失败'}</Tag>,
},
{ title: '耗时(ms)', dataIndex: 'latencyMs', width: 90, render: (v) => v ?? '—' },
{ title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true },
{ title: '输入摘要', dataIndex: 'inputSummary' },
{
title: '操作',
width: 80,
@@ -49,9 +59,13 @@ export default function WecomBotLogsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-wecom-bots', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<Typography.Title level={4}></Typography.Title>
{settingsButton}
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
@@ -96,7 +110,7 @@ export default function WecomBotLogsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+21 -5
View File
@@ -36,6 +36,9 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
name: string;
@@ -280,7 +283,7 @@ export default function WecomBotsPage() {
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
const columns: ColumnsType<WecomBotDto> = [
const baseColumns: ColumnsType<WecomBotDto> = [
{
title: '头像',
dataIndex: 'avatarUrl',
@@ -291,18 +294,24 @@ export default function WecomBotsPage() {
</Avatar>
),
},
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
{
title: '名称',
dataIndex: 'name',
width: 140,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
),
},
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
},
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
{ title: 'BotID', dataIndex: 'botId', width: 160 },
{
title: '权限',
dataIndex: 'permissions',
ellipsis: true,
render: (perms: WecomBotPermission[]) =>
perms.map((p) => (
<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 (
<div>
{settingsModal}
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
BotID / Secret / / /
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
@@ -428,7 +444,7 @@ export default function WecomBotsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
@@ -34,6 +34,9 @@ import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type FormValues = {
name: string;
@@ -193,21 +196,20 @@ function PushRoutesTab() {
}
}
const columns: ColumnsType<WecomMessagePushDto> = [
const baseColumns: ColumnsType<WecomMessagePushDto> = [
{
title: '名称',
dataIndex: 'name',
render: (name: string, row) => (
<Space>
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
<span>{name}</span>
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
</Space>
),
},
{
title: 'Webhook',
dataIndex: 'webhookUrlMasked',
ellipsis: true,
},
{
title: '推送条件',
@@ -262,8 +264,14 @@ function PushRoutesTab() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-pushes', baseColumns, {
page,
pageSize,
});
return (
<>
{settingsModal}
<Typography.Paragraph type="secondary">
Webhook / .env Webhook URL
</Typography.Paragraph>
@@ -304,6 +312,7 @@ function PushRoutesTab() {
<Button type="primary" onClick={openCreate}>
</Button>
{settingsButton}
</Space>
</Form.Item>
</Form>
+67 -6
View File
@@ -26,6 +26,9 @@ import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request, type HqProfile } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type Row = {
id: string;
@@ -37,6 +40,13 @@ type Row = {
wineryAmount: number;
status: string;
paidAt?: string | null;
paymentRef?: string | null;
wineryBank?: {
bankAccountName?: string | null;
bankName?: string | null;
bankBranch?: string | null;
bankAccountNo?: string | null;
};
};
type BillItem = {
@@ -127,14 +137,30 @@ export default function WineryBillsPage() {
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
function confirmPay(ids: string[], amountHint?: number) {
let paymentRef = '';
Modal.confirm({
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: '确认打款',
cancelText: '取消',
onOk: async () => {
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
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 {
await request('/admin/winery-bills/batch-confirm', {
method: 'POST',
@@ -212,8 +238,15 @@ export default function WineryBillsPage() {
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
const baseColumns: ColumnsType<Row> = [
{
title: '账单号',
dataIndex: 'billNo',
width: 170,
render: (v, row) => (
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
),
},
{
title: '账单日',
dataIndex: 'billDate',
@@ -267,8 +300,11 @@ export default function WineryBillsPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
return (
<div>
{settingsModal}
<div
style={{
display: 'flex',
@@ -282,6 +318,7 @@ export default function WineryBillsPage() {
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
{settingsButton}
<Typography.Text type="secondary">
T+3 8:00 3 / × {ratePct}%绿 0
@@ -375,7 +412,7 @@ export default function WineryBillsPage() {
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
}}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
@@ -399,7 +436,31 @@ export default function WineryBillsPage() {
{displayWineryStatus(detail.status, detail.wineryAmount).label}
</Descriptions.Item>
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</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>
@@ -409,7 +470,7 @@ export default function WineryBillsPage() {
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
{ title: '订单号', dataIndex: 'orderNo' },
{
title: '配送',
dataIndex: 'deliveryType',
@@ -9,6 +9,9 @@ import {
import { fmtTime } from '../../lib/constants';
import { useAdminList } from '../../lib/useAdminList';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
import { useAdminListColumns } from '../../lib/useAdminListColumns';
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
export default function PromoCodeUsersPage() {
const { id = '' } = useParams();
@@ -21,9 +24,18 @@ export default function PromoCodeUsersPage() {
[id],
);
const columns: ColumnsType<PromoCodeAttributedUser> = [
const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
{
title: '昵称',
dataIndex: 'nickname',
width: 100,
render: (v, row) => (
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
{v}
</AdminPrimaryLink>
),
},
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
{
title: '验手机',
@@ -69,6 +81,8 @@ export default function PromoCodeUsersPage() {
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
return (
<Table
rowKey="id"
@@ -76,7 +90,7 @@ export default function PromoCodeUsersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
+1 -1
View File
@@ -463,7 +463,7 @@ export default function HomePage() {
<div className="shop-home-stat">
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value">
+1 -3
View File
@@ -196,9 +196,7 @@ export default function RecordsPage() {
</div>
<p className="shop-record-time">
:{' '}
{new Date(String(r.createdAt))
.toLocaleString('zh-CN', { hour12: false })
.slice(0, 16)}
{new Date(String(r.createdAt)).toLocaleString('zh-CN', { hour12: false })}
</p>
<p className="shop-record-channel">
<span
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@dukang/mini-user",
"version": "3.4.15",
"version": "3.5.4",
"private": true,
"description": "杜康好客 · C 端用户微信小程序(Taro)",
"scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+1 -1
View File
@@ -2,7 +2,7 @@ import Taro from '@tarojs/taro';
import { fetchClientConfig } from './pay-wechat';
/** 与 package.json version 同步,供服务端 minClientVersion 比对 */
export const APP_VERSION = '3.4.15';
export const APP_VERSION = '3.5.4';
export const APP_VERSION_LABEL = `v${APP_VERSION}`;
@@ -7,9 +7,9 @@ import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { usePageView } from '../../lib/usePageView';
import {
INVOICE_KIND_LABELS,
INVOICE_CATEGORY_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceCategory,
type UserInvoiceTitleDto,
} from '@dukang/shared-types';
@@ -42,7 +42,8 @@ export default function InvoiceApplyPage() {
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
const [selectedId, setSelectedId] = useState('');
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
const [invoiceCategory, setInvoiceCategory] = useState<InvoiceCategory>('LIQUOR');
const [remark, setRemark] = useState('');
const [emailOverride, setEmailOverride] = useState('');
const [phoneOverride, setPhoneOverride] = useState('');
const [loading, setLoading] = useState(true);
@@ -103,19 +104,17 @@ export default function InvoiceApplyPage() {
toast('请填写联系电话');
return;
}
if (invoiceKind === 'SPECIAL' && selected.titleType !== 'ENTERPRISE') {
toast('专用发票仅支持企业抬头');
return;
}
setSubmitting(true);
try {
await request(`/trade/orders/${orderId}/invoices`, {
method: 'POST',
data: {
titleId: selectedId,
invoiceKind,
invoiceKind: 'NORMAL',
invoiceCategory,
email,
phone,
remark: remark.trim() || undefined,
},
});
toast('发票申请已提交', 'success');
@@ -192,20 +191,26 @@ export default function InvoiceApplyPage() {
) : null}
{canApply ? (
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<View className="invoice-title-type-row">
{(['NORMAL', 'SPECIAL'] as InvoiceKind[]).map((k) => (
<Text
key={k}
className={`invoice-title-type-chip${invoiceKind === k ? ' active' : ''}`}
onClick={() => setInvoiceKind(k)}
>
{INVOICE_KIND_LABELS[k]}
</Text>
))}
<>
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<Text className="invoice-kind-static"></Text>
</View>
</View>
<View className="invoice-title-field" style={{ marginBottom: 16 }}>
<Text className="invoice-title-label"></Text>
<View className="invoice-title-type-row">
{(['LIQUOR', 'CATERING'] as InvoiceCategory[]).map((k) => (
<Text
key={k}
className={`invoice-title-type-chip${invoiceCategory === k ? ' active' : ''}`}
onClick={() => setInvoiceCategory(k)}
>
{INVOICE_CATEGORY_LABELS[k]}
</Text>
))}
</View>
</View>
</>
) : null}
{canApply ? (
@@ -258,7 +263,11 @@ export default function InvoiceApplyPage() {
{canApply && selected && !selected.email ? (
<View className="invoice-title-field" style={{ marginTop: 16 }}>
<Text className="invoice-title-label"></Text>
<Text className="invoice-title-label">
<Text className="invoice-title-star">*</Text>
<Text className="invoice-title-label-hint"></Text>
</Text>
<Input
className="invoice-title-input"
placeholder="电子发票将发送至此邮箱"
@@ -279,6 +288,19 @@ export default function InvoiceApplyPage() {
/>
</View>
) : null}
{canApply ? (
<View className="invoice-title-field" style={{ marginTop: 16 }}>
<Text className="invoice-title-label"></Text>
<Input
className="invoice-title-input"
maxlength={200}
placeholder="选填,将显示在发票申请备注中"
value={remark}
onInput={(e) => setRemark(e.detail.value)}
/>
</View>
) : null}
</View>
{canApply && titles.length > 0 ? (
@@ -26,6 +26,24 @@ const ACTION_ICONS = {
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
function FieldLabel({
children,
required,
hint,
}: {
children: string;
required?: boolean;
hint?: string;
}) {
return (
<Text className="invoice-title-label">
{required ? <Text className="invoice-title-star">*</Text> : null}
{children}
{hint ? <Text className="invoice-title-label-hint">{hint}</Text> : null}
</Text>
);
}
function TitleActionIcon({
kind,
label,
@@ -120,6 +138,15 @@ export default function InvoiceTitlesPage() {
toast('企业抬头须填写税号');
return;
}
const email = draft.email?.trim() || '';
if (!email) {
toast('请填写接收邮箱(必填)');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
toast('邮箱格式不正确');
return;
}
setSaving(true);
try {
const payload: UpsertInvoiceTitleRequest = {
@@ -152,6 +179,10 @@ export default function InvoiceTitlesPage() {
}
async function setDefault(t: UserInvoiceTitleDto) {
if (!t.email?.trim()) {
toast('请先补全接收邮箱后再设为默认');
return;
}
try {
await request(`/trade/invoice-titles/${t.id}`, {
method: 'PUT',
@@ -241,7 +272,7 @@ export default function InvoiceTitlesPage() {
</Text>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<View className="invoice-title-type-row">
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
<Text
@@ -258,7 +289,7 @@ export default function InvoiceTitlesPage() {
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<Input
className="invoice-title-input"
maxlength={128}
@@ -270,7 +301,7 @@ export default function InvoiceTitlesPage() {
{draft.titleType === 'ENTERPRISE' ? (
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required></FieldLabel>
<Input
className="invoice-title-input"
maxlength={32}
@@ -282,11 +313,13 @@ export default function InvoiceTitlesPage() {
) : null}
<View className="invoice-title-field">
<Text className="invoice-title-label"></Text>
<FieldLabel required hint="必填,电子发票将发送至此">
</FieldLabel>
<Input
className="invoice-title-input"
maxlength={128}
placeholder="电子发票将发送至此邮箱"
placeholder="请填写邮箱(必填)"
value={draft.email || ''}
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
/>
@@ -311,7 +344,7 @@ export default function InvoiceTitlesPage() {
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
placeholder="选填"
value={draft.addressPhone || ''}
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
/>
@@ -321,7 +354,7 @@ export default function InvoiceTitlesPage() {
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
placeholder="选填"
value={draft.bankAccount || ''}
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
/>
+3 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, Image } from '@tarojs/components';
import '../../styles/order.css';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
@@ -19,13 +19,13 @@ import { applyWechatLoginResult } from '../../lib/wechat-auth';
import { isWechatEnv } from '../../lib/weixin';
import { goLogin } from '../../lib/auth-nav';
import { request, toast } from '../../lib/api';
import payLogo from '../../assets/logo2.png';
export default function PayPage() {
const router = useRouter();
const orderId = router.params.orderId ?? '';
const [loading, setLoading] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [mockMode, setMockMode] = useState(true);
const [needsWechatAuth, setNeedsWechatAuth] = useState(false);
const [msg, setMsg] = useState('');
const [orderNo, setOrderNo] = useState('');
@@ -39,7 +39,6 @@ export default function PayPage() {
const refreshPayReadiness = useCallback(async () => {
try {
const [config, profile] = await Promise.all([fetchClientConfig(), fetchUserProfile()]);
setMockMode(config.mockPay);
setNeedsWechatAuth(needsWechatAuthForPay(config, profile));
return profile;
} catch {
@@ -174,7 +173,7 @@ export default function PayPage() {
<View className="sub-page-body">
<View className="pay-status">
<View className="pay-status-icon">
<Text>¥</Text>
<Image className="pay-status-brand" src={payLogo} mode="aspectFit" />
</View>
<Text className="pay-status-title">
{needsWechatAuth ? '需完成微信授权' : '待支付'}
@@ -204,12 +203,6 @@ export default function PayPage() {
<Text className="order-row-label"></Text>
<Text className="order-row-value"></Text>
</View>
<View className="order-row">
<Text className="order-row-label"></Text>
<Text className="order-row-value">
{mockMode ? 'Mock 模式由服务端直接标记已付款' : '将调起微信收银台'}
</Text>
</View>
</View>
{msg ? (
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
@@ -165,6 +165,12 @@ export default function ProductDetailPage() {
allowOnSitePickup: activeSku.allowOnSitePickup,
}
: product;
const carouselImages = (() => {
const base = getProductCarouselImages(product);
const skuImg = activeSku?.imageUrl?.trim();
if (!skuImg) return base;
return [skuImg, ...base.filter((url) => url !== skuImg)];
})();
const sharePayload = useMemo(
() =>
@@ -172,9 +178,9 @@ export default function ProductDetailPage() {
path: `/pages/product-detail/index?id=${productId}`,
dynamicTitle: product?.name,
dynamicDesc: product?.subtitle,
dynamicImageUrl: product ? getProductMainImage(product) : undefined,
dynamicImageUrl: (activeSku?.imageUrl?.trim() || (product ? getProductMainImage(product) : undefined)),
}),
[product, productId],
[product, productId, activeSku],
);
useShareAppMessage(() => toWeappShareMessage(sharePayload));
@@ -249,7 +255,6 @@ export default function ProductDetailPage() {
const allowOnline = canBuyOnline(fulfillment ?? {});
const allowOnSite = canPickupOnSite(fulfillment ?? {});
const carouselImages = getProductCarouselImages(product);
const detailImages = getProductDetailImages(product);
const detail = product.detailContent ?? {};
const features = detail.features ?? [];
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import { View, Text, Image, ScrollView } from '@tarojs/components';
import '../../styles/store-detail.css';
import Taro, {
useDidShow,
@@ -444,17 +444,19 @@ export default function StoreDetailPage() {
</View>
) : null}
{intro ? (
{benefitRule ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
<Text className="store-detail-intro">{intro}</Text>
<Text className="store-detail-section-title store-detail-section-title--rule">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
</View>
) : null}
{benefitRule ? (
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title">使</Text>
<Text className="store-detail-intro">{benefitRule}</Text>
<Text className="store-detail-section-title"></Text>
<ScrollView className="store-detail-intro-scroll" scrollY showScrollbar>
<Text className="store-detail-intro">{intro}</Text>
</ScrollView>
</View>
) : null}
+24
View File
@@ -153,6 +153,30 @@
margin-bottom: 6px;
}
.invoice-title-star {
color: #c62828;
margin-right: 2px;
font-weight: 600;
}
.invoice-title-label-hint {
margin-left: 6px;
font-size: 12px;
color: #c62828;
font-weight: 500;
}
.invoice-kind-static {
display: block;
font-size: 15px;
color: var(--color-on-surface, #1a1a1a);
line-height: 44px;
padding: 0 12px;
background: var(--color-surface, #fafafa);
border: 1px solid var(--color-outline, #ddd);
border-radius: 8px;
}
.invoice-title-input {
width: 100%;
height: 44px;
+9 -5
View File
@@ -488,16 +488,20 @@
}
.pay-status-icon {
width: 72px;
height: 72px;
width: 88px;
height: 88px;
border-radius: 50%;
background: rgba(166, 29, 36, 0.08);
color: var(--color-heritage-red);
font-size: 36px;
background: #f7f4ee;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
overflow: hidden;
}
.pay-status-brand {
width: 72px;
height: 72px;
}
.pay-status-title {
+2 -1
View File
@@ -130,6 +130,7 @@
.product-detail-promo {
position: relative;
margin-top: 8px;
padding: 16px;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, #fff9e6 0%, #fff0c2 100%);
@@ -377,7 +378,7 @@
}
.product-detail-specs {
margin: 12px 0 4px;
margin: 16px 0 28px;
display: flex;
flex-direction: column;
gap: 12px;
@@ -211,6 +211,17 @@
color: var(--color-on-surface);
}
.store-detail-section-title--rule {
color: var(--color-heritage-red, #a61d24);
}
.store-detail-intro-scroll {
max-height: 168px;
height: 168px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.store-detail-env-grid {
display: flex;
flex-direction: column;
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const crypto = require('crypto');
const path = process.argv[2] || '/opt/dukang/server/dukang-api/.env.production';
function stripQuotes(s) {
const t = s.trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1);
}
return t;
}
function parseEnvValue(text, key) {
const re = new RegExp(`^${key}=(.*)$`, 'm');
const m = text.match(re);
if (!m) return null;
return stripQuotes(m[1]);
}
function tryKey(name, key) {
const literalN = (key.match(/\\n/g) || []).length;
const realN = (key.match(/\n/g) || []).length;
const hasBegin = /BEGIN (RSA )?PRIVATE KEY/.test(key);
try {
crypto.createPrivateKey(key);
console.log(`${name}: OK begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length}`);
return true;
} catch (e) {
console.log(
`${name}: FAIL ${e.message} begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length} head=${JSON.stringify(key.slice(0, 48))}`,
);
return false;
}
}
const text = fs.readFileSync(path, 'utf8');
const raw = parseEnvValue(text, 'WX_MCH_PRIVATE_KEY');
if (!raw) {
console.log('MISSING WX_MCH_PRIVATE_KEY in', path);
process.exit(1);
}
console.log('file=', path);
console.log('MOCK_PAY=', parseEnvValue(text, 'MOCK_PAY'));
console.log('WX_MINI_APP_ID=', parseEnvValue(text, 'WX_MINI_APP_ID'));
console.log('WX_APP_ID=', parseEnvValue(text, 'WX_APP_ID'));
console.log('WX_MCH_ID=', parseEnvValue(text, 'WX_MCH_ID'));
console.log('WX_PAY_NOTIFY_URL=', parseEnvValue(text, 'WX_PAY_NOTIFY_URL'));
const variants = {
as_is: raw,
unescape_n: raw.replace(/\\n/g, '\n'),
unescape_twice: raw.replace(/\\\\n/g, '\\n').replace(/\\n/g, '\n'),
strip_cr_unesc: raw.replace(/\\n/g, '\n').replace(/\r/g, ''),
// dotenv style sometimes leaves surrounding quotes in process.env
quoted_unesc: stripQuotes(raw).replace(/\\n/g, '\n'),
};
let ok = false;
for (const [name, key] of Object.entries(variants)) {
if (tryKey(name, key)) ok = true;
}
// also simulate dotenv load
try {
const dotenv = require('dotenv');
const parsed = dotenv.parse(text);
const fromDotenv = parsed.WX_MCH_PRIVATE_KEY || '';
console.log('dotenv_raw_literalN=', (fromDotenv.match(/\\n/g) || []).length, 'realN=', (fromDotenv.match(/\n/g) || []).length);
tryKey('dotenv_as_is', fromDotenv);
tryKey('dotenv_unescape', fromDotenv.replace(/\\n/g, '\n'));
} catch (e) {
console.log('dotenv skip', e.message);
}
process.exit(ok ? 0 : 2);
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# backup-prod-db.sh — 将线上(production)数据库 mysqldump 落盘到 deploy/backups/
#
# 与 sync-prod-db-to-local.sh 共用 SSH 隧道;仅导出,不覆盖本地库。
#
# 用法:
# bash deploy/backup-prod-db.sh # 交互确认
# bash deploy/backup-prod-db.sh --yes # 跳过确认
# bash deploy/backup-prod-db.sh --dry-run # 只打印计划
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
LOCAL_CONTAINER="dukang-v1-mysql"
TUNNEL_PORT=6019
REMOTE_ENV_FILE="/opt/dukang/server/dukang-api/.env.production"
HOST_ALIAS="host.docker.internal"
ASSUME_YES=0
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--yes|-y) ASSUME_YES=1 ;;
--dry-run) DRY_RUN=1 ;;
--help|-h) sed -n '3,12p' "$0"; exit 0 ;;
--tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;;
--container=*) LOCAL_CONTAINER="${arg#*=}" ;;
--host-alias=*) HOST_ALIAS="${arg#*=}" ;;
*) echo "未知参数: $arg" >&2; exit 2 ;;
esac
done
if [[ -f deploy.env ]]; then
# shellcheck disable=SC1091
source deploy.env
fi
DEPLOY_HOST="${DEPLOY_HOST:?请在 deploy.env 配置 DEPLOY_HOST}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PORT="${DEPLOY_PORT:-22}"
SSH=(ssh -o StrictHostKeyChecking=accept-new -p "$DEPLOY_PORT")
if [[ -n "${DEPLOY_SSH_KEY:-}" ]]; then SSH+=(-i "$DEPLOY_SSH_KEY"); fi
TARGET="$DEPLOY_USER@$DEPLOY_HOST"
echo "==> 读取线上数据库配置 ($TARGET:$REMOTE_ENV_FILE)"
PARSED="$("${SSH[@]}" "$TARGET" bash -s <<'NODEEOF'
cat > /tmp/_parse_db.js <<'JSEOF'
const fs=require("fs");
const l=fs.readFileSync("/opt/dukang/server/dukang-api/.env.production","utf8").match(/^DATABASE_URL=(.*)$/m)[1].trim();
let u=l;
if((u[0]==='"'&&u[u.length-1]==='"')||(u[0]==="'"&&u[u.length-1]==="'"))u=u.slice(1,-1);
const U=new URL(u);
const b=s=>Buffer.from(s).toString("base64");
const path=U.pathname.replace(/^\//,"").split("?")[0];
process.stdout.write([b(decodeURIComponent(U.username)),b(U.hostname),U.port||"3306",b(path),b(decodeURIComponent(U.password))].join("|")+"\n");
JSEOF
node /tmp/_parse_db.js; rm -f /tmp/_parse_db.js
NODEEOF
)"
if [[ -z "$PARSED" ]]; then
echo "无法读取远端 DATABASE_URL,中止。" >&2; exit 1
fi
IFS='|' read -r _U _H _P _DB _PASS <<< "$PARSED"
REMOTE_USER="$(echo "$_U" | base64 -d)"
REMOTE_HOST="$(echo "$_H" | base64 -d)"
REMOTE_PORT="$_P"
REMOTE_DB="$(echo "$_DB" | base64 -d)"
REMOTE_PASS="$(echo "$_PASS" | base64 -d)"
mkdir -p backups
OUT="backups/prod-${REMOTE_DB}-$(date +%Y%m%d-%H%M%S).sql"
echo
echo "备份计划:"
echo " 源(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
echo " 目标文件: $OUT"
echo " 隧道: $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run) 已结束,未做任何修改。"
exit 0
fi
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
echo "本地容器 $LOCAL_CONTAINER 未运行。请先执行: cd deploy && docker compose up -d" >&2
exit 1
fi
if [[ $ASSUME_YES -ne 1 ]]; then
read -r -p "确认备份【线上 $REMOTE_DB】到 $OUT ? [y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; }
fi
CTL="/tmp/backup-prod-db-$$.sock"
echo "==> 建立 SSH 隧道"
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
ready=0
for _ in 1 2 3 4 5 6 7 8 9 10; do
if docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqladmin -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" ping >/dev/null 2>&1; then
ready=1; break
fi
sleep 1
done
if [[ $ready -ne 1 ]]; then
echo "隧道未就绪,中止。" >&2
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
exit 1
fi
echo "==> mysqldump 导出中..."
docker exec -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
mysqldump -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \
--lock-tables=0 --add-drop-table --skip-triggers \
--skip-routines --skip-events --column-statistics=0 --no-tablespaces --set-gtid-purged=OFF \
--databases "$REMOTE_DB" > "$OUT"
RC=$?
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
if [[ $RC -ne 0 ]]; then
echo "备份失败 (mysqldump 退出码 $RC)。" >&2
exit 1
fi
echo "==> 备份完成 ✅ $OUT ($(wc -c < "$OUT") bytes)"
echo " 提示:请在阿里云 RDS 控制台再创建一次手动快照,作为整库回滚保险。"
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env bash
# 为 api.lingshivip.cn 申请 DNS 证书并启用 HTTPS
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SSL_DIR="/etc/nginx/ssl/api.lingshivip.cn"
NGINX_CONF="/etc/nginx/conf.d/dukang-lingshivip.conf"
mkdir -p "$SSL_DIR" /var/www/certbot/.well-known/acme-challenge
echo "==> 1. 发起 DNS 验证(需先在 DNSPod 添加 TXT 记录)"
~/.acme.sh/acme.sh --issue -d api.lingshivip.cn --dns \
--yes-I-know-dns-manual-mode-enough-go-ahead-please \
--server letsencrypt || true
echo ""
echo "请在 DNSPod 添加上述 TXT 记录后,执行:"
echo " $0 --renew"
echo ""
if [[ "${1:-}" != "--renew" ]]; then
exit 0
fi
echo "==> 2. 继续 DNS 验证并签发证书"
~/.acme.sh/acme.sh --renew -d api.lingshivip.cn --yes-I-know-dns-manual-mode-enough-go-ahead-please
echo "==> 3. 安装证书到 nginx 目录"
install -m 644 ~/.acme.sh/api.lingshivip.cn_ecc/fullchain.cer "$SSL_DIR/fullchain.cer"
install -m 600 ~/.acme.sh/api.lingshivip.cn_ecc/api.lingshivip.cn.key "$SSL_DIR/api.lingshivip.cn.key"
echo "==> 4. 更新 nginx 并重载"
cp "$SCRIPT_DIR/nginx-lingshivip.conf" "$NGINX_CONF"
python3 - <<'PY'
from pathlib import Path
p = Path("/etc/nginx/conf.d/dukang-lingshivip.conf")
text = p.read_text()
old = """ location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
# API 独立域名 HTTPS"""
new = """ location / {
return 301 https://$host$request_uri;
}
}
# API 独立域名 HTTPS"""
if old not in text:
raise SystemExit("nginx api http redirect patch failed")
p.write_text(text.replace(old, new, 1))
PY
nginx -t
systemctl reload nginx
echo "==> 5. 健康检查"
curl -sf -o /dev/null -w "api-https:%{http_code}\n" https://api.lingshivip.cn/api/v1/health
echo "==> api.lingshivip.cn HTTPS 已启用"
-59
View File
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
# runxian.top 裸域名申请 Let's Encrypt 并启用 HTTPS
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DOMAIN=runxian.top
EMAIL="${CERTBOT_EMAIL:-admin@runxian.top}"
echo "==> 检查 DNS: $DOMAIN"
ip="$(dig +short "$DOMAIN" A @223.5.5.5 | tail -1)"
if [[ -z "$ip" ]]; then
echo "ERROR: $DOMAIN 无 A 记录,请先在 DNS 添加指向本机公网 IP(当前服务器: $(curl -sf ifconfig.me || echo unknown)"
exit 1
fi
echo " $DOMAIN -> $ip"
mkdir -p /var/www/certbot /var/log/nginx/dukang
# 若尚未有证书,先部署仅 HTTP 的配置以便 ACME 校验
if [[ ! -f /etc/letsencrypt/live/runxian.top/fullchain.pem ]]; then
echo "==> 临时 HTTP 配置(用于 ACME..."
cat > /etc/nginx/conf.d/dukang-runxian-apex.conf <<'EOF'
server {
listen 80;
server_name runxian.top;
root /opt/dukang-haoke/public;
index index.html;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
try_files $uri $uri/ =404;
}
}
EOF
nginx -t
systemctl reload nginx
fi
echo "==> 申请证书..."
certbot certonly --webroot -w /var/www/certbot \
--cert-name runxian.top \
-d runxian.top \
--non-interactive --agree-tos -m "$EMAIL"
echo "==> 切换 HTTPS 配置..."
install -m 644 "$SCRIPT_DIR/nginx-runxian-apex.conf" /etc/nginx/conf.d/dukang-runxian-apex.conf
nginx -t
systemctl reload nginx
echo "==> 验证..."
code="$(curl -sf -o /dev/null -w '%{http_code}' "https://$DOMAIN/MP_verify_ayPJ4CQqbUcec3jX.txt" || echo fail)"
echo " https://$DOMAIN/MP_verify_ayPJ4CQqbUcec3jX.txt -> $code"
echo "==> runxian.top HTTPS 已启用"
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# 为杜康 runxian.top 申请 Let's Encrypt 并切换 HTTPS 配置
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DOMAINS=(
user.runxian.top
shop.runxian.top
partner.runxian.top
webadmin.runxian.top
dkapi.runxian.top
)
echo "==> 检查 DNS 解析到本机..."
for d in "${DOMAINS[@]}"; do
ip="$(dig +short "$d" @223.5.5.5 | tail -1)"
if [[ -z "$ip" ]]; then
echo "ERROR: $d 无 A 记录,请先在 DNS 添加指向本机公网 IP"
exit 1
fi
echo " $d -> $ip"
done
mkdir -p /var/www/certbot /var/log/nginx/dukang
echo "==> 申请证书..."
certbot certonly --webroot -w /var/www/certbot \
--cert-name user.runxian.top \
-d user.runxian.top \
-d shop.runxian.top \
-d partner.runxian.top \
-d webadmin.runxian.top \
-d dkapi.runxian.top \
--non-interactive --agree-tos -m admin@runxian.top || {
echo "certbot 失败,请确认 DNS 已生效且 80 端口可从公网访问"
exit 1
}
echo "==> 切换 nginx HTTPS 配置..."
install -m 644 "$SCRIPT_DIR/nginx-runxian-dukang-ssl.conf" /etc/nginx/conf.d/dukang-runxian.conf
nginx -t
systemctl reload nginx
echo "==> 验证..."
for url in \
https://user.runxian.top/user/ \
https://user.runxian.top/shop/ \
https://user.runxian.top/partner/ \
https://shop.runxian.top/ \
https://webadmin.runxian.top/ \
https://dkapi.runxian.top/api/v1/health; do
code="$(curl -sf -o /dev/null -w '%{http_code}' "$url" || echo fail)"
echo " $url -> $code"
done
echo "==> runxian.top 杜康域名 HTTPS 已启用(H5 统一入口 user.runxian.top"
-33
View File
@@ -1,33 +0,0 @@
map $host $dukang_h5_port {
user.ai-mirror.xyz 8091;
shop.ai-mirror.xyz 8092;
partner.ai-mirror.xyz 8093;
}
# HTTP — 证书申请期间先提供 HTTP 服务
server {
listen 80;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:$dukang_h5_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-54
View File
@@ -1,54 +0,0 @@
# 杜康好客 H5 三端 — ai-mirror.xyz
# API 内部端口 8090H5 分别 8091/8092/8093
map $host $dukang_h5_port {
user.ai-mirror.xyz 8091;
shop.ai-mirror.xyz 8092;
partner.ai-mirror.xyz 8093;
}
# HTTP → HTTPS
server {
listen 80;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name user.ai-mirror.xyz shop.ai-mirror.xyz partner.ai-mirror.xyz;
access_log /var/log/nginx/dukang/access.log main;
error_log /var/log/nginx/dukang/error.log warn;
ssl_certificate /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.ai-mirror.xyz/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_h5_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-105
View File
@@ -1,105 +0,0 @@
# 证书申请期间 — lingshivip.cn HTTPapi HTTPS 证书就绪后换 nginx-lingshivip.conf
map $host $dukang_lingshi_port {
user.lingshivip.cn 8091;
shop.lingshivip.cn 8092;
partner.lingshivip.cn 8093;
admin.lingshivip.cn 8094;
}
server {
listen 80;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 80;
server_name api.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name admin.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi.access.log main;
error_log /var/log/nginx/dukang/lingshi.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_lingshi_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-135
View File
@@ -1,135 +0,0 @@
# 杜康好客 — lingshivip.cn 五端 + API
# API 8090H5 8091/8092/8093admin-web 8094
map $host $dukang_lingshi_port {
user.lingshivip.cn 8091;
shop.lingshivip.cn 8092;
partner.lingshivip.cn 8093;
admin.lingshivip.cn 8094;
}
# HTTP → HTTPSH5 三端)
server {
listen 80;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
# API:证书就绪前 HTTP 直连;就绪后改走 HTTPS server
server {
listen 80;
server_name api.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
# admin 待 DNS + 证书就绪前先走 HTTP
server {
listen 80;
server_name admin.lingshivip.cn;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# API 独立域名 HTTPS(证书:/etc/nginx/ssl/api.lingshivip.cn/
server {
listen 443 ssl;
server_name api.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi-api.access.log main;
error_log /var/log/nginx/dukang/lingshi-api.error.log warn;
ssl_certificate /etc/nginx/ssl/api.lingshivip.cn/fullchain.cer;
ssl_certificate_key /etc/nginx/ssl/api.lingshivip.cn/api.lingshivip.cn.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 443 ssl;
server_name user.lingshivip.cn shop.lingshivip.cn partner.lingshivip.cn;
access_log /var/log/nginx/dukang/lingshi.access.log main;
error_log /var/log/nginx/dukang/lingshi.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.lingshivip.cn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.lingshivip.cn/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:$dukang_lingshi_port;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
-53
View File
@@ -1,53 +0,0 @@
# runxian.top 裸域名 — 静态公共资源(微信域名校验等)
# HTTP: 保留 MP_verify + ACME;其余跳转 HTTPS
# HTTPS: deploy/enable-runxian-apex-ssl.sh 申请证书后启用
server {
listen 80;
server_name runxian.top;
access_log /var/log/nginx/dukang/runxian-apex.access.log main;
error_log /var/log/nginx/dukang/runxian-apex.error.log warn;
root /opt/dukang-haoke/public;
index index.html;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name runxian.top;
access_log /var/log/nginx/dukang/runxian-apex.access.log main;
error_log /var/log/nginx/dukang/runxian-apex.error.log warn;
ssl_certificate /etc/letsencrypt/live/runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
root /opt/dukang-haoke/public;
index index.html;
location ~ ^/MP_verify_.*\.txt$ {
default_type text/plain;
access_log off;
}
location / {
try_files $uri $uri/ =404;
}
}
-218
View File
@@ -1,218 +0,0 @@
# 杜康好客 — runxian.top HTTPS
# 执行: deploy/enable-runxian-dukang-ssl.sh
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/
server {
listen 80;
server_name user.runxian.top shop.runxian.top partner.runxian.top m.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://user.runxian.top$request_uri;
}
}
server {
listen 443 ssl;
server_name m.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top$request_uri;
}
server {
listen 443 ssl;
server_name user.runxian.top;
access_log /var/log/nginx/dukang/runxian-h5.access.log main;
error_log /var/log/nginx/dukang/runxian-h5.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location ~ ^/MP_verify_.*\.txt$ {
root /opt/dukang-haoke/apps/mini-user/dist;
default_type text/plain;
access_log off;
}
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location = / {
return 302 /user/;
}
# Taro 路由页是 /pages/xxx/index;补全短路径,避免 UniversalRouter 404 白屏
location ~ ^/user/pages/([^/]+)/?$ {
return 302 /user/pages/$1/index$is_args$args;
}
location /user/ {
rewrite ^/user/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
# serve 若仍发出绝对 Location:/pages/...,补回 /user 前缀
proxy_redirect / /user/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /shop/ {
rewrite ^/shop/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8092;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /partner/ {
rewrite ^/partner/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8093;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 旧 C 端根路径书签(无 /user 前缀)→ /user/...
location / {
return 302 /user$request_uri;
}
}
server {
listen 443 ssl;
server_name shop.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top/shop$request_uri;
}
server {
listen 443 ssl;
server_name partner.runxian.top;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://user.runxian.top/partner$request_uri;
}
server {
listen 80;
server_name dkapi.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
include /opt/dukang-haoke/deploy/nginx-deploy-webhook.conf;
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name dkapi.runxian.top;
access_log /var/log/nginx/dukang/runxian-api.access.log main;
error_log /var/log/nginx/dukang/runxian-api.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
include /opt/dukang-haoke/deploy/nginx-deploy-webhook.conf;
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
server {
listen 80;
server_name webadmin.runxian.top;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
server_name webadmin.runxian.top;
access_log /var/log/nginx/dukang/runxian-admin.access.log main;
error_log /var/log/nginx/dukang/runxian-admin.error.log warn;
ssl_certificate /etc/letsencrypt/live/user.runxian.top/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.runxian.top/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
client_max_body_size 20m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

Some files were not shown because too many files have changed in this diff Show More