Compare commits
26 Commits
5fdddae41c
...
v3.5.8
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e8a12596 | |||
| d7241d54b1 | |||
| 282da24bc4 | |||
| 3d819dc10b | |||
| 5e0691ab35 | |||
| 5935024ea8 | |||
| 7304c7a8e1 | |||
| 8984465893 | |||
| b90a4e9f8f | |||
| a3f17cbf94 | |||
| 3f208d27a9 | |||
| 6973e3d7f5 | |||
| 114d77ca48 | |||
| fa69ced448 | |||
| a144a35c3d | |||
| 7e12eb3ca6 | |||
| 608e3e5ec5 | |||
| d98b0aefb0 | |||
| 18c5abea79 | |||
| 012626f6bd | |||
| 22cd00da42 | |||
| 5d4ed566de | |||
| 3004a65b35 | |||
| f9161368e1 | |||
| 8a061f085f | |||
| b20e566a65 |
@@ -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`
|
||||
@@ -1,33 +1,11 @@
|
||||
---
|
||||
description: Git 提交与 PR 协作规范
|
||||
description: 杜康提交 scope 与发版默认(生产)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git 规范
|
||||
# Git / 发版(杜康专属)
|
||||
|
||||
## 提交格式
|
||||
|
||||
Conventional Commits,scope = 端或模块名:
|
||||
|
||||
```
|
||||
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 Commits,scope = 端或模块:`feat(trade):`、`fix(redeem):`
|
||||
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
|
||||
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
|
||||
- Prisma 迁移须 Review
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,7 @@ description: >-
|
||||
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
|
||||
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
|
||||
or @杜康发布 / @dukang-release.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 杜康发布
|
||||
|
||||
@@ -4,6 +4,7 @@ description: >-
|
||||
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
|
||||
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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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-v1:MySQL :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 人)
|
||||
## OWNER(2026-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-v3(PRD、Wave、REQ/ACC) |
|
||||
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
|
||||
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
|
||||
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
|
||||
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
|
||||
|
||||
## 子 Agent(按职责选用)
|
||||
|
||||
在 Cursor Agent 输入 `/` 选择:
|
||||
|
||||
| 子 Agent | 负责人 | 适用场景 |
|
||||
|----------|--------|----------|
|
||||
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
|
||||
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
|
||||
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
|
||||
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
|
||||
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
|
||||
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
|
||||
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
|
||||
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
|
||||
|
||||
## Skills(按需 @)
|
||||
|
||||
| Skill | 何时用 |
|
||||
|-------|--------|
|
||||
| `dukang-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
|
||||
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
|
||||
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
|
||||
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
|
||||
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
|
||||
| `dukang-release`(杜康发布) | `dev_jacy`→`dev` 合并 + `deploy.sh` 发版 |
|
||||
|
||||
## 快速指令
|
||||
|
||||
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
||||
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
|
||||
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
|
||||
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
|
||||
- PR 前审查:`/boundary-reviewer`
|
||||
- 原型参照:`pages/{user,shop,partner}/`(只读)
|
||||
|
||||
## 核心业务常量(不可偏离)
|
||||
## 不可偏离
|
||||
|
||||
```
|
||||
权益额 = benefit_amount ?? price
|
||||
@@ -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` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||
| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.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/单测过
|
||||
|
||||
@@ -16,37 +16,37 @@ 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-cell:has(.ant-btn),
|
||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||
.ant-modal .ant-table-cell:has(.ant-btn),
|
||||
@@ -71,10 +71,9 @@ body,
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 表格单元格双行信息合并为单行省略 */
|
||||
/* 表格单元格主/副信息同一行 */
|
||||
.admin-cell-line {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.admin-cell-line-secondary {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -215,11 +215,10 @@ export default function CityPartnersPanel({
|
||||
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' },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
|
||||
@@ -48,7 +48,6 @@ export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }:
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -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 }}>
|
||||
<ListColumnPrefsProvider initial={profile?.listColumnPrefs}>
|
||||
<Outlet />
|
||||
</ListColumnPrefsProvider>
|
||||
</Content>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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 } : { 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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -195,6 +204,18 @@ export type AdminOrderRow = {
|
||||
quantity?: number;
|
||||
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 };
|
||||
|
||||
@@ -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,84 @@
|
||||
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) => byKey.get(k)!).filter(Boolean), ...actions.map((c) => 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,103 @@
|
||||
import { useMemo, 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,
|
||||
settingItems,
|
||||
type ListColumnSettingItem,
|
||||
} from './list-column-prefs';
|
||||
|
||||
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 pref = prefs[listKey];
|
||||
|
||||
const configured = useMemo(
|
||||
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
||||
[allColumns, pref],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() => settingItems(allColumns as ColumnType<T>[], pref),
|
||||
[allColumns, pref],
|
||||
);
|
||||
|
||||
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 columns = useMemo<ColumnsType<T>>(
|
||||
() => [
|
||||
serialCol,
|
||||
...configured.map((col) =>
|
||||
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
||||
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
||||
: col,
|
||||
),
|
||||
],
|
||||
[configured, serialCol],
|
||||
);
|
||||
|
||||
async function persist(next: ListColumnSettingItem[] | null) {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (!next) {
|
||||
await save(listKey, null);
|
||||
} else {
|
||||
await save(listKey, {
|
||||
order: next.map((i) => i.key),
|
||||
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
message.success(next ? '列设置已保存' : '已恢复默认列');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { request } from '../lib/api';
|
||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -99,7 +101,7 @@ export default function BenefitCouponsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
||||
@@ -113,7 +115,7 @@ export default function BenefitCouponsPage() {
|
||||
{ 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' },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -155,15 +157,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>
|
||||
<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 +198,7 @@ export default function BenefitCouponsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -316,7 +324,7 @@ export default function BenefitCouponsPage() {
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
|
||||
{ title: '核销号', dataIndex: 'redeemNo' },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
@@ -327,7 +335,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>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ 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';
|
||||
|
||||
|
||||
type WarehouseRow = {
|
||||
id: string;
|
||||
@@ -223,7 +225,7 @@ export default function CitiesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||
@@ -261,7 +263,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 +272,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 +313,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 +331,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 +629,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 +646,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 || '—',
|
||||
},
|
||||
|
||||
@@ -36,6 +36,8 @@ 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';
|
||||
|
||||
|
||||
type SubRow = PartnerSubAccountRow;
|
||||
|
||||
@@ -261,22 +263,20 @@ 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 },
|
||||
{
|
||||
title: '主账号姓名',
|
||||
dataIndex: 'name',
|
||||
ellipsis: true,
|
||||
width: 120,
|
||||
|
||||
render: (v, row) => (
|
||||
@@ -311,7 +311,6 @@ export default function CityPartnersPage() {
|
||||
},
|
||||
{
|
||||
title: '管仓仓库',
|
||||
ellipsis: true,
|
||||
dataIndex: 'managedWarehouseName',
|
||||
width: 110,
|
||||
|
||||
@@ -338,12 +337,16 @@ export default function CityPartnersPage() {
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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}
|
||||
@@ -427,7 +430,7 @@ export default function CityPartnersPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
scroll={{ x: 1200 }}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={toTableRows(data?.items ?? [])}
|
||||
scroll={{ x: 'max-content' }}
|
||||
|
||||
@@ -30,6 +30,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -70,6 +72,7 @@ function FulfillmentFields({
|
||||
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||||
}) {
|
||||
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -219,7 +222,7 @@ export default function CityWarehousesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '城市',
|
||||
width: 100,
|
||||
@@ -229,8 +232,8 @@ export default function CityWarehousesPage() {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
|
||||
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
|
||||
{ title: '仓库', dataIndex: 'name', width: 140 },
|
||||
{ title: '地址', dataIndex: 'address', width: 180 },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
@@ -242,7 +245,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 +300,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 +376,7 @@ export default function CityWarehousesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1400 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -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([]));
|
||||
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([]));
|
||||
}, [loadVersion]);
|
||||
}
|
||||
}, [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 }}>
|
||||
{!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,14 +598,15 @@ 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>
|
||||
{canPromo ? (
|
||||
<Form.Item name="promoCodeId" label="推广码">
|
||||
<Select
|
||||
allowClear
|
||||
@@ -497,6 +618,8 @@ export default function DashboardPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{canPartners ? (
|
||||
<Form.Item name="partnerAccountId" label="合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
@@ -508,32 +631,40 @@ export default function DashboardPage() {
|
||||
}))}
|
||||
/>
|
||||
</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 }}>
|
||||
{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 }}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -59,7 +61,7 @@ export default function DeliveriesPage() {
|
||||
setTrackOpen(true);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||
@@ -83,16 +85,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={
|
||||
|
||||
@@ -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,11 @@ 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';
|
||||
|
||||
|
||||
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
||||
value: v,
|
||||
@@ -56,6 +62,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 +96,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 +129,7 @@ export default function DevPlanTasksPage() {
|
||||
content: values.content,
|
||||
type: values.type,
|
||||
supportTicketId: values.supportTicketId || undefined,
|
||||
attachmentUrls,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
@@ -199,7 +214,35 @@ export default function DevPlanTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DevPlanTaskDto> = [
|
||||
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 },
|
||||
{
|
||||
title: '类型',
|
||||
@@ -215,7 +258,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 +296,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 +367,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 +413,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,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
|
||||
@@ -119,7 +121,7 @@ export default function DevPlanVersionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<DevPlanVersionDto> = [
|
||||
const baseColumns: ColumnsType<DevPlanVersionDto> = [
|
||||
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
|
||||
{
|
||||
title: '状态',
|
||||
@@ -129,7 +131,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 +160,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 +196,7 @@ export default function DevPlanVersionsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -4,6 +4,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -55,7 +57,7 @@ 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: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||
@@ -76,8 +78,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,8 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
@@ -173,7 +175,7 @@ export default function FulfillmentProvidersPage() {
|
||||
void load();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FulfillmentProviderDto> = [
|
||||
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
@@ -230,13 +232,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 +262,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 ? '编辑承运商' : '注册承运商'}
|
||||
|
||||
@@ -3,9 +3,12 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -17,14 +20,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,14 +48,27 @@ 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> = [
|
||||
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' },
|
||||
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||
@@ -67,7 +82,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 +107,7 @@ export default function HqAccountsPage() {
|
||||
loginName: row.loginName,
|
||||
adminRole: row.adminRole,
|
||||
status: row.status,
|
||||
cityIds: row.cityIds ?? [],
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
@@ -88,16 +115,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 +139,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;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const payload = { ...v };
|
||||
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 +183,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 +203,19 @@ export default function HqAccountsPage() {
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
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 +224,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: '请输入手机号' }]}>
|
||||
|
||||
@@ -9,6 +9,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -52,12 +54,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
|
||||
@@ -92,9 +93,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">
|
||||
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
||||
@@ -150,7 +155,7 @@ export default function HqLogsPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
scroll={{ x: 900 }}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
|
||||
@@ -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,45 +315,33 @@ 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));
|
||||
@@ -324,10 +352,14 @@ export default function HqPermissionsPage() {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
@@ -30,6 +30,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -181,7 +183,7 @@ export default function InvoicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{
|
||||
@@ -202,7 +204,7 @@ export default function InvoicesPage() {
|
||||
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||
: '—',
|
||||
},
|
||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||
{ title: '名称', dataIndex: 'titleName' },
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
@@ -227,8 +229,11 @@ export default function InvoicesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -240,6 +245,7 @@ export default function InvoicesPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
发票管理
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -286,7 +292,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,
|
||||
|
||||
@@ -25,6 +25,8 @@ 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';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -124,9 +126,9 @@ export default function KnowledgeBasesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<KnowledgeBaseDto> = [
|
||||
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||
{
|
||||
title: '启用',
|
||||
@@ -184,7 +186,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 +252,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>
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -153,7 +155,7 @@ export default function LlmConfigsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<LlmApiConfigDto> = [
|
||||
const baseColumns: ColumnsType<LlmApiConfigDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '提供商',
|
||||
@@ -161,7 +163,7 @@ export default function LlmConfigsPage() {
|
||||
width: 120,
|
||||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||||
{ title: '模型', dataIndex: 'modelName' },
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'apiKeyConfigured',
|
||||
@@ -257,12 +259,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,7 @@ 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';
|
||||
|
||||
type BillRow = {
|
||||
id: string;
|
||||
@@ -44,6 +45,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 +148,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 +241,12 @@ 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 },
|
||||
{
|
||||
title: '承运商',
|
||||
width: 140,
|
||||
@@ -248,6 +273,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 +338,6 @@ export default function LogisticsBillsPage() {
|
||||
{
|
||||
title: '银行账户',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, r) =>
|
||||
r.bankAccountName
|
||||
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
||||
@@ -355,12 +389,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 +542,7 @@ export default function LogisticsBillsPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={billColumns}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
@@ -550,7 +591,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"
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Collapse,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -20,7 +23,11 @@ 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 { downloadBase64File } from '../lib/exportExcel';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
DELIVERY_PROVIDER_LABELS,
|
||||
@@ -130,6 +137,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 [];
|
||||
@@ -192,8 +303,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 +352,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 +360,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 +569,93 @@ export default function OrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '城市',
|
||||
width: 90,
|
||||
render: (_, row) => row.city?.name || '—',
|
||||
},
|
||||
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: '商品',
|
||||
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) => (
|
||||
width: 240,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<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.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
{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>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{[row.productSpec, row.quantity != null ? `×${row.quantity}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: 130,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<Tag color={ORDER_STATUS_COLORS[row.status] || 'default'}>
|
||||
{ORDER_STATUS_LABELS[row.status] || row.status}
|
||||
</Tag>
|
||||
<div>¥{row.payAmount}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '快递',
|
||||
width: 100,
|
||||
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
|
||||
title: '好客权益',
|
||||
width: 200,
|
||||
render: (_, row) => formatBenefitBrief(row),
|
||||
},
|
||||
{
|
||||
title: '收货信息',
|
||||
width: 240,
|
||||
render: (_, row) => {
|
||||
const address = formatReceiverAddress(row);
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{row.receiverName || '—'} {row.receiverPhone || ''}
|
||||
</div>
|
||||
{address ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{address}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '下单时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||
render: fmtTime,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -555,26 +687,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 +722,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 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>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<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>
|
||||
<Form.Item name="orderType" label="类型">
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
placeholder="全部"
|
||||
options={[
|
||||
{ value: 'NORMAL', label: '普通订单' },
|
||||
{ value: 'PROXY', label: '代下单' },
|
||||
]}
|
||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
</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"
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="receiverPhone" label="收货手机">
|
||||
<Input allowClear />
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={6}>
|
||||
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
|
||||
<Input allowClear placeholder="手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部"
|
||||
options={[{ value: true, label: '仅待确认大单' }]}
|
||||
/>
|
||||
</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">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
|
||||
<Checkbox>过滤测试</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => { form.resetFields(); setPage(1); void load(); }}>重置</Button>
|
||||
</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>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
@@ -647,16 +838,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 +1022,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 +1033,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 +1148,6 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -1251,7 +1442,6 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '商品',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_, row) =>
|
||||
row.productName
|
||||
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
|
||||
|
||||
@@ -7,6 +7,8 @@ import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
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,11 +171,10 @@ 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}
|
||||
@@ -189,7 +190,6 @@ export default function PartnerAccountsPage() {
|
||||
{
|
||||
title: '开城合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.companyName || row.parent?.name || '—',
|
||||
},
|
||||
{
|
||||
@@ -254,11 +254,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 +312,7 @@ export default function PartnerAccountsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={treeData}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
@@ -399,7 +406,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 +414,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' }} />
|
||||
),
|
||||
},
|
||||
]} />
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -20,6 +22,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -31,8 +35,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 +95,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 +147,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 +215,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 +240,17 @@ 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 },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
|
||||
render: (_, r) => partnerName(r),
|
||||
},
|
||||
@@ -238,6 +277,16 @@ export default function PartnerBillsPage() {
|
||||
dataIndex: 'totalAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '收款户名',
|
||||
width: 100,
|
||||
|
||||
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
|
||||
},
|
||||
{
|
||||
title: '收款账号',
|
||||
width: 140,
|
||||
|
||||
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
|
||||
},
|
||||
@@ -259,6 +308,9 @@ export default function PartnerBillsPage() {
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
@@ -284,12 +336,16 @@ export default function PartnerBillsPage() {
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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}
|
||||
@@ -395,7 +451,7 @@ export default function PartnerBillsPage() {
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
scroll={{ x: 1280 }}
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
@@ -465,6 +521,59 @@ export default function PartnerBillsPage() {
|
||||
</Form.Item>
|
||||
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
|
||||
确认驳回
|
||||
</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>
|
||||
|
||||
@@ -14,6 +14,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -71,12 +73,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 ? (
|
||||
@@ -88,7 +89,6 @@ export default function PartnerLogsPage() {
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
ellipsis: true,
|
||||
width: 150,
|
||||
|
||||
render: (_, r) => (
|
||||
@@ -116,7 +116,6 @@ export default function PartnerLogsPage() {
|
||||
width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
ellipsis: true,
|
||||
{
|
||||
title: '摘要',
|
||||
|
||||
@@ -137,11 +136,15 @@ export default function PartnerLogsPage() {
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
合伙人日志
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
@@ -216,7 +219,7 @@ export default function PartnerLogsPage() {
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
scroll={{ x: 1100 }}
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
|
||||
@@ -27,6 +27,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -173,17 +175,16 @@ 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' },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
@@ -205,10 +206,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); }}>
|
||||
新建城市合伙人
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||
PENDING: 'orange',
|
||||
@@ -94,13 +96,12 @@ export default function PendingRedeemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<RedeemPendingItem> = [
|
||||
const baseColumns: ColumnsType<RedeemPendingItem> = [
|
||||
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
|
||||
{
|
||||
title: '核销码 ID',
|
||||
dataIndex: 'redeemToken',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
|
||||
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}…</Typography.Text>,
|
||||
},
|
||||
@@ -132,9 +133,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}
|
||||
@@ -175,7 +180,7 @@ export default function PendingRedeemPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
scroll={{ x: 1100 }}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
|
||||
@@ -13,6 +13,8 @@ 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';
|
||||
|
||||
|
||||
type Row = ProductDetailTemplateDto;
|
||||
|
||||
@@ -136,10 +138,10 @@ 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: '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 +160,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 +184,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 && (
|
||||
|
||||
@@ -14,6 +14,8 @@ 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';
|
||||
|
||||
|
||||
type ProductDetailContentDto = {
|
||||
storyTitle?: string;
|
||||
@@ -21,6 +23,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 +48,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 +238,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' && (
|
||||
@@ -256,6 +306,12 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
{hideFulfillment ? (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
||||
该商品有多种规格,履约(线上 / 跨城 / 现场)请到「规格与 SKU」中按规格设置。
|
||||
</Typography.Paragraph>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="allowOnlinePurchase"
|
||||
label="允许线上购买"
|
||||
@@ -289,6 +345,8 @@ function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormIns
|
||||
<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" />
|
||||
@@ -331,13 +389,36 @@ 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 },
|
||||
{
|
||||
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>
|
||||
) },
|
||||
@@ -348,23 +429,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) => (
|
||||
@@ -377,7 +442,7 @@ export default function ProductsPage() {
|
||||
}}>编辑</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该商品?"
|
||||
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
|
||||
description={`将永久删除「${row.name}」,此操作不可恢复。`}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
@@ -390,14 +455,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>
|
||||
@@ -406,17 +519,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); } }} />
|
||||
<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);
|
||||
@@ -426,13 +568,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="默认 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: '详情页',
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = PromoCodeItem;
|
||||
|
||||
@@ -74,8 +76,8 @@ 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 },
|
||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||
{
|
||||
title: '场景',
|
||||
@@ -160,10 +162,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 +200,7 @@ export default function PromoCodesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -7,6 +7,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -75,7 +77,7 @@ export default function RedeemRecordsPage() {
|
||||
}
|
||||
}, [data, initialRedeemNo, loading]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '核销号',
|
||||
dataIndex: 'redeemNo',
|
||||
@@ -133,9 +135,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 +181,7 @@ export default function RedeemRecordsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1300 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,6 +6,8 @@ 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';
|
||||
|
||||
|
||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||
|
||||
@@ -74,7 +76,7 @@ export default function StoreAccountsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
@@ -138,11 +140,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 +191,7 @@ export default function StoreAccountsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -30,6 +30,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
kind: StoreSettlementKind;
|
||||
@@ -44,6 +46,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 +62,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 +130,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,20 +269,21 @@ 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>
|
||||
@@ -268,9 +297,21 @@ export default function StoreBillsPage() {
|
||||
dataIndex: 'date',
|
||||
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 || '—' : '—',
|
||||
},
|
||||
@@ -288,7 +329,9 @@ export default function StoreBillsPage() {
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string, row) => (
|
||||
<Tag color={settlementStatusColor(row.kind, s)}>
|
||||
{storeSettlementStatusLabel(row.kind, s)}
|
||||
</Tag>
|
||||
@@ -332,14 +375,19 @@ export default function StoreBillsPage() {
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
}
|
||||
| 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 }}>
|
||||
门店账单
|
||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Typography.Text type="secondary">
|
||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
||||
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
||||
@@ -468,7 +516,7 @@ export default function StoreBillsPage() {
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({
|
||||
scroll={{ x: 1300 }}
|
||||
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
|
||||
}),
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
@@ -503,7 +551,32 @@ export default function StoreBillsPage() {
|
||||
<Descriptions.Item label="状态">
|
||||
{storeSettlementStatusLabel('T1_BILL', String(detail.status))}
|
||||
</Descriptions.Item>
|
||||
<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}
|
||||
|
||||
@@ -3,7 +3,9 @@ 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';
|
||||
|
||||
|
||||
type CategoryNode = {
|
||||
id: string;
|
||||
@@ -38,7 +40,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 +64,7 @@ export default function StoreCategoriesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
@@ -112,7 +117,7 @@ export default function StoreCategoriesPage() {
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FlatRow> = [
|
||||
const baseColumns: ColumnsType<FlatRow> = [
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'level',
|
||||
@@ -141,13 +146,14 @@ 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}
|
||||
{canDelete ? (
|
||||
<Popconfirm
|
||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||
onConfirm={async () => {
|
||||
@@ -158,19 +164,28 @@ export default function StoreCategoriesPage() {
|
||||
>
|
||||
<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>
|
||||
{canDelete ? (
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
@@ -180,6 +195,7 @@ export default function StoreCategoriesPage() {
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
@@ -12,6 +12,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -80,14 +82,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}
|
||||
@@ -111,7 +113,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 +129,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 +170,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 && (
|
||||
|
||||
@@ -7,6 +7,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
|
||||
@@ -40,7 +42,7 @@ export default function StoreMediaPage() {
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
||||
{
|
||||
@@ -49,7 +51,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 +75,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 +92,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,7 @@ 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';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '待审核',
|
||||
@@ -55,10 +56,12 @@ function InfoChangeImageDiff({
|
||||
field,
|
||||
live,
|
||||
proposed,
|
||||
imageSize = 72,
|
||||
}: {
|
||||
field: string;
|
||||
live: unknown;
|
||||
proposed: unknown;
|
||||
imageSize?: number;
|
||||
}) {
|
||||
const liveUrls =
|
||||
field === 'coverUrl'
|
||||
@@ -83,7 +86,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 +102,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 +131,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);
|
||||
@@ -286,12 +290,19 @@ 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={() => setInfoFullscreen((v) => !v)}>
|
||||
{infoFullscreen ? '退出全屏' : '全屏查看'}
|
||||
</Button>
|
||||
{detail?.status === 'PENDING' ? (
|
||||
<>
|
||||
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
||||
<Button
|
||||
danger
|
||||
@@ -303,8 +314,9 @@ function InfoChangeAuditPanel({
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
@@ -328,7 +340,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 +405,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,7 +470,7 @@ export default function StorePackageAuditsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
{
|
||||
title: '状态',
|
||||
@@ -502,9 +520,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 +579,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);
|
||||
|
||||
@@ -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,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -147,7 +149,7 @@ export default function StoreWithdrawalsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '提现单号',
|
||||
dataIndex: 'withdrawNo',
|
||||
@@ -229,11 +231,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 +305,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,
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type StoreCreateForm,
|
||||
} from '../lib/storeCreate';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { resolveRegionBinding } from '../lib/china-region';
|
||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
@@ -198,6 +199,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 +724,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}>
|
||||
<span>{name}</span>
|
||||
{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 +786,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 +795,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,15 +864,24 @@ 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>
|
||||
<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>
|
||||
<Form.Item name="phone" label="电话"><Input allowClear /></Form.Item>
|
||||
@@ -918,7 +941,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,8 @@ 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';
|
||||
|
||||
|
||||
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
||||
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
|
||||
@@ -218,8 +220,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 +371,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 +472,7 @@ export default function SupportTicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<TicketRow> = [
|
||||
const baseColumns: ColumnsType<TicketRow> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
@@ -494,7 +505,7 @@ export default function SupportTicketsPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -537,8 +548,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 +564,7 @@ export default function SupportTicketsPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
技术支持
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Space>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
@@ -610,7 +625,7 @@ export default function SupportTicketsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={
|
||||
isSuperAdmin
|
||||
? {
|
||||
|
||||
@@ -20,6 +20,7 @@ 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';
|
||||
|
||||
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||
|
||||
@@ -257,7 +258,6 @@ export default function TestWhitelistPage() {
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -334,7 +334,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 +347,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 +386,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 +479,7 @@ export default function TestWhitelistPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={phonesLoading}
|
||||
columns={phoneColumns}
|
||||
columns={columns}
|
||||
dataSource={phones?.items ?? []}
|
||||
pagination={{
|
||||
current: phonePage,
|
||||
|
||||
@@ -4,6 +4,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -69,7 +71,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,12 +89,10 @@ 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 || '—' },
|
||||
@@ -114,9 +114,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 +150,7 @@ export default function ThirdPartyLogsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -115,7 +117,7 @@ export default function TicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
@@ -132,7 +134,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 +156,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 +172,7 @@ export default function TicketsPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
工单中心
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
@@ -205,7 +211,7 @@ export default function TicketsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -7,6 +7,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -61,10 +63,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}
|
||||
@@ -84,7 +86,7 @@ export default function UserLogsPage() {
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要', ellipsis: true,
|
||||
title: '摘要',
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
@@ -98,9 +100,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 +137,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 && (
|
||||
|
||||
@@ -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,7 @@ 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 { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||
|
||||
type UserOrderRow = {
|
||||
@@ -30,6 +31,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 +316,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 +328,36 @@ export default function UsersPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
width: 140,
|
||||
render: (v: string | null) => v || '—',
|
||||
},
|
||||
{
|
||||
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 +385,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 +401,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 +470,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
|
||||
@@ -391,6 +492,7 @@ export default function UsersPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="模糊搜索" allowClear />
|
||||
@@ -429,7 +531,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 +564,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,8 @@ 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';
|
||||
|
||||
|
||||
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||
|
||||
@@ -90,12 +92,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>,
|
||||
},
|
||||
@@ -123,7 +124,6 @@ export default function WechatBindingsPage() {
|
||||
render: (v) => maskPhone(v),
|
||||
},
|
||||
{
|
||||
ellipsis: true,
|
||||
title: '身份摘要',
|
||||
|
||||
render: (_, r) => (
|
||||
@@ -160,7 +160,6 @@ export default function WechatBindingsPage() {
|
||||
width: 120,
|
||||
render: (t: ActorType) => <Tag color={ACTOR_TYPE_COLORS[t]}>{ACTOR_TYPE_LABELS[t]}</Tag>,
|
||||
},
|
||||
ellipsis: true,
|
||||
{
|
||||
title: '账号',
|
||||
|
||||
@@ -172,14 +171,12 @@ export default function WechatBindingsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
ellipsis: true,
|
||||
title: '归属',
|
||||
dataIndex: 'refLabel',
|
||||
width: 160,
|
||||
|
||||
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
|
||||
},
|
||||
ellipsis: true,
|
||||
{
|
||||
title: 'wxOpenId',
|
||||
dataIndex: 'wxOpenId',
|
||||
@@ -205,9 +202,13 @@ export default function WechatBindingsPage() {
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (v) => String(v),
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
|
||||
@@ -5,6 +5,8 @@ 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';
|
||||
|
||||
|
||||
export default function WecomBotLogsPage() {
|
||||
const [form] = Form.useForm();
|
||||
@@ -24,12 +26,12 @@ 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) => v || r.botKey || '—' },
|
||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
||||
{ title: '动作', dataIndex: 'action', width: 160 },
|
||||
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'success',
|
||||
@@ -37,7 +39,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 +51,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 +102,7 @@ export default function WecomBotLogsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -36,6 +36,8 @@ 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';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -280,7 +282,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 +293,17 @@ export default function WecomBotsPage() {
|
||||
</Avatar>
|
||||
),
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
||||
{ title: '名称', dataIndex: 'name', width: 140 },
|
||||
{
|
||||
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 +368,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 +436,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,8 @@ 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';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -193,7 +195,7 @@ function PushRoutesTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<WecomMessagePushDto> = [
|
||||
const baseColumns: ColumnsType<WecomMessagePushDto> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
@@ -207,7 +209,6 @@ function PushRoutesTab() {
|
||||
{
|
||||
title: 'Webhook',
|
||||
dataIndex: 'webhookUrlMasked',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '推送条件',
|
||||
@@ -262,8 +263,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 +311,7 @@ function PushRoutesTab() {
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建推送
|
||||
</Button>
|
||||
{settingsButton}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -26,6 +26,8 @@ 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';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -37,6 +39,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 +136,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 +237,8 @@ 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 },
|
||||
{
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
@@ -267,8 +292,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 +310,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 +404,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 +428,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 +462,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,8 @@ import {
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import { useAdminList } from '../../lib/useAdminList';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
import { useAdminListColumns } from '../../lib/useAdminListColumns';
|
||||
|
||||
|
||||
export default function PromoCodeUsersPage() {
|
||||
const { id = '' } = useParams();
|
||||
@@ -21,7 +23,7 @@ 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: 'phone', width: 120, render: (v) => v || '—' },
|
||||
@@ -69,6 +71,8 @@ export default function PromoCodeUsersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<Table
|
||||
rowKey="id"
|
||||
@@ -76,7 +80,7 @@ export default function PromoCodeUsersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/mini-user",
|
||||
"version": "3.4.15",
|
||||
"version": "3.5.4",
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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 控制台再创建一次手动快照,作为整库回滚保险。"
|
||||
@@ -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 已启用"
|
||||
@@ -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 已启用"
|
||||
@@ -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)"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
# 杜康好客 H5 三端 — ai-mirror.xyz
|
||||
# API 内部端口 8090;H5 分别 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;
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
# 证书申请期间 — lingshivip.cn HTTP(api 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;
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
# 杜康好客 — lingshivip.cn 五端 + API
|
||||
# API 8090;H5 8091/8092/8093;admin-web 8094
|
||||
|
||||
map $host $dukang_lingshi_port {
|
||||
user.lingshivip.cn 8091;
|
||||
shop.lingshivip.cn 8092;
|
||||
partner.lingshivip.cn 8093;
|
||||
admin.lingshivip.cn 8094;
|
||||
}
|
||||
|
||||
# HTTP → HTTPS(H5 三端)
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
# 杜康好客 — runxian.top(HTTP)
|
||||
# 三端 H5 统一入口 user.runxian.top/{user,shop,partner}/(微信网页授权单域名)
|
||||
# shop/partner 子域名 301 到 user.runxian.top 路径;webadmin → 8094;dkapi → 8090
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
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;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
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/;
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 302 /user$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name shop.runxian.top;
|
||||
return 301 http://user.runxian.top/shop$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name partner.runxian.top;
|
||||
return 301 http://user.runxian.top/partner$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name m.runxian.top;
|
||||
return 301 http://user.runxian.top$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
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;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
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 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;
|
||||
|
||||
client_max_body_size 20m;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 续部署:user.runxian.top 统一路径入口 + nginx 切换
|
||||
set -euo pipefail
|
||||
|
||||
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
|
||||
H5_BASE="https://user.runxian.top"
|
||||
|
||||
cd "$APP_ROOT"
|
||||
|
||||
echo "==> 1. PM2 重启(H5 dist 已含 /user|shop|partner/ 路径)"
|
||||
pm2 restart dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner dukang-admin-web
|
||||
pm2 save
|
||||
|
||||
echo "==> 2. 安装 nginx HTTPS 配置"
|
||||
install -m 644 "$APP_ROOT/deploy/nginx-runxian-dukang-ssl.conf" /etc/nginx/conf.d/dukang-runxian.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> 3. 更新 USER_H5_URL"
|
||||
ENV_FILE="$APP_ROOT/server/dukang-api/.env.production"
|
||||
if grep -q '^USER_H5_URL=' "$ENV_FILE"; then
|
||||
sed -i "s|^USER_H5_URL=.*|USER_H5_URL=${H5_BASE}/user|" "$ENV_FILE"
|
||||
else
|
||||
echo "USER_H5_URL=${H5_BASE}/user" >> "$ENV_FILE"
|
||||
fi
|
||||
pm2 restart dukang-api
|
||||
|
||||
echo "==> 4. 验证"
|
||||
for url in \
|
||||
"${H5_BASE}/user/" \
|
||||
"${H5_BASE}/shop/" \
|
||||
"${H5_BASE}/partner/" \
|
||||
"https://shop.runxian.top/" \
|
||||
"https://dkapi.runxian.top/api/v1/health"; do
|
||||
code="$(curl -sk -o /dev/null -w '%{http_code}' "$url" || echo fail)"
|
||||
echo " $url -> $code"
|
||||
done
|
||||
|
||||
echo "==> user.runxian.top 统一部署完成"
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_ROOT="/opt/dukang-haoke"
|
||||
DEPLOY_DIR="$APP_ROOT/deploy"
|
||||
|
||||
echo "==> install api deps only"
|
||||
cd "$APP_ROOT"
|
||||
export NODE_OPTIONS="--max-old-space-size=1024"
|
||||
pnpm install --filter @dukang/api... --filter @dukang/domain --filter @dukang/shared-types
|
||||
|
||||
echo "==> env"
|
||||
cat > "$APP_ROOT/server/dukang-api/.env.production" <<'ENV'
|
||||
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
JWT_SECRET="gky4CNZgwdrYoDIDy4AWBiJRa4WX2ax8STe3TEG93Ht"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
PORT=8090
|
||||
MOCK_SMS=true
|
||||
MOCK_SMS_CODE=123456
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
ENV
|
||||
|
||||
echo "==> prisma"
|
||||
cd "$APP_ROOT/server/dukang-api"
|
||||
pnpm prisma:generate
|
||||
npx prisma db push --accept-data-loss
|
||||
pnpm prisma:seed
|
||||
|
||||
echo "==> serve"
|
||||
npm install -g serve
|
||||
|
||||
echo "==> pm2"
|
||||
mkdir -p /var/log/nginx/dukang /var/www/certbot
|
||||
pm2 delete dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner 2>/dev/null || true
|
||||
pm2 start "$DEPLOY_DIR/ecosystem.config.cjs"
|
||||
pm2 save
|
||||
|
||||
echo "==> ssl"
|
||||
if [ ! -f /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem ]; then
|
||||
certbot certonly --webroot -w /var/www/certbot \
|
||||
-d user.ai-mirror.xyz -d shop.ai-mirror.xyz -d partner.ai-mirror.xyz \
|
||||
--non-interactive --agree-tos -m admin@ai-mirror.xyz
|
||||
fi
|
||||
|
||||
echo "==> nginx"
|
||||
cp "$DEPLOY_DIR/nginx-ai-mirror.conf" /etc/nginx/conf.d/dukang-ai-mirror.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> DONE"
|
||||
pm2 list
|
||||
ss -tlnp | grep -E '809[0-3]' || true
|
||||
curl -s -o /dev/null -w "user:%{http_code}\n" http://127.0.0.1:8091/
|
||||
curl -s -o /dev/null -w "api:%{http_code}\n" http://127.0.0.1:8090/api/v1/health 2>/dev/null || curl -s -o /dev/null -w "api:%{http_code}\n" http://127.0.0.1:8090/
|
||||
@@ -1,79 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP_ROOT="/opt/dukang-haoke"
|
||||
DEPLOY_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "==> 1. 安装 MySQL(如未安装)"
|
||||
if ! command -v mysqld &>/dev/null; then
|
||||
dnf install -y mysql-server
|
||||
systemctl enable --now mysqld
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
echo "==> 2. 配置 MySQL 数据库"
|
||||
mysql -uroot -e "CREATE DATABASE IF NOT EXISTS dukang_haoke CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" 2>/dev/null || true
|
||||
mysql -uroot -e "CREATE USER IF NOT EXISTS 'dukang'@'localhost' IDENTIFIED BY 'Dukang2026!';" 2>/dev/null || \
|
||||
mysql -uroot -e "ALTER USER 'dukang'@'localhost' IDENTIFIED BY 'Dukang2026!';" 2>/dev/null || true
|
||||
mysql -uroot -e "GRANT ALL PRIVILEGES ON dukang_haoke.* TO 'dukang'@'localhost'; FLUSH PRIVILEGES;" 2>/dev/null || true
|
||||
|
||||
echo "==> 3. 启用 pnpm"
|
||||
corepack enable
|
||||
corepack prepare pnpm@11.2.2 --activate
|
||||
|
||||
echo "==> 4. 安装依赖并构建"
|
||||
cd "$APP_ROOT"
|
||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||
pnpm build
|
||||
|
||||
echo "==> 5. 写入 API 生产环境变量"
|
||||
cat > "$APP_ROOT/server/dukang-api/.env.production" <<'ENV'
|
||||
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
JWT_SECRET="gky4CNZgwdrYoDIDy4AWBiJRa4WX2ax8STe3TEG93Ht"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
PORT=8090
|
||||
MOCK_SMS=true
|
||||
MOCK_SMS_CODE=123456
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
ENV
|
||||
|
||||
echo "==> 6. 初始化数据库"
|
||||
cd "$APP_ROOT/server/dukang-api"
|
||||
pnpm prisma:generate
|
||||
npx prisma db push --accept-data-loss
|
||||
pnpm prisma:seed
|
||||
|
||||
echo "==> 7. 安装 serve(静态服务)"
|
||||
npm install -g serve
|
||||
|
||||
echo "==> 8. 启动 PM2 进程"
|
||||
mkdir -p /var/log/nginx/dukang /var/www/certbot
|
||||
pm2 delete dukang-api dukang-h5-user dukang-h5-shop dukang-h5-partner 2>/dev/null || true
|
||||
pm2 start "$DEPLOY_DIR/ecosystem.config.cjs"
|
||||
pm2 save
|
||||
|
||||
echo "==> 9. 申请 SSL 证书"
|
||||
if [ ! -f /etc/letsencrypt/live/user.ai-mirror.xyz/fullchain.pem ]; then
|
||||
certbot certonly --webroot -w /var/www/certbot \
|
||||
-d user.ai-mirror.xyz \
|
||||
-d shop.ai-mirror.xyz \
|
||||
-d partner.ai-mirror.xyz \
|
||||
--non-interactive --agree-tos -m admin@ai-mirror.xyz || \
|
||||
certbot certonly --nginx \
|
||||
-d user.ai-mirror.xyz \
|
||||
-d shop.ai-mirror.xyz \
|
||||
-d partner.ai-mirror.xyz \
|
||||
--non-interactive --agree-tos -m admin@ai-mirror.xyz
|
||||
fi
|
||||
|
||||
echo "==> 10. 配置 Nginx"
|
||||
cp "$DEPLOY_DIR/nginx-ai-mirror.conf" /etc/nginx/conf.d/dukang-ai-mirror.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> 部署完成"
|
||||
pm2 list
|
||||
ss -tlnp | grep -E '809[0-3]' || true
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
# restore-prod-db.sh — 将本地 mysqldump 恢复到线上(production)数据库
|
||||
#
|
||||
# 仅在用户明确要求恢复生产库时使用。
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/restore-prod-db.sh backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
|
||||
# bash deploy/restore-prod-db.sh --yes backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
|
||||
# bash deploy/restore-prod-db.sh --dry-run backups/prod-dukang_prod-YYYYMMDD-HHMMSS.sql
|
||||
#
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
LOCAL_CONTAINER="dukang-v1-mysql"
|
||||
TUNNEL_PORT=6021
|
||||
HOST_ALIAS="host.docker.internal"
|
||||
ASSUME_YES=0
|
||||
DRY_RUN=0
|
||||
DUMP=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--yes|-y) ASSUME_YES=1 ;;
|
||||
--dry-run) DRY_RUN=1 ;;
|
||||
--help|-h) sed -n '3,14p' "$0"; exit 0 ;;
|
||||
--tunnel-port=*) TUNNEL_PORT="${arg#*=}" ;;
|
||||
--container=*) LOCAL_CONTAINER="${arg#*=}" ;;
|
||||
--host-alias=*) HOST_ALIAS="${arg#*=}" ;;
|
||||
-*) echo "未知参数: $arg" >&2; exit 2 ;;
|
||||
*) DUMP="$arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$DUMP" ]]; then
|
||||
echo "请指定 dump 文件,例如: bash deploy/restore-prod-db.sh backups/prod-dukang_prod-20260823-202044.sql" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$DUMP" ]]; then
|
||||
echo "文件不存在: $DUMP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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)"
|
||||
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)"
|
||||
|
||||
echo
|
||||
echo "恢复计划:"
|
||||
echo " 源文件: $DUMP ($(wc -c < "$DUMP") bytes)"
|
||||
echo " 目标(线上): $REMOTE_USER@$REMOTE_HOST:$REMOTE_PORT/$REMOTE_DB"
|
||||
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 未运行。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $ASSUME_YES -ne 1 ]]; then
|
||||
read -r -p "确认用该 dump 覆盖【线上 $REMOTE_DB】? [y/N] " ans
|
||||
[[ "$ans" == "y" || "$ans" == "Y" ]] || { echo "已取消。"; exit 0; }
|
||||
fi
|
||||
|
||||
CTL="/tmp/restore-prod-db-$$.sock"
|
||||
echo "==> 建立 SSH 隧道"
|
||||
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
|
||||
|
||||
cleanup_tunnel() {
|
||||
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup_tunnel EXIT
|
||||
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> 导入 dump 到线上 $REMOTE_DB ..."
|
||||
docker exec -i -e "MYSQL_PWD=$REMOTE_PASS" "$LOCAL_CONTAINER" \
|
||||
mysql -h "$HOST_ALIAS" -P "$TUNNEL_PORT" -u "$REMOTE_USER" \
|
||||
--default-character-set=utf8mb4 < "$DUMP"
|
||||
echo "==> 恢复完成 ✅ 线上 $REMOTE_DB 已回滚到 $DUMP"
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# run-prod-cleanup.sh — 经 SSH 隧道连接生产库,执行测试数据清理
|
||||
#
|
||||
# 用法:
|
||||
# bash deploy/run-prod-cleanup.sh # dry-run
|
||||
# bash deploy/run-prod-cleanup.sh --apply --yes # 写库(默认带 --force 处理已打款测试核销)
|
||||
# bash deploy/run-prod-cleanup.sh --inventory # 仅盘点
|
||||
#
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
LOCAL_CONTAINER="dukang-v1-mysql"
|
||||
TUNNEL_PORT=6020
|
||||
DOCKER_HOST_ALIAS="host.docker.internal"
|
||||
CLIENT_HOST="127.0.0.1"
|
||||
APPLY=0
|
||||
ASSUME_YES=0
|
||||
FORCE=1
|
||||
INVENTORY=0
|
||||
SCRIPT_NAME=""
|
||||
EXTRA_ARGS=()
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--apply) APPLY=1 ;;
|
||||
--yes|-y) ASSUME_YES=1 ;;
|
||||
--no-force) FORCE=0 ;;
|
||||
--force) FORCE=1 ;;
|
||||
--inventory) INVENTORY=1 ;;
|
||||
--script=*) SCRIPT_NAME="${arg#*=}" ;;
|
||||
--help|-h)
|
||||
sed -n '3,12p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*) EXTRA_ARGS+=("$arg") ;;
|
||||
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"
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$LOCAL_CONTAINER"; then
|
||||
echo "本地容器 $LOCAL_CONTAINER 未运行。" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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
|
||||
)"
|
||||
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)"
|
||||
|
||||
CTL="/tmp/run-prod-cleanup-$$.sock"
|
||||
echo "==> 建立 SSH 隧道 $TARGET -L $TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT"
|
||||
"${SSH[@]}" -M -S "$CTL" -f -N -L "$TUNNEL_PORT:$REMOTE_HOST:$REMOTE_PORT" "$TARGET"
|
||||
|
||||
cleanup_tunnel() {
|
||||
"${SSH[@]}" -S "$CTL" -O exit "$TARGET" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup_tunnel EXIT
|
||||
|
||||
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 "$DOCKER_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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ENC_PASS="$(node -e "console.log(encodeURIComponent(process.argv[1]))" "$REMOTE_PASS")"
|
||||
export DATABASE_URL="mysql://${REMOTE_USER}:${ENC_PASS}@${CLIENT_HOST}:${TUNNEL_PORT}/${REMOTE_DB}?charset=utf8mb4"
|
||||
export FORCE_DATABASE_URL=1
|
||||
|
||||
cd "$REPO_ROOT/server/dukang-api"
|
||||
CMD=(pnpm)
|
||||
if [[ -n "$SCRIPT_NAME" ]]; then
|
||||
CMD+=(ts-node --transpile-only "scripts/${SCRIPT_NAME}.ts")
|
||||
[[ $FORCE -eq 1 ]] && CMD+=(--force)
|
||||
[[ $APPLY -eq 1 ]] && CMD+=(--apply)
|
||||
[[ $ASSUME_YES -eq 1 ]] && CMD+=(--yes)
|
||||
[[ ${#EXTRA_ARGS[@]} -gt 0 ]] && CMD+=("${EXTRA_ARGS[@]}")
|
||||
elif [[ $INVENTORY -eq 1 ]]; then
|
||||
CMD+=(inventory:test-data -- "${EXTRA_ARGS[@]}")
|
||||
else
|
||||
CMD+=(cleanup:test-data --)
|
||||
[[ $FORCE -eq 1 ]] && CMD+=(--force)
|
||||
[[ $APPLY -eq 1 ]] && CMD+=(--apply)
|
||||
[[ $ASSUME_YES -eq 1 ]] && CMD+=(--yes)
|
||||
CMD+=("${EXTRA_ARGS[@]}")
|
||||
fi
|
||||
|
||||
echo "==> 执行: DATABASE_URL=*** ${CMD[*]}"
|
||||
"${CMD[@]}"
|
||||
@@ -1,4 +0,0 @@
|
||||
# 服务器机器相关(可合并到 .env.production 的参考项)
|
||||
PORT=8090
|
||||
DATABASE_URL="mysql://dukang:Dukang2026!@localhost:3306/dukang_haoke"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
+7
-21
@@ -2,7 +2,7 @@
|
||||
# 在服务器上执行:安装 Webhook 自动发版
|
||||
set -euo pipefail
|
||||
|
||||
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
|
||||
APP_ROOT="${APP_ROOT:-/opt/dukang}"
|
||||
DEPLOY_DIR="$APP_ROOT/deploy"
|
||||
ENV_FILE="$DEPLOY_DIR/auto-release.env"
|
||||
LOG_DIR="/var/log/dukang"
|
||||
@@ -30,28 +30,14 @@ HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
|
||||
rm -f /etc/nginx/conf.d/dukang-deploy-webhook.conf
|
||||
|
||||
MARKER="include $HOOK_CONF;"
|
||||
for conf in \
|
||||
/etc/nginx/sites-available/dukang \
|
||||
/etc/nginx/conf.d/dukang-runxian.conf \
|
||||
/etc/nginx/conf.d/dukang-runxian-ssl.conf
|
||||
do
|
||||
if [[ ! -f "$conf" ]]; then
|
||||
continue
|
||||
fi
|
||||
# 清理旧错误 include
|
||||
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
|
||||
if grep -q 'server_name api.dukanghaoke.com' "$conf"; then
|
||||
if ! grep -qF "$MARKER" "$conf"; then
|
||||
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$conf"
|
||||
echo " 已 patch $conf (api.dukanghaoke.com)"
|
||||
fi
|
||||
elif grep -q 'server_name dkapi.runxian.top' "$conf"; then
|
||||
if ! grep -qF "$MARKER" "$conf"; then
|
||||
sed -i "/server_name dkapi.runxian.top;/a\\ $MARKER" "$conf"
|
||||
echo " 已 patch $conf (dkapi.runxian.top)"
|
||||
NGINX_SITE="/etc/nginx/sites-available/dukang"
|
||||
if [[ -f "$NGINX_SITE" ]]; then
|
||||
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$NGINX_SITE"
|
||||
if grep -q 'server_name api.dukanghaoke.com' "$NGINX_SITE" && ! grep -qF "$MARKER" "$NGINX_SITE"; then
|
||||
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$NGINX_SITE"
|
||||
echo " 已 patch $NGINX_SITE (api.dukanghaoke.com)"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# 测试数据清理(通用)
|
||||
|
||||
按 HQ 白名单 + `isTest` 打标清理,**不写死手机号/订单号**。dump 落在 `deploy/backups/`(已 gitignore)。
|
||||
|
||||
## 流程
|
||||
|
||||
1. 备份:`bash deploy/backup-prod-db.sh --yes`;建议同时在 RDS 控制台打手动快照
|
||||
2. 同步到本地演练:`bash deploy/sync-prod-db-to-local.sh --yes`
|
||||
3. 盘点(默认本地 `.env`):`pnpm --dir server/dukang-api inventory:test-data`
|
||||
4. 本地 dry-run / apply:`pnpm --dir server/dukang-api cleanup:test-data`,确认后加 `--apply --yes`
|
||||
5. 生产须口头允许后再执行:`bash deploy/run-prod-cleanup.sh`(dry-run)→ `--apply --yes`
|
||||
6. 回滚:`bash deploy/restore-prod-db.sh --yes backups/prod-<库名>-<时间>.sql`
|
||||
|
||||
已打款且混有测试流水时需 `--force`。有交叉风险(测试合伙人下仍有正式门店等)会中止。
|
||||
+34
-3
@@ -23,6 +23,12 @@
|
||||
| 3.4.15 | 08-07 | mini-user 门店列表卡片;HQ 门店照片替换/删除;套餐多图上限 20 | [`v3.4.15`](./杜康好客-v3.4.15-mini-user门店列表优化.md) |
|
||||
| 3.4.16 | 08-09 | 联系电话分离;取消自动 ST;套餐 UI;合伙人列表/入驻客服门槛;HQ 门店列表表格 | [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md) |
|
||||
| 3.5.3 | 08-20 | 门店照片/企微审核/分享;核销金额序列化与提现补建;测试流水计结算;核销浮动提示;权益金额门店图标 | [`v3.5.3`](./杜康好客-v3.5.3-开发文档.md) |
|
||||
| 3.5.4 | 08-21 | 商品 SPU+SKU 规格;DK 码/独立 69 码/规格主图;下单兼容回落;发票品类;权益文案与门店详情/收银台 UI;客户端版本号 | [`v3.5.4`](./杜康好客-v3.5.4-开发文档.md) |
|
||||
| 3.5.5 | 08-23 | HQ 订单监控导出 Excel/PDF + 列表页布局优化 | [`v3.5.5`](./杜康好客-v3.5.5-开发文档.md) |
|
||||
| 3.5.6 | 08-23 | HQ 任务导出;核销/财务银行信息;工单图片入任务;运营去调试;门店审核全屏 | [`v3.5.6`](./杜康好客-v3.5.6-开发文档.md) |
|
||||
| 3.5.7 | 08-23 | 门店端核销记录时间显示秒;首页「今日核销金额」文案 | [`v3.5.7`](./杜康好客-v3.5.7-开发文档.md) |
|
||||
| 3.5.8 | 08-24 | HQ 单账号追加/撤销权限;运营改客服修复;城市门店服务+城市范围 | [`v3.5.8`](./杜康好客-v3.5.8-开发文档.md) |
|
||||
| 3.5.9 | 08-25 | 门店累计核销好客权益;HQ 表格去省略号;序号列 + 列设置;用户备注 / 手机号不脱敏 | [`v3.5.9`](./杜康好客-v3.5.9-开发文档.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -56,6 +62,14 @@
|
||||
|
||||
**权限**:合伙人平级隔离;推广员仅拓店+看自己店;手机号脱敏(门店/合伙人中间4位)。
|
||||
|
||||
**HQ WebAdmin 权限(v3.5.8)**:
|
||||
|
||||
- 分组:超级管理员 / 运营 / 财务 / 客服 / **城市门店服务**
|
||||
- 生效权限 =(角色权限 ∪ 账号追加)− 账号撤销
|
||||
- 任意 HQ 账号可勾选负责城市;未勾选 = 全国。城市门店服务必须 ≥1 城,且只能看勾选城市的门店(列表、入驻审核、套餐/信息变更审核、评价、账户、资源)。分类为全国字典。
|
||||
- 城市门店服务默认:概览、门店列表、审核通知、评价、分类(不含账户/资源)。分类可新增/编辑,不可删除。概览按权限裁剪,且只看负责城市。
|
||||
- 超管始终全权限、全国可见
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心规则
|
||||
@@ -68,6 +82,13 @@
|
||||
|
||||
`待付款 → 已付款 → 已完成`;30min 未付取消。同城自动推小飞侠(有仓+API);**≥10箱(60瓶)** 大单待 HQ 确认。跨城总部物流到付。现场提货支付即完成。
|
||||
|
||||
**HQ 订单导出**(`admin-web` 订单监控):
|
||||
|
||||
- 支持按筛选(下单日期、状态、类型、城市、配送、收货手机等)或勾选订单导出
|
||||
- 格式:Excel(`.xlsx`)/ PDF(`.pdf`)
|
||||
- 单次上限 5000 条;按筛选且未指定日期时默认近 30 天
|
||||
- 权限:`orders`;操作审计 `ORDER_EXPORT`
|
||||
|
||||
### 3.3 佣金与结算
|
||||
|
||||
| 类型 | 默认 | 释放 |
|
||||
@@ -80,6 +101,13 @@
|
||||
**FIN-001~003**:白名单未出账提现 · 单店日限 ¥5000 · T+0 审完预警。
|
||||
合伙人 **T+30** 独立月账/打款。
|
||||
|
||||
**HQ 财务打款信息**(`admin-web` 财务四页,v3.5.6):
|
||||
|
||||
- 门店/合伙人/酒厂/物流账单列表、详情、导出展示收款账户(户名、账号、开户行)
|
||||
- 确认打款时可填写打款凭证号(`paymentRef`),已打款后在详情与导出中展示
|
||||
- 门店账单统一列表:T+1 终态「已打款」、手动提现终态「已结算」(均为 `PAID`,文案区分业务类型)
|
||||
- 核销详情展示核销门店主账户收款信息
|
||||
|
||||
### 3.4~3.8 Wave 能力
|
||||
|
||||
- **3.4** 门店一号多店、主账号、店员子账号(W2)
|
||||
@@ -93,7 +121,7 @@
|
||||
| § | 主题 | 文档 |
|
||||
|---|------|------|
|
||||
| 3.9 | 门店套餐 ≤10 条、独立审核 | v3.4.10 |
|
||||
| 3.10 | 开发计划/任务/版本/技术支持联动 | v3.4.11 |
|
||||
| 3.10 | 开发计划/任务/版本/技术支持联动;任务导出 md/docx/xlsx/pdf;任务附件 | v3.4.11 / v3.5.6 |
|
||||
| 3.11 | 企微智能机器人 + 消息推送 Webhook | v3.4.11 |
|
||||
| 3.12 | **登录手机号 ≠ 对外联系电话**;体验优化 | v3.4.16 |
|
||||
|
||||
@@ -108,8 +136,11 @@
|
||||
- 未填联系电话时,对外展示回退登录手机号(兼容旧数据)。
|
||||
- **不**再因客户端 `validation_error` 自动创建技术支持工单。
|
||||
- 合伙人入驻提交前须展示 HQ 可配企微客服二维码,并确认已添加客服。
|
||||
- HQ 门店列表:长店名截断、操作列右固定、表过宽可横向滚动。
|
||||
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)。
|
||||
- HQ 门店列表:店名完整展示、操作列右固定、表过宽可横向滚动(v3.5.9 去掉省略号)。
|
||||
- HQ 主列表:最左序号;「列设置」控制显示列与顺序,偏好挂当前 HQ 账号。
|
||||
- 门店列表展示「累计核销好客权益」(该店核销券面额合计)。
|
||||
- HQ 用户列表:昵称只读;双击「备注」离开即保存(`hq_remark`);列表手机号不脱敏(详情仍脱敏)。
|
||||
- 详 [`v3.4.16`](./杜康好客-v3.4.16-门店联系电话与体验优化.md)、[`v3.5.9`](./杜康好客-v3.5.9-开发文档.md)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+15
-3
@@ -9,7 +9,7 @@
|
||||
|------|------|
|
||||
| 主链路 | 登录→下单支付→权益→扫码核销→payout→HQ 打款 **可跑通** |
|
||||
| C 端 | `mini-user` 小程序 + h5-user;v3.4.13 版本门控/门店/物流已上 |
|
||||
| 近期版本 | … · v3.4.16 · **v3.5.3 门店照片/企微审核/分享 + 核销金额/提现补修 + 权益图标** |
|
||||
| 近期版本 | … · v3.5.3 · **v3.5.4 商品规格 SPU+SKU + 发票品类 + 权益/门店详情 UI** |
|
||||
| 冒烟 | `scripts/smoke-v3.mjs` 窄路径 ≠ 全量 ACC |
|
||||
| REQ 明细 | PRD §4 + `.cursor/skills/dukang-v3/reference-req-index.md` |
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
| **mini-user** | 购酒/权益/核销/门店/物流/版本门控 | 规则弹窗、发票、四类型工单、问卷 |
|
||||
| **h5-shop** | 扫码核销、记录、营业、iOS 扫码 OAuth | 手机号核销、提现、子账号、弱网兜底 |
|
||||
| **h5-partner** | 子账号、拓店、订单/账单、套餐 | 试核销100、负责人复核、代下单 |
|
||||
| **admin-web** | 商品/开城/门店/订单/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
|
||||
| **admin-web** | 商品/开城/门店/订单(含 Excel/PDF 导出)/权益/核销/结算/推广码 metrics/开发计划/技术支持 | 完整 SOP 审核 UI、发票、热力图 |
|
||||
| **后端** | 主模块、支付、权益、核销、payout、Courier 适配 | 30min 取消 job、部分 Wave3 |
|
||||
|
||||
## 3. 场景 SC-01~09
|
||||
@@ -57,10 +57,22 @@
|
||||
| 3.4.13 | [`体验优化`](./杜康好客-v3.4.13-体验优化开发文档.md) | ✅ 生产 |
|
||||
| 3.4.14 | [`mini-user 门店体验 + 小程序可配置 + 测试白名单`](./杜康好客-v3.4.14-mini-user门店体验开发文档.md) | ✅ 生产 |
|
||||
| 3.4.15 | [`mini-user 门店列表 + HQ 照片/套餐多图`](./杜康好客-v3.4.15-mini-user门店列表优化.md) | ✅ 生产 |
|
||||
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | 🔶 开发中 |
|
||||
| 3.5.3 | [`门店照片/企微审核/分享 + 核销金额与提现补修`](./杜康好客-v3.5.3-开发文档.md) | ✅ 生产 |
|
||||
| 3.5.4 | [`商品规格 SPU+SKU + 发票/文案 UI`](./杜康好客-v3.5.4-开发文档.md) | ✅ 生产(weapp 版本号需单独上传) |
|
||||
| 3.5.5 | [`HQ 订单导出 + 订单监控页优化`](./杜康好客-v3.5.5-开发文档.md) | 🔶 开发完成,待发版 |
|
||||
| 3.5.6 | [`HQ 任务导出/财务银行信息/工单图片/运营去调试/审核全屏`](./杜康好客-v3.5.6-开发文档.md) | ✅ 已交付 |
|
||||
| 3.5.7 | [`门店核销记录时间秒 + 今日核销金额文案`](./杜康好客-v3.5.7-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.8 | [`HQ 追加/撤销权限 + 运营改客服 + 城市门店服务`](./杜康好客-v3.5.8-开发文档.md) | 🔶 开发完成 |
|
||||
| 3.5.9 | [`门店累计核销 + HQ 去截断 + 列设置`](./杜康好客-v3.5.9-开发文档.md) | 🔶 开发完成 |
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
| 2026-08-25 | v3.5.9:门店列表累计核销好客权益;HQ 表格去省略号;主列表序号 + 列设置存 HQ 账号;用户列表备注(不改昵称)、手机号不脱敏 |
|
||||
| 2026-08-24 | v3.5.8:HQ 单账号追加/撤销权限;运营改客服;城市门店服务与城市范围;分类删除权限与概览按权限/城市裁剪 |
|
||||
| 2026-08-23 | v3.5.7:门店核销记录时间显示秒;首页「今日到账金额」→「今日核销金额」 |
|
||||
| 2026-08-23 | v3.5.6:开发计划任务导出;核销/财务银行信息与打款凭证;工单图片入任务;运营去调试;门店审核全屏 |
|
||||
| 2026-08-23 | HQ 订单监控:筛选/勾选导出 Excel(xlsx)与 PDF;`POST /admin/orders/export` |
|
||||
| 2026-08-21 | v3.5.4 发生产:规格表/回填 SKU/DK 码/主图列/发票品类;H5+API 200 |
|
||||
| 2026-08-20 | v3.5.3 增补:Decimal 序列化/提现补建、测试流水计结算、核销浮动提示、好客权益金额图标 |
|
||||
| 2026-08-08 | v3.4.14 / v3.4.15 已发生产,更新状态 |
|
||||
| 2026-08-07 | v3.4.14 增补统一测试白名单(限测可见 / Mock 旁路;测试流水计入结算) |
|
||||
|
||||
+119
-75
@@ -1,101 +1,145 @@
|
||||
# 杜康好客 · v3.5.4 商品规格(SPU + SKU)
|
||||
# 杜康好客 · v3.5.4 开发文档
|
||||
|
||||
> **2026-08-21** · mini-user / admin-web / h5-partner / API / Prisma
|
||||
> 目标:商品详情页内可选销售规格;价格、权益、69 码、履约、上下架按 SKU;箱装按瓶当量起购与物流。
|
||||
> **生产 commit**:`5fdddae`(规格主图与门店使用规则合并入 `main`)及后续客户端版本号 `3.5.4`
|
||||
> **主题**:商品规格(SPU + SKU)上线 + 同批次发票 / 文案 / 门店详情体验
|
||||
|
||||
## 范围
|
||||
---
|
||||
|
||||
| # | 项 | 交付 |
|
||||
## 1. 版本目标
|
||||
|
||||
1. 商品由单规格升级为 **SPU + 多 SKU**:价格、权益、69 码、履约、上下架、主图按规格。
|
||||
2. 箱装按 **瓶当量** 起购与物流,不改城市起购阈值口径。
|
||||
3. **旧客户端兼容**:不传 `skuId` 仍可下单;无 SKU 回落 SPU,禁止「商品暂无可售规格」误伤。
|
||||
4. 同批次补齐:发票品类、权益文案、收银台 logo、门店详情「使用规则」、C 端版本号。
|
||||
|
||||
**不做**:SKU 库存、把现有酒祖 10/15/20 合并为一个 SPU。
|
||||
|
||||
---
|
||||
|
||||
## 2. 功能清单(完整)
|
||||
|
||||
### 2.1 商品规格(核心)
|
||||
|
||||
| # | 模块 | 交付说明 |
|
||||
|---|------|----------|
|
||||
| 1 | 数据模型 | `common_product_item` 作 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
|
||||
| 2 | 存量迁移 | 每商品默认 1 个瓶装 SKU;SQL `migrate-product-sku-v354.sql`;脚本 `backfill-product-skus.ts` |
|
||||
| 3 | Catalog | 列表 `specEnabled`/`saleUnit`;详情 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 |
|
||||
| 4 | Trade | preview/create 可选 `skuId`;有可售 SKU → 默认/唯一;**0 可售 → 回落 SPU**;订单 `sku_id` 可空 |
|
||||
| 5 | Domain | 起购/大单/物流按 `quantity × bottlesPerUnit` 瓶当量 |
|
||||
| 6 | Admin | `PUT .../specs`、`PUT .../skus`;抽屉加宽;「填写」弹窗;规格轴 + SKU 矩阵 |
|
||||
| 7 | mini-user | 详情规格 chips;确认页带 `skuId`;箱装数量文案;选规格切主图 |
|
||||
| 8 | 代下单 | HQ / 合伙人可选规格 SKU |
|
||||
|
||||
### 2.2 SKU 码与 69 码
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 1 | 数据模型 | `common_product_item` 升级为 SPU;新增 `spec_attr` / `spec_value` / `sku` / `sku_spec`;订单快照 `sku_id` / `sale_unit` / `bottles_per_unit` |
|
||||
| 2 | 存量迁移 | 每商品 1 个默认瓶装 SKU;SQL:`prisma/migrate-product-sku-v354.sql`;脚本:`prisma/backfill-product-skus.ts` |
|
||||
| 3 | Catalog | 列表附加 `specEnabled`/`saleUnit`;详情附加 `specAttrs`/`skus`/`defaultSkuId`;旧字段拍平保留 |
|
||||
| 4 | Trade | preview/create 可选 `skuId`;无 SKU 回落 SPU;有可售 SKU 用默认/唯一 |
|
||||
| 5 | Admin | `PUT /admin/products/:id/specs`、`PUT .../skus`;商品抽屉加宽;规格弹窗填写;每规格主图 |
|
||||
| 6 | mini-user | 详情规格 chips;选规格切主图;确认页带 `skuId`;箱装数量文案 |
|
||||
| 7 | 代下单 | HQ / 合伙人可选规格 SKU |
|
||||
| 8 | 权益文案 | 「好客权益券」→「好客权益」;首页角标「享{amount}好客权益」(去掉门店 icon) |
|
||||
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可填;每规格独立 69 码 |
|
||||
| 10 | 规格主图 | `common_product_sku.image_url` 可选;空则 C 端回落商品封面 |
|
||||
| 9 | SKU 码 | 系统生成 `DK` + 6 位数字,后台不可手填;脚本 `rewrite-sku-codes-dk.ts` 改存量 |
|
||||
| 10 | 69 码 | **每规格独立**;同商品内 + 跨商品唯一;不可与其它 SKU 冲突 |
|
||||
|
||||
**不做**:库存、把现有酒祖 10/15/20 合并为一个 SPU。
|
||||
### 2.3 规格主图
|
||||
|
||||
## 兼容规则(线上)
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 11 | DB | `common_product_sku.image_url`(`migrate-sku-image.sql`) |
|
||||
| 12 | Admin | 弹窗可为每 SKU 上传主图 |
|
||||
| 13 | C 端 | 选规格优先 SKU 主图,空则回落商品封面 |
|
||||
|
||||
- 不改 `/api/v1` 前缀;**只增字段,不删旧字段语义**。
|
||||
### 2.4 发票(同批次)
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 14 | 品类 | `user_invoice.invoice_category`:`LIQUOR` 酒水类 / `CATERING` 餐饮类 |
|
||||
| 15 | C 端申请 | 票种写死「增值税普通发票」;类型选酒水/餐饮;备注写入申请 |
|
||||
| 16 | 抬头 | 必填项打 `*`;强调邮箱必填 |
|
||||
| 17 | Admin | 发票列表展示品类;专票仍可走后台 |
|
||||
|
||||
### 2.5 文案与 UI(同批次)
|
||||
|
||||
| # | 位置 | 变更 |
|
||||
|---|------|------|
|
||||
| 18 | 全局权益文案 | 「好客权益券」→「好客权益」 |
|
||||
| 19 | 首页角标 | 「享{amount}好客权益」,去掉门店 icon |
|
||||
| 20 | 商品详情 | 规格区与「好客权益」说明加大间距 |
|
||||
| 21 | 待支付/收银台 | 顶部 logo 用 `logo2.png`(杜康印章) |
|
||||
| 22 | 门店详情 | 「使用规则」红色,置于「门店详情」上方;详情正文限高可滚动 |
|
||||
| 23 | 客户端版本号 | `package.json` + `client-version.ts` → **3.5.4**(「我的」页展示) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 兼容规则(线上)
|
||||
|
||||
- 不改 `/api/v1`;**只增字段,不删旧字段语义**。
|
||||
- 旧客户端不传 `skuId`:
|
||||
- 有 ≥1 个可售 SKU → 用默认(或第一个)可售 SKU
|
||||
- **没有任何可售 SKU(未回填)→ 回落 SPU 字段,照常下单**(禁止再报「商品暂无可售规格」)
|
||||
- 传入 `skuId`(新客户端选规格)→ 按该 SKU 校验。
|
||||
- 旧 admin `PUT /admin/products/:id` 不传规格时:仅同步**唯一**默认 SKU(多规格时不同步,防误改);不同步覆盖已生成的 DK 码。
|
||||
- SPU 上 `sku_code` / `barcode_69` 去掉唯一约束,唯一下沉到 `common_product_sku`;SPU 列保留为默认 SKU 冗余。
|
||||
- 订单 `sku_id` 可空;无规格快照走商品 69 码 / 价格。
|
||||
- ≥1 个可售 SKU → 默认(或第一个)可售 SKU
|
||||
- **0 个可售 SKU → 回落 SPU,照常下单**
|
||||
- 新客户端传 `skuId` → 按该 SKU 校验。
|
||||
- 旧 admin 不传规格:仅同步**唯一**默认 SKU;不覆盖已生成的 DK 码。
|
||||
- SPU 上 `sku_code` / `barcode_69` 唯一约束下沉到 SKU;SPU 列保留冗余。
|
||||
|
||||
## 发版一并执行
|
||||
---
|
||||
|
||||
按顺序在**目标环境库**执行(先测试后生产)。本机 `npx ts-node …` 只改本地 `.env` 库,不会自动打线上。
|
||||
## 4. 发版与生产执行记录
|
||||
|
||||
1. **规格表** `server/dukang-api/prisma/migrate-product-sku-v354.sql`
|
||||
建 spec/sku 表,订单加 `sku_id` / `sale_unit` / `bottles_per_unit`。
|
||||
2. **回填默认 SKU**
|
||||
`cd server/dukang-api && npx ts-node prisma/backfill-product-skus.ts`
|
||||
尚无 SKU 的商品各建 1 条默认瓶装 SKU(码用 DK)。
|
||||
3. **SKU 主图列** `server/dukang-api/prisma/migrate-sku-image.sql`
|
||||
```sql
|
||||
ALTER TABLE `common_product_sku`
|
||||
ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`;
|
||||
```
|
||||
4. **SKU 码改 DK**(生产须在服务器用 `.env.production` 的 `DATABASE_URL`)
|
||||
`npx dotenv -e .env.production -- npx ts-node prisma/rewrite-sku-codes-dk.ts`
|
||||
已是 `DK`+数字的不动;`JZ-10` / `QX-001` 等会改。**不改 69 码、订单、价格。**
|
||||
5. **发票品类列**(同批次)`server/dukang-api/prisma/migrate-invoice-category.sql`
|
||||
```sql
|
||||
ALTER TABLE `user_invoice`
|
||||
ADD COLUMN `invoice_category` VARCHAR(16) NOT NULL DEFAULT 'LIQUOR' AFTER `invoice_kind`;
|
||||
```
|
||||
6. 发布 API + mini-user + admin-web。
|
||||
**此时不要给商品加第二规格**,等新小程序上线后再配「单瓶 / 整箱」。
|
||||
按序在目标库执行(先测后产)。本机脚本只打本地 `.env`。
|
||||
|
||||
## 起购与物流
|
||||
| 步骤 | 动作 | 生产状态(2026-08-21) |
|
||||
|------|------|------------------------|
|
||||
| 1 | `migrate-product-sku-v354.sql` / Prisma sync | ✅ `db push` 已对齐 |
|
||||
| 2 | `backfill-product-skus.ts` | ✅ 回填 9 个默认 SKU(扫 10 商品) |
|
||||
| 3 | `migrate-sku-image.sql` / schema `image_url` | ✅ |
|
||||
| 4 | `rewrite-sku-codes-dk.ts` | ✅ 13 条均已 DK,改写 0 |
|
||||
| 5 | `migrate-invoice-category.sql` | ✅ schema 已含 |
|
||||
| 6 | 发 API + mini-user H5 + admin | ✅ `mini-user 200` / `api 200` |
|
||||
| 7 | 微信小程序 weapp 上传 | ⚠️ 需单独上传后「我的」页才见 v3.5.4 |
|
||||
|
||||
运营注意:新包上线前**不要**给商品加第二规格;上线后再配「单瓶 / 整箱」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 起购与物流
|
||||
|
||||
- `bottleQty = quantity × bottlesPerUnit`
|
||||
- 城市起购阈值仍是瓶;箱装 SKU(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6。
|
||||
- 大单拦截、承运商 `goodsNum` 使用瓶当量。
|
||||
- 城市起购仍按瓶;箱装(`bottlesPerUnit=6`)数量 1 即可过同城 2 / 跨城 6
|
||||
- 大单拦截、承运商 `goodsNum` 用瓶当量
|
||||
|
||||
## 关键表摘要
|
||||
---
|
||||
|
||||
## 6. 关键表 / 字段摘要
|
||||
|
||||
- `common_product_spec_attr` / `common_product_spec_value`
|
||||
- `common_product_sku`(`sale_unit`=`BOTTLE|BOX`,`bottles_per_unit`,`image_url` 可选)
|
||||
- `common_product_sku`(`sale_unit`、`bottles_per_unit`、`image_url`)
|
||||
- `common_product_sku_spec`
|
||||
- `user_order.sku_id` / `sale_unit` / `bottles_per_unit`
|
||||
- `user_invoice.invoice_category`:`LIQUOR` 酒水类 / `CATERING` 餐饮类;票种 C 端固定增值税普通发票
|
||||
- `user_invoice.invoice_category`
|
||||
|
||||
## SKU 码与 69 码
|
||||
---
|
||||
|
||||
- SKU 码:服务端生成 `DK000001` 起,后台只展示。
|
||||
- 每个规格必须填写**互不相同**的 69 码(全局不可与其它商品 SKU 冲突)。
|
||||
- 总部「规格与 SKU」点「填写」弹窗编辑 69 码 / 价 / 履约 / 主图。
|
||||
## 7. 关键路径(便于排查)
|
||||
|
||||
## 权益与小程序 UI(同批次)
|
||||
| 域 | 路径 |
|
||||
|----|------|
|
||||
| Prisma / 脚本 | `server/dukang-api/prisma/migrate-product-sku-v354.sql`、`backfill-product-skus.ts`、`rewrite-sku-codes-dk.ts`、`migrate-sku-image.sql`、`migrate-invoice-category.sql` |
|
||||
| 规格工具 | `server/.../catalog/product-sku.util.ts` |
|
||||
| Admin 规格 | `apps/admin-web/src/components/ProductSpecsEditor.tsx` |
|
||||
| C 端详情 | `apps/mini-user/src/pages/product-detail/` |
|
||||
| 版本号 | `apps/mini-user/src/lib/client-version.ts` |
|
||||
| 门店详情 | `apps/mini-user/src/pages/store-detail/` |
|
||||
| 发票 | `apps/mini-user/src/pages/invoice-apply/`、`invoice-titles/` |
|
||||
|
||||
| 位置 | 变更 |
|
||||
|------|------|
|
||||
| 门店详情等 | 「好客权益券」→「好客权益」 |
|
||||
| 首页商品角标 `CouponBadge` | 纯文案 **「享{amount}好客权益」**,无门店 icon |
|
||||
| 商品详情 | 规格区与「好客权益」说明之间加大间距 |
|
||||
| 待支付 / 收银台 | 顶部 logo 使用 `public/images/logo2.png`(杜康印章) |
|
||||
| 门店详情 | 「使用规则」四字红色,放在「门店详情」**上面**;门店详情正文限高,超出上下滚动 |
|
||||
| 发票申请 | 票种写死增值税普通发票;类型选酒水类/餐饮类;申请备注;抬头邮箱必填打 `*` |
|
||||
---
|
||||
|
||||
其它页 `BenefitFigure`(我的/权益/核销等)仍保留门店核销图标,仅首页角标去 icon。
|
||||
## 8. 验收清单
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 未配规格:旧小程序/H5/代下单路径与现网一致(无 SKU 也能下单)
|
||||
- [ ] 多规格:详情选规格后价格/权益/履约/主图变化
|
||||
- [ ] 整箱 SKU:数量 1 过起购;订单快照 `bottles_per_unit=6`
|
||||
- [ ] `GET /catalog/products/:id` 仍含 `id/name/price/spec/skuCode/...`
|
||||
- [ ] 小程序无「好客权益券」字样;首页角标为「享{金额}好客权益」且无门店 icon
|
||||
- [ ] 后台 SKU 码为 DK 开头且不可手填;每规格独立 69 码与主图
|
||||
- [ ] 门店详情「使用规则」红色且在门店详情上方;门店详情超长可滚动
|
||||
- [ ] 待支付页顶部为 logo2 印章
|
||||
- [ ] 未配规格:旧端下单与现网一致(无 SKU 也能下)
|
||||
- [ ] 多规格:选规格后价格/权益/履约/主图变化;漏选 400
|
||||
- [ ] 整箱:数量 1 过起购;订单 `bottles_per_unit=6`
|
||||
- [ ] 详情 API 仍含 `id/name/price/spec/skuCode/...`
|
||||
- [ ] 后台 SKU 码 DK 开头不可手填;每规格独立 69 码与主图
|
||||
- [ ] 无「好客权益券」;首页角标「享{金额}好客权益」无门店 icon
|
||||
- [ ] 门店详情「使用规则」红色且在详情上方;详情超长可滚
|
||||
- [ ] 待支付页为 logo2 印章
|
||||
- [ ] 发票:普票固定 + 酒水/餐饮品类 + 邮箱必填
|
||||
- [ ] 「我的」页版本 **杜康好客 v3.5.4**(需 weapp 新包)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
# 杜康好客 · v3.5.5 开发文档
|
||||
|
||||
> **2026-08-23** · admin-web / API
|
||||
> **主题**:HQ 订单监控导出(Excel / PDF)+ 订单列表页布局优化
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本目标
|
||||
|
||||
1. 总部 **订单监控** 支持按筛选或勾选导出 **Excel(.xlsx)** / **PDF(.pdf)**。
|
||||
2. 导出内容聚焦运营履约:**商品明细、收货信息、好客权益摘要**;次要字段从列表与导出列中精简。
|
||||
3. **订单列表页** 筛选区改为网格布局,支持日期快捷选择;导出、查询、重置、批量删除同一操作行。
|
||||
4. 修复现场取货订单地址展示为「现场现场取货现场取货」的问题。
|
||||
|
||||
**不做**:C 端 / 合伙人 / 门店端改动;无 Prisma 迁移。
|
||||
|
||||
---
|
||||
|
||||
## 2. 功能清单
|
||||
|
||||
### 2.1 HQ 订单导出(核心)
|
||||
|
||||
| # | 模块 | 交付说明 |
|
||||
|---|------|----------|
|
||||
| 1 | API | `POST /api/v1/admin/orders/export` — 生成文件,响应 `{ filename, mimeType, contentBase64, count }` |
|
||||
| 2 | 预览 | `POST /api/v1/admin/orders/export/preview` — 返回 `{ count, max, exceeds }`(上限 5000) |
|
||||
| 3 | 导出范围 | `scope=filter` 按当前筛选;`scope=selected` 仅导出勾选 `ids` |
|
||||
| 4 | 格式 | `format=xlsx`(exceljs)/ `format=pdf`(pdfkit,横向 A4) |
|
||||
| 5 | 权限 | `HqAuthGuard` + `orders`;操作审计 `ORDER_EXPORT` |
|
||||
| 6 | 筛选复用 | `buildOrderWhere()` 与列表查询共用;新增 `deliveryType` 筛选 |
|
||||
|
||||
**导出列**(11 列):
|
||||
|
||||
订单号、下单时间、状态、商品、规格、数量、实付、好客权益、收货人、手机、收货地址。
|
||||
|
||||
好客权益列:有权益券时 `总额¥X / 已用¥Y / 余¥Z`;否则回落订单 `benefitAmount`。
|
||||
|
||||
### 2.2 订单列表页(admin-web)
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 7 | 筛选布局 | 网格表单:下单日期、状态、类型、配送、城市、收货手机;大单拦截 / 过滤测试 |
|
||||
| 8 | 日期快捷 | 当日、当周(周一至周日)、当月、当季、当年 |
|
||||
| 9 | 列表列 | 商品(含规格/数量/标签)、状态+实付、好客权益、收货信息、下单时间、操作 |
|
||||
| 10 | 勾选导出 | 列表常驻勾选;左侧 Excel/PDF +「导出已勾选」「导出全部筛选」 |
|
||||
| 11 | 操作行 | 右侧同一行:查询、重置、批量删除(有权限时) |
|
||||
| 12 | 现场取货 | 收货地址识别 `ON_SITE_PICKUP` 或省市区占位后,统一显示「现场取货」 |
|
||||
|
||||
### 2.3 列表数据增强
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 13 | 权益关联 | `GET /admin/orders` include `benefitCoupon`(券号、总额/已用/余额、状态) |
|
||||
| 14 | 收货字段 | `AdminOrderRow` 增加省市区、地址、`benefitAmount`、`benefitCoupon` 类型 |
|
||||
|
||||
---
|
||||
|
||||
## 3. API 契约
|
||||
|
||||
### 3.1 导出请求体 `AdminOrdersExportDto`
|
||||
|
||||
```json
|
||||
{
|
||||
"scope": "filter",
|
||||
"format": "xlsx",
|
||||
"ids": ["123"],
|
||||
"status": "PENDING_SHIP",
|
||||
"orderType": "NORMAL",
|
||||
"cityId": "1",
|
||||
"receiverPhone": "138",
|
||||
"deliveryType": "LOCAL",
|
||||
"fulfillmentHold": true,
|
||||
"excludeTest": true,
|
||||
"createdFrom": "2026-08-01",
|
||||
"createdTo": "2026-08-23"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `scope` | `filter` \| `selected`(勾选时仅传 `ids`,忽略其它筛选) |
|
||||
| `format` | `xlsx` \| `pdf` |
|
||||
| `createdFrom` / `createdTo` | 按筛选导出且均未填时,服务端默认近 30 天 |
|
||||
|
||||
### 3.2 错误码
|
||||
|
||||
| 场景 | HTTP | message |
|
||||
|------|------|---------|
|
||||
| 超过 5000 条 | 400 | 超过 5000 条,请缩小日期或筛选条件 |
|
||||
| 无数据 | 400 | 没有可导出的订单 |
|
||||
| 勾选为空 | 400 | 请先勾选要导出的订单 |
|
||||
| PDF 无字体 | 500 | 未找到可用于 PDF 的中文字体… |
|
||||
|
||||
### 3.3 列表查询增量
|
||||
|
||||
`GET /admin/orders` 查询参数新增(与导出筛选一致):
|
||||
|
||||
- `deliveryType`:`LOCAL` \| `CROSS_CITY` \| `ON_SITE_PICKUP`
|
||||
- `createdFrom` / `createdTo`:下单日期(已有 DTO,本版接 UI)
|
||||
|
||||
---
|
||||
|
||||
## 4. 依赖与部署
|
||||
|
||||
### 4.1 新增 npm 依赖(仅 API)
|
||||
|
||||
- `exceljs` — xlsx 生成
|
||||
- `pdfkit` + `@types/pdfkit` — pdf 生成
|
||||
|
||||
### 4.2 PDF 中文字体
|
||||
|
||||
路径:`server/dukang-api/assets/fonts/`(见 `README.md`)。
|
||||
|
||||
解析顺序:
|
||||
|
||||
1. 环境变量 `EXPORT_PDF_FONT_PATH`
|
||||
2. `assets/fonts/NotoSansSC-Regular.otf` / `simhei.ttf` / `msyh.ttc`
|
||||
3. Linux 系统 Noto CJK
|
||||
4. Windows 系统字体(开发机)
|
||||
|
||||
> 字体二进制已加入 `.gitignore`;生产部署需放置字体或安装 `fonts-noto-cjk`。
|
||||
|
||||
### 4.3 发版步骤
|
||||
|
||||
| 步骤 | 动作 |
|
||||
|------|------|
|
||||
| 1 | `pnpm install`(根目录,拉取 exceljs/pdfkit) |
|
||||
| 2 | 确认 `server/dukang-api/assets/fonts/` 有中文字体(PDF 必需) |
|
||||
| 3 | `pnpm build`(API + admin-web) |
|
||||
| 4 | 发 `dukang-api` + `admin-web`;**无需** `prisma migrate` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键路径(便于排查)
|
||||
|
||||
| 域 | 路径 |
|
||||
|----|------|
|
||||
| 导出工具 | `server/dukang-api/src/modules/ops/admin-order-export.util.ts` |
|
||||
| 订单服务 | `server/dukang-api/src/modules/ops/admin-orders.service.ts` |
|
||||
| 控制器 | `server/dukang-api/src/modules/ops/admin-orders.controller.ts` |
|
||||
| DTO | `server/dukang-api/src/modules/ops/dto/admin-query.dto.ts` |
|
||||
| 审计常量 | `server/dukang-api/src/common/hq-operation/hq-operation.constants.ts`(`ORDER_EXPORT`) |
|
||||
| 前端页面 | `apps/admin-web/src/pages/OrdersPage.tsx` |
|
||||
| 下载工具 | `apps/admin-web/src/lib/exportExcel.ts`(`downloadBase64File`) |
|
||||
| 类型 | `apps/admin-web/src/lib/api.ts`(`AdminOrderRow`) |
|
||||
| 需求 | `docs/杜康好客-v3-PRD.md` §3.2 HQ 订单导出 |
|
||||
| 现状 | `docs/杜康好客-v3-现状对照.md` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收清单
|
||||
|
||||
- [ ] 订单列表:网格筛选 + 日期快捷(当日/当周/当月/当季/当年)可用
|
||||
- [ ] 列表展示:商品(规格×数量)、状态/实付、好客权益、收货人+手机+地址
|
||||
- [ ] 现场取货订单地址仅显示「现场取货」,不出现重复拼接
|
||||
- [ ] 勾选若干订单 →「导出已勾选」→ xlsx / pdf 条数与勾选一致
|
||||
- [ ] 设筛选条件 →「导出全部筛选」→ 文件含筛选范围内全部订单(≤5000)
|
||||
- [ ] 超 5000 条时导出全部筛选返回明确错误
|
||||
- [ ] Excel / PDF 中文正常;PDF 表头与分页可读
|
||||
- [ ] 无 `orders` 权限账号无法调用导出接口
|
||||
- [ ] HQ 操作日志有 `ORDER_EXPORT` 记录
|
||||
- [ ] 无删除权限账号仍可勾选并导出;有删除权限时批量删除与导出/查询同一行
|
||||
|
||||
---
|
||||
|
||||
## 7. 与 v3.5.4 关系
|
||||
|
||||
- **仅 HQ 内部工具**,不影响 C 端小程序版本号。
|
||||
- 商品规格(SPU/SKU)、发票等 v3.5.4 能力保持不变。
|
||||
- PRD §3.2 已补充 HQ 订单导出业务规则;本版为实现与 UI 落地说明。
|
||||
@@ -0,0 +1,129 @@
|
||||
# 杜康好客 · v3.5.6 开发文档
|
||||
|
||||
> **2026-08-23** · admin-web / API
|
||||
> **主题**:HQ 运营与财务体验增强(任务导出、核销/账单银行信息、工单图片、权限、审核全屏)
|
||||
|
||||
---
|
||||
|
||||
## 1. 版本目标
|
||||
|
||||
1. **开发计划** 任务列表支持导出 Markdown / DOCX / Excel / PDF。
|
||||
2. **好客权益 · 核销详情** 展示核销门店收款账户(户名/账号/开户行)。
|
||||
3. **门店账单** 保留「已打款」「已结算」文案,用颜色与类型标签区分;页头说明两类终态含义。
|
||||
4. **财务四页**(门店/合伙人/酒厂/物流)统一展示收款账户与打款凭证;确认打款可填凭证号。
|
||||
5. **技术支持 → 开发任务** 审批创建任务时带入工单图片;新建任务支持上传图片。
|
||||
6. **运营权限** 去掉调试模块(核销调试、小飞侠联调),仅超级管理员可见。
|
||||
7. **门店审核对比** 支持全屏查看详情。
|
||||
|
||||
**不做**:C 端 / 合伙人 / 门店端改动;不升小程序版本号。
|
||||
|
||||
---
|
||||
|
||||
## 2. 功能清单
|
||||
|
||||
### 2.1 开发计划任务导出
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 1 | API | `POST /api/v1/admin/dev-plan/tasks/export` |
|
||||
| 2 | 范围 | `scope=filter` 按筛选;`scope=selected` 仅勾选 `ids` |
|
||||
| 3 | 格式 | `markdown` / `docx` / `xlsx` / `pdf` |
|
||||
| 4 | 列 | 任务编号、类型、状态、内容、关联工单、创建人、创建时间、完成时间、附件 |
|
||||
| 5 | 权限 | `dev_plan` |
|
||||
|
||||
### 2.2 核销详情 · 门店收款账户
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 6 | API | `GET /admin/redeem-records/:id` 增加 `store.primaryAccount` |
|
||||
| 7 | UI | `RedeemRecordDetailDescriptions` 增加「收款账户」区块 |
|
||||
|
||||
### 2.3 门店账单状态区分
|
||||
|
||||
| 类型 | 终态 | 颜色 | 含义 |
|
||||
|------|------|------|------|
|
||||
| T+1 出账 | 已打款 | 绿 | HQ 确认已对公打款 |
|
||||
| 手动提现 | 已结算 | 青 | HQ 审核通过并完成结算 |
|
||||
|
||||
两类单据终态枚举均为 `PAID`,故文案不同;本版不改文案,仅强化视觉区分。
|
||||
|
||||
### 2.4 财务四页 · 银行卡打款信息
|
||||
|
||||
| 页 | 收款账户来源 | 打款凭证 |
|
||||
|----|-------------|----------|
|
||||
| 门店账单 | 主 `StoreAccount` | `StoreBill.paymentRef`(T+1);提现沿用 `StoreWithdrawRequest` |
|
||||
| 合伙人账单 | `PartnerAccount` | `PartnerBill.paymentRef` |
|
||||
| 酒厂账单 | `WINERY_BANK_*` 系统配置 | `WineryBill.paymentRef` |
|
||||
| 物流对账 | `FulfillmentProvider` | `LogisticsBill.paymentRef`(挂账模式) |
|
||||
|
||||
列表 / 详情 / 导出均展示户名、账号、开户行(及酒厂开户银行);确认打款弹窗可选填凭证号。
|
||||
|
||||
### 2.5 工单 → 任务图片
|
||||
|
||||
| # | 项 | 说明 |
|
||||
|---|----|------|
|
||||
| 8 | DB | `dev_plan_task.attachment_urls` JSON |
|
||||
| 9 | 审批创建 | 默认拷贝工单 `attachmentUrls` |
|
||||
| 10 | 新建任务 | `DevPlanTasksPage` 支持 `OssUpload` |
|
||||
|
||||
### 2.6 运营去掉调试模块
|
||||
|
||||
| 菜单 | 路由 | 新权限键 |
|
||||
|------|------|----------|
|
||||
| 核销调试 | `/redeem/debug` | `benefit_debug` |
|
||||
| 小飞侠联调 | `/deliveries/xiaofeixia` | `deliveries_debug` |
|
||||
|
||||
仅 `SUPER_ADMIN` 默认拥有;调试 API 同步鉴权。
|
||||
|
||||
### 2.7 门店审核全屏
|
||||
|
||||
`StorePackageAuditsPage` 套餐对比 / 信息变更 Drawer 支持「全屏查看」切换 `width=100%`;全屏时图片放大。
|
||||
|
||||
---
|
||||
|
||||
## 3. Prisma 迁移
|
||||
|
||||
- `store_bill.payment_ref`
|
||||
- `partner_bill.payment_ref`
|
||||
- `winery_bill.payment_ref`
|
||||
- `logistics_bill.payment_ref`
|
||||
- `dev_plan_task.attachment_urls`
|
||||
|
||||
脚本:`prisma/migrate-finance-payment-ref-v356.sql`、`prisma/migrate-dev-plan-attachments-v356.sql`
|
||||
|
||||
---
|
||||
|
||||
## 4. 发版步骤
|
||||
|
||||
| 步骤 | 动作 |
|
||||
|------|------|
|
||||
| 1 | `pnpm install`(新增 `docx`) |
|
||||
| 2 | 执行 Prisma 迁移 |
|
||||
| 3 | `pnpm build`(API + admin-web) |
|
||||
| 4 | 发 `dukang-api` + `admin-web` |
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键路径
|
||||
|
||||
| 域 | 路径 |
|
||||
|----|------|
|
||||
| 任务导出 | `server/.../dev-plan/admin-dev-plan-export.util.ts` |
|
||||
| 核销详情 | `admin-redeem.service.ts` + `RedeemRecordDetailDescriptions.tsx` |
|
||||
| 门店账单 | `StoreBillsPage.tsx` + `settlement.service.ts` |
|
||||
| 合伙人/酒厂/物流 | `PartnerBillsPage` / `WineryBillsPage` / `LogisticsBillsPage` |
|
||||
| 工单图片 | `dev-plan.service.ts` + `SupportTicketsPage.tsx` + `DevPlanTasksPage.tsx` |
|
||||
| 权限 | `hq-permissions.ts` + `AdminLayout.tsx` |
|
||||
| 审核全屏 | `StorePackageAuditsPage.tsx` + `StorePackageAuditPanel.tsx` |
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收清单
|
||||
|
||||
- [ ] 开发计划:勾选/筛选导出 md/docx/xlsx/pdf,中文正常
|
||||
- [ ] 核销详情:有主账户时展示户名/账号/开户行
|
||||
- [ ] 门店账单:T+1「已打款」绿色、提现「已结算」青色;类型 Tag 醒目
|
||||
- [ ] 财务四页:列表/详情/导出含收款账户;确认打款可填凭证;已打款后可见凭证
|
||||
- [ ] 工单审批创建任务:图片带入;新建任务可上传图
|
||||
- [ ] 运营账号无「核销调试」「小飞侠联调」菜单;直链 403
|
||||
- [ ] 门店审核对比可全屏;图片放大可预览
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user