Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b8e3f1347 | |||
| a8a37fbc4a | |||
| 1440a59fc8 | |||
| e34416a9b3 | |||
| 11659484c6 | |||
| 6c44e9c56f | |||
| 0fb7ab7abb | |||
| 905e145af3 |
@@ -0,0 +1,55 @@
|
||||
---
|
||||
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,11 +1,33 @@
|
||||
---
|
||||
description: 杜康提交 scope 与发版默认(生产)
|
||||
description: Git 提交与 PR 协作规范
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git / 发版(杜康专属)
|
||||
# Git 规范
|
||||
|
||||
- Conventional Commits,scope = 端或模块:`feat(trade):`、`fix(redeem):`
|
||||
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
|
||||
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
|
||||
- Prisma 迁移须 Review
|
||||
## 提交格式
|
||||
|
||||
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
|
||||
|
||||
@@ -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 段 + 对应 `apps/` 或 `server/dukang-api/AGENTS.md` → **只改本 OWNER 路径**
|
||||
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 表 → **只改本 OWNER 路径**
|
||||
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
|
||||
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
|
||||
5. 编码前过一遍下方 Checklist
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,7 +4,6 @@ description: >-
|
||||
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
|
||||
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
|
||||
or @杜康发布 / @dukang-release.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# 杜康发布
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,8 +21,4 @@ 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,37 +1,133 @@
|
||||
# 杜康好客 · AGENTS.md
|
||||
|
||||
> AI 编码入口。人类读 [`agent.md`](./agent.md)。启动、端口、测试账号见 [`README.md`](./README.md)。
|
||||
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。
|
||||
|
||||
杜康酒业 O2O:**购酒 → 发券 → 门店核销**。Monorepo + NestJS 单体(`/api/v1`,`{ code, message, data }`)。
|
||||
## 项目概览
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `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 原型,只读 |
|
||||
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
|
||||
|
||||
前端:[`apps/AGENTS.md`](./apps/AGENTS.md)。后端模块/表 OWNER:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)。
|
||||
| 阶段 | 手册 | 说明 |
|
||||
|------|------|------|
|
||||
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
|
||||
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
|
||||
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
|
||||
| ~~V2 / preV1~~ | 历史参考(**已压缩**;V2 全文见 git 2026-08-06 前) | **不再作为需求依据** |
|
||||
|
||||
## 文档(冲突时)
|
||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
||||
|
||||
1. `杜康好客-v3-PRD.md` — **唯一需求事实源**(编码时禁止改业务规则;需求变更只改它)
|
||||
2. `杜康好客-v3-现状对照.md` — 进度
|
||||
3. `杜康好客-v3编码手册.md` — 验收、核销、分工
|
||||
4. `conventions.md` — 协作边界
|
||||
5. V2 手册 §四–§六 — 架构/DB/API **参考**(与 V3 冲突则忽略)
|
||||
## 启动命令
|
||||
|
||||
```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 冲突时忽略)
|
||||
6. `pages/{user,shop,partner,hq}/` — UI 参照
|
||||
|
||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 过时文档;`apps/*` import `server/*`;跨模块 `prisma` 写他人表。V2 / preV1 手册不再作需求依据。
|
||||
> **V2 / preV1 手册不再作为需求依据。**
|
||||
|
||||
## OWNER(2026-07 起暂由 jacy 代管全部)
|
||||
## 团队与 OWNER 边界(2 人)
|
||||
|
||||
`jacy-dukang` 可改 `apps/*`、`packages/*`、全部后端模块。逻辑边界仍按 A/B/C/D,便于子 Agent 隔离。Prisma 迁移由 jacy 主导。
|
||||
| 负责人 | Git 账号 | 职责 |
|
||||
|--------|----------|------|
|
||||
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
|
||||
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管**) |
|
||||
|
||||
- 跨模块只 inject 对方 **exported Service**
|
||||
- Mock 只在 `integrations/*`;微信/配送回调只进 `callbacks/`
|
||||
> **临时分工(2026-07)**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
|
||||
|
||||
## 不可偏离
|
||||
逻辑模块边界仍按 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
|
||||
@@ -41,10 +137,38 @@ C 端门店仅 status=OPEN
|
||||
订单 Tab:待付款 | 已付款 | 已完成
|
||||
```
|
||||
|
||||
微信:iOS H5 登录后禁止仅 SPA 跳转再扫码(`packages/weixin-sdk/GOTCHAS.md`)。小程序 `open-type` 按钮祖先禁止 `stopPropagation`;weapp 标题只用 `navigationBarTitleText`。`h5-partner` 与 `admin-web` 同端口 5175,勿同时开。
|
||||
**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`。
|
||||
|
||||
## 按需加载
|
||||
## 环境与发版
|
||||
|
||||
- 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产)
|
||||
- 子 Agent:`/owner-a-user-trade` 等(见 `.cursor/agents/`)
|
||||
- DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
|
||||
| 环境 | 分支 | 目录 | 端口 | 域名 |
|
||||
|------|------|------|------|------|
|
||||
| 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)
|
||||
|
||||
+30
-113
@@ -16,109 +16,37 @@ body,
|
||||
}
|
||||
|
||||
/*
|
||||
* 后台表格:不换行、完整展示(v3.5.9 去掉省略号)
|
||||
* 描述列表仍省略,避免详情抽屉被长字段撑破
|
||||
* 后台内容行:不换行 + 超出省略
|
||||
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
|
||||
*/
|
||||
.admin-layout .ant-table-cell,
|
||||
.ant-drawer .ant-table-cell,
|
||||
.ant-modal .ant-table-cell {
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ant-modal .ant-table-cell,
|
||||
.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: visible;
|
||||
text-overflow: clip;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 拖表头分割线改列宽 */
|
||||
.admin-layout .ant-table-thead > tr > th.admin-th-resizable {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-col-resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 10px;
|
||||
cursor: col-resize;
|
||||
z-index: 3;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.admin-col-resize-handle:hover,
|
||||
.admin-col-resizing .admin-col-resize-handle {
|
||||
background: rgba(22, 119, 255, 0.18);
|
||||
}
|
||||
|
||||
body.admin-col-resizing,
|
||||
body.admin-col-resizing * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
/* 列表页顶栏:标题左、主操作右、列设置最右 */
|
||||
.admin-list-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-list-header-left {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-list-header-desc {
|
||||
margin-top: 4px;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-list-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-list-settings-slot {
|
||||
display: inline-flex;
|
||||
margin-left: auto;
|
||||
order: 99;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-list-settings-btn.ant-btn {
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
padding-inline: 8px;
|
||||
}
|
||||
|
||||
.admin-list-settings-btn.ant-btn:hover {
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
|
||||
/* 操作列保持可见 */
|
||||
/* 操作列、含交互控件的不截断 */
|
||||
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||
.ant-modal .ant-table-cell:has(.ant-btn),
|
||||
@@ -143,9 +71,10 @@ body.admin-col-resizing * {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* 表格单元格主/副信息同一行 */
|
||||
/* 表格单元格双行信息合并为单行省略 */
|
||||
.admin-cell-line {
|
||||
display: inline;
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.admin-cell-line-secondary {
|
||||
@@ -153,23 +82,6 @@ body.admin-col-resizing * {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-primary-link {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-primary-link:hover {
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.admin-table-nowrap .ant-table-cell,
|
||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||
white-space: nowrap;
|
||||
@@ -396,6 +308,14 @@ body:has(.big-screen-page),
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.big-screen-list-head {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 28px 10px 40px;
|
||||
font-size: 15px;
|
||||
color: rgba(170, 200, 230, 0.55);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.big-screen-list-body {
|
||||
flex: 1 1 0%;
|
||||
min-height: 0;
|
||||
@@ -540,20 +460,16 @@ body:has(.big-screen-page),
|
||||
}
|
||||
|
||||
.big-screen-empty {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
/* 全屏高度黄金分割点(≈0.382),略偏上 */
|
||||
top: 38.2%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
color: rgba(145, 190, 230, 0.42);
|
||||
padding: 0 40px;
|
||||
padding: 48px 40px;
|
||||
font-size: 60px;
|
||||
letter-spacing: 6px;
|
||||
font-weight: 400;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.big-screen-fx {
|
||||
@@ -806,6 +722,7 @@ body:has(.big-screen-page),
|
||||
.big-screen-clock {
|
||||
font-size: 56px;
|
||||
}
|
||||
.big-screen-list-head,
|
||||
.big-screen-row {
|
||||
grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
|
||||
font-size: 16px;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,10 +1,12 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
type AdminCellLineProps = {
|
||||
primary?: string | null;
|
||||
secondary?: string | null;
|
||||
separator?: string;
|
||||
};
|
||||
|
||||
/** 表格单元格:主/副信息同一行完整展示 */
|
||||
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
|
||||
export function AdminCellLine({
|
||||
primary,
|
||||
secondary,
|
||||
@@ -12,9 +14,14 @@ export function AdminCellLine({
|
||||
}: AdminCellLineProps) {
|
||||
const main = primary?.trim() || '—';
|
||||
const sub = secondary?.trim();
|
||||
const full = sub ? `${main}${separator}${sub}` : main;
|
||||
|
||||
return (
|
||||
<span className="admin-cell-line">
|
||||
<Typography.Text
|
||||
className="admin-cell-line"
|
||||
ellipsis={{ tooltip: full }}
|
||||
style={{ maxWidth: '100%' }}
|
||||
>
|
||||
{main}
|
||||
{sub ? (
|
||||
<span className="admin-cell-line-secondary">
|
||||
@@ -22,6 +29,6 @@ export function AdminCellLine({
|
||||
{sub}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Space, Typography } from 'antd';
|
||||
|
||||
/** 列表页顶栏:标题在左,主操作在右,列设置永远最右 */
|
||||
export function AdminListHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
settings,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
settings?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-list-header">
|
||||
<div className="admin-list-header-left">
|
||||
{title == null || title === '' ? null : typeof title === 'string' || typeof title === 'number' ? (
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
{title}
|
||||
</Typography.Title>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
{description ? (
|
||||
<div className="admin-list-header-desc">{description}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="admin-list-header-right">
|
||||
{actions ? <Space wrap>{actions}</Space> : null}
|
||||
{settings}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
|
||||
/** HQ 主列表主展示列:下划线,点击进入编辑或详情 */
|
||||
export function AdminPrimaryLink({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
onClick: (e?: MouseEvent) => void;
|
||||
}) {
|
||||
const empty = children == null || children === '';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-primary-link"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
}}
|
||||
>
|
||||
{empty ? '—' : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -215,10 +215,11 @@ 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: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
|
||||
@@ -48,6 +48,7 @@ export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }:
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,616 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||
} from 'antd';
|
||||
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { request } from '../lib/api';
|
||||
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
||||
import OssUpload from './OssUpload';
|
||||
|
||||
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
||||
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
||||
type SkuRow = {
|
||||
id?: string;
|
||||
specValueIds: string[];
|
||||
skuCode?: string;
|
||||
barcode69: string;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
status: string;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
allowOnSitePickup: boolean;
|
||||
saleUnit: 'BOTTLE' | 'BOX';
|
||||
bottlesPerUnit: number;
|
||||
isDefault: boolean;
|
||||
sortOrder?: number;
|
||||
specText?: string;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
function cartesian(attrs: SpecAttr[]): string[][] {
|
||||
if (!attrs.length) return [[]];
|
||||
return attrs.reduce<string[][]>(
|
||||
(acc, attr) => {
|
||||
const next: string[][] = [];
|
||||
const values = attr.values.filter((v) => v.name?.trim());
|
||||
for (const prev of acc) {
|
||||
for (const v of values) {
|
||||
const vid = v.id || `__new__:${attr.name}:${v.name}`;
|
||||
next.push([...prev, vid]);
|
||||
}
|
||||
}
|
||||
return next.length ? next : acc;
|
||||
},
|
||||
[[]],
|
||||
);
|
||||
}
|
||||
|
||||
function keyOf(ids: string[]) {
|
||||
return [...ids].sort().join('_');
|
||||
}
|
||||
|
||||
type Props = {
|
||||
productId: string;
|
||||
initialAttrs: SpecAttr[];
|
||||
initialSkus: SkuRow[];
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||
const [attrs, setAttrs] = useState<SpecAttr[]>(
|
||||
initialAttrs.length
|
||||
? initialAttrs
|
||||
: [],
|
||||
);
|
||||
const [skus, setSkus] = useState<SkuRow[]>(
|
||||
initialSkus.length
|
||||
? initialSkus.map((s) => ({
|
||||
...s,
|
||||
specValueIds: s.specValueIds ?? [],
|
||||
status: s.status || 'ON_SALE',
|
||||
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||
isDefault: !!s.isDefault,
|
||||
imageUrl: s.imageUrl ?? '',
|
||||
}))
|
||||
: [
|
||||
{
|
||||
specValueIds: [],
|
||||
barcode69: '',
|
||||
price: 0,
|
||||
status: 'ON_SALE',
|
||||
allowOnlinePurchase: true,
|
||||
allowCrossCityDelivery: true,
|
||||
allowOnSitePickup: false,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: true,
|
||||
imageUrl: '',
|
||||
},
|
||||
],
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||
|
||||
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||
|
||||
function regenerateMatrix() {
|
||||
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
|
||||
const next: SkuRow[] = combos.map((ids, i) => {
|
||||
const existing = byKey.get(keyOf(ids));
|
||||
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
|
||||
return {
|
||||
specValueIds: ids,
|
||||
barcode69: '',
|
||||
price: skus[0]?.price ?? 0,
|
||||
benefitAmount: skus[0]?.benefitAmount,
|
||||
status: 'DRAFT',
|
||||
allowOnlinePurchase: true,
|
||||
allowCrossCityDelivery: true,
|
||||
allowOnSitePickup: false,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: i === 0,
|
||||
sortOrder: i,
|
||||
imageUrl: '',
|
||||
};
|
||||
});
|
||||
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
||||
setSkus(next.length ? next : skus);
|
||||
}
|
||||
|
||||
function patchSku(index: number, patch: Partial<SkuRow>) {
|
||||
setSkus((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], ...patch };
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
|
||||
const specsRes = await request<{
|
||||
specAttrs: SpecAttr[];
|
||||
skus: SkuRow[];
|
||||
}>(`/admin/products/${productId}/specs`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
attrs: attrs.map((a, i) => ({
|
||||
id: a.id,
|
||||
name: a.name.trim(),
|
||||
sortOrder: i,
|
||||
values: a.values
|
||||
.filter((v) => v.name.trim())
|
||||
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
// 映射临时 id → 真实 id
|
||||
const nameToId = new Map<string, string>();
|
||||
for (const a of specsRes.specAttrs ?? []) {
|
||||
for (const v of a.values ?? []) {
|
||||
nameToId.set(`${a.name}:${v.name}`, v.id!);
|
||||
}
|
||||
}
|
||||
const resolveIds = (ids: string[]) =>
|
||||
ids.map((id) => {
|
||||
if (!id.startsWith('__new__:')) return id;
|
||||
const [, attrName, valueName] = id.split(':');
|
||||
const real = nameToId.get(`${attrName}:${valueName}`);
|
||||
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
|
||||
return real;
|
||||
});
|
||||
|
||||
const payloadSkus = skus.map((s, i) => ({
|
||||
id: s.id,
|
||||
specValueIds: resolveIds(s.specValueIds ?? []),
|
||||
barcode69: s.barcode69.trim(),
|
||||
price: s.price,
|
||||
benefitAmount: s.benefitAmount,
|
||||
status: s.status,
|
||||
allowOnlinePurchase: s.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: s.allowCrossCityDelivery,
|
||||
allowOnSitePickup: s.allowOnSitePickup,
|
||||
saleUnit: s.saleUnit,
|
||||
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
||||
isDefault: !!s.isDefault,
|
||||
sortOrder: i,
|
||||
imageUrl: s.imageUrl?.trim() || null,
|
||||
}));
|
||||
|
||||
const barcodes = payloadSkus.map((row) => row.barcode69);
|
||||
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
|
||||
if (new Set(barcodes).size !== barcodes.length) {
|
||||
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
|
||||
}
|
||||
|
||||
await request(`/admin/products/${productId}/skus`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ skus: payloadSkus }),
|
||||
});
|
||||
message.success('规格与 SKU 已保存');
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const editing = editIndex != null ? skus[editIndex] : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Paragraph type="secondary">
|
||||
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码,并可单独上传主图。
|
||||
点「填写」在弹窗中编辑。先配置销售规格轴(如「包装」),再生成 SKU 矩阵。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Typography.Title level={5}>规格轴</Typography.Title>
|
||||
{attrs.map((attr, ai) => (
|
||||
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||
<Space align="start" style={{ width: '100%' }} wrap>
|
||||
<Input
|
||||
style={{ width: 140 }}
|
||||
placeholder="规格名"
|
||||
value={attr.name}
|
||||
onChange={(e) => {
|
||||
const next = [...attrs];
|
||||
next[ai] = { ...attr, name: e.target.value };
|
||||
setAttrs(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<MinusCircleOutlined />}
|
||||
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{attr.values.map((val, vi) => (
|
||||
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
|
||||
<Input
|
||||
placeholder="规格值"
|
||||
value={val.name}
|
||||
onChange={(e) => {
|
||||
const next = [...attrs];
|
||||
const values = [...attr.values];
|
||||
values[vi] = { ...val, name: e.target.value };
|
||||
next[ai] = { ...attr, values };
|
||||
setAttrs(next);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<MinusCircleOutlined />}
|
||||
onClick={() => {
|
||||
const next = [...attrs];
|
||||
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
|
||||
setAttrs(next);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
))}
|
||||
<Button
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
const next = [...attrs];
|
||||
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
|
||||
setAttrs(next);
|
||||
}}
|
||||
>
|
||||
添加规格值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
disabled={attrs.length >= 3}
|
||||
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
|
||||
>
|
||||
添加规格轴
|
||||
</Button>
|
||||
<Button onClick={regenerateMatrix}>按规格生成 SKU 矩阵</Button>
|
||||
</Space>
|
||||
|
||||
<Typography.Title level={5}>SKU 列表</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, i) => String(i)}
|
||||
pagination={false}
|
||||
scroll={{ x: 1240 }}
|
||||
dataSource={skus}
|
||||
columns={[
|
||||
{
|
||||
title: '规格',
|
||||
width: 140,
|
||||
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
|
||||
},
|
||||
{
|
||||
title: 'SKU',
|
||||
width: 110,
|
||||
render: (_, row) => (
|
||||
<Typography.Text type="secondary">
|
||||
{row.skuCode || '保存后自动生成'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <span>69码 <Typography.Text type="danger">*</Typography.Text></span>,
|
||||
width: 180,
|
||||
render: (_, row, index) => (
|
||||
<Input
|
||||
placeholder="本规格独立 69 码"
|
||||
value={row.barcode69}
|
||||
onChange={(e) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, barcode69: e.target.value };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '售价',
|
||||
width: 100,
|
||||
render: (_, row, index) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={row.price}
|
||||
onChange={(v) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, price: Number(v) || 0 };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权益',
|
||||
width: 100,
|
||||
render: (_, row, index) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: '100%' }}
|
||||
value={row.benefitAmount}
|
||||
onChange={(v) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
render: (_, row, index) => (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={row.status}
|
||||
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={(status) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, status };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '单位',
|
||||
width: 160,
|
||||
render: (_, row, index) => (
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 80 }}
|
||||
value={row.saleUnit}
|
||||
options={[
|
||||
{ value: 'BOTTLE', label: '瓶' },
|
||||
{ value: 'BOX', label: '箱' },
|
||||
]}
|
||||
onChange={(saleUnit) => {
|
||||
const next = [...skus];
|
||||
next[index] = {
|
||||
...row,
|
||||
saleUnit,
|
||||
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
|
||||
};
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
{row.saleUnit === 'BOX' ? (
|
||||
<InputNumber
|
||||
min={1}
|
||||
style={{ width: 70 }}
|
||||
value={row.bottlesPerUnit}
|
||||
onChange={(v) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '履约',
|
||||
width: 200,
|
||||
render: (_, row, index) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Switch
|
||||
checkedChildren="线上"
|
||||
unCheckedChildren="线上"
|
||||
checked={row.allowOnlinePurchase}
|
||||
onChange={(checked) => {
|
||||
const next = [...skus];
|
||||
next[index] = {
|
||||
...row,
|
||||
allowOnlinePurchase: checked,
|
||||
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
|
||||
};
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="跨城"
|
||||
unCheckedChildren="跨城"
|
||||
disabled={!row.allowOnlinePurchase}
|
||||
checked={row.allowCrossCityDelivery}
|
||||
onChange={(checked) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, allowCrossCityDelivery: checked };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="现场"
|
||||
unCheckedChildren="现场"
|
||||
checked={row.allowOnSitePickup}
|
||||
onChange={(checked) => {
|
||||
const next = [...skus];
|
||||
next[index] = { ...row, allowOnSitePickup: checked };
|
||||
setSkus(next);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '默认',
|
||||
width: 70,
|
||||
render: (_, row, index) => (
|
||||
<Radio
|
||||
checked={row.isDefault}
|
||||
onChange={() => {
|
||||
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '主图',
|
||||
width: 72,
|
||||
render: (_, row) =>
|
||||
row.imageUrl ? (
|
||||
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
) : (
|
||||
<Typography.Text type="secondary">无</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '编辑',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row, index) => (
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
|
||||
填写
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
|
||||
保存规格与 SKU
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
|
||||
open={editIndex != null}
|
||||
onCancel={() => setEditIndex(null)}
|
||||
onOk={() => setEditIndex(null)}
|
||||
okText="完成"
|
||||
width={560}
|
||||
destroyOnClose
|
||||
>
|
||||
{editing && editIndex != null ? (
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">SKU</Typography.Text>
|
||||
<div>{editing.skuCode || '保存后自动生成'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>69 码 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
|
||||
<Input
|
||||
placeholder="本规格独立 69 码"
|
||||
value={editing.barcode69}
|
||||
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>主图</Typography.Text>
|
||||
<OssUpload
|
||||
bizType="COVER"
|
||||
value={editing.imageUrl || ''}
|
||||
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<div>
|
||||
<Typography.Text>售价</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: 140 }}
|
||||
value={editing.price}
|
||||
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text>权益</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
style={{ width: 140 }}
|
||||
value={editing.benefitAmount}
|
||||
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
<div>
|
||||
<Typography.Text>状态</Typography.Text>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={editing.status}
|
||||
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={(status) => patchSku(editIndex, { status })}
|
||||
/>
|
||||
</div>
|
||||
<Space>
|
||||
<Select
|
||||
style={{ width: 100 }}
|
||||
value={editing.saleUnit}
|
||||
options={[
|
||||
{ value: 'BOTTLE', label: '瓶' },
|
||||
{ value: 'BOX', label: '箱' },
|
||||
]}
|
||||
onChange={(saleUnit) =>
|
||||
patchSku(editIndex, {
|
||||
saleUnit,
|
||||
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{editing.saleUnit === 'BOX' ? (
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={editing.bottlesPerUnit}
|
||||
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
<Space>
|
||||
<Switch
|
||||
checkedChildren="线上"
|
||||
unCheckedChildren="线上"
|
||||
checked={editing.allowOnlinePurchase}
|
||||
onChange={(checked) =>
|
||||
patchSku(editIndex, {
|
||||
allowOnlinePurchase: checked,
|
||||
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="跨城"
|
||||
unCheckedChildren="跨城"
|
||||
disabled={!editing.allowOnlinePurchase}
|
||||
checked={editing.allowCrossCityDelivery}
|
||||
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="现场"
|
||||
unCheckedChildren="现场"
|
||||
checked={editing.allowOnSitePickup}
|
||||
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
|
||||
/>
|
||||
<Switch
|
||||
checkedChildren="默认"
|
||||
unCheckedChildren="默认"
|
||||
checked={editing.isDefault}
|
||||
onChange={(checked) => {
|
||||
if (!checked) return;
|
||||
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||
const [addressDetail, setAddressDetail] = useState('');
|
||||
const [productId, setProductId] = useState<string>();
|
||||
const [skuId, setSkuId] = useState<string>();
|
||||
const [quantity, setQuantity] = useState(2);
|
||||
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||
@@ -88,37 +87,9 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
}, [open]);
|
||||
|
||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||
const selectedSku =
|
||||
skuOptions.find((s) => s.id === skuId) ||
|
||||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
||||
skuOptions[0];
|
||||
const allowOnline = selectedSku
|
||||
? selectedSku.allowOnlinePurchase !== false
|
||||
: selectedProduct
|
||||
? selectedProduct.allowOnlinePurchase !== false
|
||||
: true;
|
||||
const allowOnSite = selectedSku
|
||||
? !!selectedSku.allowOnSitePickup
|
||||
: !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedSku
|
||||
? selectedSku.allowCrossCityDelivery !== false
|
||||
: selectedProduct
|
||||
? selectedProduct.allowCrossCityDelivery !== false
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedProduct) return;
|
||||
const def =
|
||||
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
||||
skuOptions.find((s) => s.isDefault) ||
|
||||
skuOptions[0];
|
||||
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
||||
setSkuId(def.id);
|
||||
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, productId, selectedProduct?.id]);
|
||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !selectedProduct) return;
|
||||
@@ -144,7 +115,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
productId,
|
||||
quantity,
|
||||
deliveryMode,
|
||||
skuId: skuId || undefined,
|
||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||
}),
|
||||
@@ -154,7 +124,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||
|
||||
useEffect(() => () => stopPoll(), []);
|
||||
|
||||
@@ -165,8 +135,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
setRegionCodes([]);
|
||||
setAddressDetail('');
|
||||
setQuantity(2);
|
||||
setProductId(undefined);
|
||||
setSkuId(undefined);
|
||||
setPromoCodeId(undefined);
|
||||
setDeliveryMode('ADDRESS');
|
||||
setAutoReceive(false);
|
||||
@@ -234,7 +202,6 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
productId: productId!,
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
skuId: skuId || undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -350,10 +317,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
loading={loadingOptions}
|
||||
placeholder="选择商品"
|
||||
value={productId}
|
||||
onChange={(id) => {
|
||||
setProductId(id);
|
||||
setSkuId(undefined);
|
||||
}}
|
||||
onChange={setProductId}
|
||||
options={(options?.products ?? []).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||
@@ -361,25 +325,7 @@ export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrder
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||
<Form.Item label="规格" required>
|
||||
<Select
|
||||
placeholder="选择规格"
|
||||
value={skuId}
|
||||
onChange={(id) => {
|
||||
setSkuId(id);
|
||||
const sku = skuOptions.find((s) => s.id === id);
|
||||
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
||||
}}
|
||||
options={skuOptions.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
|
||||
<Form.Item label="数量" required>
|
||||
<InputNumber
|
||||
min={1}
|
||||
value={quantity}
|
||||
|
||||
@@ -23,11 +23,6 @@ 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;
|
||||
@@ -51,15 +46,6 @@ 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 ? (
|
||||
|
||||
@@ -1,459 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Image, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
||||
import type {
|
||||
StorePackageAuditDetailDto,
|
||||
StorePackageItemDto,
|
||||
StorePackageViewDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待审核',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||
const name = String(pkg.name ?? '').trim();
|
||||
return name ? `name:${name}` : `idx:${index}`;
|
||||
}
|
||||
|
||||
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||
}
|
||||
|
||||
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||||
|
||||
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--) {
|
||||
for (let j = n - 1; j >= 0; j--) {
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (a[i] === b[j]) {
|
||||
raw.push({ type: 'equal', text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
raw.push({ type: 'delete', text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
raw.push({ type: 'insert', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
||||
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
||||
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||
for (const s of raw) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.type === s.type) last.text += s.text;
|
||||
else merged.push({ ...s });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function fieldChanges(
|
||||
live: StorePackageItemDto | StorePackageViewDto,
|
||||
proposed: StorePackageItemDto | StorePackageViewDto,
|
||||
): FieldChange[] {
|
||||
const changes: FieldChange[] = [];
|
||||
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||||
const pushText = (label: string, oldV: string, newV: string) => {
|
||||
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||||
};
|
||||
const pushValue = (label: string, oldV: string, newV: string) => {
|
||||
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
||||
};
|
||||
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
||||
pushText('套餐名称', text(live.name), text(proposed.name));
|
||||
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
||||
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
||||
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
||||
const liveImgs = normalizeStorePackageImageUrls(live);
|
||||
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
||||
if (imageSignature(live) !== imageSignature(proposed)) {
|
||||
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
||||
const rows: Array<{
|
||||
key: string;
|
||||
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
live?: StorePackageViewDto;
|
||||
proposed?: StorePackageItemDto;
|
||||
changes?: FieldChange[];
|
||||
}> = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const l = liveMap.get(key);
|
||||
const p = proposedMap.get(key);
|
||||
if (l && !p) {
|
||||
rows.push({ key, change: 'removed', live: l });
|
||||
} else if (!l && p) {
|
||||
rows.push({ key, change: 'added', proposed: p });
|
||||
} else if (l && p) {
|
||||
const changed =
|
||||
l.price !== p.price ||
|
||||
l.dishes !== p.dishes ||
|
||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||
imageSignature(l) !== imageSignature(p);
|
||||
rows.push({
|
||||
key,
|
||||
change: changed ? 'changed' : 'unchanged',
|
||||
live: l,
|
||||
proposed: p,
|
||||
changes: changed ? fieldChanges(l, p) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const CHANGE_LABELS = {
|
||||
added: { text: '新增', color: 'green' },
|
||||
removed: { text: '删除', color: 'red' },
|
||||
changed: { text: '变更', color: 'orange' },
|
||||
unchanged: { text: '未变', color: 'default' },
|
||||
} as const;
|
||||
|
||||
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||
const segs = diffText(oldText, newText);
|
||||
return (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text type="secondary">原:</Typography.Text>
|
||||
{segs
|
||||
.filter((s) => s.type !== 'insert')
|
||||
.map((s, idx) =>
|
||||
s.type === 'delete' ? (
|
||||
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
||||
{s.text || '(空)'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text type="secondary">新:</Typography.Text>
|
||||
{segs
|
||||
.filter((s) => s.type !== 'delete')
|
||||
.map((s, idx) =>
|
||||
s.type === 'insert' ? (
|
||||
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
||||
{s.text || '(空)'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageDetailCard({
|
||||
title,
|
||||
pkg,
|
||||
change,
|
||||
changes,
|
||||
imageSize = 72,
|
||||
}: {
|
||||
title?: string;
|
||||
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||
change?: keyof typeof CHANGE_LABELS;
|
||||
changes?: FieldChange[];
|
||||
imageSize?: number;
|
||||
}) {
|
||||
const images = normalizeStorePackageImageUrls(pkg);
|
||||
const meta = change ? CHANGE_LABELS[change] : null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Space style={{ marginBottom: 8 }} wrap>
|
||||
{title ? <Typography.Text type="secondary">{title}</Typography.Text> : null}
|
||||
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||||
</Space>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>{pkg.name}</strong>
|
||||
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||
</div>
|
||||
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||
{pkg.dishes || '—'}
|
||||
</Typography.Paragraph>
|
||||
{pkg.usableTime ? (
|
||||
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 4 }}>
|
||||
可用时间:{pkg.usableTime}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{pkg.otherNotes ? (
|
||||
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||
其他说明:{pkg.otherNotes}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{images.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{images.map((url) => (
|
||||
<Image key={url} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||
)}
|
||||
{changes && changes.length ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffe7ba',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||
变更明细
|
||||
</Typography.Text>
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||
{changes.map((c) => (
|
||||
<li key={c.label} style={{ marginBottom: 6 }}>
|
||||
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
||||
{c.kind === 'text' ? (
|
||||
<TextDiff oldText={c.old} newText={c.now} />
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text delete type="secondary">
|
||||
{c.old}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>{c.now}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type StorePackageAuditPanelProps = {
|
||||
requestId: string;
|
||||
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
|
||||
showActions?: boolean;
|
||||
fullscreen?: boolean;
|
||||
onAudited?: () => void;
|
||||
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
|
||||
};
|
||||
|
||||
/** 套餐变更对比 + 通过/驳回(审核通知页与门店详情抽屉共用) */
|
||||
export default function StorePackageAuditPanel({
|
||||
requestId,
|
||||
showActions = true,
|
||||
fullscreen = false,
|
||||
onAudited,
|
||||
onDetailLoaded,
|
||||
}: StorePackageAuditPanelProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${requestId}`);
|
||||
setDetail(data);
|
||||
onDetailLoaded?.(data);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载套餐审核详情失败');
|
||||
setDetail(null);
|
||||
onDetailLoaded?.(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [requestId]);
|
||||
|
||||
async function audit(action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||
if (!detail) return;
|
||||
setAuditing(true);
|
||||
try {
|
||||
await request(`/admin/store-package-audits/${detail.id}/audit`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason: reason } : { action }),
|
||||
});
|
||||
message.success(action === 'APPROVE' ? '套餐已通过' : '套餐已驳回');
|
||||
notifyPackageAuditChanged();
|
||||
onAudited?.();
|
||||
await load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setAuditing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !detail) {
|
||||
return <Typography.Text type="secondary">加载套餐变更…</Typography.Text>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <Typography.Text type="secondary">暂无套餐审核详情</Typography.Text>;
|
||||
}
|
||||
|
||||
const diffRows = diffPackages(detail.livePackages ?? [], detail.packages ?? []);
|
||||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Space wrap>
|
||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status}</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{showActions && detail.status === 'PENDING' ? (
|
||||
<Space>
|
||||
<Button type="primary" loading={auditing} onClick={() => void audit('APPROVE')}>
|
||||
通过套餐
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
loading={auditing}
|
||||
onClick={() => {
|
||||
setRejectReason('');
|
||||
setRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回套餐
|
||||
</Button>
|
||||
</Space>
|
||||
) : null}
|
||||
</Space>
|
||||
{detail.rejectReason ? (
|
||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||
) : null}
|
||||
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||||
<Typography.Text type="secondary">
|
||||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Tag color="green">新增 {addedCount}</Tag>
|
||||
<Tag color="red">删除 {removedCount}</Tag>
|
||||
<Tag color="orange">变更 {changedCount}</Tag>
|
||||
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<div className="admin-package-audit-cols">
|
||||
<div className="admin-package-audit-col">
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
线上已审核套餐
|
||||
</Typography.Title>
|
||||
{(detail.livePackages ?? []).length ? (
|
||||
(detail.livePackages ?? []).map((pkg, index) => (
|
||||
<PackageDetailCard
|
||||
key={`live-${packageKey(pkg, index)}`}
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
imageSize={fullscreen ? 160 : 72}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-package-audit-col">
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
待审核套餐
|
||||
</Typography.Title>
|
||||
{(detail.packages ?? []).length ? (
|
||||
(detail.packages ?? []).map((pkg, index) => (
|
||||
<PackageDetailCard
|
||||
key={`pending-${packageKey(pkg, index)}`}
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
changes={changesByKey.get(packageKey(pkg, index))}
|
||||
imageSize={fullscreen ? 160 : 72}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title="驳回套餐变更"
|
||||
open={rejectOpen}
|
||||
confirmLoading={auditing}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
onOk={() => {
|
||||
if (!rejectReason.trim()) {
|
||||
message.warning('请填写驳回原因');
|
||||
return;
|
||||
}
|
||||
void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false));
|
||||
}}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={rejectReason}
|
||||
placeholder="驳回原因"
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 供门店详情抽屉 extra 调用 */
|
||||
export async function auditStorePackageRequest(
|
||||
requestId: string,
|
||||
action: 'APPROVE' | 'REJECT',
|
||||
rejectReason?: string,
|
||||
) {
|
||||
await request(`/admin/store-package-audits/${requestId}/audit`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason } : { action }),
|
||||
});
|
||||
notifyPackageAuditChanged();
|
||||
}
|
||||
@@ -20,11 +20,10 @@ import {
|
||||
AccountBookOutlined,
|
||||
ProjectOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { hasAnySystemSettingsPermission, hasAnyStoreMenuPermission } from '@dukang/shared-types';
|
||||
import { hasAnySystemSettingsPermission } 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;
|
||||
|
||||
@@ -157,9 +156,6 @@ 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');
|
||||
}
|
||||
@@ -179,11 +175,11 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/knowledge-bases': 'knowledge_bases',
|
||||
'stores-group': 'stores',
|
||||
'/stores': 'stores',
|
||||
'/store-package-audits': 'store_audits',
|
||||
'/store-ratings': 'store_ratings',
|
||||
'/store-categories': 'store_categories',
|
||||
'/store-accounts': 'store_accounts',
|
||||
'/store-media': 'store_media',
|
||||
'/store-package-audits': 'stores',
|
||||
'/store-ratings': 'stores',
|
||||
'/store-categories': 'stores',
|
||||
'/store-accounts': 'stores',
|
||||
'/store-media': 'stores',
|
||||
'partners-group': 'partners',
|
||||
'/cities': 'partners',
|
||||
'/city-partners': 'partners',
|
||||
@@ -200,10 +196,10 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/benefit/ledgers': 'benefit',
|
||||
'/redeem-records': 'benefit',
|
||||
'/redeem-pending': 'benefit',
|
||||
'/redeem/debug': 'benefit_debug',
|
||||
'/redeem/debug': 'benefit',
|
||||
'deliveries-group': 'deliveries',
|
||||
'/deliveries': 'deliveries',
|
||||
'/deliveries/xiaofeixia': 'deliveries_debug',
|
||||
'/deliveries/xiaofeixia': 'deliveries',
|
||||
'/tickets': 'tickets',
|
||||
'/tickets/support': 'tech_support',
|
||||
'dev-plan-group': 'dev_plan',
|
||||
@@ -417,9 +413,7 @@ export default function AdminLayout() {
|
||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||
>
|
||||
<Content style={{ margin: 24 }}>
|
||||
<ListColumnPrefsProvider initial={profile?.listColumnPrefs}>
|
||||
<Outlet />
|
||||
</ListColumnPrefsProvider>
|
||||
</Content>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { HqListColumnKey, HqListColumnPref, HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
type Ctx = {
|
||||
prefs: HqListColumnPrefsMap;
|
||||
ready: boolean;
|
||||
setPrefs: (next: HqListColumnPrefsMap) => void;
|
||||
save: (listKey: HqListColumnKey, next: HqListColumnPref | null) => Promise<void>;
|
||||
};
|
||||
|
||||
const ListColumnPrefsContext = createContext<Ctx>({
|
||||
prefs: {},
|
||||
ready: false,
|
||||
setPrefs: () => {},
|
||||
save: async () => {},
|
||||
});
|
||||
|
||||
export function ListColumnPrefsProvider({
|
||||
initial,
|
||||
children,
|
||||
}: {
|
||||
initial?: HqListColumnPrefsMap;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [prefs, setPrefs] = useState<HqListColumnPrefsMap>(initial ?? {});
|
||||
const [ready] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (initial) setPrefs(initial);
|
||||
}, [initial]);
|
||||
|
||||
const save = useCallback(async (listKey: HqListColumnKey, next: HqListColumnPref | null) => {
|
||||
const res = await request<{ listColumnPrefs: HqListColumnPrefsMap }>(
|
||||
`/admin/me/list-columns/${listKey}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(
|
||||
next
|
||||
? { order: next.order, hidden: next.hidden, widths: next.widths }
|
||||
: { reset: true },
|
||||
),
|
||||
},
|
||||
);
|
||||
setPrefs(res.listColumnPrefs ?? {});
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ prefs, ready, setPrefs, save }), [prefs, ready, save]);
|
||||
return <ListColumnPrefsContext.Provider value={value}>{children}</ListColumnPrefsContext.Provider>;
|
||||
}
|
||||
|
||||
export function useListColumnPrefs() {
|
||||
return useContext(ListColumnPrefsContext);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
export const CLIENT_APP = 'HQ_WEB';
|
||||
|
||||
@@ -10,9 +8,6 @@ export type HqProfile = {
|
||||
adminRole: string;
|
||||
status: string;
|
||||
permissionKeys?: string[];
|
||||
cityIds?: string[];
|
||||
cityScoped?: boolean;
|
||||
listColumnPrefs?: HqListColumnPrefsMap;
|
||||
};
|
||||
|
||||
export function getToken() {
|
||||
@@ -92,9 +87,6 @@ export type DashboardStats = {
|
||||
openTickets?: number;
|
||||
pendingStoreWithdrawals?: number;
|
||||
overdueStoreWithdrawals?: number;
|
||||
pendingStoreOnboard?: number;
|
||||
pendingStorePackageAudits?: number;
|
||||
pendingStoreInfoChanges?: number;
|
||||
ordersByStatus: Array<{ status: string; count: number }>;
|
||||
};
|
||||
|
||||
@@ -169,7 +161,6 @@ export type AdminUserRow = {
|
||||
wxOpenId: string | null;
|
||||
wechatVerified: boolean;
|
||||
nickname: string | null;
|
||||
hqRemark: string | null;
|
||||
status: number;
|
||||
sourceType: string;
|
||||
sourceRefId: string | null;
|
||||
@@ -202,21 +193,8 @@ export type AdminOrderRow = {
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
quantity?: number;
|
||||
saleUnit?: string;
|
||||
receiverName: string;
|
||||
receiverPhone: string;
|
||||
receiverProvince?: string;
|
||||
receiverCity?: string;
|
||||
receiverDistrict?: string;
|
||||
receiverAddress?: string;
|
||||
benefitAmount?: number;
|
||||
benefitCoupon?: {
|
||||
couponNo?: string;
|
||||
totalAmount?: number;
|
||||
usedAmount?: number;
|
||||
balance?: number;
|
||||
status?: string;
|
||||
} | null;
|
||||
createdAt: string;
|
||||
cityId?: string;
|
||||
city?: { id: string; name: string; code: string };
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { MouseEvent, ReactNode } from 'react';
|
||||
import type { ColumnType } from 'antd/es/table';
|
||||
|
||||
export const MIN_COL_WIDTH = 48;
|
||||
export const MAX_COL_WIDTH = 960;
|
||||
|
||||
export function clampColWidth(n: number): number {
|
||||
return Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, Math.round(n)));
|
||||
}
|
||||
|
||||
export function beginColumnResize(
|
||||
e: MouseEvent,
|
||||
startWidth: number,
|
||||
onMove: (width: number) => void,
|
||||
onEnd: (width: number) => void,
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
let latest = clampColWidth(startWidth);
|
||||
let moved = false;
|
||||
document.body.classList.add('admin-col-resizing');
|
||||
|
||||
const onMouseMove = (ev: globalThis.MouseEvent) => {
|
||||
moved = true;
|
||||
latest = clampColWidth(startWidth + ev.clientX - startX);
|
||||
onMove(latest);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
document.body.classList.remove('admin-col-resizing');
|
||||
if (moved) onEnd(latest);
|
||||
};
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
}
|
||||
|
||||
export function withResizeTitle<T>(
|
||||
title: ColumnType<T>['title'],
|
||||
onMouseDown: (e: MouseEvent) => void,
|
||||
): ColumnType<T>['title'] {
|
||||
const handle: ReactNode = (
|
||||
<span
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="拖动调整列宽"
|
||||
className="admin-col-resize-handle"
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
onMouseDown={onMouseDown}
|
||||
/>
|
||||
);
|
||||
if (typeof title === 'function') {
|
||||
return (props) => (
|
||||
<>
|
||||
{title(props)}
|
||||
{handle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
{handle}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,12 +7,6 @@ export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
|
||||
MANUAL: '手动',
|
||||
};
|
||||
|
||||
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
|
||||
LOCAL: '同城',
|
||||
CROSS_CITY: '跨城',
|
||||
ON_SITE_PICKUP: '现场提货',
|
||||
};
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING_PAY: '待付款',
|
||||
PENDING_SHIP: '待发货',
|
||||
|
||||
@@ -8,19 +8,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import type { ColumnType } from 'antd/es/table';
|
||||
import type { HqListColumnKey, HqListColumnPref } from '@dukang/shared-types';
|
||||
|
||||
export const SERIAL_COLUMN_KEY = '__serial';
|
||||
export const ACTIONS_COLUMN_KEY = '__actions';
|
||||
|
||||
export function columnKey<T>(col: ColumnType<T>, index: number): string {
|
||||
if (col.key != null && String(col.key).length) return String(col.key);
|
||||
const dataIndex = col.dataIndex;
|
||||
if (typeof dataIndex === 'string' && dataIndex) return dataIndex;
|
||||
if (Array.isArray(dataIndex) && dataIndex.length) return dataIndex.map(String).join('.');
|
||||
if (typeof col.title === 'string' && col.title) return col.title;
|
||||
return `col-${index}`;
|
||||
}
|
||||
|
||||
export function isActionsColumn<T>(col: ColumnType<T>, key: string): boolean {
|
||||
if (key === ACTIONS_COLUMN_KEY || key === 'actions' || key === 'action') return true;
|
||||
return col.title === '操作';
|
||||
}
|
||||
|
||||
export function mergeColumnOrder(defaultKeys: string[], pref?: HqListColumnPref): string[] {
|
||||
const known = new Set(defaultKeys);
|
||||
const ordered = (pref?.order ?? []).filter((k) => known.has(k));
|
||||
for (const key of defaultKeys) {
|
||||
if (ordered.includes(key)) continue;
|
||||
const defaultIdx = defaultKeys.indexOf(key);
|
||||
let insertAt = ordered.length;
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
if (defaultKeys.indexOf(ordered[i]) > defaultIdx) {
|
||||
insertAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
ordered.splice(insertAt, 0, key);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
export function visibleColumnKeys(defaultKeys: string[], pref?: HqListColumnPref): string[] {
|
||||
const hidden = new Set(pref?.hidden ?? []);
|
||||
return mergeColumnOrder(defaultKeys, pref).filter((k) => !hidden.has(k));
|
||||
}
|
||||
|
||||
export function applyColumnPrefs<T>(
|
||||
columns: ColumnType<T>[],
|
||||
pref: HqListColumnPref | undefined,
|
||||
): ColumnType<T>[] {
|
||||
const keyed = columns.map((col, i) => {
|
||||
const key = columnKey(col, i);
|
||||
return { col: { ...col, key }, key, actions: isActionsColumn(col, key) };
|
||||
});
|
||||
const configurable = keyed.filter((c) => !c.actions);
|
||||
const actions = keyed.filter((c) => c.actions);
|
||||
const defaultKeys = configurable.map((c) => c.key);
|
||||
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
||||
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
||||
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
|
||||
return [
|
||||
...orderedKeys
|
||||
.map((k) => {
|
||||
const col = byKey.get(k);
|
||||
if (!col) return null;
|
||||
const w = pref?.widths?.[k];
|
||||
return w != null ? { ...col, width: w } : col;
|
||||
})
|
||||
.filter((c): c is ColumnType<T> => !!c),
|
||||
...actions.map((c) => {
|
||||
const w = pref?.widths?.[c.key];
|
||||
return w != null ? { ...c.col, width: w } : c.col;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export type ListColumnSettingItem = {
|
||||
key: string;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export function settingItems<T>(
|
||||
columns: ColumnType<T>[],
|
||||
pref?: HqListColumnPref,
|
||||
): ListColumnSettingItem[] {
|
||||
const keyed = columns
|
||||
.map((col, i) => ({ col, key: columnKey(col, i) }))
|
||||
.filter(({ col, key }) => !isActionsColumn(col, key));
|
||||
const hidden = new Set(pref?.hidden ?? []);
|
||||
const order = mergeColumnOrder(keyed.map((c) => c.key), pref);
|
||||
const byKey = new Map(keyed.map((c) => [c.key, c.col]));
|
||||
return order.map((key) => {
|
||||
const col = byKey.get(key);
|
||||
const title = typeof col?.title === 'string' ? col.title : key;
|
||||
return { key, title, visible: !hidden.has(key) };
|
||||
});
|
||||
}
|
||||
|
||||
export type { HqListColumnKey };
|
||||
@@ -1,175 +0,0 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Button, message } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||
import type { HqListColumnKey } from '@dukang/shared-types';
|
||||
import { AdminListColumnsModal } from '../components/AdminListColumnsModal';
|
||||
import { useListColumnPrefs } from './ListColumnPrefsContext';
|
||||
import {
|
||||
ACTIONS_COLUMN_KEY,
|
||||
SERIAL_COLUMN_KEY,
|
||||
applyColumnPrefs,
|
||||
columnKey,
|
||||
settingItems,
|
||||
type ListColumnSettingItem,
|
||||
} from './list-column-prefs';
|
||||
import { beginColumnResize, withResizeTitle } from './column-resize';
|
||||
|
||||
type Options = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
export function useAdminListColumns<T>(
|
||||
listKey: HqListColumnKey,
|
||||
allColumns: ColumnsType<T>,
|
||||
options: Options = {},
|
||||
) {
|
||||
const { page = 1, pageSize = 20 } = options;
|
||||
const { prefs, save } = useListColumnPrefs();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [localWidths, setLocalWidths] = useState<Record<string, number>>({});
|
||||
const pref = prefs[listKey];
|
||||
const prefRef = useRef(pref);
|
||||
prefRef.current = pref;
|
||||
const localWidthsRef = useRef(localWidths);
|
||||
localWidthsRef.current = localWidths;
|
||||
const itemsRef = useRef<ListColumnSettingItem[]>([]);
|
||||
|
||||
const configured = useMemo(
|
||||
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
||||
[allColumns, pref],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
() => settingItems(allColumns as ColumnType<T>[], pref),
|
||||
[allColumns, pref],
|
||||
);
|
||||
itemsRef.current = items;
|
||||
|
||||
const serialCol: ColumnType<T> = useMemo(
|
||||
() => ({
|
||||
key: SERIAL_COLUMN_KEY,
|
||||
title: '序号',
|
||||
width: 64,
|
||||
fixed: 'left' as const,
|
||||
render: (_: unknown, __: T, index: number) => (page - 1) * pageSize + index + 1,
|
||||
}),
|
||||
[page, pageSize],
|
||||
);
|
||||
|
||||
const persistPref = useCallback(
|
||||
async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
|
||||
if (!next) {
|
||||
setLocalWidths({});
|
||||
await save(listKey, null);
|
||||
return;
|
||||
}
|
||||
await save(listKey, next);
|
||||
},
|
||||
[listKey, save],
|
||||
);
|
||||
|
||||
const persist = useCallback(
|
||||
async (next: ListColumnSettingItem[] | null) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (!next) {
|
||||
await persistPref(null);
|
||||
} else {
|
||||
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
|
||||
await persistPref({
|
||||
order: next.map((i) => i.key),
|
||||
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||
...(Object.keys(widths).length ? { widths } : {}),
|
||||
});
|
||||
}
|
||||
setOpen(false);
|
||||
message.success(next ? '列设置已保存' : '已恢复默认列');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[persistPref],
|
||||
);
|
||||
|
||||
const persistWidth = useCallback(
|
||||
async (key: string, width: number) => {
|
||||
const current = prefRef.current;
|
||||
const prevW = current?.widths?.[key];
|
||||
if (prevW === width) return;
|
||||
const order = current?.order?.length ? current.order : itemsRef.current.map((i) => i.key);
|
||||
const hidden = current?.hidden ?? [];
|
||||
const widths = { ...(current?.widths ?? {}), ...localWidthsRef.current, [key]: width };
|
||||
try {
|
||||
await persistPref({ order, hidden, widths });
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '列宽保存失败');
|
||||
}
|
||||
},
|
||||
[persistPref],
|
||||
);
|
||||
|
||||
const columns = useMemo<ColumnsType<T>>(() => {
|
||||
const merged: ColumnType<T>[] = [
|
||||
serialCol,
|
||||
...configured.map((col) =>
|
||||
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
||||
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
||||
: col,
|
||||
),
|
||||
];
|
||||
const widths = { ...(pref?.widths ?? {}), ...localWidths };
|
||||
return merged.map((col, i) => {
|
||||
const key = columnKey(col, i);
|
||||
const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined);
|
||||
const prevHeader = col.onHeaderCell;
|
||||
return {
|
||||
...col,
|
||||
key,
|
||||
width,
|
||||
title: withResizeTitle(col.title, (e) => {
|
||||
const th = (e.currentTarget as HTMLElement).closest('th');
|
||||
const startWidth = width ?? th?.getBoundingClientRect().width ?? 120;
|
||||
beginColumnResize(
|
||||
e,
|
||||
startWidth,
|
||||
(next) => setLocalWidths((prev) => ({ ...prev, [key]: next })),
|
||||
(next) => void persistWidth(key, next),
|
||||
);
|
||||
}),
|
||||
onHeaderCell: (column) => {
|
||||
const extra = typeof prevHeader === 'function' ? prevHeader(column) : {};
|
||||
return {
|
||||
...extra,
|
||||
className: [extra.className, 'admin-th-resizable'].filter(Boolean).join(' '),
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [configured, serialCol, pref?.widths, localWidths, persistWidth]);
|
||||
|
||||
const settingsButton = (
|
||||
<span className="admin-list-settings-slot">
|
||||
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
|
||||
列设置
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
|
||||
const settingsModal = (
|
||||
<AdminListColumnsModal
|
||||
open={open}
|
||||
items={items}
|
||||
saving={saving}
|
||||
onCancel={() => setOpen(false)}
|
||||
onReset={() => void persist(null)}
|
||||
onSave={(draft) => void persist(draft)}
|
||||
/>
|
||||
);
|
||||
|
||||
return { columns, settingsButton, settingsModal };
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -10,7 +9,6 @@ import {
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
@@ -19,23 +17,9 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
type CouponOrder = {
|
||||
id?: string;
|
||||
orderNo?: string;
|
||||
productName?: string | null;
|
||||
productSpec?: string | null;
|
||||
quantity?: number | null;
|
||||
saleUnit?: string | null;
|
||||
deliveryType?: string | null;
|
||||
payAmount?: number | string | null;
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -46,43 +30,10 @@ type Row = {
|
||||
status: string;
|
||||
sourceProduct: string;
|
||||
createdAt: string;
|
||||
user?: { id?: string; userNo: string; phone: string | null };
|
||||
order?: CouponOrder | null;
|
||||
user?: { userNo: string; phone: string | null };
|
||||
order?: { orderNo: string } | null;
|
||||
};
|
||||
|
||||
function saleUnitLabel(unit?: string | null) {
|
||||
if (unit === 'BOX') return '箱';
|
||||
if (unit === 'BOTTLE') return '瓶';
|
||||
return '';
|
||||
}
|
||||
|
||||
function formatPayAmount(v?: number | string | null) {
|
||||
if (v == null || v === '') return '';
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '';
|
||||
}
|
||||
|
||||
/** 来源:有订单时展示商品名、规格、数量、配送方式、金额;手动发放沿用 sourceProduct */
|
||||
function formatCouponSource(row: { sourceProduct?: string; order?: CouponOrder | null }) {
|
||||
const order = row.order;
|
||||
if (!order?.orderNo && !order?.productName) {
|
||||
return row.sourceProduct || '—';
|
||||
}
|
||||
const unit = saleUnitLabel(order.saleUnit);
|
||||
const qty =
|
||||
order.quantity != null ? `${order.quantity}${unit}` : '';
|
||||
const delivery = DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '';
|
||||
const amount = formatPayAmount(order.payAmount);
|
||||
const parts = [
|
||||
order.productName || row.sourceProduct,
|
||||
order.productSpec,
|
||||
qty,
|
||||
delivery,
|
||||
amount,
|
||||
].filter((p) => p != null && String(p).trim() !== '');
|
||||
return parts.join(' / ') || row.sourceProduct || '—';
|
||||
}
|
||||
|
||||
type CouponRedeemRecord = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
@@ -107,10 +58,11 @@ type CouponRedeemSummary = {
|
||||
type CouponDetail = Row & {
|
||||
redeemSummary?: CouponRedeemSummary | null;
|
||||
redeemRecords?: CouponRedeemRecord[];
|
||||
user?: { userNo?: string; phone?: string | null };
|
||||
order?: { orderNo?: string } | null;
|
||||
};
|
||||
|
||||
export default function BenefitCouponsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
@@ -147,62 +99,21 @@ export default function BenefitCouponsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '券号',
|
||||
dataIndex: 'couponNo',
|
||||
width: 200,
|
||||
ellipsis: false,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: ['user', 'userNo'],
|
||||
width: 120,
|
||||
ellipsis: false,
|
||||
render: (v: string | undefined, row) =>
|
||||
row.user?.id ? (
|
||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
v || '—'
|
||||
),
|
||||
},
|
||||
const columns: 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 || '—' },
|
||||
{
|
||||
title: '订单',
|
||||
dataIndex: ['order', 'orderNo'],
|
||||
width: 180,
|
||||
ellipsis: false,
|
||||
render: (v: string | undefined) =>
|
||||
v ? (
|
||||
<AdminPrimaryLink onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{ 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',
|
||||
width: 360,
|
||||
ellipsis: false,
|
||||
render: (_: string, row) => formatCouponSource(row),
|
||||
},
|
||||
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -244,20 +155,16 @@ export default function BenefitCouponsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="好客权益券"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
好客权益券
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||
手动发放
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="couponNo" label="券号">
|
||||
@@ -283,7 +190,7 @@ export default function BenefitCouponsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -361,37 +268,15 @@ export default function BenefitCouponsPage() {
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">
|
||||
{detail.user?.id ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => navigate('/users', { state: { openUserId: String(detail.user!.id) } })}
|
||||
>
|
||||
{detail.user?.userNo}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
(detail.user?.userNo ?? '—')
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">
|
||||
{detail.order?.orderNo ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() =>
|
||||
navigate(`/orders?orderNo=${encodeURIComponent(detail.order!.orderNo!)}`)
|
||||
}
|
||||
>
|
||||
{detail.order.orderNo}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{COUPON_STATUS_LABELS[detail.status] || detail.status}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{formatCouponSource(detail)}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
|
||||
@@ -431,7 +316,7 @@ export default function BenefitCouponsPage() {
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo' },
|
||||
{ title: '核销号', dataIndex: 'redeemNo', ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
@@ -442,7 +327,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,9 +3,6 @@ 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';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
|
||||
@@ -27,22 +24,19 @@ export default function BenefitLedgersPage() {
|
||||
[filters],
|
||||
);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: 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' },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-ledgers', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="权益流水" settings={settingsButton} />
|
||||
<Typography.Title level={4}>权益流水</Typography.Title>
|
||||
<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>
|
||||
@@ -51,7 +45,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: 'max-content' }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { request } from '../lib/api';
|
||||
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
||||
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
||||
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
||||
|
||||
export type BigScreenOrder = {
|
||||
id: string;
|
||||
@@ -24,57 +21,6 @@ const MIN_SCROLL_ROWS = 10;
|
||||
const FX_DURATION_MS = 10_000;
|
||||
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||
|
||||
const CELEBRATE_SFX: Record<AmountTier, string> = {
|
||||
1: sfxTier1,
|
||||
2: sfxTier2,
|
||||
3: sfxTier3,
|
||||
};
|
||||
|
||||
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
||||
let audioUnlocked = false;
|
||||
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
||||
|
||||
function unlockCelebrateAudio() {
|
||||
if (audioUnlocked || !sharedAudio) return;
|
||||
sharedAudio.muted = true;
|
||||
sharedAudio.src = CELEBRATE_SFX[1];
|
||||
void sharedAudio
|
||||
.play()
|
||||
.then(() => {
|
||||
sharedAudio.pause();
|
||||
sharedAudio.currentTime = 0;
|
||||
sharedAudio.muted = false;
|
||||
audioUnlocked = true;
|
||||
})
|
||||
.catch(() => {
|
||||
/* 等待下次手势 */
|
||||
});
|
||||
}
|
||||
|
||||
function playCelebrateSfx(tier: AmountTier): () => void {
|
||||
if (!sharedAudio) return () => undefined;
|
||||
try {
|
||||
sharedAudio.pause();
|
||||
sharedAudio.currentTime = 0;
|
||||
sharedAudio.src = CELEBRATE_SFX[tier];
|
||||
sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
|
||||
sharedAudio.muted = false;
|
||||
void sharedAudio.play().catch(() => {
|
||||
/* 未解锁时静默失败 */
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return () => {
|
||||
try {
|
||||
sharedAudio.pause();
|
||||
sharedAudio.currentTime = 0;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function pad(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
@@ -95,20 +41,11 @@ function formatOrderTime(iso: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// function formatAmount(n: number): string {
|
||||
// return Number(n || 0).toLocaleString('zh-CN', {
|
||||
// minimumFractionDigits: 0,
|
||||
// maximumFractionDigits: 0,
|
||||
// });
|
||||
// }
|
||||
|
||||
function formatAmount(n: number): string {
|
||||
const num = Number(n || 0);
|
||||
// 先保留两位小数,再分割处理
|
||||
const fixed = num.toFixed(2);
|
||||
const [intStr, decStr] = fixed.split('.');
|
||||
const intFormatted = Number(intStr).toLocaleString('zh-CN');
|
||||
return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`;
|
||||
return Number(n || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function amountCssClass(payAmount: number, base: string): string {
|
||||
@@ -238,11 +175,6 @@ function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => v
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [order.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const stop = playCelebrateSfx(tier);
|
||||
return stop;
|
||||
}, [order.id, tier]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tier !== 3) return;
|
||||
const start = performance.now();
|
||||
@@ -416,17 +348,6 @@ export default function BigScreenPage() {
|
||||
return () => clearInterval(id);
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
unlockCelebrateAudio();
|
||||
const unlock = () => unlockCelebrateAudio();
|
||||
window.addEventListener('pointerdown', unlock, { once: true });
|
||||
window.addEventListener('keydown', unlock, { once: true });
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', unlock);
|
||||
window.removeEventListener('keydown', unlock);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const prevHtml = html.style.overflow;
|
||||
@@ -491,8 +412,16 @@ export default function BigScreenPage() {
|
||||
<LiveClock />
|
||||
|
||||
<div className="big-screen-list">
|
||||
<div className="big-screen-list-head">
|
||||
<span>订单金额</span>
|
||||
<span>订单商品</span>
|
||||
<span>下单时间</span>
|
||||
<span>下单账号</span>
|
||||
</div>
|
||||
<div className="big-screen-list-body" ref={viewportRef}>
|
||||
{items.length === 0 ? null : (
|
||||
{items.length === 0 ? (
|
||||
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
|
||||
) : (
|
||||
<div
|
||||
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
|
||||
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
|
||||
@@ -509,10 +438,6 @@ export default function BigScreenPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
|
||||
) : null}
|
||||
|
||||
{celebrate ? (
|
||||
<CelebrateFx
|
||||
key={celebrate.id}
|
||||
|
||||
@@ -24,10 +24,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/con
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityPartnersPanel from '../components/CityPartnersPanel';
|
||||
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type WarehouseRow = {
|
||||
id: string;
|
||||
@@ -227,16 +223,9 @@ export default function CitiesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||
{
|
||||
title: '城市',
|
||||
dataIndex: 'name',
|
||||
width: 100,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
||||
@@ -272,7 +261,7 @@ export default function CitiesPage() {
|
||||
|
||||
const warehouseColumns: ColumnsType<WarehouseRow> = [
|
||||
{ title: '仓库名', dataIndex: 'name' },
|
||||
{ title: '地址', dataIndex: 'address' },
|
||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
@@ -281,7 +270,7 @@ export default function CitiesPage() {
|
||||
width: 100,
|
||||
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
|
||||
},
|
||||
{ title: '合伙人', dataIndex: 'partnerCompanyName', render: (v) => v || '—' },
|
||||
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -322,16 +311,12 @@ export default function CitiesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('cities', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="城市"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={openCreateModal}>新建城市</Button>}
|
||||
/>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>城市</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateModal}>新建城市</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="code" label="编码"><Input allowClear /></Form.Item>
|
||||
@@ -340,7 +325,7 @@ export default function CitiesPage() {
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table scroll={{ x: 'max-content' }} rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
<Table 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)}>
|
||||
@@ -638,9 +623,9 @@ export default function CitiesPage() {
|
||||
)}
|
||||
columns={[
|
||||
{ title: '类型', dataIndex: 'kind', width: 80 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '名称', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||
{ title: '归属', dataIndex: 'parent' },
|
||||
{ title: '归属', dataIndex: 'parent', ellipsis: true },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -655,12 +640,13 @@ export default function CitiesPage() {
|
||||
locale={{ emptyText: '无门店' }}
|
||||
dataSource={deletePreview.stores}
|
||||
columns={[
|
||||
{ title: '门店名', dataIndex: 'name' },
|
||||
{ title: '门店名', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -36,10 +36,6 @@ import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type SubRow = PartnerSubAccountRow;
|
||||
|
||||
@@ -265,27 +261,22 @@ export default function CityPartnersPage() {
|
||||
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: 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',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
|
||||
{
|
||||
title: '主账号姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<span>{v}</span>
|
||||
@@ -320,6 +311,7 @@ export default function CityPartnersPage() {
|
||||
title: '管仓仓库',
|
||||
dataIndex: 'managedWarehouseName',
|
||||
width: 110,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -346,15 +338,12 @@ export default function CityPartnersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="城市合伙人"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
城市合伙人
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -372,8 +361,7 @@ export default function CityPartnersPage() {
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -439,7 +427,7 @@ export default function CityPartnersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={toTableRows(data?.items ?? [])}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1200 }}
|
||||
childrenColumnName="__noTreeChildren__"
|
||||
expandable={{
|
||||
expandedRowRender: (record) => (
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Switch,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -29,10 +30,6 @@ import {
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -73,7 +70,6 @@ function FulfillmentFields({
|
||||
onModeChange: (mode: WarehouseFulfillmentMode) => void;
|
||||
}) {
|
||||
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -223,7 +219,7 @@ export default function CityWarehousesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '城市',
|
||||
width: 100,
|
||||
@@ -233,15 +229,8 @@ export default function CityWarehousesPage() {
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '仓库',
|
||||
dataIndex: 'name',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '地址', dataIndex: 'address', width: 180 },
|
||||
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
|
||||
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
@@ -253,6 +242,7 @@ export default function CityWarehousesPage() {
|
||||
title: '合伙人',
|
||||
dataIndex: 'partnerCompanyName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) =>
|
||||
v && row.partnerAccountId ? (
|
||||
<Link to="/city-partners">{v}</Link>
|
||||
@@ -308,18 +298,10 @@ export default function CityWarehousesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-warehouses', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="仓库"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>仓库</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -337,8 +319,7 @@ export default function CityWarehousesPage() {
|
||||
>
|
||||
新增仓库
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -386,7 +367,7 @@ export default function CityWarehousesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -64,20 +64,6 @@ 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];
|
||||
@@ -129,26 +115,18 @@ 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([]));
|
||||
}
|
||||
}, [profile]);
|
||||
}, [loadVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAnalytics(filters);
|
||||
@@ -228,19 +206,9 @@ 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 },
|
||||
legend: { data: ['新增用户', '订单数'] },
|
||||
grid: { left: 40, right: 20, top: 40, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -248,25 +216,18 @@ export default function DashboardPage() {
|
||||
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series,
|
||||
series: [
|
||||
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
|
||||
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
|
||||
],
|
||||
};
|
||||
}, [analytics, canUsers, canOrders]);
|
||||
}, [analytics]);
|
||||
|
||||
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 },
|
||||
legend: { data: ['用户', '订单'] },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -274,25 +235,18 @@ export default function DashboardPage() {
|
||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series,
|
||||
series: [
|
||||
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
||||
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
||||
],
|
||||
};
|
||||
}, [analytics, canUsers, canOrders]);
|
||||
}, [analytics]);
|
||||
|
||||
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 },
|
||||
legend: { data: ['用户', '订单'] },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 64 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -300,36 +254,18 @@ export default function DashboardPage() {
|
||||
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series,
|
||||
series: [
|
||||
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
|
||||
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
|
||||
],
|
||||
};
|
||||
}, [analytics, canUsers, canOrders]);
|
||||
}, [analytics]);
|
||||
|
||||
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 },
|
||||
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] },
|
||||
grid: { left: 48, right: 48, top: 48, bottom: 40 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -340,29 +276,26 @@ export default function DashboardPage() {
|
||||
{ type: 'value', name: '数量', minInterval: 1 },
|
||||
{ type: 'value', name: '金额', minInterval: 1 },
|
||||
],
|
||||
series,
|
||||
series: [
|
||||
{ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) },
|
||||
{ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) },
|
||||
{ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) },
|
||||
{
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [analytics, canPartners, canStores, canBenefit]);
|
||||
}, [analytics]);
|
||||
|
||||
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 },
|
||||
legend: { data: ['合伙人', '门店', '核销笔数'] },
|
||||
grid: { left: 48, right: 20, top: 40, bottom: 48 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -370,31 +303,19 @@ export default function DashboardPage() {
|
||||
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
|
||||
},
|
||||
yAxis: { type: 'value', minInterval: 1 },
|
||||
series,
|
||||
series: [
|
||||
{ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 },
|
||||
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
||||
],
|
||||
};
|
||||
}, [analytics, canPartners, canStores, canBenefit]);
|
||||
}, [analytics]);
|
||||
|
||||
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 },
|
||||
legend: { data: ['门店', '核销笔数', '核销金额'] },
|
||||
grid: { left: 48, right: 48, top: 40, bottom: 64 },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
@@ -405,9 +326,18 @@ export default function DashboardPage() {
|
||||
{ type: 'value', name: '数量', minInterval: 1 },
|
||||
{ type: 'value', name: '金额' },
|
||||
],
|
||||
series,
|
||||
series: [
|
||||
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
|
||||
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
|
||||
{
|
||||
name: '核销金额',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: rows.map((r) => r.redeemAmount),
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [analytics, canStores, canBenefit]);
|
||||
}, [analytics]);
|
||||
|
||||
const redeemByCityOption = useMemo<EChartsOption>(() => {
|
||||
const rows = analytics?.byCity ?? [];
|
||||
@@ -438,9 +368,7 @@ export default function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>
|
||||
{cityScoped ? '数据概览(负责城市)' : '数据概览'}
|
||||
</Typography.Title>
|
||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
||||
|
||||
{isSuperAdmin ? (
|
||||
<Card
|
||||
@@ -483,94 +411,46 @@ 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
|
||||
@@ -598,15 +478,14 @@ export default function DashboardPage() {
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={cityScoped ? '全部负责城市' : '全部开城'}
|
||||
placeholder="全部开城"
|
||||
style={{ width: 160 }}
|
||||
options={[
|
||||
...(cityScoped ? [] : [{ value: 'none', label: '未选城' }]),
|
||||
{ value: 'none', label: '未选城' },
|
||||
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{canPromo ? (
|
||||
<Form.Item name="promoCodeId" label="推广码">
|
||||
<Select
|
||||
allowClear
|
||||
@@ -618,8 +497,6 @@ export default function DashboardPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{canPartners ? (
|
||||
<Form.Item name="partnerAccountId" label="合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
@@ -631,40 +508,32 @@ export default function DashboardPage() {
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{canUsers || canOrders ? (
|
||||
<Card
|
||||
title={canUsers && canOrders ? '用户 / 订单统计' : canUsers ? '用户统计' : '订单统计'}
|
||||
title="用户 / 订单统计"
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
{canOrders ? <Link to={`/orders?${ordersDrillQs}`}>查看订单</Link> : null}
|
||||
{canUsers ? <Link to="/users">查看用户</Link> : null}
|
||||
<Link to={`/orders?${ordersDrillQs}`}>查看订单</Link>
|
||||
<Link to="/users">查看用户</Link>
|
||||
</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]}>
|
||||
@@ -673,7 +542,7 @@ export default function DashboardPage() {
|
||||
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={canPromo ? 12 : 24}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按城市(点击柱联动筛选)"
|
||||
@@ -693,7 +562,6 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{canPromo ? (
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
@@ -716,53 +584,42 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{canPartners || canStores || canBenefit ? (
|
||||
<Card
|
||||
title={[canPartners && '合伙人', canStores && '门店', canBenefit && '核销'].filter(Boolean).join(' / ') + '统计'}
|
||||
title="合伙人 / 门店 / 核销统计"
|
||||
style={{ marginBottom: 24 }}
|
||||
extra={
|
||||
<Space>
|
||||
{canPartners ? <Link to="/city-partners">查看合伙人</Link> : null}
|
||||
{canStores ? <Link to="/stores">查看门店</Link> : null}
|
||||
{canBenefit ? <Link to="/redeem-records">查看核销</Link> : null}
|
||||
<Link to="/city-partners">查看合伙人</Link>
|
||||
<Link to="/stores">查看门店</Link>
|
||||
<Link to="/redeem-records">查看核销</Link>
|
||||
</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={canBenefit ? 12 : 24}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
title="按城市(点击柱联动筛选)"
|
||||
@@ -782,7 +639,6 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
{canBenefit ? (
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
type="inner"
|
||||
@@ -803,8 +659,6 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{canPartners && (canStores || canBenefit) ? (
|
||||
<Col xs={24}>
|
||||
<Card
|
||||
type="inner"
|
||||
@@ -827,14 +681,10 @@ 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}
|
||||
@@ -854,8 +704,6 @@ export default function DashboardPage() {
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{canFinance ? (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
@@ -869,8 +717,6 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{canFinance ? (
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
@@ -891,28 +737,20 @@ 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]}>
|
||||
{canUsers ? (
|
||||
<Col xs={24} lg={canOrders ? 12 : 24}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="已合并访客账号" loading={loading}>
|
||||
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
{canOrders ? (
|
||||
<Col xs={24} lg={canUsers ? 12 : 24}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="订单状态分布" loading={loading}>
|
||||
<Table
|
||||
size="small"
|
||||
@@ -930,9 +768,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||
import { request } from '../lib/api';
|
||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -19,8 +15,6 @@ type Row = {
|
||||
trackingNo: string | null;
|
||||
providerOrderNo: string | null;
|
||||
updatedAt: string;
|
||||
/** 当次应付物流费 */
|
||||
logisticsFee?: number | null;
|
||||
order?: {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
@@ -65,32 +59,10 @@ export default function DeliveriesPage() {
|
||||
setTrackOpen(true);
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: ['order', 'orderNo'],
|
||||
width: 170,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||
{
|
||||
title: '运费',
|
||||
dataIndex: 'logisticsFee',
|
||||
width: 90,
|
||||
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
||||
},
|
||||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||
@@ -111,19 +83,16 @@ export default function DeliveriesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||||
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
||||
<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: 'max-content' }}
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
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={
|
||||
@@ -144,9 +113,6 @@ export default function DeliveriesPage() {
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
||||
<Descriptions.Item label="运费">
|
||||
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
@@ -19,7 +18,6 @@ import {
|
||||
DEV_PLAN_TASK_TYPE_LABELS,
|
||||
SUPPORT_TICKET_STATUS_LABELS,
|
||||
type DevPlanTaskDto,
|
||||
type DevPlanTaskExportFormat,
|
||||
type DevPlanTaskStatusDto,
|
||||
type DevPlanTaskTypeDto,
|
||||
type DevPlanVersionDto,
|
||||
@@ -27,13 +25,7 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { downloadBase64File } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
||||
value: v,
|
||||
@@ -64,8 +56,6 @@ 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 }>();
|
||||
@@ -98,30 +88,24 @@ export default function DevPlanTasksPage() {
|
||||
|
||||
function openCreate() {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined, attachmentUrls: [''] });
|
||||
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined });
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: DevPlanTaskDto) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
||||
});
|
||||
form.setFieldsValue({ content: row.content, type: row.type, status: row.status });
|
||||
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, attachmentUrls }),
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
message.success('已更新');
|
||||
} else {
|
||||
@@ -131,7 +115,6 @@ export default function DevPlanTasksPage() {
|
||||
content: values.content,
|
||||
type: values.type,
|
||||
supportTicketId: values.supportTicketId || undefined,
|
||||
attachmentUrls,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
@@ -216,43 +199,8 @@ export default function DevPlanTasksPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function exportTasks(scope: 'filter' | 'selected') {
|
||||
setExporting(true);
|
||||
try {
|
||||
const result = await request<{
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
contentBase64: string;
|
||||
count: number;
|
||||
}>('/admin/dev-plan/tasks/export', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
scope,
|
||||
format: exportFormat,
|
||||
ids: scope === 'selected' ? selectedRowKeys : undefined,
|
||||
status: filters.status || undefined,
|
||||
type: filters.type || undefined,
|
||||
keyword: filters.keyword || undefined,
|
||||
}),
|
||||
});
|
||||
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
||||
message.success(`已导出 ${result.count} 条任务`);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
||||
{
|
||||
title: '任务号',
|
||||
dataIndex: 'taskNo',
|
||||
width: 160,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<DevPlanTaskDto> = [
|
||||
{ title: '任务号', dataIndex: 'taskNo', width: 160 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
@@ -267,23 +215,7 @@ export default function DevPlanTasksPage() {
|
||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
||||
),
|
||||
},
|
||||
{ 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: 'content', ellipsis: true },
|
||||
{ title: '来源工单', dataIndex: 'supportTicketNo', width: 140, render: (v) => v || '—' },
|
||||
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
@@ -305,33 +237,13 @@ export default function DevPlanTasksPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="开发计划 · 任务列表"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<>
|
||||
<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>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
开发计划 · 任务列表
|
||||
</Typography.Title>
|
||||
<Space>
|
||||
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
|
||||
批量编辑
|
||||
</Button>
|
||||
@@ -341,9 +253,8 @@ export default function DevPlanTasksPage() {
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建任务
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
@@ -376,7 +287,7 @@ export default function DevPlanTasksPage() {
|
||||
selectedRowKeys,
|
||||
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -422,29 +333,6 @@ 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>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -21,10 +22,6 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
|
||||
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
|
||||
@@ -122,10 +119,8 @@ export default function DevPlanVersionsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<DevPlanVersionDto> = [
|
||||
{ title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||
) },
|
||||
const columns: ColumnsType<DevPlanVersionDto> = [
|
||||
{ title: '版本号', dataIndex: 'versionNo', width: 120 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -134,7 +129,7 @@ export default function DevPlanVersionsPage() {
|
||||
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '内容', dataIndex: 'content', render: (v) => v || '—' },
|
||||
{ title: '内容', dataIndex: 'content', ellipsis: true, 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) : '—') },
|
||||
@@ -163,20 +158,16 @@ export default function DevPlanVersionsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-versions', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="开发计划 · 版本列表"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
开发计划 · 版本列表
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建版本
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
layout="inline"
|
||||
@@ -199,7 +190,7 @@ export default function DevPlanVersionsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -4,9 +4,6 @@ import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -58,22 +55,9 @@ export default function DomainEventsPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'eventType',
|
||||
width: 130,
|
||||
render: (v, r) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||
}}
|
||||
>
|
||||
<Tag>{v}</Tag>
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
|
||||
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 100 },
|
||||
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
|
||||
@@ -92,13 +76,8 @@ 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="领域事件" extra={settingsButton}>
|
||||
{settingsModal}
|
||||
<Card title="领域事件">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -28,10 +28,6 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
@@ -69,10 +65,7 @@ export default function FulfillmentProvidersPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
|
||||
setRows(Array.isArray(res) ? res : []);
|
||||
} catch (e) {
|
||||
setRows([]);
|
||||
message.error(e instanceof Error ? e.message : '加载承运商失败');
|
||||
setRows(res);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -98,7 +91,6 @@ export default function FulfillmentProvidersPage() {
|
||||
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
deliveryHintHtml: '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -127,7 +119,6 @@ export default function FulfillmentProvidersPage() {
|
||||
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
|
||||
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
|
||||
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
|
||||
deliveryHintHtml: row.deliveryHintHtml || '',
|
||||
});
|
||||
setOpen(true);
|
||||
}
|
||||
@@ -150,7 +141,6 @@ export default function FulfillmentProvidersPage() {
|
||||
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
|
||||
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
|
||||
},
|
||||
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
|
||||
};
|
||||
|
||||
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
|
||||
@@ -183,25 +173,9 @@ export default function FulfillmentProvidersPage() {
|
||||
void load();
|
||||
}
|
||||
|
||||
async function remove(row: FulfillmentProviderDto) {
|
||||
try {
|
||||
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
||||
const columns: ColumnsType<FulfillmentProviderDto> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
@@ -247,45 +221,30 @@ export default function FulfillmentProvidersPage() {
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确认删除承运商「${row.name}」?`}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void remove(row)}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, {
|
||||
page: 1,
|
||||
pageSize: rows.length || 20,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="仓配管理"
|
||||
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
仓配管理
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
注册承运商
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
@@ -294,7 +253,7 @@ export default function FulfillmentProvidersPage() {
|
||||
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||||
/>
|
||||
|
||||
<Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title={editRow ? '编辑承运商' : '注册承运商'}
|
||||
@@ -317,17 +276,6 @@ export default function FulfillmentProvidersPage() {
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="deliveryHintHtml"
|
||||
label="配送信息提示"
|
||||
extra="C 端同城送展示。支持 HTML:span/p/br/b/strong/i/em/font,style 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={2000}
|
||||
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Divider orientation="left">物流对账</Divider>
|
||||
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
|
||||
|
||||
@@ -3,14 +3,9 @@ import {
|
||||
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
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 { request, type HqProfile } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -22,13 +17,14 @@ type Row = {
|
||||
status: string;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
cityIds?: string[];
|
||||
};
|
||||
|
||||
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]));
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
SUPER_ADMIN: '超级管理员',
|
||||
OPS: '运营',
|
||||
FINANCE: '财务',
|
||||
CUSTOMER_SERVICE: '客服',
|
||||
};
|
||||
|
||||
export default function HqAccountsPage() {
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
@@ -50,52 +46,15 @@ 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(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperAdmin) return;
|
||||
request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setCities(res.items))
|
||||
.catch(() => {});
|
||||
}, [isSuperAdmin]);
|
||||
|
||||
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
render: (v, row) =>
|
||||
isSuperAdmin ? (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => {
|
||||
setDetail(row);
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
loginName: row.loginName,
|
||||
adminRole: row.adminRole,
|
||||
status: row.status,
|
||||
cityIds: row.cityIds ?? [],
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
v
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
@@ -108,18 +67,7 @@ export default function HqAccountsPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ 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: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -133,7 +81,6 @@ export default function HqAccountsPage() {
|
||||
loginName: row.loginName,
|
||||
adminRole: row.adminRole,
|
||||
status: row.status,
|
||||
cityIds: row.cityIds ?? [],
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
@@ -141,57 +88,43 @@ export default function HqAccountsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('hq-accounts', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="HQ 账户"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
isSuperAdmin ? (
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
||||
{isSuperAdmin && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
|
||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' });
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
<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: 160 }} options={ROLE_OPTIONS} />
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
<Table 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={460} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Button type="primary" loading={saving} onClick={async () => {
|
||||
<Button type="primary" onClick={async () => {
|
||||
if (!detail) return;
|
||||
try {
|
||||
const v = await editForm.validateFields();
|
||||
const payload: Record<string, unknown> = { ...v };
|
||||
const payload = { ...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">
|
||||
@@ -211,15 +144,7 @@ export default function HqAccountsPage() {
|
||||
<Input.Password placeholder="至少 6 位" />
|
||||
</Form.Item>
|
||||
<Form.Item name="adminRole" 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} />
|
||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
@@ -231,19 +156,15 @@ 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', cityIds: [] }}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}>
|
||||
<Form.Item name="credentialType" label="创建方式">
|
||||
<Radio.Group>
|
||||
<Radio value="phone">手机号账户(短信登录)</Radio>
|
||||
@@ -252,15 +173,7 @@ export default function HqAccountsPage() {
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="adminRole" 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} />
|
||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
{credentialType === 'phone' ? (
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||
|
||||
@@ -9,10 +9,6 @@ import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -56,11 +52,12 @@ export default function HqLogsPage() {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作人',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.hqName}
|
||||
@@ -72,17 +69,7 @@ export default function HqLogsPage() {
|
||||
title: '行为',
|
||||
dataIndex: 'actionLabel',
|
||||
width: 160,
|
||||
render: (v, r) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
const res = await request<Row>(`/admin/logs/hq/${r.id}`);
|
||||
setDetail(res);
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
<Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
|
||||
},
|
||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||
@@ -105,16 +92,12 @@ export default function HqLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="HQ 操作日志"
|
||||
settings={settingsButton}
|
||||
description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
|
||||
/>
|
||||
<Typography.Title level={4}>HQ 操作日志</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
@@ -167,7 +150,7 @@ export default function HqLogsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import {
|
||||
HQ_ADMIN_ROLES,
|
||||
HQ_PERMISSION_CATALOG,
|
||||
computeHqEffectivePermissionKeys,
|
||||
type HqPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
@@ -28,8 +27,6 @@ type AccountPermRes = {
|
||||
account: AccountOption;
|
||||
permissionKeys: string[];
|
||||
rolePermissionKeys: string[];
|
||||
grantKeys?: string[];
|
||||
denyKeys?: string[];
|
||||
userPermissionKeys: string[];
|
||||
effectivePermissionKeys: string[];
|
||||
};
|
||||
@@ -49,17 +46,11 @@ 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%' }}
|
||||
@@ -90,13 +81,6 @@ 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>
|
||||
@@ -104,16 +88,7 @@ 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}
|
||||
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||||
) : grantSet.has(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||||
) : roleSet.has(item.key) ? (
|
||||
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||||
) : null}
|
||||
</Checkbox>
|
||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
@@ -125,14 +100,6 @@ 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');
|
||||
@@ -144,8 +111,6 @@ 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);
|
||||
|
||||
@@ -156,8 +121,8 @@ export default function HqPermissionsPage() {
|
||||
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
|
||||
|
||||
const previewEffectiveKeys = useMemo(
|
||||
() => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys),
|
||||
[roleInheritedKeys, grantKeys, denyKeys],
|
||||
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
|
||||
[roleInheritedKeys, accountKeys],
|
||||
);
|
||||
|
||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||
@@ -186,10 +151,8 @@ export default function HqPermissionsPage() {
|
||||
setAccountLoading(true);
|
||||
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
||||
.then((res) => {
|
||||
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||||
setDenyKeys(res.denyKeys ?? []);
|
||||
setAccountKeys(res.userPermissionKeys);
|
||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||
setAccountKeys(res.effectivePermissionKeys);
|
||||
})
|
||||
.finally(() => setAccountLoading(false));
|
||||
}, [isSuperAdmin, accountId]);
|
||||
@@ -216,12 +179,10 @@ export default function HqPermissionsPage() {
|
||||
try {
|
||||
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ grantKeys, denyKeys }),
|
||||
body: JSON.stringify({ permissionKeys: accountKeys }),
|
||||
});
|
||||
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||||
setDenyKeys(res.denyKeys ?? []);
|
||||
setAccountKeys(res.userPermissionKeys);
|
||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||
setAccountKeys(res.effectivePermissionKeys);
|
||||
message.success('用户权限已保存');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
@@ -243,9 +204,8 @@ export default function HqPermissionsPage() {
|
||||
<div>
|
||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
按角色配置基础权限;按用户可追加或撤销。最终生效权限 =(角色权限 ∪ 追加)− 撤销。
|
||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限。
|
||||
超级管理员默认拥有除「危险操作」外的全部权限;删除用户/订单/城市需在「按用户分配」中单独勾选(默认均无)。
|
||||
运营/财务默认可删除门店分类;城市门店服务可新增分类,不可删除。
|
||||
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||
</Typography.Paragraph>
|
||||
|
||||
@@ -315,33 +275,45 @@ export default function HqPermissionsPage() {
|
||||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||
) : (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary">
|
||||
勾选表示该账号最终拥有该权限。取消角色已有项即为撤销;勾选角色没有的项即为追加。
|
||||
切换角色会清空账号级追加/撤销。
|
||||
</Typography.Paragraph>
|
||||
<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);
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<span style={{ marginRight: 8 }}>角色继承:</span>
|
||||
{selectedIsSuperAdmin ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="超级管理员基础权限不可撤销。危险操作请到下方以外的方式:当前勾选仅展示生效权限。"
|
||||
/>
|
||||
<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} />
|
||||
<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));
|
||||
@@ -352,14 +324,10 @@ export default function HqPermissionsPage() {
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={accountSaving}
|
||||
disabled={selectedIsSuperAdmin}
|
||||
onClick={() => void saveAccountPermissions()}
|
||||
>
|
||||
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
|
||||
保存用户权限
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -11,16 +10,15 @@ import {
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
INVOICE_CATEGORY_LABELS,
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_STATUS_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceCategory,
|
||||
type InvoiceKind,
|
||||
type InvoiceStatus,
|
||||
type InvoiceTitleType,
|
||||
@@ -29,10 +27,6 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -40,7 +34,6 @@ type Row = {
|
||||
orderNo?: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
invoiceCategory?: InvoiceCategory;
|
||||
titleName: string;
|
||||
status: InvoiceStatus;
|
||||
overdue?: boolean;
|
||||
@@ -60,7 +53,6 @@ type CreateFormValues = {
|
||||
orderNo: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
invoiceCategory?: InvoiceCategory;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
@@ -71,22 +63,12 @@ type CreateFormValues = {
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialStatus = searchParams.get('status')?.trim() || '';
|
||||
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
|
||||
const [filterForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||
const init: Record<string, string> = {};
|
||||
if (initialStatus) init.status = initialStatus;
|
||||
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
|
||||
return init;
|
||||
});
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/invoices',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -99,29 +81,12 @@ export default function InvoicesPage() {
|
||||
const [createForm] = Form.useForm<CreateFormValues>();
|
||||
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||
const titleType = Form.useWatch('titleType', createForm);
|
||||
const deepLinkOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
filterForm.setFieldsValue({
|
||||
status: filters.status || undefined,
|
||||
invoiceNo: filters.invoiceNo || undefined,
|
||||
});
|
||||
}, [filterForm, filters.invoiceNo, filters.status]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetail(await request(`/admin/invoices/${id}`));
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
if (first && String(first.invoiceNo) === initialInvoiceNo) {
|
||||
deepLinkOpenedRef.current = true;
|
||||
void openDetail(first.id);
|
||||
}
|
||||
}, [data, initialInvoiceNo, loading]);
|
||||
|
||||
async function issueWithFile(file: File) {
|
||||
if (!detail) return false;
|
||||
setUploading(true);
|
||||
@@ -163,7 +128,6 @@ export default function InvoicesPage() {
|
||||
orderNo: values.orderNo.trim(),
|
||||
titleType: values.titleType,
|
||||
invoiceKind: values.invoiceKind,
|
||||
invoiceCategory: values.invoiceCategory,
|
||||
titleName: values.titleName.trim(),
|
||||
taxNo: values.taxNo?.trim() || undefined,
|
||||
addressPhone: values.addressPhone?.trim() || undefined,
|
||||
@@ -184,7 +148,7 @@ export default function InvoicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{
|
||||
@@ -194,24 +158,10 @@ export default function InvoicesPage() {
|
||||
},
|
||||
{
|
||||
title: '票种',
|
||||
width: 140,
|
||||
width: 120,
|
||||
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
width: 88,
|
||||
render: (_, r) =>
|
||||
r.invoiceCategory
|
||||
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'titleName',
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
@@ -236,32 +186,33 @@ export default function InvoicesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="发票管理"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
发票管理
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.setFieldsValue({
|
||||
titleType: 'PERSONAL',
|
||||
invoiceKind: 'NORMAL',
|
||||
invoiceCategory: 'LIQUOR',
|
||||
});
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
创建发票申请
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
@@ -280,9 +231,6 @@ export default function InvoicesPage() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="invoiceNo" label="申请单号">
|
||||
<Input allowClear placeholder="发票申请单号" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
@@ -292,7 +240,7 @@ export default function InvoicesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1000 }}
|
||||
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
|
||||
pagination={{
|
||||
current: page,
|
||||
@@ -344,11 +292,6 @@ export default function InvoicesPage() {
|
||||
<Descriptions.Item label="发票类型">
|
||||
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{detail.invoiceCategory
|
||||
? INVOICE_CATEGORY_LABELS[detail.invoiceCategory]
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||
@@ -407,18 +350,6 @@ export default function InvoicesPage() {
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="invoiceCategory"
|
||||
label="类型"
|
||||
rules={[{ required: true, message: '请选择类型' }]}
|
||||
>
|
||||
<Select
|
||||
options={(Object.keys(INVOICE_CATEGORY_LABELS) as InvoiceCategory[]).map((k) => ({
|
||||
value: k,
|
||||
label: INVOICE_CATEGORY_LABELS[k],
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="titleType"
|
||||
label="抬头类型"
|
||||
|
||||
@@ -25,10 +25,6 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -128,15 +124,9 @@ export default function KnowledgeBasesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description' },
|
||||
const columns: ColumnsType<KnowledgeBaseDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '说明', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
|
||||
{
|
||||
title: '启用',
|
||||
@@ -194,7 +184,7 @@ export default function KnowledgeBasesPage() {
|
||||
|
||||
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '文件', dataIndex: 'fileName' },
|
||||
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -260,16 +250,16 @@ export default function KnowledgeBasesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('knowledge-bases', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="知识库"
|
||||
settings={settingsButton}
|
||||
description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索"
|
||||
/>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
知识库
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
|
||||
@@ -25,10 +25,6 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -157,21 +153,15 @@ export default function LlmConfigsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const baseColumns: ColumnsType<LlmApiConfigDto> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<LlmApiConfigDto> = [
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '提供商',
|
||||
dataIndex: 'provider',
|
||||
width: 120,
|
||||
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName' },
|
||||
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
|
||||
{
|
||||
title: 'Key',
|
||||
dataIndex: 'apiKeyConfigured',
|
||||
@@ -267,16 +257,16 @@ export default function LlmConfigsPage() {
|
||||
|
||||
const fullEdit = !editing || editing.canEditFull;
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('llm-configs', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="语言模型配置"
|
||||
settings={settingsButton}
|
||||
description="非超管仅可见自己创建的配置,且创建后只能改是否生效"
|
||||
/>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
语言模型配置
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
非超管仅可见自己创建的配置,且创建后只能改是否生效
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
|
||||
@@ -29,9 +29,6 @@ import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
type BillRow = {
|
||||
id: string;
|
||||
@@ -47,13 +44,6 @@ 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 = {
|
||||
@@ -150,30 +140,14 @@ export default function LogisticsBillsPage() {
|
||||
}, [summaryPeriod]);
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
let paymentRef = '';
|
||||
Modal.confirm({
|
||||
title: '确认结算?',
|
||||
content: (
|
||||
<div>
|
||||
<div>
|
||||
将确认 {ids.length} 笔物流对账单
|
||||
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。
|
||||
</div>
|
||||
<Input
|
||||
placeholder="打款凭证号(可选)"
|
||||
style={{ marginTop: 8 }}
|
||||
onChange={(e) => {
|
||||
paymentRef = e.target.value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
|
||||
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', body });
|
||||
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/logistics-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
@@ -243,19 +217,11 @@ 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,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '承运商',
|
||||
width: 140,
|
||||
@@ -282,16 +248,6 @@ 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',
|
||||
@@ -347,6 +303,7 @@ export default function LogisticsBillsPage() {
|
||||
{
|
||||
title: '银行账户',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, r) =>
|
||||
r.bankAccountName
|
||||
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
|
||||
@@ -398,19 +355,16 @@ export default function LogisticsBillsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-logistics-bills', billColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="物流对账"
|
||||
settings={settingsButton}
|
||||
description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。"
|
||||
/>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
物流对账
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
@@ -547,7 +501,7 @@ export default function LogisticsBillsPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
columns={billColumns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
@@ -596,31 +550,7 @@ 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"
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Collapse,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
@@ -23,17 +20,10 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
import { downloadBase64File } from '../lib/exportExcel';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
DELIVERY_PROVIDER_LABELS,
|
||||
DELIVERY_TYPE_LABELS,
|
||||
ORDER_STATUS_COLORS,
|
||||
ORDER_STATUS_LABELS,
|
||||
ORDER_STATUS_OPERATOR_LABELS,
|
||||
@@ -140,117 +130,6 @@ type OrderDetail = AdminOrderRow & {
|
||||
|
||||
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
|
||||
|
||||
type OrderExportScope = 'filter' | 'selected';
|
||||
type OrderExportFormat = 'xlsx' | 'pdf';
|
||||
|
||||
type OrderExportFilters = {
|
||||
orderNo?: string;
|
||||
status?: string | string[];
|
||||
orderType?: string;
|
||||
cityId?: string;
|
||||
receiverPhone?: string;
|
||||
fulfillmentHold?: boolean;
|
||||
excludeTest?: boolean;
|
||||
deliveryType?: string;
|
||||
dateRange?: [Dayjs, Dayjs];
|
||||
};
|
||||
|
||||
type OrderExportResult = {
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
contentBase64: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
const ORDER_TYPE_OPTIONS = Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
const DELIVERY_TYPE_OPTIONS = [
|
||||
{ value: 'LOCAL', label: '同城' },
|
||||
{ value: 'CROSS_CITY', label: '跨城' },
|
||||
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
|
||||
];
|
||||
|
||||
function isOnSitePickupOrder(row: Pick<AdminOrderRow, 'deliveryType' | 'receiverProvince' | 'receiverCity' | 'receiverAddress'>) {
|
||||
return (
|
||||
row.deliveryType === 'ON_SITE_PICKUP' ||
|
||||
row.receiverAddress === '现场取货' ||
|
||||
row.receiverAddress === '现场提货' ||
|
||||
(row.receiverProvince === '现场' && row.receiverCity === '现场')
|
||||
);
|
||||
}
|
||||
|
||||
function formatReceiverAddress(row: AdminOrderRow) {
|
||||
if (isOnSitePickupOrder(row)) return '现场取货';
|
||||
const region = [row.receiverProvince, row.receiverCity, row.receiverDistrict].filter(Boolean).join('');
|
||||
const detail = row.receiverAddress || '';
|
||||
if (!region) return detail;
|
||||
if (!detail || detail.startsWith(region)) return detail || region;
|
||||
return `${region}${detail}`;
|
||||
}
|
||||
|
||||
function datePresetRange(kind: 'day' | 'week' | 'month' | 'quarter' | 'year'): [Dayjs, Dayjs] {
|
||||
const now = dayjs();
|
||||
if (kind === 'day') return [now.startOf('day'), now.endOf('day')];
|
||||
if (kind === 'week') {
|
||||
const monday = now.startOf('day').subtract((now.day() + 6) % 7, 'day');
|
||||
return [monday, monday.add(6, 'day').endOf('day')];
|
||||
}
|
||||
if (kind === 'month') return [now.startOf('month'), now.endOf('month')];
|
||||
if (kind === 'quarter') {
|
||||
const start = now.month(Math.floor(now.month() / 3) * 3).startOf('month');
|
||||
return [start, start.add(2, 'month').endOf('month')];
|
||||
}
|
||||
return [now.startOf('year'), now.endOf('year')];
|
||||
}
|
||||
|
||||
const DATE_PRESETS: Array<{ key: 'day' | 'week' | 'month' | 'quarter' | 'year'; label: string }> = [
|
||||
{ key: 'day', label: '当日' },
|
||||
{ key: 'week', label: '当周' },
|
||||
{ key: 'month', label: '当月' },
|
||||
{ key: 'quarter', label: '当季' },
|
||||
{ key: 'year', label: '当年' },
|
||||
];
|
||||
|
||||
function formatBenefitBrief(row: AdminOrderRow) {
|
||||
const coupon = row.benefitCoupon;
|
||||
if (coupon) {
|
||||
return `总额¥${Number(coupon.totalAmount ?? 0).toFixed(0)} / 已用¥${Number(coupon.usedAmount ?? 0).toFixed(0)} / 余¥${Number(coupon.balance ?? 0).toFixed(0)}`;
|
||||
}
|
||||
if (row.benefitAmount != null) return `¥${Number(row.benefitAmount).toFixed(0)}`;
|
||||
return '—';
|
||||
}
|
||||
|
||||
function selectedStatuses(status: unknown): string[] {
|
||||
if (Array.isArray(status)) return status.filter((s): s is string => Boolean(s));
|
||||
if (typeof status === 'string' && status) return [status];
|
||||
return [];
|
||||
}
|
||||
|
||||
function buildExportPayload(
|
||||
scope: OrderExportScope,
|
||||
format: OrderExportFormat,
|
||||
filters: OrderExportFilters,
|
||||
selectedIds: string[],
|
||||
) {
|
||||
const payload: Record<string, unknown> = { scope, format };
|
||||
if (scope === 'selected') {
|
||||
payload.ids = selectedIds;
|
||||
return payload;
|
||||
}
|
||||
if (filters.orderNo) payload.orderNo = filters.orderNo;
|
||||
const statuses = selectedStatuses(filters.status);
|
||||
if (statuses.length) payload.status = statuses;
|
||||
if (filters.orderType) payload.orderType = filters.orderType;
|
||||
if (filters.cityId) payload.cityId = filters.cityId;
|
||||
if (filters.receiverPhone) payload.receiverPhone = filters.receiverPhone;
|
||||
if (filters.deliveryType) payload.deliveryType = filters.deliveryType;
|
||||
if (filters.fulfillmentHold) payload.fulfillmentHold = true;
|
||||
if (filters.excludeTest) payload.excludeTest = true;
|
||||
if (filters.dateRange?.[0]) payload.createdFrom = filters.dateRange[0].format('YYYY-MM-DD');
|
||||
if (filters.dateRange?.[1]) payload.createdTo = filters.dateRange[1].format('YYYY-MM-DD');
|
||||
return payload;
|
||||
}
|
||||
|
||||
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
|
||||
if (detail.items?.length) return detail.items;
|
||||
if (!detail.productName) return [];
|
||||
@@ -283,9 +162,6 @@ function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): S
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialOrderNo = searchParams.get('orderNo')?.trim() || '';
|
||||
const [form] = Form.useForm();
|
||||
const [shipForm] = Form.useForm();
|
||||
const [logisticsForm] = Form.useForm();
|
||||
@@ -296,7 +172,6 @@ export default function OrdersPage() {
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [detail, setDetail] = useState<OrderDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const deepLinkOpenedRef = useRef(false);
|
||||
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
|
||||
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
|
||||
@@ -314,11 +189,8 @@ 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)),
|
||||
@@ -329,21 +201,6 @@ export default function OrdersPage() {
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialOrderNo) {
|
||||
form.setFieldsValue({ orderNo: initialOrderNo });
|
||||
}
|
||||
}, [form, initialOrderNo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialOrderNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
if (first && String(first.orderNo) === initialOrderNo) {
|
||||
deepLinkOpenedRef.current = true;
|
||||
void openDetail(first.id);
|
||||
}
|
||||
}, [data, initialOrderNo, loading]);
|
||||
|
||||
async function openRedeemDetail(redeemId: string) {
|
||||
setRedeemDetailLoading(true);
|
||||
setRedeemDrawerOpen(true);
|
||||
@@ -363,23 +220,19 @@ 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);
|
||||
for (const status of selectedStatuses(values.status)) qs.append('status', status);
|
||||
if (values.status) qs.set('status', values.status);
|
||||
if (values.orderType) qs.set('orderType', values.orderType);
|
||||
if (values.cityId) qs.set('cityId', values.cityId);
|
||||
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, initialOrderNo]);
|
||||
}, [form, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -580,128 +433,79 @@ export default function OrdersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
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> = [
|
||||
const columns: ColumnsType<AdminOrderRow> = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
width: 180,
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
key: 'user',
|
||||
width: 120,
|
||||
render: (_, row) =>
|
||||
row.user?.id ? (
|
||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
||||
{row.user.userNo}
|
||||
</AdminPrimaryLink>
|
||||
) : (
|
||||
(row.user?.userNo || '—')
|
||||
),
|
||||
title: '城市',
|
||||
width: 90,
|
||||
render: (_, row) => row.city?.name || '—',
|
||||
},
|
||||
{
|
||||
title: '商品',
|
||||
key: 'productName',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
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) => (
|
||||
<Space size={4} wrap>
|
||||
<span>{row.productName || '—'}</span>
|
||||
{row.fulfillmentHold ? <Tag color="orange">大单</Tag> : null}
|
||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||
{row.fulfillmentHold ? <Tag color="orange">大单待确认</Tag> : null}
|
||||
{row.orderType === 'PROXY' || row.isProxyOrder ? (
|
||||
<Tag color="purple">代下单</Tag>
|
||||
<Tag color="purple" title={row.proxyPartnerName || undefined}>
|
||||
代下单
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '规格',
|
||||
dataIndex: 'productSpec',
|
||||
width: 140,
|
||||
render: (v: string | undefined) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '数量',
|
||||
dataIndex: 'quantity',
|
||||
width: 80,
|
||||
render: (v: number | undefined, row) =>
|
||||
v == null ? '—' : `${v}${row.saleUnit === 'BOX' ? '箱' : '瓶'}`,
|
||||
},
|
||||
{
|
||||
title: '配送方式',
|
||||
title: '配送',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 100,
|
||||
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string) => (
|
||||
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'payAmount',
|
||||
width: 90,
|
||||
render: (v: number) => `¥${v}`,
|
||||
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: 200,
|
||||
render: (_, row) => formatBenefitBrief(row),
|
||||
},
|
||||
{
|
||||
title: '收货人',
|
||||
dataIndex: 'receiverName',
|
||||
width: 90,
|
||||
render: (v: string | undefined) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'receiverPhone',
|
||||
width: 120,
|
||||
render: (v: string | undefined) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '地址',
|
||||
key: 'receiverAddress',
|
||||
width: 260,
|
||||
render: (_, row) => formatReceiverAddress(row) || '—',
|
||||
title: '快递',
|
||||
width: 100,
|
||||
render: (_, row) => row.delivery?.trackingNo || row.delivery?.provider || '—',
|
||||
},
|
||||
{
|
||||
title: '下单时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: fmtTime,
|
||||
render: (v) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
@@ -733,28 +537,28 @@ export default function OrdersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('orders', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="订单监控"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>订单监控</Typography.Title>
|
||||
<Space>
|
||||
<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>
|
||||
|
||||
{!canDeleteOrders && profile ? (
|
||||
<Alert
|
||||
@@ -770,117 +574,54 @@ export default function OrdersPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<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 form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="orderNo" label="订单号">
|
||||
<Input placeholder="DK..." allowClear />
|
||||
</Form.Item>
|
||||
<Space size={8} wrap>
|
||||
{DATE_PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.key}
|
||||
size="small"
|
||||
onClick={() => form.setFieldsValue({ dateRange: datePresetRange(preset.key) })}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</Space>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8} lg={6}>
|
||||
<Form.Item name="status" label="状态" style={{ marginBottom: 12 }}>
|
||||
<Form.Item name="orderType" label="类型">
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
style={{ width: 120 }}
|
||||
placeholder="全部"
|
||||
options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
options={[
|
||||
{ value: 'NORMAL', label: '普通订单' },
|
||||
{ value: 'PROXY', label: '代下单' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Form.Item name="orderType" label="类型" style={{ marginBottom: 12 }}>
|
||||
<Select allowClear placeholder="全部" options={ORDER_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Form.Item name="deliveryType" label="配送" style={{ marginBottom: 12 }}>
|
||||
<Select allowClear placeholder="全部" options={DELIVERY_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={5} lg={3}>
|
||||
<Form.Item name="cityId" label="城市" style={{ marginBottom: 12 }}>
|
||||
<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>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={6} lg={6}>
|
||||
<Form.Item name="receiverPhone" label="收货手机" style={{ marginBottom: 12 }}>
|
||||
<Input allowClear placeholder="手机号" />
|
||||
<Form.Item name="receiverPhone" label="收货手机">
|
||||
<Input allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={8} lg={8}>
|
||||
<Form.Item label=" " colon={false} style={{ marginBottom: 12 }}>
|
||||
<Space size={16} wrap>
|
||||
<Form.Item name="fulfillmentHold" valuePropName="checked" noStyle>
|
||||
<Checkbox>大单拦截</Checkbox>
|
||||
<Form.Item name="fulfillmentHold" label="大单拦截" valuePropName="checked">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部"
|
||||
options={[{ value: true, label: '仅待确认大单' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="excludeTest" valuePropName="checked" noStyle>
|
||||
<Checkbox>过滤测试</Checkbox>
|
||||
<Form.Item name="excludeTest" valuePropName="checked">
|
||||
<Checkbox>过滤测试账号</Checkbox>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', margin: '8px 0 16px', gap: 12, flexWrap: 'wrap' }}>
|
||||
{canExportOrders ? (
|
||||
<Space size={12} wrap>
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
value={exportFormat}
|
||||
onChange={(e) => setExportFormat(e.target.value)}
|
||||
>
|
||||
<Radio.Button value="xlsx">Excel</Radio.Button>
|
||||
<Radio.Button value="pdf">PDF</Radio.Button>
|
||||
</Radio.Group>
|
||||
<Button
|
||||
disabled={!selectedRowKeys.length}
|
||||
loading={exporting}
|
||||
onClick={() => void submitExport('selected')}
|
||||
>
|
||||
导出已勾选{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
<Button loading={exporting} onClick={() => void submitExport('filter')}>
|
||||
导出全部筛选
|
||||
</Button>
|
||||
</Space>
|
||||
) : <span />}
|
||||
<Space size={12} wrap>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<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.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
@@ -888,18 +629,16 @@ export default function OrdersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
rowSelection={{
|
||||
scroll={{ x: 1500 }}
|
||||
rowSelection={canDeleteOrders ? {
|
||||
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);
|
||||
@@ -1072,7 +811,7 @@ export default function OrdersPage() {
|
||||
locale={{ emptyText: '暂无关联核销单' }}
|
||||
dataSource={detail.redeemRecords ?? []}
|
||||
columns={[
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160 },
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
@@ -1083,6 +822,7 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
ellipsis: true,
|
||||
render: (v: string | undefined, row) =>
|
||||
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||
},
|
||||
@@ -1198,6 +938,7 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'remark',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -1492,6 +1233,7 @@ export default function OrdersPage() {
|
||||
{
|
||||
title: '商品',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_, row) =>
|
||||
row.productName
|
||||
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
|
||||
|
||||
@@ -5,11 +5,8 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
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';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type ParentAccount = { id: string; name: string; phone: string };
|
||||
@@ -172,24 +169,27 @@ export default function PartnerAccountsPage() {
|
||||
return keys;
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<AccountTreeRow> = [
|
||||
const columns: ColumnsType<AccountTreeRow> = [
|
||||
{
|
||||
title: '姓名 / 类型',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (_, row) => (
|
||||
<span className="admin-cell-line">
|
||||
<AdminPrimaryLink onClick={() => void openAccount(row.id)}>{row.name}</AdminPrimaryLink>
|
||||
<span className="admin-cell-line-secondary">
|
||||
{' · '}
|
||||
{row.parentAccountId ? `子账号 · ${staffRoleLabel(row.staffRole)}` : '主账号'}
|
||||
</span>
|
||||
</span>
|
||||
<AdminCellLine
|
||||
primary={row.name}
|
||||
secondary={
|
||||
row.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
||||
: '主账号'
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '开城合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.companyName || row.parent?.name || '—',
|
||||
},
|
||||
{
|
||||
@@ -254,19 +254,16 @@ 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}
|
||||
<AdminListHeader
|
||||
title="合伙人子账号"
|
||||
settings={settingsButton}
|
||||
description="主账号在「开城合伙人」创建;此处仅管理子账号树与权限"
|
||||
/>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -308,7 +305,7 @@ export default function PartnerAccountsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={treeData}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
@@ -402,7 +399,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: 'max-content' }} />
|
||||
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -410,7 +407,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: 'max-content' }} />
|
||||
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} />
|
||||
),
|
||||
},
|
||||
]} />
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
@@ -22,10 +20,6 @@ import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -37,22 +31,8 @@ type Row = {
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
rejectReason?: string | null;
|
||||
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;
|
||||
};
|
||||
partner?: { companyName?: string; phone?: string };
|
||||
partnerAccount?: { companyName?: string; phone?: string };
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
||||
@@ -97,8 +77,6 @@ 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}`)
|
||||
@@ -149,30 +127,17 @@ export default function PartnerBillsPage() {
|
||||
}
|
||||
|
||||
function markPaid(ids: string[], amountHint?: number) {
|
||||
let paymentRef = '';
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: (
|
||||
<div>
|
||||
<div>
|
||||
将标记 {ids.length} 笔账单为已打款
|
||||
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。
|
||||
</div>
|
||||
<Input
|
||||
placeholder="打款凭证号(可选)"
|
||||
style={{ marginTop: 8 }}
|
||||
onChange={(e) => {
|
||||
paymentRef = e.target.value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`,
|
||||
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 });
|
||||
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
});
|
||||
} else {
|
||||
await request('/admin/partner-bills/batch-mark-paid', {
|
||||
method: 'POST',
|
||||
@@ -217,12 +182,6 @@ 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 {
|
||||
@@ -242,24 +201,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 baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '账单号',
|
||||
dataIndex: 'billNo',
|
||||
width: 180,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_, r) => partnerName(r),
|
||||
},
|
||||
{
|
||||
@@ -286,16 +238,6 @@ export default function PartnerBillsPage() {
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '收款户名',
|
||||
width: 100,
|
||||
render: (_, r) => partnerBank(r)?.bankAccountName || '—',
|
||||
},
|
||||
{
|
||||
title: '收款账号',
|
||||
width: 140,
|
||||
render: (_, r) => partnerBank(r)?.bankAccountNo || '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -317,9 +259,6 @@ export default function PartnerBillsPage() {
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
|
||||
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
|
||||
发送
|
||||
@@ -345,16 +284,16 @@ export default function PartnerBillsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-partner-bills', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="合伙人账单"
|
||||
settings={settingsButton}
|
||||
description="每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款"
|
||||
/>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
合伙人账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
@@ -456,7 +395,7 @@ export default function PartnerBillsPage() {
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1280 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -526,59 +465,6 @@ export default function PartnerBillsPage() {
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title="合伙人账单明细"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={560}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人">{partnerName(detail)}</Descriptions.Item>
|
||||
<Descriptions.Item label="账期">
|
||||
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="订单佣金">
|
||||
¥{Number(detail.orderCommission).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销佣金">
|
||||
¥{Number(detail.redeemCommission).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="应付合计">
|
||||
¥{Number(detail.totalAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLORS[detail.status] || 'default'}>
|
||||
{STATUS_LABELS[detail.status] || detail.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打款时间">
|
||||
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打款凭证">
|
||||
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
收款账户
|
||||
</Typography.Title>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="户名">
|
||||
{partnerBank(detail)?.bankAccountName || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账号">
|
||||
{partnerBank(detail)?.bankAccountNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">
|
||||
{partnerBank(detail)?.bankBranch || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -75,11 +71,12 @@ export default function PartnerLogsPage() {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
r.isSubAccount ? (
|
||||
<Tag color="blue">子账号</Tag>
|
||||
@@ -91,6 +88,7 @@ export default function PartnerLogsPage() {
|
||||
{
|
||||
title: '账号',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.accountName}
|
||||
@@ -110,21 +108,7 @@ export default function PartnerLogsPage() {
|
||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'eventName',
|
||||
width: 180,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/logs/partners/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
||||
{
|
||||
title: '关联',
|
||||
width: 120,
|
||||
@@ -132,6 +116,7 @@ export default function PartnerLogsPage() {
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
@@ -152,12 +137,11 @@ export default function PartnerLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-partners', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="合伙人日志" settings={settingsButton} />
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
合伙人日志
|
||||
</Typography.Title>
|
||||
<Segmented
|
||||
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={category}
|
||||
@@ -232,7 +216,7 @@ export default function PartnerLogsPage() {
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
|
||||
@@ -27,10 +27,6 @@ import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -177,22 +173,17 @@ export default function PartnersPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: 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',
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '管辖',
|
||||
@@ -214,20 +205,14 @@ export default function PartnersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('partners', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="开城合伙人"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||
新建城市合伙人
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
||||
|
||||
@@ -23,10 +23,6 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
|
||||
PENDING: 'orange',
|
||||
@@ -98,19 +94,13 @@ export default function PendingRedeemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<RedeemPendingItem> = [
|
||||
{
|
||||
title: '待处理单号',
|
||||
dataIndex: 'pendingNo',
|
||||
width: 170,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: 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>,
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
|
||||
@@ -142,12 +132,9 @@ export default function PendingRedeemPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-pending', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="待处理核销(弱网兜底)" settings={settingsButton} />
|
||||
<Typography.Title level={4}>待处理核销(弱网兜底)</Typography.Title>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -188,7 +175,7 @@ export default function PendingRedeemPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -13,9 +13,6 @@ import { useAdminList } from '../lib/useAdminList';
|
||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
|
||||
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = ProductDetailTemplateDto;
|
||||
|
||||
@@ -139,26 +136,10 @@ export default function ProductDetailTemplatesPage() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 120 },
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(mapToForm(d));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '说明', dataIndex: 'description', width: 200 },
|
||||
{ title: '名称', dataIndex: 'name', width: 120 },
|
||||
{ title: '说明', dataIndex: 'description', width: 200, ellipsis: true },
|
||||
{ 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 },
|
||||
@@ -177,17 +158,10 @@ 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); }}>
|
||||
@@ -201,7 +175,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: 'max-content' }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
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 && (
|
||||
|
||||
@@ -12,12 +12,7 @@ import OssUpload from '../components/OssUpload';
|
||||
import MultiImageUpload from '../components/MultiImageUpload';
|
||||
import DetailImageUrlList from '../components/DetailImageUrlList';
|
||||
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
|
||||
import ProductSpecsEditor from '../components/ProductSpecsEditor';
|
||||
import type { FormInstance } from 'antd/es/form';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type ProductDetailContentDto = {
|
||||
storyTitle?: string;
|
||||
@@ -25,22 +20,6 @@ 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;
|
||||
@@ -50,15 +29,9 @@ 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;
|
||||
@@ -240,33 +213,7 @@ function VisibilityWhitelistFields({ form }: { 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;
|
||||
}) {
|
||||
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
|
||||
return (
|
||||
<>
|
||||
{mode === 'create' && (
|
||||
@@ -274,13 +221,8 @@ function BaseInfoFields({
|
||||
<Form.Item label="SKU">
|
||||
<Input disabled placeholder="保存后自动生成" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="barcode69"
|
||||
label="69码"
|
||||
rules={[{ required: true, message: '请填写默认规格 69 码' }]}
|
||||
extra="默认规格的 69 码;若有多种规格,保存后请到「规格与 SKU」为每个规格分别填写不同 69 码"
|
||||
>
|
||||
<Input placeholder="默认规格 69 码" />
|
||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
@@ -308,12 +250,6 @@ function BaseInfoFields({
|
||||
<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="允许线上购买"
|
||||
@@ -347,8 +283,6 @@ function BaseInfoFields({
|
||||
<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" />
|
||||
@@ -391,43 +325,13 @@ export default function ProductsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = useMemo(() => [
|
||||
const columns: ColumnsType<Row> = useMemo(() => [
|
||||
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
|
||||
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
|
||||
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
|
||||
{ title: '品名', dataIndex: 'name', width: 200, render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(mapDetailToForm(d));
|
||||
setDrawerOpen(true);
|
||||
}}>{v}</AdminPrimaryLink>
|
||||
) },
|
||||
{
|
||||
title: '累计销售',
|
||||
dataIndex: 'soldBottles',
|
||||
width: 110,
|
||||
render: (v: number | undefined) => `累计 ${v ?? 0} 瓶`,
|
||||
},
|
||||
{
|
||||
title: '售价',
|
||||
key: 'priceRange',
|
||||
width: 120,
|
||||
render: (_, row) => {
|
||||
const min = row.priceMin ?? row.price;
|
||||
const max = row.priceMax ?? row.price;
|
||||
if (min === max) return `¥${min}`;
|
||||
return `¥${min} ~ ¥${max}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '规格数',
|
||||
dataIndex: 'skuCount',
|
||||
width: 80,
|
||||
render: (v: number | undefined, row) => {
|
||||
const real = v ?? 0;
|
||||
if (real > 0) return real;
|
||||
return row.skus?.some((s) => s.virtual) ? 1 : 0;
|
||||
},
|
||||
},
|
||||
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
|
||||
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
|
||||
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
|
||||
) },
|
||||
@@ -438,7 +342,23 @@ 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) => (
|
||||
@@ -451,7 +371,7 @@ export default function ProductsPage() {
|
||||
}}>编辑</Button>
|
||||
<Popconfirm
|
||||
title="确认删除该商品?"
|
||||
description={`将永久删除「${row.name}」,此操作不可恢复。`}
|
||||
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
|
||||
okText="确认删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
@@ -464,62 +384,14 @@ 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}
|
||||
<AdminListHeader
|
||||
title="商品管理"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>新建商品</Button>}
|
||||
/>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>商品管理</Typography.Title>
|
||||
<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 placeholder="名称 / SKU / 69 码" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称"><Input allowClear /></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>
|
||||
@@ -528,46 +400,17 @@ 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: '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)}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
const multiSku = Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1;
|
||||
if (!multiSku && !v.allowOnlinePurchase && !v.allowOnSitePickup) {
|
||||
if (!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);
|
||||
@@ -577,24 +420,13 @@ export default function ProductsPage() {
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="默认 SKU">{String(detail.skuCode)}(系统生成)</Descriptions.Item>
|
||||
<Descriptions.Item label="默认 69 码">{String(detail.barcode69)}</Descriptions.Item>
|
||||
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
|
||||
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
|
||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs items={[
|
||||
{
|
||||
key: 'base',
|
||||
label: '基础信息',
|
||||
children: (
|
||||
<BaseInfoFields
|
||||
mode="edit"
|
||||
form={editForm}
|
||||
hideFulfillment={Array.isArray(detail.skus) && (detail.skus as unknown[]).length > 1}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情页',
|
||||
@@ -605,23 +437,6 @@ export default function ProductsPage() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'specs',
|
||||
label: '规格与 SKU',
|
||||
children: (
|
||||
<ProductSpecsEditor
|
||||
productId={String(detail.id)}
|
||||
initialAttrs={(detail.specAttrs as never) ?? []}
|
||||
initialSkus={(detail.skus as never) ?? []}
|
||||
onSaved={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/products/${detail.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(mapDetailToForm(d));
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]} />
|
||||
</Form>
|
||||
</>
|
||||
|
||||
@@ -14,10 +14,6 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = PromoCodeItem;
|
||||
|
||||
@@ -78,15 +74,8 @@ export default function PromoCodesPage() {
|
||||
void loadScenes();
|
||||
}, []);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
width: 160,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => navigate(`/promo-codes/${row.id}`)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '码值', dataIndex: 'code', width: 110 },
|
||||
{
|
||||
title: '场景',
|
||||
@@ -171,16 +160,12 @@ export default function PromoCodesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-codes', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="推广码管理"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>}
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>推广码管理</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>创建推广码</Button>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
@@ -209,7 +194,7 @@ export default function PromoCodesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import { Button, Checkbox, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||
@@ -7,10 +6,6 @@ import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDesc
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -31,14 +26,8 @@ function maskPhone(phone: string | null | undefined) {
|
||||
}
|
||||
|
||||
export default function RedeemRecordsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialRedeemNo = searchParams.get('redeemNo')?.trim() || '';
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
if (initialRedeemNo) init.redeemNo = initialRedeemNo;
|
||||
return init;
|
||||
});
|
||||
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/redeem-records',
|
||||
() => {
|
||||
@@ -54,11 +43,6 @@ export default function RedeemRecordsPage() {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const deepLinkOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialRedeemNo) form.setFieldsValue({ redeemNo: initialRedeemNo });
|
||||
}, [form, initialRedeemNo]);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetailLoading(true);
|
||||
@@ -70,23 +54,14 @@ export default function RedeemRecordsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialRedeemNo || deepLinkOpenedRef.current || loading) return;
|
||||
const first = data?.items?.[0];
|
||||
if (first && String(first.redeemNo) === initialRedeemNo) {
|
||||
deepLinkOpenedRef.current = true;
|
||||
void openDetail(first.id);
|
||||
}
|
||||
}, [data, initialRedeemNo, loading]);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '核销号',
|
||||
dataIndex: 'redeemNo',
|
||||
width: 200,
|
||||
render: (v, row) => (
|
||||
<span>
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
{v}
|
||||
{row.isTest ? (
|
||||
<Tag color="orange" style={{ marginLeft: 6 }}>
|
||||
测试
|
||||
@@ -137,12 +112,9 @@ export default function RedeemRecordsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('redeem-records', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="核销记录" settings={settingsButton} />
|
||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -182,7 +154,7 @@ export default function RedeemRecordsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -12,9 +12,6 @@ import {
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -61,7 +58,7 @@ export default function ResourcesPage() {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{
|
||||
title: '归属',
|
||||
@@ -87,8 +84,8 @@ export default function ResourcesPage() {
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{ title: 'URL', dataIndex: 'url' },
|
||||
{ title: 'OSS Key', dataIndex: 'ossKey', width: 160 },
|
||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
|
||||
{
|
||||
title: '状态',
|
||||
@@ -117,16 +114,16 @@ export default function ResourcesPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('resources', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="OSS 资源库"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={() => setCreateOpen(true)}>登记资源</Button>}
|
||||
/>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
OSS 资源库
|
||||
</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
登记资源
|
||||
</Button>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -173,7 +170,7 @@ export default function ResourcesPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -6,10 +6,6 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
|
||||
|
||||
@@ -78,21 +74,14 @@ export default function StoreAccountsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v, row) => (
|
||||
<Space size={4}>
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/store-accounts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
<span>{v}</span>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
@@ -149,16 +138,15 @@ export default function StoreAccountsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="门店账户"
|
||||
settings={settingsButton}
|
||||
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
|
||||
actions={
|
||||
<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">
|
||||
主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -168,8 +156,7 @@ export default function StoreAccountsPage() {
|
||||
>
|
||||
新建账户
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -198,7 +185,7 @@ export default function StoreAccountsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -30,10 +30,6 @@ import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
kind: StoreSettlementKind;
|
||||
@@ -48,11 +44,6 @@ 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 };
|
||||
@@ -64,11 +55,6 @@ 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',
|
||||
@@ -78,13 +64,10 @@ export default function StoreBillsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
|
||||
const initialStoreId = searchParams.get('storeId') || '';
|
||||
const initialStatus =
|
||||
searchParams.get('status')?.trim() ||
|
||||
(initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '');
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({
|
||||
kind: initialKind,
|
||||
status: initialStatus,
|
||||
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
|
||||
storeId: initialStoreId,
|
||||
});
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
@@ -132,30 +115,14 @@ export default function StoreBillsPage() {
|
||||
}, []);
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
let paymentRef = '';
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
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>
|
||||
),
|
||||
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
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', body });
|
||||
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
@@ -271,24 +238,23 @@ export default function StoreBillsPage() {
|
||||
);
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'kind',
|
||||
width: 100,
|
||||
render: (k: StoreSettlementKind) => (
|
||||
<Tag color={KIND_COLORS[k]} style={{ minWidth: 72, textAlign: 'center' }}>
|
||||
{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}
|
||||
</Tag>
|
||||
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '单号',
|
||||
dataIndex: 'billNo',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v, row) => (
|
||||
<Space>
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||
<span>{v}</span>
|
||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
@@ -299,21 +265,9 @@ export default function StoreBillsPage() {
|
||||
width: 160,
|
||||
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||
{
|
||||
title: '收款户名',
|
||||
width: 100,
|
||||
render: (_, row) =>
|
||||
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountName || '—' : '—',
|
||||
},
|
||||
{
|
||||
title: '收款账号',
|
||||
width: 140,
|
||||
render: (_, row) =>
|
||||
row.kind === 'T1_BILL' ? row.bankAccount?.bankAccountNo || '—' : '—',
|
||||
},
|
||||
{
|
||||
title: '笔数',
|
||||
width: 80,
|
||||
@@ -331,9 +285,7 @@ export default function StoreBillsPage() {
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s: string, row) => (
|
||||
<Tag color={settlementStatusColor(row.kind, s)}>
|
||||
{storeSettlementStatusLabel(row.kind, s)}
|
||||
</Tag>
|
||||
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -377,28 +329,21 @@ export default function StoreBillsPage() {
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-bills', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="门店账单"
|
||||
settings={settingsButton}
|
||||
description={
|
||||
<>
|
||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口。
|
||||
「已打款」= T+1 出账终态,「已结算」= 手动提现终态。
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
门店账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
含 T+1 自动出账与门店手动提现;按类型筛选,详情与审核动作沿用原接口
|
||||
</Typography.Text>
|
||||
{overdueSummary && overdueSummary.overdueCount > 0 ? (
|
||||
<div>
|
||||
<Typography.Text type="danger">
|
||||
手动提现超时未审 {overdueSummary.overdueCount} 笔(待审共 {overdueSummary.pendingCount})
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
@@ -520,7 +465,7 @@ export default function StoreBillsPage() {
|
||||
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
|
||||
}),
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -555,32 +500,7 @@ export default function StoreBillsPage() {
|
||||
<Descriptions.Item label="打款时间">
|
||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="打款凭证">
|
||||
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{(detail.storeAccount as {
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
} | null) ? (
|
||||
<>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
收款账户
|
||||
</Typography.Title>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="户名">
|
||||
{(detail.storeAccount as { bankAccountName?: string }).bankAccountName || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="账号">
|
||||
{(detail.storeAccount as { bankAccountNo?: string }).bankAccountNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">
|
||||
{(detail.storeAccount as { bankBranch?: string }).bankBranch || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
) : null}
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
核销明细
|
||||
</Typography.Title>
|
||||
|
||||
@@ -3,11 +3,7 @@ import {
|
||||
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type CategoryNode = {
|
||||
id: string;
|
||||
@@ -42,9 +38,7 @@ 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(
|
||||
@@ -66,7 +60,6 @@ export default function StoreCategoriesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
@@ -119,7 +112,7 @@ export default function StoreCategoriesPage() {
|
||||
void reload();
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<FlatRow> = [
|
||||
const columns: ColumnsType<FlatRow> = [
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'level',
|
||||
@@ -132,7 +125,7 @@ export default function StoreCategoriesPage() {
|
||||
render: (name, row) => (
|
||||
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -148,14 +141,13 @@ export default function StoreCategoriesPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: canDelete ? 220 : 160,
|
||||
width: 220,
|
||||
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 () => {
|
||||
@@ -166,27 +158,19 @@ 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}
|
||||
<AdminListHeader
|
||||
title="门店分类"
|
||||
settings={settingsButton}
|
||||
description="两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择"
|
||||
actions={
|
||||
<>
|
||||
{canDelete ? (
|
||||
<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">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
@@ -196,11 +180,9 @@ export default function StoreCategoriesPage() {
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
|
||||
@@ -12,10 +12,6 @@ import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -84,14 +80,14 @@ export default function StoreLogsPage() {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '门店', width: 180,
|
||||
title: '门店', width: 180, ellipsis: true,
|
||||
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
|
||||
},
|
||||
{
|
||||
title: '账号', width: 150,
|
||||
title: '账号', width: 150, ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.accountName}
|
||||
@@ -105,23 +101,7 @@ export default function StoreLogsPage() {
|
||||
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'eventName',
|
||||
width: 160,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
const parsed = parseCompositeId(row.id);
|
||||
if (!parsed) return;
|
||||
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
||||
{
|
||||
title: '来源', dataIndex: 'source', width: 100,
|
||||
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
||||
@@ -131,7 +111,7 @@ export default function StoreLogsPage() {
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
title: '摘要', ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
@@ -147,16 +127,12 @@ export default function StoreLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-stores', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="商户日志"
|
||||
settings={settingsButton}
|
||||
description="门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。"
|
||||
/>
|
||||
<Typography.Title level={4}>商户日志</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
||||
</Typography.Paragraph>
|
||||
<Segmented
|
||||
style={{ marginBottom: 16 }}
|
||||
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
@@ -188,7 +164,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: 'max-content' }}
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||
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,10 +7,6 @@ import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string; mediaType: string; url: string; sortOrder: number; createdAt: string;
|
||||
@@ -44,23 +40,8 @@ export default function StoreMediaPage() {
|
||||
setStores(res.items);
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => {
|
||||
setEditing(row);
|
||||
editForm.setFieldsValue(row);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
|
||||
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
|
||||
{
|
||||
title: '预览', dataIndex: 'url', width: 100,
|
||||
@@ -68,7 +49,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' },
|
||||
{ title: 'URL', dataIndex: 'url', ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -92,16 +73,12 @@ export default function StoreMediaPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-media', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="门店资源"
|
||||
settings={settingsButton}
|
||||
actions={<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>}
|
||||
/>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店资源</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新增资源</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="mediaType" label="类型">
|
||||
@@ -109,7 +86,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: 'max-content' }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
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();
|
||||
|
||||
@@ -20,21 +20,20 @@ import type {
|
||||
StoreInfoChangeFieldDiff,
|
||||
StoreInfoChangeRequestDto,
|
||||
StoreInfoChangeStatus,
|
||||
StorePackageAuditDetailDto,
|
||||
StorePackageAuditSummaryDto,
|
||||
StorePackageChangeRequestDto,
|
||||
StorePackageChangeStatus,
|
||||
StorePackageItemDto,
|
||||
StorePackageViewDto,
|
||||
} from '@dukang/shared-types';
|
||||
import {
|
||||
STORE_INFO_CHANGE_STATUS_LABELS,
|
||||
STORE_INFO_CHANGEABLE_FIELD_LABELS,
|
||||
normalizeStorePackageImageUrls,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import StorePackageAuditPanel from '../components/StorePackageAuditPanel';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
PENDING: '待审核',
|
||||
@@ -42,80 +41,293 @@ const HQ_PACKAGE_STATUS_LABELS: Record<StorePackageChangeStatus, string> = {
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||
const name = String(pkg.name ?? '').trim();
|
||||
return name ? `name:${name}` : `idx:${index}`;
|
||||
}
|
||||
|
||||
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||
}
|
||||
|
||||
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||||
|
||||
/** 文本逐字差异:LCS 比对,产出 equal / delete / insert 段落,用于高亮具体变了哪些字 */
|
||||
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||
for (let i = m - 1; i >= 0; i--) {
|
||||
for (let j = n - 1; j >= 0; j--) {
|
||||
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < m && j < n) {
|
||||
if (a[i] === b[j]) {
|
||||
raw.push({ type: 'equal', text: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||
raw.push({ type: 'delete', text: a[i] });
|
||||
i++;
|
||||
} else {
|
||||
raw.push({ type: 'insert', text: b[j] });
|
||||
j++;
|
||||
}
|
||||
}
|
||||
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
||||
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
||||
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||
for (const s of raw) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && last.type === s.type) last.text += s.text;
|
||||
else merged.push({ ...s });
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** 逐字段比较套餐内容,返回发生变化的字段明细(用于“变动的地方详细列出”) */
|
||||
function fieldChanges(
|
||||
live: StorePackageItemDto | StorePackageViewDto,
|
||||
proposed: StorePackageItemDto | StorePackageViewDto,
|
||||
): FieldChange[] {
|
||||
const changes: FieldChange[] = [];
|
||||
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||||
const pushText = (label: string, oldV: string, newV: string) => {
|
||||
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||||
};
|
||||
const pushValue = (label: string, oldV: string, newV: string) => {
|
||||
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
||||
};
|
||||
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
||||
pushText('套餐名称', text(live.name), text(proposed.name));
|
||||
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
||||
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
||||
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
||||
const liveImgs = normalizeStorePackageImageUrls(live);
|
||||
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
||||
if (imageSignature(live) !== imageSignature(proposed)) {
|
||||
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
||||
const rows: Array<{
|
||||
key: string;
|
||||
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
live?: StorePackageViewDto;
|
||||
proposed?: StorePackageItemDto;
|
||||
changes?: FieldChange[];
|
||||
}> = [];
|
||||
|
||||
for (const key of keys) {
|
||||
const l = liveMap.get(key);
|
||||
const p = proposedMap.get(key);
|
||||
if (l && !p) {
|
||||
rows.push({ key, change: 'removed', live: l });
|
||||
} else if (!l && p) {
|
||||
rows.push({ key, change: 'added', proposed: p });
|
||||
} else if (l && p) {
|
||||
const changed =
|
||||
l.price !== p.price ||
|
||||
l.dishes !== p.dishes ||
|
||||
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||
imageSignature(l) !== imageSignature(p);
|
||||
rows.push({
|
||||
key,
|
||||
change: changed ? 'changed' : 'unchanged',
|
||||
live: l,
|
||||
proposed: p,
|
||||
changes: changed ? fieldChanges(l, p) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const CHANGE_LABELS = {
|
||||
added: { text: '新增', color: 'green' },
|
||||
removed: { text: '删除', color: 'red' },
|
||||
changed: { text: '变更', color: 'orange' },
|
||||
unchanged: { text: '未变', color: 'default' },
|
||||
} as const;
|
||||
|
||||
/** 文本逐字差异渲染:原行红色删除线标出被删的字,新行绿色标出新增的字 */
|
||||
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||
const segs = diffText(oldText, newText);
|
||||
return (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text type="secondary">原:</Typography.Text>
|
||||
{segs
|
||||
.filter((s) => s.type !== 'insert')
|
||||
.map((s, idx) =>
|
||||
s.type === 'delete' ? (
|
||||
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
||||
{s.text || '(空)'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text type="secondary">新:</Typography.Text>
|
||||
{segs
|
||||
.filter((s) => s.type !== 'delete')
|
||||
.map((s, idx) =>
|
||||
s.type === 'insert' ? (
|
||||
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
||||
{s.text || '(空)'}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PackageDetailCard({
|
||||
title,
|
||||
pkg,
|
||||
change,
|
||||
changes,
|
||||
}: {
|
||||
title?: string;
|
||||
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||
change?: keyof typeof CHANGE_LABELS;
|
||||
changes?: FieldChange[];
|
||||
}) {
|
||||
const images = normalizeStorePackageImageUrls(pkg);
|
||||
const meta = change ? CHANGE_LABELS[change] : null;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
<Space style={{ marginBottom: 8 }} wrap>
|
||||
{title ? (
|
||||
<Typography.Text type="secondary">{title}</Typography.Text>
|
||||
) : null}
|
||||
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||||
</Space>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<strong>{pkg.name}</strong>
|
||||
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||
</div>
|
||||
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||
{pkg.dishes || '—'}
|
||||
</Typography.Paragraph>
|
||||
{pkg.usableTime ? (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
className="admin-package-audit-text"
|
||||
style={{ marginBottom: 4 }}
|
||||
>
|
||||
可用时间:{pkg.usableTime}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{pkg.otherNotes ? (
|
||||
<Typography.Paragraph
|
||||
type="secondary"
|
||||
className="admin-package-audit-text"
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
其他说明:{pkg.otherNotes}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
{images.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{images.map((url) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
width={72}
|
||||
height={72}
|
||||
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||
)}
|
||||
{changes && changes.length ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 8,
|
||||
background: '#fff7e6',
|
||||
border: '1px solid #ffe7ba',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||
变更明细
|
||||
</Typography.Text>
|
||||
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||
{changes.map((c) => (
|
||||
<li key={c.label} style={{ marginBottom: 6 }}>
|
||||
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
||||
{c.kind === 'text' ? (
|
||||
<TextDiff oldText={c.old} newText={c.now} />
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text delete type="secondary">
|
||||
{c.old}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary"> → </Typography.Text>
|
||||
<Typography.Text strong>{c.now}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INFO_CHANGE_FIELD_LABELS: Record<string, string> = {
|
||||
name: '门店名称',
|
||||
contactPhone: '联系电话',
|
||||
address: '详细地址',
|
||||
intro: '门店简介',
|
||||
benefitUsageRule: '权益券使用规则',
|
||||
latitude: '纬度',
|
||||
longitude: '经度',
|
||||
openTime: '营业开始',
|
||||
closeTime: '营业结束',
|
||||
openTime2: '第二段开始',
|
||||
closeTime2: '第二段结束',
|
||||
avgPrice: '人均费用',
|
||||
};
|
||||
|
||||
function fmtFieldValue(field: string, v: unknown): string {
|
||||
if (v == null || String(v).trim() === '') return '(空)';
|
||||
if (field === 'avgPrice' || field === 'latitude' || field === 'longitude') {
|
||||
return String(v);
|
||||
}
|
||||
if (field === 'envPhotoUrls' && Array.isArray(v)) {
|
||||
return `${v.length} 张`;
|
||||
}
|
||||
if (field === 'coverUrl') return '见对比图';
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function InfoChangeImageDiff({
|
||||
field,
|
||||
live,
|
||||
proposed,
|
||||
imageSize = 72,
|
||||
}: {
|
||||
field: string;
|
||||
live: unknown;
|
||||
proposed: unknown;
|
||||
imageSize?: number;
|
||||
}) {
|
||||
const liveUrls =
|
||||
field === 'coverUrl'
|
||||
? [String(live || '').trim()].filter(Boolean)
|
||||
: Array.isArray(live)
|
||||
? live.map((u) => String(u || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
const proposedUrls =
|
||||
field === 'coverUrl'
|
||||
? [String(proposed || '').trim()].filter(Boolean)
|
||||
: Array.isArray(proposed)
|
||||
? proposed.map((u) => String(u || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
||||
变更前
|
||||
</Typography.Text>
|
||||
{liveUrls.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{liveUrls.map((url) => (
|
||||
<Image key={`live-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">(空)</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 6 }}>
|
||||
变更后
|
||||
</Typography.Text>
|
||||
{proposedUrls.length ? (
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={8}>
|
||||
{proposedUrls.map((url) => (
|
||||
<Image key={`new-${url}`} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<Typography.Text type="secondary">(空)</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoChangeAuditPanel({
|
||||
initialRequestId,
|
||||
}: {
|
||||
@@ -133,7 +345,6 @@ 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);
|
||||
@@ -201,15 +412,7 @@ function InfoChangeAuditPanel({
|
||||
}
|
||||
|
||||
const columns: ColumnsType<StoreInfoChangeRequestDto> = [
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: 'storeName',
|
||||
render: (_, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
|
||||
{row.storeName || row.storeId}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -222,7 +425,7 @@ function InfoChangeAuditPanel({
|
||||
render: (_, row) =>
|
||||
row.changedFields?.length
|
||||
? row.changedFields.map((f) => (
|
||||
<Tag key={f}>{STORE_INFO_CHANGEABLE_FIELD_LABELS[f as keyof typeof STORE_INFO_CHANGEABLE_FIELD_LABELS] ?? f}</Tag>
|
||||
<Tag key={f}>{INFO_CHANGE_FIELD_LABELS[f] ?? f}</Tag>
|
||||
))
|
||||
: '—',
|
||||
},
|
||||
@@ -300,19 +503,12 @@ function InfoChangeAuditPanel({
|
||||
|
||||
<Drawer
|
||||
title={detail ? `${detail.storeName || detail.storeId} · 信息变更` : '信息变更详情'}
|
||||
width={infoFullscreen ? '100%' : 680}
|
||||
width={680}
|
||||
open={detailOpen}
|
||||
onClose={() => {
|
||||
setDetailOpen(false);
|
||||
setInfoFullscreen(false);
|
||||
}}
|
||||
onClose={() => setDetailOpen(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
|
||||
@@ -324,9 +520,8 @@ function InfoChangeAuditPanel({
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detailLoading ? (
|
||||
@@ -347,16 +542,8 @@ function InfoChangeAuditPanel({
|
||||
{detail.diffs.map((d) => (
|
||||
<Descriptions.Item
|
||||
key={d.field}
|
||||
label={STORE_INFO_CHANGEABLE_FIELD_LABELS[d.field] ?? d.field}
|
||||
label={INFO_CHANGE_FIELD_LABELS[d.field] ?? d.field}
|
||||
>
|
||||
{d.field === 'coverUrl' || d.field === 'envPhotoUrls' ? (
|
||||
<InfoChangeImageDiff
|
||||
field={d.field}
|
||||
live={d.live}
|
||||
proposed={d.proposed}
|
||||
imageSize={infoFullscreen ? 160 : 72}
|
||||
/>
|
||||
) : (
|
||||
<span>
|
||||
<Typography.Text delete type="secondary">
|
||||
{fmtFieldValue(d.field, d.live)}
|
||||
@@ -366,7 +553,6 @@ function InfoChangeAuditPanel({
|
||||
{fmtFieldValue(d.field, d.proposed)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
@@ -413,9 +599,8 @@ export default function StorePackageAuditsPage() {
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [activeRequestId, setActiveRequestId] = useState<string | null>(null);
|
||||
const [drawerTitle, setDrawerTitle] = useState('套餐变更详情');
|
||||
const [packageFullscreen, setPackageFullscreen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||
|
||||
async function reload(nextPage = page, nextStatus = status) {
|
||||
setLoading(true);
|
||||
@@ -444,23 +629,30 @@ export default function StorePackageAuditsPage() {
|
||||
void reload(1, status);
|
||||
}, [status]);
|
||||
|
||||
// 从门店详情 / 门店列表跳转过来时,带 requestId 自动打开审核(对比)抽屉
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialTab = searchParams.get('tab') === 'info' ? 'info' : 'package';
|
||||
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
||||
const infoRequestId = searchParams.get('infoRequestId');
|
||||
useEffect(() => {
|
||||
const rid = searchParams.get('requestId');
|
||||
if (rid) {
|
||||
setActiveRequestId(rid);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
if (rid) void openDetail(rid);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function openDetail(id: string) {
|
||||
setActiveRequestId(id);
|
||||
setDrawerTitle('套餐变更详情');
|
||||
async function openDetail(id: string) {
|
||||
setDetailOpen(true);
|
||||
setDetailLoading(true);
|
||||
setDetail(null);
|
||||
try {
|
||||
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${id}`);
|
||||
setDetail(data);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载详情失败');
|
||||
setDetailOpen(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function audit(id: string, action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||
@@ -480,16 +672,16 @@ export default function StorePackageAuditsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: 'storeName',
|
||||
render: (_, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>
|
||||
{row.storeName || row.storeId}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const diffRows = detail ? diffPackages(detail.livePackages ?? [], detail.packages ?? []) : [];
|
||||
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||
|
||||
const columns: ColumnsType<StorePackageChangeRequestDto> = [
|
||||
{ title: '门店', dataIndex: 'storeName', render: (_, row) => row.storeName || row.storeId },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -510,7 +702,7 @@ export default function StorePackageAuditsPage() {
|
||||
title: '操作',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openDetail(row.id)}>
|
||||
<Button type="link" onClick={() => void openDetail(row.id)}>
|
||||
查看变更
|
||||
</Button>
|
||||
{row.status === 'PENDING' ? (
|
||||
@@ -538,15 +730,9 @@ export default function StorePackageAuditsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-package-audits', baseColumns, {
|
||||
page,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="审核通知" settings={settingsButton} />
|
||||
<Typography.Title level={4}>审核通知</Typography.Title>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
@@ -595,32 +781,90 @@ export default function StorePackageAuditsPage() {
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title={drawerTitle}
|
||||
width={packageFullscreen ? '100%' : 880}
|
||||
title={detail ? `${detail.storeName || detail.storeId} · 套餐变更` : '套餐变更详情'}
|
||||
width={880}
|
||||
open={detailOpen}
|
||||
onClose={() => {
|
||||
setDetailOpen(false);
|
||||
setPackageFullscreen(false);
|
||||
}}
|
||||
destroyOnClose
|
||||
onClose={() => setDetailOpen(false)}
|
||||
extra={
|
||||
<Button onClick={() => setPackageFullscreen((v) => !v)}>
|
||||
{packageFullscreen ? '退出全屏' : '全屏查看'}
|
||||
detail?.status === 'PENDING' ? (
|
||||
<Space>
|
||||
<Button onClick={() => void audit(detail.id, 'APPROVE')}>通过</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
setActiveId(detail.id);
|
||||
setRejectReason('');
|
||||
setRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{activeRequestId ? (
|
||||
<StorePackageAuditPanel
|
||||
requestId={activeRequestId}
|
||||
fullscreen={packageFullscreen}
|
||||
onAudited={() => {
|
||||
setDetailOpen(false);
|
||||
void reload(page, status);
|
||||
}}
|
||||
onDetailLoaded={(d) => {
|
||||
if (d) setDrawerTitle(`${d.storeName || d.storeId} · 套餐变更`);
|
||||
}}
|
||||
{detailLoading ? (
|
||||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||
) : detail ? (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status]}</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{detail.rejectReason ? (
|
||||
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||
) : null}
|
||||
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||||
<Typography.Text type="secondary">
|
||||
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Tag color="green">新增 {addedCount}</Tag>
|
||||
<Tag color="red">删除 {removedCount}</Tag>
|
||||
<Tag color="orange">变更 {changedCount}</Tag>
|
||||
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||||
</Space>
|
||||
</Space>
|
||||
<div className="admin-package-audit-cols">
|
||||
<div className="admin-package-audit-col">
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
线上已审核套餐
|
||||
</Typography.Title>
|
||||
{(detail.livePackages ?? []).length ? (
|
||||
(detail.livePackages ?? []).map((pkg, index) => (
|
||||
<PackageDetailCard
|
||||
key={`live-${packageKey(pkg, index)}`}
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-package-audit-col">
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
待审核套餐
|
||||
</Typography.Title>
|
||||
{(detail.packages ?? []).length ? (
|
||||
(detail.packages ?? []).map((pkg, index) => (
|
||||
<PackageDetailCard
|
||||
key={`pending-${packageKey(pkg, index)}`}
|
||||
title={`套餐 ${index + 1}`}
|
||||
pkg={pkg}
|
||||
change={changeByKey.get(packageKey(pkg, index))}
|
||||
changes={changesByKey.get(packageKey(pkg, index))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
@@ -648,4 +892,3 @@ export default function StorePackageAuditsPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, Select, Table, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { fmtTime, ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -24,30 +20,22 @@ 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 baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||
{
|
||||
title: '门店',
|
||||
@@ -65,12 +53,11 @@ export default function StoreRatingsPage() {
|
||||
{ title: '评价时间', dataIndex: 'createdAt', width: 170, render: fmtTime },
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-ratings', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="门店评价" settings={settingsButton} />
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
门店评价
|
||||
</Typography.Title>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
@@ -79,22 +66,11 @@ 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,10 +20,6 @@ import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -151,14 +147,14 @@ export default function StoreWithdrawalsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '提现单号',
|
||||
dataIndex: 'withdrawNo',
|
||||
width: 180,
|
||||
render: (v, row) => (
|
||||
<Space>
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link>
|
||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
@@ -233,12 +229,11 @@ export default function StoreWithdrawalsPage() {
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-store-withdrawals', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="门店提现审" settings={settingsButton} />
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
门店提现审
|
||||
</Typography.Title>
|
||||
{overdueSummary ? (
|
||||
<Typography.Paragraph type="secondary">
|
||||
待审 {overdueSummary.pendingCount} 笔
|
||||
@@ -304,7 +299,7 @@ export default function StoreWithdrawalsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||||
pagination={{
|
||||
current: page,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
@@ -39,9 +38,6 @@ import {
|
||||
type StoreCreateForm,
|
||||
} from '../lib/storeCreate';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
import { resolveRegionBinding } from '../lib/china-region';
|
||||
import ChinaRegionCascader from '../components/ChinaRegionCascader';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
@@ -50,9 +46,6 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||
import AdminStorePackagesSection, {
|
||||
type AdminStorePackagesHandle,
|
||||
} from '../components/AdminStorePackagesSection';
|
||||
import StorePackageAuditPanel, {
|
||||
auditStorePackageRequest,
|
||||
} from '../components/StorePackageAuditPanel';
|
||||
|
||||
const CREATE_STEPS = [
|
||||
{ title: '基本信息' },
|
||||
@@ -201,9 +194,6 @@ 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 };
|
||||
@@ -252,8 +242,6 @@ export default function StoresPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialCityId = searchParams.get('cityId') ?? '';
|
||||
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
||||
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
|
||||
const initialStoreId = searchParams.get('storeId') ?? '';
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||
@@ -261,7 +249,6 @@ export default function StoresPage() {
|
||||
const init: Record<string, string | boolean> = {};
|
||||
if (initialCityId) init.cityId = initialCityId;
|
||||
if (initialPartnerId) init.partnerId = initialPartnerId;
|
||||
if (initialAuditStatus) init.auditStatus = initialAuditStatus;
|
||||
return init;
|
||||
});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<StoreRow>(
|
||||
@@ -283,11 +270,7 @@ export default function StoresPage() {
|
||||
const [filterPartners, setFilterPartners] = useState<PartnerOption[]>([]);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [detailTab, setDetailTab] = useState('basic');
|
||||
const packagesRef = useRef<AdminStorePackagesHandle>(null);
|
||||
const [packageRejectOpen, setPackageRejectOpen] = useState(false);
|
||||
const [packageRejectReason, setPackageRejectReason] = useState('');
|
||||
const [packageAuditing, setPackageAuditing] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [auditing, setAuditing] = useState(false);
|
||||
@@ -303,7 +286,6 @@ export default function StoresPage() {
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [phoneMismatch, setPhoneMismatch] = useState<string | null>(null);
|
||||
const deepLinkStoreOpenedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
@@ -314,12 +296,6 @@ export default function StoresPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialAuditStatus) {
|
||||
form.setFieldsValue({ auditStatus: initialAuditStatus });
|
||||
}
|
||||
}, [form, initialAuditStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
const cityId = searchParams.get('cityId') ?? '';
|
||||
const partnerId = searchParams.get('partnerId') ?? '';
|
||||
@@ -372,18 +348,13 @@ export default function StoresPage() {
|
||||
.map((n) => ({ value: n.id, label: n.name }));
|
||||
}, [categoryTree, editCategoryParentId]);
|
||||
|
||||
async function openStoreDetail(row: StoreRow, opts?: { tab?: string }) {
|
||||
async function openStoreDetail(row: StoreRow) {
|
||||
const [d, cats] = await Promise.all([
|
||||
request<Record<string, unknown>>(`/admin/stores/${row.id}`),
|
||||
request<CategoryNode[]>('/admin/store-categories').catch(() => [] as CategoryNode[]),
|
||||
]);
|
||||
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||
setDetail({
|
||||
...d,
|
||||
pendingPackageAuditId: row.pendingPackageAuditId ?? d.pendingPackageAuditId,
|
||||
pendingInfoChangeId: row.pendingInfoChangeId ?? d.pendingInfoChangeId,
|
||||
});
|
||||
setDetailTab(opts?.tab || 'basic');
|
||||
setDetail(d);
|
||||
const category = d.category && typeof d.category === 'object'
|
||||
? (d.category as { id?: string; parentId?: string | null })
|
||||
: null;
|
||||
@@ -470,19 +441,6 @@ export default function StoresPage() {
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialStoreId || deepLinkStoreOpenedRef.current || loading) return;
|
||||
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
|
||||
if (row) {
|
||||
deepLinkStoreOpenedRef.current = true;
|
||||
void openStoreDetail(row);
|
||||
} else if (data && (data.items?.length ?? 0) >= 0) {
|
||||
// 列表无该店时仍尝试直拉详情
|
||||
deepLinkStoreOpenedRef.current = true;
|
||||
void openStoreDetail({ id: initialStoreId } as StoreRow);
|
||||
}
|
||||
}, [data, initialStoreId, loading]);
|
||||
|
||||
async function saveStoreDetail() {
|
||||
if (!detail) return;
|
||||
setSaving(true);
|
||||
@@ -726,60 +684,47 @@ export default function StoresPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<StoreRow> = [
|
||||
const columns: 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} wrap={false}>
|
||||
<AdminPrimaryLink onClick={() => void openStoreDetail(row)}>{name === '—' ? '' : name}</AdminPrimaryLink>
|
||||
<Space size={4} style={{ maxWidth: '100%' }} wrap={false}>
|
||||
<Typography.Text ellipsis={{ tooltip: name }} style={{ maxWidth: row.isTest ? 110 : 160 }}>
|
||||
{name}
|
||||
</Typography.Text>
|
||||
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
title: '分类',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.category?.name || '—',
|
||||
},
|
||||
{ key: 'cityName', title: '城市', dataIndex: 'cityName', width: 80 },
|
||||
{ key: 'phone', title: '登录号', dataIndex: 'phone', width: 120 },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 80, ellipsis: true },
|
||||
{ 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';
|
||||
@@ -788,7 +733,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 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, maxWidth: 88 }} ellipsis={{ tooltip: row.rejectReason }}>
|
||||
{row.rejectReason}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
@@ -797,29 +742,32 @@ export default function StoresPage() {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'partner',
|
||||
title: '开城合伙人',
|
||||
dataIndex: 'partner',
|
||||
width: 140,
|
||||
ellipsis: { showTitle: false },
|
||||
render: (partner: StoreRow['partner']) => {
|
||||
if (!partner) return '—';
|
||||
return partnerOptionLabel({ id: partner.id ?? '', ...partner });
|
||||
const label = partnerOptionLabel({ id: partner.id ?? '', ...partner });
|
||||
return (
|
||||
<Typography.Text ellipsis={{ tooltip: label }} style={{ maxWidth: 124 }}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'visibilityWhitelistEnabled',
|
||||
title: '可见',
|
||||
dataIndex: 'visibilityWhitelistEnabled',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? <Tag color="orange">限测</Tag> : <Tag>公开</Tag>,
|
||||
},
|
||||
{ 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 },
|
||||
{ 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: 'actions',
|
||||
title: '操作',
|
||||
width: 280,
|
||||
fixed: 'right',
|
||||
@@ -828,13 +776,22 @@ export default function StoresPage() {
|
||||
<Button type="link" size="small" onClick={() => void openStoreDetail(row)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/store-ratings?storeId=${row.id}`)}>评价</Button>
|
||||
{row.pendingPackageAuditId ? (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => void openStoreDetail(row, { tab: 'packages' })}
|
||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||
>
|
||||
审核套餐
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => navigate(`/store-package-audits?requestId=${row.pendingPackageAuditId}`)}
|
||||
>
|
||||
对比
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{row.pendingInfoChangeId ? (
|
||||
<>
|
||||
@@ -866,22 +823,15 @@ export default function StoresPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('stores', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="门店"
|
||||
settings={settingsButton}
|
||||
description={`共 ${data?.total ?? 0} 家门店(含合伙人录入)`}
|
||||
actions={
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店</Typography.Title>
|
||||
<Typography.Text type="secondary">共 {data?.total ?? 0} 家门店(含合伙人录入)</Typography.Text>
|
||||
</Space>
|
||||
<Button type="primary" onClick={openCreateModal}>新建门店</Button>
|
||||
}
|
||||
/>
|
||||
</Space>
|
||||
<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>
|
||||
@@ -941,7 +891,7 @@ export default function StoresPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1720 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -953,7 +903,7 @@ export default function StoresPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer title="门店详情" width={880} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space wrap>
|
||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||
@@ -990,41 +940,6 @@ export default function StoresPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{detail.pendingPackageAuditId ? (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
loading={packageAuditing}
|
||||
onClick={async () => {
|
||||
setPackageAuditing(true);
|
||||
try {
|
||||
await auditStorePackageRequest(String(detail.pendingPackageAuditId), 'APPROVE');
|
||||
message.success('套餐已通过');
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '套餐审核失败');
|
||||
} finally {
|
||||
setPackageAuditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
通过套餐
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
ghost
|
||||
loading={packageAuditing}
|
||||
onClick={() => {
|
||||
setPackageRejectReason('');
|
||||
setPackageRejectOpen(true);
|
||||
}}
|
||||
>
|
||||
驳回套餐
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Select value={String(detail.status)} style={{ width: 120 }}
|
||||
options={Object.entries(STORE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
@@ -1039,8 +954,6 @@ export default function StoresPage() {
|
||||
{detail && (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Tabs
|
||||
activeKey={detailTab}
|
||||
onChange={setDetailTab}
|
||||
destroyInactiveTabPane={false}
|
||||
items={[
|
||||
{
|
||||
@@ -1289,76 +1202,15 @@ export default function StoresPage() {
|
||||
},
|
||||
{
|
||||
key: 'packages',
|
||||
label: detail.pendingPackageAuditId ? (
|
||||
<Badge dot offset={[4, 0]}>
|
||||
套餐
|
||||
</Badge>
|
||||
) : (
|
||||
'套餐'
|
||||
),
|
||||
label: '套餐',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<>
|
||||
{detail.pendingPackageAuditId ? (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
待审核套餐变更
|
||||
</Typography.Title>
|
||||
<StorePackageAuditPanel
|
||||
requestId={String(detail.pendingPackageAuditId)}
|
||||
showActions
|
||||
onAudited={() => {
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />
|
||||
</>
|
||||
),
|
||||
children: <AdminStorePackagesSection ref={packagesRef} storeId={String(detail.id)} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal
|
||||
title="驳回套餐变更"
|
||||
open={packageRejectOpen}
|
||||
confirmLoading={packageAuditing}
|
||||
onCancel={() => setPackageRejectOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail?.pendingPackageAuditId) return;
|
||||
if (!packageRejectReason.trim()) {
|
||||
message.warning('请填写驳回原因');
|
||||
return;
|
||||
}
|
||||
setPackageAuditing(true);
|
||||
try {
|
||||
await auditStorePackageRequest(
|
||||
String(detail.pendingPackageAuditId),
|
||||
'REJECT',
|
||||
packageRejectReason.trim(),
|
||||
);
|
||||
message.success('套餐已驳回');
|
||||
setPackageRejectOpen(false);
|
||||
setDetail({ ...detail, pendingPackageAuditId: null });
|
||||
void reload();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '套餐驳回失败');
|
||||
} finally {
|
||||
setPackageAuditing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={packageRejectReason}
|
||||
placeholder="驳回原因"
|
||||
onChange={(e) => setPackageRejectReason(e.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="新建门店"
|
||||
open={createOpen}
|
||||
|
||||
@@ -46,9 +46,6 @@ import { request, type HqProfile } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
const DISPATCH_WECOM_STORAGE_KEY = 'support_ticket_dispatch_wecom';
|
||||
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
|
||||
@@ -221,13 +218,8 @@ 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),
|
||||
attachmentUrls: ticketUrls,
|
||||
}],
|
||||
tasks: [{ content: summary || detail!.title, type: mapSupportTicketTypeToDevPlanTask(detail!.ticketType) }],
|
||||
dispatchToWecom: localStorage.getItem(DISPATCH_WECOM_STORAGE_KEY) !== 'false',
|
||||
dispatchSupplement: DEFAULT_DISPATCH_SUPPLEMENT,
|
||||
});
|
||||
@@ -372,11 +364,7 @@ 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,
|
||||
attachmentUrls: t.attachmentUrls?.length ? t.attachmentUrls : detail!.attachmentUrls,
|
||||
})),
|
||||
tasks: values.tasks.map((t) => ({ content: t.content.trim(), type: t.type })),
|
||||
dispatchToWecom: !!values.dispatchToWecom,
|
||||
dispatchSupplement: values.dispatchSupplement?.trim() || undefined,
|
||||
}),
|
||||
@@ -473,7 +461,7 @@ export default function SupportTicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<TicketRow> = [
|
||||
const columns: ColumnsType<TicketRow> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
@@ -506,13 +494,7 @@ export default function SupportTicketsPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(String(row.id))}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -555,11 +537,8 @@ export default function SupportTicketsPage() {
|
||||
return null;
|
||||
})();
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('support-tickets', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -571,7 +550,6 @@ export default function SupportTicketsPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
技术支持
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Space>
|
||||
{isSuperAdmin ? (
|
||||
<>
|
||||
@@ -632,7 +610,7 @@ export default function SupportTicketsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 900 }}
|
||||
rowSelection={
|
||||
isSuperAdmin
|
||||
? {
|
||||
|
||||
@@ -249,89 +249,26 @@ export default function SystemSettingsPage() {
|
||||
|
||||
const collapseItems = useMemo(() => {
|
||||
if (!meta) return [];
|
||||
const SHARE_SUBGROUP_LABELS: Record<string, string> = {
|
||||
global: '全局',
|
||||
home: '首页',
|
||||
stores: '门店列表',
|
||||
storeDetail: '门店详情',
|
||||
benefit: '权益页',
|
||||
mine: '我的',
|
||||
productDetail: '商品详情',
|
||||
orderDetail: '订单详情',
|
||||
};
|
||||
const SHARE_SUBGROUP_ORDER = [
|
||||
'global',
|
||||
'home',
|
||||
'stores',
|
||||
'storeDetail',
|
||||
'benefit',
|
||||
'mine',
|
||||
'productDetail',
|
||||
'orderDetail',
|
||||
];
|
||||
|
||||
return meta.groups.map((group) => {
|
||||
const fields = meta.fields.filter((f) => f.group === group.key);
|
||||
const hasSubgroups = fields.some((f) => f.subgroup);
|
||||
|
||||
let children: ReactNode;
|
||||
if (group.key === 'wechat_mini_share' && hasSubgroups) {
|
||||
const bySub = new Map<string, SystemConfigFieldMeta[]>();
|
||||
for (const f of fields) {
|
||||
const sk = f.subgroup || 'global';
|
||||
if (!bySub.has(sk)) bySub.set(sk, []);
|
||||
bySub.get(sk)!.push(f);
|
||||
}
|
||||
const orderedKeys = [
|
||||
...SHARE_SUBGROUP_ORDER.filter((k) => bySub.has(k)),
|
||||
...[...bySub.keys()].filter((k) => !SHARE_SUBGROUP_ORDER.includes(k)),
|
||||
];
|
||||
children = (
|
||||
<Collapse
|
||||
defaultActiveKey={[]}
|
||||
items={orderedKeys.map((sk) => ({
|
||||
key: sk,
|
||||
label: SHARE_SUBGROUP_LABELS[sk] || sk,
|
||||
forceRender: true,
|
||||
children: (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{(bySub.get(sk) ?? []).map((f) =>
|
||||
renderField(
|
||||
f,
|
||||
meta.configuredSecrets,
|
||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
children = (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{fields.map((f) =>
|
||||
renderField(
|
||||
f,
|
||||
meta.configuredSecrets,
|
||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
return meta.groups.map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
forceRender: true,
|
||||
children,
|
||||
};
|
||||
});
|
||||
children: (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
{meta.fields
|
||||
.filter((f) => f.group === group.key)
|
||||
.map((f) =>
|
||||
renderField(
|
||||
f,
|
||||
meta.configuredSecrets,
|
||||
f.key === 'MOCK_SMS' && mockSmsEnabled ? (
|
||||
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
|
||||
) : undefined,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
}, [meta, mockSmsEnabled, loading]);
|
||||
|
||||
async function onSave() {
|
||||
|
||||
@@ -20,9 +20,6 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
const MOCK_KEYS = ['MOCK_SMS', 'MOCK_WECHAT', 'MOCK_PAY'] as const;
|
||||
|
||||
@@ -256,25 +253,11 @@ export default function TestWhitelistPage() {
|
||||
}
|
||||
|
||||
const phoneColumns: ColumnsType<PhoneRow> = [
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => {
|
||||
setEditRow(row);
|
||||
editForm.setFieldsValue({ note: row.note ?? '' });
|
||||
setEditOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '手机号', dataIndex: 'phone', width: 140 },
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -351,7 +334,7 @@ export default function TestWhitelistPage() {
|
||||
if (accountType === 'partner') {
|
||||
return [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '公司', dataIndex: 'companyName', render: (v) => (v as string) || '—' },
|
||||
{ title: '公司', dataIndex: 'companyName', ellipsis: true, render: (v) => (v as string) || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '标记',
|
||||
@@ -364,7 +347,7 @@ export default function TestWhitelistPage() {
|
||||
}
|
||||
if (accountType === 'store') {
|
||||
return [
|
||||
{ title: '门店名', dataIndex: 'name', width: 160 },
|
||||
{ title: '门店名', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
@@ -403,15 +386,11 @@ export default function TestWhitelistPage() {
|
||||
];
|
||||
}
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('test-whitelist', phoneColumns, {
|
||||
page: phonePage,
|
||||
pageSize: phonePageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="白名单管理" settings={settingsButton} />
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
白名单管理
|
||||
</Typography.Title>
|
||||
|
||||
<Card size="small" loading={mockLoading} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
@@ -493,7 +472,7 @@ export default function TestWhitelistPage() {
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={phonesLoading}
|
||||
columns={columns}
|
||||
columns={phoneColumns}
|
||||
dataSource={phones?.items ?? []}
|
||||
pagination={{
|
||||
current: phonePage,
|
||||
|
||||
@@ -4,10 +4,6 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -73,7 +69,7 @@ export default function ThirdPartyLogsPage() {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||
@@ -91,27 +87,15 @@ 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, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/common/third-party-logs/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '外部单号', dataIndex: 'externalNo', width: 140, render: (v) => v || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
@@ -130,15 +114,9 @@ export default function ThirdPartyLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-third-party', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="第三方日志" settings={settingsButton} />
|
||||
<Typography.Title level={4}>第三方日志</Typography.Title>
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
@@ -165,7 +143,7 @@ export default function ThirdPartyLogsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -25,9 +25,6 @@ import {
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -118,22 +115,8 @@ export default function TicketsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '工单号',
|
||||
dataIndex: 'ticketNo',
|
||||
width: 180,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'ticketType',
|
||||
@@ -149,7 +132,7 @@ export default function TicketsPage() {
|
||||
),
|
||||
},
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '备注', dataIndex: 'remark' },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -171,11 +154,8 @@ 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',
|
||||
@@ -187,7 +167,6 @@ export default function TicketsPage() {
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
工单中心
|
||||
</Typography.Title>
|
||||
{settingsButton}
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||
创建工单
|
||||
</Button>
|
||||
@@ -226,7 +205,7 @@ export default function TicketsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -7,10 +7,6 @@ import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -65,10 +61,10 @@ export default function UserLogsPage() {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const baseColumns: ColumnsType<Row> = [
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '用户', width: 180,
|
||||
title: '用户', width: 180, ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.nickname || r.userNo}
|
||||
@@ -82,27 +78,13 @@ export default function UserLogsPage() {
|
||||
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '事件',
|
||||
dataIndex: 'eventName',
|
||||
width: 160,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/logs/users/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
||||
{
|
||||
title: '关联', width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
title: '摘要', ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
@@ -116,12 +98,9 @@ export default function UserLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-users', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader title="用户日志" settings={settingsButton} />
|
||||
<Typography.Title level={4}>用户日志</Typography.Title>
|
||||
<Segmented
|
||||
style={{ marginBottom: 16 }}
|
||||
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
@@ -152,7 +131,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: 'max-content' }}
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
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, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
@@ -19,9 +19,6 @@ 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 { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
|
||||
|
||||
type UserOrderRow = {
|
||||
@@ -33,77 +30,6 @@ 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);
|
||||
@@ -318,7 +244,7 @@ export default function UsersPage() {
|
||||
{ title: '下单时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
];
|
||||
|
||||
const baseColumns: ColumnsType<AdminUserRow> = [
|
||||
const columns: ColumnsType<AdminUserRow> = [
|
||||
{
|
||||
title: '用户编号',
|
||||
dataIndex: 'userNo',
|
||||
@@ -330,38 +256,12 @@ export default function UsersPage() {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
width: 140,
|
||||
render: (v: string | null, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'hqRemark',
|
||||
width: 160,
|
||||
render: (v: string | null, row) => (
|
||||
<HqRemarkCell
|
||||
id={row.id}
|
||||
hqRemark={v}
|
||||
onSaved={(id, hqRemark) => {
|
||||
setData((prev) =>
|
||||
prev
|
||||
? { ...prev, items: prev.items.map((u) => (u.id === id ? { ...u, hqRemark } : u)) }
|
||||
: prev,
|
||||
);
|
||||
setDetail((d) => (d && d.id === id ? { ...d, hqRemark } : d));
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100 },
|
||||
{
|
||||
title: '手机',
|
||||
dataIndex: 'phone',
|
||||
width: 120,
|
||||
render: (v) => v || '—',
|
||||
render: (v) => maskPhone(v),
|
||||
},
|
||||
{
|
||||
title: '验手机',
|
||||
@@ -389,6 +289,7 @@ export default function UsersPage() {
|
||||
title: '来源 ID',
|
||||
dataIndex: 'sourceRefId',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
render: (v, row) => {
|
||||
if (!v) return '—';
|
||||
if (row.sourceType === 'PROMO_CODE') {
|
||||
@@ -405,11 +306,13 @@ export default function UsersPage() {
|
||||
title: '来源标签',
|
||||
dataIndex: 'sourceLabel',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
title: 'deviceKey',
|
||||
dataIndex: 'deviceKey',
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{
|
||||
@@ -474,19 +377,11 @@ export default function UsersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('users', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="用户监控"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
canDeleteUsers ? (
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>用户监控</Typography.Title>
|
||||
{canDeleteUsers ? (
|
||||
<Button
|
||||
danger
|
||||
disabled={!selectedRowKeys.length}
|
||||
@@ -494,9 +389,8 @@ export default function UsersPage() {
|
||||
>
|
||||
批量删除{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="模糊搜索" allowClear />
|
||||
@@ -535,7 +429,7 @@ export default function UsersPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1830 }}
|
||||
rowSelection={canDeleteUsers ? {
|
||||
selectedRowKeys,
|
||||
preserveSelectedRowKeys: true,
|
||||
@@ -568,7 +462,6 @@ 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,10 +7,6 @@ import { request } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { fmtTime, maskPhone } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
|
||||
|
||||
@@ -94,11 +90,12 @@ export default function WechatBindingsPage() {
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<GroupRow> = [
|
||||
const columns: ColumnsType<GroupRow> = [
|
||||
{
|
||||
title: 'unionId',
|
||||
dataIndex: 'unionId',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v) => v || <Tag>无 unionId</Tag>,
|
||||
},
|
||||
{
|
||||
@@ -122,12 +119,11 @@ export default function WechatBindingsPage() {
|
||||
title: '手机号',
|
||||
dataIndex: 'primaryPhone',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row)}>{maskPhone(v)}</AdminPrimaryLink>
|
||||
),
|
||||
render: (v) => maskPhone(v),
|
||||
},
|
||||
{
|
||||
title: '身份摘要',
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
|
||||
@@ -164,6 +160,7 @@ export default function WechatBindingsPage() {
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
ellipsis: true,
|
||||
render: (_, r) => (
|
||||
<AdminCellLine
|
||||
primary={r.name || '—'}
|
||||
@@ -175,12 +172,14 @@ export default function WechatBindingsPage() {
|
||||
title: '归属',
|
||||
dataIndex: 'refLabel',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (v, r) => (v ? `${v}${r.refId ? ` #${r.refId}` : ''}` : '—'),
|
||||
},
|
||||
{
|
||||
title: 'wxOpenId',
|
||||
dataIndex: 'wxOpenId',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '手机验证',
|
||||
@@ -206,16 +205,12 @@ export default function WechatBindingsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('wechat-bindings', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="微信绑定总览"
|
||||
settings={settingsButton}
|
||||
description="按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。"
|
||||
/>
|
||||
<Typography.Title level={4}>微信绑定总览</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
按 unionId 聚合展示已绑定微信的 C 端用户、门店账号、合伙人账号与 HQ 账号;无 unionId 时按单账号分组。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -5,10 +5,6 @@ import { useAdminList } from '../lib/useAdminList';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import type { WecomBotLogDto } from '@dukang/shared-types';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
export default function WecomBotLogsPage() {
|
||||
const [form] = Form.useForm();
|
||||
@@ -28,19 +24,12 @@ export default function WecomBotLogsPage() {
|
||||
[filters],
|
||||
);
|
||||
|
||||
const baseColumns: ColumnsType<WecomBotLogDto> = [
|
||||
const columns: ColumnsType<WecomBotLogDto> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '机器人',
|
||||
dataIndex: 'botName',
|
||||
width: 120,
|
||||
render: (v, r) => (
|
||||
<AdminPrimaryLink onClick={() => setDetail(r)}>{v || r.botKey || ''}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '企微用户', dataIndex: 'wecomUserId', width: 120 },
|
||||
{ title: '动作', dataIndex: 'action', width: 160 },
|
||||
{ title: '权限', dataIndex: 'permission', width: 140, render: (v) => v || '—' },
|
||||
{ title: '机器人', dataIndex: '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: 'success',
|
||||
@@ -48,7 +37,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' },
|
||||
{ title: '输入摘要', dataIndex: 'inputSummary', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
@@ -60,16 +49,12 @@ export default function WecomBotLogsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-wecom-bots', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="企微机器人日志"
|
||||
settings={settingsButton}
|
||||
description="记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。"
|
||||
/>
|
||||
<Typography.Title level={4}>企微机器人日志</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
记录机器人在企微内的查询与审批操作,含权限点、耗时与成败。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
@@ -111,7 +96,7 @@ export default function WecomBotLogsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -36,9 +36,6 @@ import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -283,7 +280,7 @@ export default function WecomBotsPage() {
|
||||
|
||||
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
|
||||
|
||||
const baseColumns: ColumnsType<WecomBotDto> = [
|
||||
const columns: ColumnsType<WecomBotDto> = [
|
||||
{
|
||||
title: '头像',
|
||||
dataIndex: 'avatarUrl',
|
||||
@@ -294,24 +291,18 @@ export default function WecomBotsPage() {
|
||||
</Avatar>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
width: 140,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
width: 120,
|
||||
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
|
||||
},
|
||||
{ title: 'BotID', dataIndex: 'botId', width: 160 },
|
||||
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (perms: WecomBotPermission[]) =>
|
||||
perms.map((p) => (
|
||||
<Tag key={p} style={{ marginBottom: 2 }}>
|
||||
@@ -376,20 +367,13 @@ 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 ? '开' : '关'}` : ''}
|
||||
@@ -444,7 +428,7 @@ export default function WecomBotsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
@@ -22,22 +21,13 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WECOM_PUSH_CONDITION_GROUPS,
|
||||
WECOM_PUSH_CONDITION_LABELS,
|
||||
WECOM_TEMPLATE_EVENT_KEYS,
|
||||
WECOM_TEMPLATE_EVENT_LABELS,
|
||||
WECOM_TEMPLATE_PLACEHOLDERS,
|
||||
type WecomMessagePushDto,
|
||||
type WecomPushCondition,
|
||||
type WecomPushTemplateDto,
|
||||
type WecomTemplateEventKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -88,7 +78,7 @@ function WecomPushConditionPicker({
|
||||
);
|
||||
}
|
||||
|
||||
function PushRoutesTab() {
|
||||
export default function WecomMessagePushesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
@@ -197,20 +187,21 @@ function PushRoutesTab() {
|
||||
}
|
||||
}
|
||||
|
||||
const baseColumns: ColumnsType<WecomMessagePushDto> = [
|
||||
const columns: ColumnsType<WecomMessagePushDto> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (name: string, row) => (
|
||||
<Space>
|
||||
<Avatar src={row.avatarUrl ?? undefined}>{name.slice(0, 1)}</Avatar>
|
||||
<AdminPrimaryLink onClick={() => openEdit(row)}>{name}</AdminPrimaryLink>
|
||||
<span>{name}</span>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Webhook',
|
||||
dataIndex: 'webhookUrlMasked',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '推送条件',
|
||||
@@ -265,23 +256,13 @@ function PushRoutesTab() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('wecom-pushes', baseColumns, {
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
settings={settingsButton}
|
||||
description="配置群机器人 Webhook:按推送条件订阅业务通知 / 告警。运行时不再读取 .env 中的 Webhook URL。"
|
||||
actions={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建推送
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
配置群机器人 Webhook 多实例,按推送条件分发运营告警、技术支持工单、开发任务派发等消息。运行时不再读取
|
||||
.env 中的 Webhook URL。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
@@ -316,6 +297,9 @@ function PushRoutesTab() {
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建推送
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -348,10 +332,10 @@ function PushRoutesTab() {
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="如:业务待办通知群" />
|
||||
<Input placeholder="如:运营告警" />
|
||||
</Form.Item>
|
||||
<Form.Item name="avatarUrl" label="头像(HQ 列表展示)">
|
||||
<OssUpload bizType="WECOM_BOT_AVATAR" />
|
||||
<OssUpload />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="webhookUrl"
|
||||
@@ -416,208 +400,6 @@ function PushRoutesTab() {
|
||||
</Descriptions>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplatesTab() {
|
||||
const [templates, setTemplates] = useState<WecomPushTemplateDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [eventKey, setEventKey] = useState<WecomTemplateEventKey>('order.paid');
|
||||
const [form] = Form.useForm<{ title: string; body: string; handleLabel: string }>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [preview, setPreview] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await request<WecomPushTemplateDto[]>('/admin/wecom-push-templates');
|
||||
setTemplates(list);
|
||||
setEventKey((prev) => {
|
||||
const current = list.find((t) => t.eventKey === prev) ?? list[0];
|
||||
if (current) {
|
||||
form.setFieldsValue({
|
||||
title: current.title,
|
||||
body: current.body,
|
||||
handleLabel: current.handleLabel,
|
||||
});
|
||||
return current.eventKey;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载模板失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [form]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function selectEvent(key: WecomTemplateEventKey) {
|
||||
setEventKey(key);
|
||||
const row = templates.find((t) => t.eventKey === key);
|
||||
if (row) {
|
||||
form.setFieldsValue({
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
handleLabel: row.handleLabel,
|
||||
});
|
||||
setPreview('');
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await request<WecomPushTemplateDto>(
|
||||
`/admin/wecom-push-templates/${eventKey}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(values),
|
||||
},
|
||||
);
|
||||
message.success('模板已保存');
|
||||
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await request<WecomPushTemplateDto>(
|
||||
`/admin/wecom-push-templates/${eventKey}/reset`,
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
message.success('已恢复默认文案');
|
||||
form.setFieldsValue({
|
||||
title: updated.title,
|
||||
body: updated.body,
|
||||
handleLabel: updated.handleLabel,
|
||||
});
|
||||
setTemplates((prev) => prev.map((t) => (t.eventKey === eventKey ? updated : t)));
|
||||
setPreview('');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '恢复失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function testSend() {
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await request<{ ok: boolean; message: string; preview: string }>(
|
||||
`/admin/wecom-push-templates/${eventKey}/test`,
|
||||
{ method: 'POST', body: '{}' },
|
||||
);
|
||||
setPreview(res.preview || '');
|
||||
message.success(res.message || '已发送');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '测试失败');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const placeholders = WECOM_TEMPLATE_PLACEHOLDERS[eventKey] ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Typography.Paragraph type="secondary">
|
||||
每种业务事件全站一份文案,使用 {'{{orderNo}}'} 等形式占位。快链由系统注入 {'{{handleUrl}}'}
|
||||
;须在「推送路由」中勾选对应条件并配置 Webhook 才会发出。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Space align="start" style={{ width: '100%' }} size={24} wrap>
|
||||
<div style={{ minWidth: 200 }}>
|
||||
<Typography.Text strong>事件</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{WECOM_TEMPLATE_EVENT_KEYS.map((k) => (
|
||||
<div key={k} style={{ marginBottom: 4 }}>
|
||||
<Button
|
||||
type={k === eventKey ? 'primary' : 'text'}
|
||||
size="small"
|
||||
onClick={() => selectEvent(k)}
|
||||
block
|
||||
style={{ textAlign: 'left' }}
|
||||
>
|
||||
{WECOM_TEMPLATE_EVENT_LABELS[k]}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, minWidth: 360 }}>
|
||||
<Form form={form} layout="vertical" disabled={loading}>
|
||||
<Form.Item name="title" label="标题(管理用)" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="body"
|
||||
label="正文(企微 markdown)"
|
||||
rules={[{ required: true }]}
|
||||
extra={`可用占位符:${placeholders.map((p) => `{{${p}}}`).join(' ')}`}
|
||||
>
|
||||
<Input.TextArea rows={12} style={{ fontFamily: 'monospace' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="handleLabel" label="快链按钮文案" rules={[{ required: true }]}>
|
||||
<Input placeholder="去处理" />
|
||||
</Form.Item>
|
||||
<Space wrap>
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
<Popconfirm title="恢复代码默认文案?将覆盖当前编辑" onConfirm={() => void reset()}>
|
||||
<Button loading={saving}>恢复默认</Button>
|
||||
</Popconfirm>
|
||||
<Button loading={testing} onClick={() => void testSend()}>
|
||||
用示例数据测试推送
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
|
||||
{preview ? (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Typography.Text strong>预览</Typography.Text>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: 12,
|
||||
background: '#f5f5f5',
|
||||
whiteSpace: 'pre-wrap',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
{preview}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Space>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WecomMessagePushesPage() {
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>企微机器人 · 消息推送</Typography.Title>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'routes', label: '推送路由', children: <PushRoutesTab /> },
|
||||
{ key: 'templates', label: '通知模板', children: <TemplatesTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,10 +26,6 @@ import { fmtTime } from '../lib/constants';
|
||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||
import { request, type HqProfile } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
@@ -41,13 +37,6 @@ 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 = {
|
||||
@@ -138,30 +127,14 @@ export default function WineryBillsPage() {
|
||||
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
let paymentRef = '';
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: (
|
||||
<div>
|
||||
<div>
|
||||
将确认 {ids.length} 笔酒厂对账单
|
||||
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。
|
||||
</div>
|
||||
<Input
|
||||
placeholder="打款凭证号(可选)"
|
||||
style={{ marginTop: 8 }}
|
||||
onChange={(e) => {
|
||||
paymentRef = e.target.value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
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', body });
|
||||
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/winery-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
@@ -239,15 +212,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 baseColumns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '账单号',
|
||||
dataIndex: 'billNo',
|
||||
width: 170,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
@@ -301,23 +267,32 @@ export default function WineryBillsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-winery-bills', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="酒厂对账单"
|
||||
settings={settingsButton}
|
||||
description={`T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × ${ratePct}%);未打款红色、已打款绿色、应付为 0 无需打款(灰),可展开订单明细`}
|
||||
actions={
|
||||
canEditWineryBank ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: 16,
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+3:每日 8:00 汇总 3 天前(自然日)已完成的同城/跨城订单(实付 × {ratePct}%);未打款红色、已打款绿色、应付为 0
|
||||
无需打款(灰),可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
{canEditWineryBank ? (
|
||||
<Button type="default" onClick={() => void openBankModal()}>
|
||||
酒厂银行账户信息配置
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
@@ -400,7 +375,7 @@ export default function WineryBillsPage() {
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: !canConfirmWineryPay(r) }),
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -424,31 +399,7 @@ 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>
|
||||
@@ -458,7 +409,7 @@ export default function WineryBillsPage() {
|
||||
pagination={false}
|
||||
dataSource={detail.items ?? []}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'orderNo' },
|
||||
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
|
||||
{
|
||||
title: '配送',
|
||||
dataIndex: 'deliveryType',
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import { useAdminList } from '../../lib/useAdminList';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
import { useAdminListColumns } from '../../lib/useAdminListColumns';
|
||||
import { AdminPrimaryLink } from '../../components/AdminPrimaryLink';
|
||||
import { AdminListHeader } from '../../components/AdminListHeader';
|
||||
|
||||
|
||||
export default function PromoCodeUsersPage() {
|
||||
const { id = '' } = useParams();
|
||||
@@ -25,18 +21,9 @@ export default function PromoCodeUsersPage() {
|
||||
[id],
|
||||
);
|
||||
|
||||
const baseColumns: ColumnsType<PromoCodeAttributedUser> = [
|
||||
const columns: ColumnsType<PromoCodeAttributedUser> = [
|
||||
{ title: '用户编号', dataIndex: 'userNo', width: 120 },
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
width: 100,
|
||||
render: (v, row) => (
|
||||
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: row.id } })}>
|
||||
{v}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' },
|
||||
{
|
||||
title: '验手机',
|
||||
@@ -82,19 +69,14 @@ export default function PromoCodeUsersPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const { columns, settingsButton, settingsModal } = useAdminListColumns('promo-code-users', baseColumns, { page, pageSize });
|
||||
|
||||
return (
|
||||
<>
|
||||
{settingsModal}
|
||||
<AdminListHeader settings={settingsButton} />
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 'max-content' }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -103,6 +85,5 @@ export default function PromoCodeUsersPage() {
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.mp3' {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
@@ -17,11 +17,6 @@ type Props = {
|
||||
accept?: string;
|
||||
/** 计量单位文案,如「张」「个」 */
|
||||
unit?: string;
|
||||
/**
|
||||
* stack:缩略图 + 下方独立上传按钮(合同等)
|
||||
* grid:九宫格,末尾「+」格上传,无独立大按钮(环境图)
|
||||
*/
|
||||
variant?: 'stack' | 'grid';
|
||||
};
|
||||
|
||||
function isCancelError(msg: string): boolean {
|
||||
@@ -47,7 +42,6 @@ export default function MultiOssUploadField({
|
||||
label,
|
||||
accept = 'image/*',
|
||||
unit = '张',
|
||||
variant = 'stack',
|
||||
}: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const pickingRef = useRef(false);
|
||||
@@ -59,7 +53,6 @@ export default function MultiOssUploadField({
|
||||
const onChangeRef = useRef(onChange);
|
||||
const remaining = Math.max(0, maxCount - urls.length);
|
||||
const inWechat = isWechatEnv();
|
||||
const isGrid = variant === 'grid';
|
||||
|
||||
useEffect(() => {
|
||||
urlsRef.current = urls;
|
||||
@@ -133,18 +126,30 @@ export default function MultiOssUploadField({
|
||||
}
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (inWechat) void pickWechat();
|
||||
else inputRef.current?.click();
|
||||
}
|
||||
|
||||
function removeAt(index: number) {
|
||||
if (disabled) return;
|
||||
onChange?.(urls.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
const thumb = (url: string, index: number) => (
|
||||
<div key={`${url}-${index}`} className={isGrid ? 'partner-upload-grid-thumb' : undefined} style={isGrid ? undefined : { position: 'relative', width: 88, height: 88 }}>
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple
|
||||
className="partner-oss-upload-input"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onChange={(e) => {
|
||||
const list = Array.from(e.target.files ?? []);
|
||||
if (list.length) void uploadFiles(list);
|
||||
}}
|
||||
/>
|
||||
|
||||
{urls.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{urls.map((url, index) => (
|
||||
<div key={`${url}-${index}`} style={{ position: 'relative', width: 88, height: 88 }}>
|
||||
{isPdf(url) ? (
|
||||
<a
|
||||
href={url}
|
||||
@@ -152,8 +157,8 @@ export default function MultiOssUploadField({
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: isGrid ? '100%' : 88,
|
||||
height: isGrid ? '100%' : 88,
|
||||
width: 88,
|
||||
height: 88,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -173,13 +178,7 @@ export default function MultiOssUploadField({
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
style={{
|
||||
width: isGrid ? '100%' : 88,
|
||||
height: isGrid ? '100%' : 88,
|
||||
objectFit: 'cover',
|
||||
borderRadius: isGrid ? 12 : 8,
|
||||
display: 'block',
|
||||
}}
|
||||
style={{ width: 88, height: 88, objectFit: 'cover', borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
{!disabled ? (
|
||||
@@ -203,52 +202,18 @@ export default function MultiOssUploadField({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple
|
||||
className="partner-oss-upload-input"
|
||||
<button
|
||||
type="button"
|
||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onChange={(e) => {
|
||||
const list = Array.from(e.target.files ?? []);
|
||||
if (list.length) void uploadFiles(list);
|
||||
onClick={() => {
|
||||
if (inWechat) void pickWechat();
|
||||
else inputRef.current?.click();
|
||||
}}
|
||||
/>
|
||||
|
||||
{isGrid ? (
|
||||
<div className="partner-upload-grid">
|
||||
{urls.map((url, index) => thumb(url, index))}
|
||||
{remaining > 0 && !disabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||
disabled={uploading}
|
||||
onClick={openPicker}
|
||||
aria-label={uploading ? '上传中' : `添加${unit}(${urls.length}/${maxCount})`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||
{uploading ? 'hourglass_top' : 'add'}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{urls.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 8 }}>
|
||||
{urls.map((url, index) => thumb(url, index))}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="partner-upload-dashed partner-upload-dashed--compact"
|
||||
disabled={disabled || uploading || remaining <= 0}
|
||||
onClick={openPicker}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 28 }}>
|
||||
{uploading ? 'hourglass_top' : 'add_a_photo'}
|
||||
@@ -261,8 +226,6 @@ export default function MultiOssUploadField({
|
||||
: label ?? `批量上传(${urls.length}/${maxCount})`}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{error ? (
|
||||
<p className="partner-form-error" role="alert">
|
||||
{error}
|
||||
|
||||
@@ -58,7 +58,6 @@ export default function ProxyOrderPage() {
|
||||
const [regionCodes, setRegionCodes] = useState<string[]>(draft0.regionCodes);
|
||||
const [addressDetail, setAddressDetail] = useState(draft0.addressDetail);
|
||||
const [productId, setProductId] = useState(draft0.productId);
|
||||
const [skuId, setSkuId] = useState('');
|
||||
const [quantity, setQuantity] = useState(draft0.quantity);
|
||||
const [promoCodeId, setPromoCodeId] = useState(draft0.promoCodeId);
|
||||
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>(draft0.deliveryMode);
|
||||
@@ -157,37 +156,9 @@ export default function ProxyOrderPage() {
|
||||
]);
|
||||
|
||||
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||
const selectedSku =
|
||||
skuOptions.find((s) => s.id === skuId) ||
|
||||
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
||||
skuOptions[0];
|
||||
const allowOnline = selectedSku
|
||||
? selectedSku.allowOnlinePurchase !== false
|
||||
: selectedProduct
|
||||
? selectedProduct.allowOnlinePurchase !== false
|
||||
: true;
|
||||
const allowOnSite = selectedSku
|
||||
? !!selectedSku.allowOnSitePickup
|
||||
: !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedSku
|
||||
? selectedSku.allowCrossCityDelivery !== false
|
||||
: selectedProduct
|
||||
? selectedProduct.allowCrossCityDelivery !== false
|
||||
: true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProduct) return;
|
||||
const def =
|
||||
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
||||
skuOptions.find((s) => s.isDefault) ||
|
||||
skuOptions[0];
|
||||
if (def && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
||||
setSkuId(def.id);
|
||||
if (def.saleUnit === 'BOX') setQuantity(1);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [productId, selectedProduct?.id, options]);
|
||||
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
|
||||
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
|
||||
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProduct) return;
|
||||
@@ -213,7 +184,6 @@ export default function ProxyOrderPage() {
|
||||
productId,
|
||||
quantity,
|
||||
deliveryMode,
|
||||
skuId: skuId || undefined,
|
||||
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||
}),
|
||||
@@ -230,7 +200,7 @@ export default function ProxyOrderPage() {
|
||||
.finally(() => setPreviewLoading(false));
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [productId, skuId, quantity, deliveryMode, region?.city, region?.district]);
|
||||
}, [productId, quantity, deliveryMode, region?.city, region?.district]);
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||
@@ -389,7 +359,6 @@ export default function ProxyOrderPage() {
|
||||
productId,
|
||||
quantity,
|
||||
promoCodeId: promoCodeId || undefined,
|
||||
skuId: skuId || undefined,
|
||||
};
|
||||
|
||||
setSubmitting(true);
|
||||
@@ -555,35 +524,8 @@ export default function ProxyOrderPage() {
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">规格</label>
|
||||
<div className="partner-input-wrap">
|
||||
<select
|
||||
className="partner-input"
|
||||
value={skuId}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value;
|
||||
setSkuId(id);
|
||||
const sku = skuOptions.find((s) => s.id === id);
|
||||
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
||||
}}
|
||||
>
|
||||
{skuOptions.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.specText || '默认'} · ¥{fmtMoney(s.price)} ·{' '}
|
||||
{s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="partner-form-section">
|
||||
<label className="partner-form-label">
|
||||
{selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'}
|
||||
</label>
|
||||
<label className="partner-form-label">数量</label>
|
||||
<div className="partner-input-wrap">
|
||||
<input
|
||||
className="partner-input"
|
||||
|
||||
@@ -1007,9 +1007,9 @@ export default function StoreCreatePage() {
|
||||
<MultiOssUploadField
|
||||
bizType="STORE_ENV"
|
||||
maxCount={20}
|
||||
variant="grid"
|
||||
value={form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)}
|
||||
onChange={(urls) => setForm((prev) => ({ ...prev, envPhotoUrls: urls.length ? urls : [''] }))}
|
||||
label={`批量上传(${form.envPhotoUrls.map((u) => u.trim()).filter(Boolean).length}/20)`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export default function StoreDetailPage() {
|
||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mediaSaving, setMediaSaving] = useState(false);
|
||||
const [mapPickerOpen, setMapPickerOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||
@@ -158,91 +159,18 @@ export default function StoreDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function mediaChanged(): boolean {
|
||||
if (!store) return false;
|
||||
const nextCover = coverUrl.trim();
|
||||
const origCover = String(store.coverUrl || '').trim();
|
||||
if (nextCover !== origCover) return true;
|
||||
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||||
const origEnv = uniqueEnvUrls(
|
||||
Array.isArray(store.media)
|
||||
? (store.media as Array<{ url?: string; bizType?: string }>)
|
||||
.filter((m) => m.bizType === 'ENV')
|
||||
.map((m) => String(m.url || ''))
|
||||
: [],
|
||||
);
|
||||
if (nextEnv.length !== origEnv.length) return true;
|
||||
return nextEnv.some((u, i) => u !== origEnv[i]);
|
||||
}
|
||||
|
||||
function basicChanged(): boolean {
|
||||
if (!store) return false;
|
||||
const liveContact = String(store.contactPhone || store.phone || '').trim();
|
||||
const liveIntro = String(store.intro || '').trim();
|
||||
const liveRuleRaw = String(store.benefitUsageRule || '').trim();
|
||||
const liveRule = liveRuleRaw && !/^null$/i.test(liveRuleRaw) ? liveRuleRaw : '';
|
||||
const liveLat =
|
||||
store.latitude != null && store.latitude !== '' ? String(store.latitude) : '';
|
||||
const liveLng =
|
||||
store.longitude != null && store.longitude !== '' ? String(store.longitude) : '';
|
||||
return (
|
||||
form.name.trim() !== String(store.name || '').trim() ||
|
||||
form.contactPhone.trim() !== liveContact ||
|
||||
form.address.trim() !== String(store.address || '').trim() ||
|
||||
form.intro.trim() !== liveIntro ||
|
||||
form.benefitUsageRule.trim() !== liveRule ||
|
||||
form.latitude.trim() !== liveLat ||
|
||||
form.longitude.trim() !== liveLng
|
||||
);
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
async function saveBasic() {
|
||||
if (!id || saving || status === 'CLOSED') return;
|
||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||
if (auditStatus === 'PENDING') {
|
||||
setActionError('门店审核中,暂不可修改资料');
|
||||
return;
|
||||
}
|
||||
|
||||
const wantMedia = mediaChanged();
|
||||
const wantBasic = basicChanged();
|
||||
if (!wantMedia && !wantBasic) {
|
||||
setActionError('没有检测到需要变更的字段');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextCover = coverUrl.trim();
|
||||
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||||
if (wantMedia) {
|
||||
if (!nextCover) {
|
||||
setActionError('请上传门头照');
|
||||
return;
|
||||
}
|
||||
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
|
||||
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
// 入驻被驳回:直写 media/basic 并重提门店审核;已通过门店:统一走信息变更审核
|
||||
if (auditStatus === 'REJECTED') {
|
||||
if (wantMedia) {
|
||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
coverUrl: nextCover,
|
||||
envPhotoUrls: nextEnv,
|
||||
}),
|
||||
});
|
||||
applyStore(data);
|
||||
}
|
||||
if (wantBasic) {
|
||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
// v3.5.1 #5:基本信息变更走「提交变更」审核流,由总部审核通过后覆盖门店
|
||||
await submitStoreInfoChangeRequest(id, {
|
||||
name: form.name.trim(),
|
||||
contactPhone: form.contactPhone.trim(),
|
||||
address: form.address.trim(),
|
||||
@@ -254,37 +182,9 @@ export default function StoreDetailPage() {
|
||||
longitude: Number(form.longitude),
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
applyStore(data);
|
||||
}
|
||||
toastSuccess('已重新提交审核');
|
||||
return;
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (wantBasic) {
|
||||
fields.name = form.name.trim();
|
||||
fields.contactPhone = form.contactPhone.trim();
|
||||
fields.address = form.address.trim();
|
||||
fields.intro = form.intro.trim();
|
||||
fields.benefitUsageRule = form.benefitUsageRule.trim() || null;
|
||||
if (form.latitude.trim() && form.longitude.trim()) {
|
||||
fields.latitude = Number(form.latitude);
|
||||
fields.longitude = Number(form.longitude);
|
||||
}
|
||||
}
|
||||
if (wantMedia) {
|
||||
fields.coverUrl = nextCover;
|
||||
fields.envPhotoUrls = nextEnv;
|
||||
}
|
||||
await submitStoreInfoChangeRequest(id, fields);
|
||||
setPendingInfoChange(true);
|
||||
toastSuccess(
|
||||
wantMedia
|
||||
? '变更已提交(含门头照/环境图),总部审核通过后生效'
|
||||
: '变更已提交,等待总部审核',
|
||||
);
|
||||
toastSuccess('变更已提交,等待总部审核');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
@@ -292,6 +192,42 @@ export default function StoreDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMedia() {
|
||||
if (!id || mediaSaving || status === 'CLOSED') return;
|
||||
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
|
||||
if (auditStatus === 'PENDING') {
|
||||
setActionError('门店审核中,暂不可修改资料');
|
||||
return;
|
||||
}
|
||||
const nextCover = coverUrl.trim();
|
||||
const nextEnv = uniqueEnvUrls(envPhotoUrls);
|
||||
if (!nextCover) {
|
||||
setActionError('请上传门头照');
|
||||
return;
|
||||
}
|
||||
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
|
||||
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
|
||||
return;
|
||||
}
|
||||
setMediaSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
coverUrl: nextCover,
|
||||
envPhotoUrls: nextEnv,
|
||||
}),
|
||||
});
|
||||
applyStore(data);
|
||||
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '照片更新失败');
|
||||
} finally {
|
||||
setMediaSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="partner-detail-page partner-home--flush-top">
|
||||
@@ -509,13 +445,27 @@ export default function StoreDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
{canMutate && !readOnly ? (
|
||||
<>
|
||||
<MultiOssUploadField
|
||||
bizType="STORE_ENV"
|
||||
maxCount={20}
|
||||
variant="grid"
|
||||
value={uniqueEnvUrls(envPhotoUrls)}
|
||||
onChange={(urls) => setEnvPhotoUrls(urls.length ? urls : [''])}
|
||||
label={`批量上传环境照(${uniqueEnvUrls(envPhotoUrls).length}/20)`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ width: '100%', marginTop: 16 }}
|
||||
disabled={mediaSaving}
|
||||
onClick={() => void saveMedia()}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
|
||||
upload
|
||||
</span>
|
||||
{mediaSaving ? '上传中…' : '重新上传照片'}
|
||||
</button>
|
||||
</>
|
||||
) : envPhotos.length > 0 ? (
|
||||
<div className="partner-photo-grid">
|
||||
{envPhotos.map((url, index) => (
|
||||
@@ -545,7 +495,7 @@ export default function StoreDetailPage() {
|
||||
<footer className="partner-save-footer">
|
||||
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}>返回</button>
|
||||
{canMutate && (
|
||||
<button type="button" className="partner-save-submit" onClick={() => void saveChanges()} disabled={readOnly || saving}>
|
||||
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
{saving ? '提交中…' : '提交变更'}
|
||||
</button>
|
||||
|
||||
@@ -1622,19 +1622,10 @@ nav.app-tabbar .app-tabbar-label {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.partner-upload-grid-thumb {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.partner-upload-grid .partner-upload-dashed {
|
||||
aspect-ratio: 1;
|
||||
padding: 12px;
|
||||
font-size: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.partner-upload-dashed--compact {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { uploadRedeemPendingPhoto } from '../lib/upload';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
|
||||
@@ -19,22 +18,25 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
setUploading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const registered = await uploadRedeemPendingPhoto(file);
|
||||
setPhotoResourceId(registered.id);
|
||||
setPreviewUrl(registered.url);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '上传失败');
|
||||
setMsg(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickPhoto() {
|
||||
setMsg('');
|
||||
if (isWechatEnv()) {
|
||||
try {
|
||||
setUploading(true);
|
||||
@@ -49,7 +51,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
} catch (e) {
|
||||
const text = e instanceof Error ? e.message : '选图失败';
|
||||
if (!/cancel/i.test(text)) {
|
||||
toastError(`${text},可改从系统相册选择`);
|
||||
setMsg(`${text},可改从系统相册选择`);
|
||||
setShowAlbumFallback(true);
|
||||
inputRef.current?.click();
|
||||
}
|
||||
@@ -63,10 +65,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
|
||||
async function submitPending() {
|
||||
if (!photoResourceId) {
|
||||
toastError('请先拍摄或上传核销码照片');
|
||||
setMsg('请先拍摄或上传核销码照片');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
|
||||
method: 'POST',
|
||||
@@ -78,7 +81,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
});
|
||||
setResult(res);
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '提交失败');
|
||||
setMsg(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -86,8 +89,8 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
|
||||
function copyText(text: string) {
|
||||
void navigator.clipboard?.writeText(text).then(
|
||||
() => toastSuccess('已复制'),
|
||||
() => toastError('复制失败,请手动长按复制'),
|
||||
() => setMsg('已复制'),
|
||||
() => setMsg('复制失败,请手动长按复制'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -166,6 +169,8 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { registerShopToastListener, type ShopToastVariant } from '../lib/toast';
|
||||
|
||||
type ShopToastContextValue = {
|
||||
showToast: (message: string, variant?: ShopToastVariant) => void;
|
||||
};
|
||||
|
||||
const ShopToastContext = createContext<ShopToastContextValue | null>(null);
|
||||
|
||||
export function ShopToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toast, setToast] = useState('');
|
||||
const [variant, setVariant] = useState<ShopToastVariant>('error');
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const showToast = useCallback((message: string, nextVariant: ShopToastVariant = 'error') => {
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
setVariant(nextVariant);
|
||||
setToast(message);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setToast('');
|
||||
timerRef.current = null;
|
||||
}, nextVariant === 'error' ? 2800 : 2000);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
registerShopToastListener(showToast);
|
||||
return () => {
|
||||
registerShopToastListener(null);
|
||||
if (timerRef.current) window.clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [showToast]);
|
||||
|
||||
return (
|
||||
<ShopToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
{toast ? (
|
||||
<div
|
||||
className={`shop-float-toast${variant === 'error' ? ' shop-float-toast--error' : ''}`}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
{toast}
|
||||
</div>
|
||||
) : null}
|
||||
</ShopToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useShopToast(): ShopToastContextValue {
|
||||
const ctx = useContext(ShopToastContext);
|
||||
if (!ctx) throw new Error('useShopToast 必须在 ShopToastProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/** 把接口里的金额(number / 数字字符串 / Prisma Decimal 残影)转成有限数字 */
|
||||
export function toMoneyNumber(value: unknown): number {
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const o = value as { toNumber?: () => number; toString?: () => string; d?: unknown };
|
||||
if (typeof o.toNumber === 'function') {
|
||||
const n = Number(o.toNumber());
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
if (typeof o.toString === 'function' && o.toString !== Object.prototype.toString) {
|
||||
const n = Number(o.toString());
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function formatMoney(n: number) {
|
||||
return toMoneyNumber(n).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export type ShopToastVariant = 'success' | 'error';
|
||||
|
||||
type ShopToastListener = (message: string, variant: ShopToastVariant) => void;
|
||||
|
||||
let listener: ShopToastListener | null = null;
|
||||
|
||||
export function registerShopToastListener(fn: ShopToastListener | null) {
|
||||
listener = fn;
|
||||
}
|
||||
|
||||
export function showShopToast(message: string, variant: ShopToastVariant = 'error') {
|
||||
const text = message.trim();
|
||||
if (!text || !listener) return;
|
||||
listener(text, variant);
|
||||
}
|
||||
|
||||
export function toastError(message: string) {
|
||||
showShopToast(message, 'error');
|
||||
}
|
||||
|
||||
export function toastSuccess(message: string) {
|
||||
showShopToast(message, 'success');
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getRouterBasename } from '@dukang/weixin-sdk';
|
||||
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
||||
import { ShopToastProvider } from './contexts/ShopToastContext';
|
||||
import { installClientErrorReporting } from '@dukang/client-logging';
|
||||
import App from './App';
|
||||
import { apiBase } from './lib/api';
|
||||
@@ -20,9 +19,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={getRouterBasename()}>
|
||||
<StoreSessionProvider>
|
||||
<ShopToastProvider>
|
||||
<App />
|
||||
</ShopToastProvider>
|
||||
</StoreSessionProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { isIosDevice, isScanPermissionWarmupError } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
import { request } from '../lib/api';
|
||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||
|
||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||
|
||||
@@ -47,6 +46,14 @@ import { trackStore } from '../lib/analytics';
|
||||
|
||||
|
||||
|
||||
function formatMoney(n: number) {
|
||||
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatScanError(e: unknown, opts?: { afterAuth?: boolean }): string {
|
||||
|
||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||
@@ -463,13 +470,13 @@ 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">
|
||||
|
||||
<span style={{ fontSize: 18 }}>¥</span>
|
||||
|
||||
{formatMoney(toMoneyNumber(dash?.todayAmount))}
|
||||
{formatMoney(Number(dash?.todayAmount || 0))}
|
||||
|
||||
</p>
|
||||
|
||||
@@ -589,7 +596,7 @@ export default function HomePage() {
|
||||
|
||||
</div>
|
||||
|
||||
<p className="shop-home-record-amount">¥{formatMoney(toMoneyNumber(r.amount))}</p>
|
||||
<p className="shop-home-record-amount">¥{formatMoney(Number(r.amount))}</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
@@ -18,6 +17,7 @@ export default function PhoneRedeemPage() {
|
||||
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||
const [showOpenModal, setShowOpenModal] = useState(false);
|
||||
@@ -41,10 +41,11 @@ export default function PhoneRedeemPage() {
|
||||
async function prepareDirectRedeem() {
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
toastError('请输入有效核销金额');
|
||||
setMsg('请输入有效核销金额');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||
method: 'POST',
|
||||
@@ -53,10 +54,10 @@ export default function PhoneRedeemPage() {
|
||||
setPrepared(result);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(60);
|
||||
toastSuccess(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||
} catch (e) {
|
||||
setPrepared(null);
|
||||
toastError(e instanceof Error ? e.message : '发送验证码失败');
|
||||
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -64,7 +65,7 @@ export default function PhoneRedeemPage() {
|
||||
|
||||
async function sendConfirmSms() {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
toastError('请输入正确的手机号');
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
if (storeClosed) {
|
||||
@@ -87,7 +88,7 @@ export default function PhoneRedeemPage() {
|
||||
await prepareDirectRedeem();
|
||||
} catch (e) {
|
||||
setShowOpenModal(false);
|
||||
toastError(e instanceof Error ? e.message : '开启营业失败');
|
||||
setMsg(e instanceof Error ? e.message : '开启营业失败');
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
@@ -95,14 +96,15 @@ export default function PhoneRedeemPage() {
|
||||
|
||||
async function confirmRedeem() {
|
||||
if (!prepared) {
|
||||
toastError('请先发送核销验证码');
|
||||
setMsg('请先发送核销验证码');
|
||||
return;
|
||||
}
|
||||
if (!confirmCode.trim()) {
|
||||
toastError('请输入确认验证码');
|
||||
setMsg('请输入确认验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||
method: 'POST',
|
||||
@@ -116,7 +118,7 @@ export default function PhoneRedeemPage() {
|
||||
state: { result, storeName, user: prepared.user },
|
||||
});
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '核销失败');
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -223,6 +225,8 @@ export default function PhoneRedeemPage() {
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||
</button>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -2,12 +2,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
type RangeKey = 'today' | '7d' | '30d';
|
||||
type StatusFilter = 'all' | 'pending' | 'paid';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function inRange(dateStr: string, range: RangeKey) {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
@@ -82,8 +85,8 @@ export default function RecordsPage() {
|
||||
}, [records, range, statusFilter]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const totalAmount = filtered.reduce((s, r) => s + toMoneyNumber(r.amount), 0);
|
||||
const totalSettle = filtered.reduce((s, r) => s + toMoneyNumber(r.settleAmount), 0);
|
||||
const totalAmount = filtered.reduce((s, r) => s + Number(r.amount || 0), 0);
|
||||
const totalSettle = filtered.reduce((s, r) => s + Number(r.settleAmount || 0), 0);
|
||||
const rate = totalAmount > 0 ? Math.round((totalSettle / totalAmount) * 100) : 60;
|
||||
return { totalAmount, totalSettle, rate };
|
||||
}, [filtered]);
|
||||
@@ -178,8 +181,8 @@ export default function RecordsPage() {
|
||||
) : (
|
||||
<div className="shop-records-list">
|
||||
{filtered.map((r) => {
|
||||
const amount = toMoneyNumber(r.amount);
|
||||
const settle = toMoneyNumber(r.settleAmount);
|
||||
const amount = Number(r.amount || 0);
|
||||
const settle = Number(r.settleAmount || 0);
|
||||
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
||||
@@ -196,7 +199,9 @@ export default function RecordsPage() {
|
||||
</div>
|
||||
<p className="shop-record-time">
|
||||
核销时间:{' '}
|
||||
{new Date(String(r.createdAt)).toLocaleString('zh-CN', { hour12: false })}
|
||||
{new Date(String(r.createdAt))
|
||||
.toLocaleString('zh-CN', { hour12: false })
|
||||
.slice(0, 16)}
|
||||
</p>
|
||||
<p className="shop-record-channel">
|
||||
<span
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||
import { request } from '../lib/api';
|
||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||
import { toastError } from '../lib/toast';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
@@ -23,6 +22,7 @@ export default function RedeemConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [token, setToken] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
@@ -61,9 +61,10 @@ export default function RedeemConfirmPage() {
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
setPreview(p);
|
||||
setMsg('');
|
||||
} catch (e) {
|
||||
setPreview(null);
|
||||
toastError(e instanceof Error ? e.message : '无法预览核销码');
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
const report = await reportRedeemFailure(token, 'preview', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
@@ -81,10 +82,11 @@ export default function RedeemConfirmPage() {
|
||||
|
||||
async function doConfirm() {
|
||||
if (!token.trim()) {
|
||||
toastError('请先扫码获取核销码');
|
||||
setMsg('请先扫码获取核销码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/confirm', {
|
||||
method: 'POST',
|
||||
@@ -93,7 +95,7 @@ export default function RedeemConfirmPage() {
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '核销失败');
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
const report = await reportRedeemFailure(token, 'confirm', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
@@ -124,12 +126,13 @@ export default function RedeemConfirmPage() {
|
||||
});
|
||||
setStoreClosed(false);
|
||||
setShowOpenModal(false);
|
||||
setMsg('');
|
||||
// 开张后重新拉取预览(门店已 OPEN,后端不再拦截),再继续核销
|
||||
await loadPreview();
|
||||
await doConfirm();
|
||||
} catch (e) {
|
||||
setShowOpenModal(false);
|
||||
toastError(e instanceof Error ? e.message : '开启营业失败');
|
||||
setMsg(e instanceof Error ? e.message : '开启营业失败');
|
||||
} finally {
|
||||
setOpening(false);
|
||||
}
|
||||
@@ -214,6 +217,8 @@ export default function RedeemConfirmPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
|
||||
{!showWeakNet && (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { notifyRedeemSuccess } from '../lib/useRedeemSuccessBus';
|
||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,7 +24,7 @@ export default function RedeemSuccessPage() {
|
||||
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
|
||||
const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
|
||||
const userLabel = user?.nickname || user?.phone || '—';
|
||||
const amount = toMoneyNumber(result?.amount);
|
||||
const amount = Number(result?.amount ?? 0);
|
||||
const redeemNo = String(result?.redeemNo || '—');
|
||||
const createdAt = result?.createdAt
|
||||
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
|
||||
@@ -49,7 +52,7 @@ export default function RedeemSuccessPage() {
|
||||
<span className="material-symbols-outlined">check_circle</span>
|
||||
</div>
|
||||
<h2 className="shop-success-title">核销成功</h2>
|
||||
<p className="shop-success-amount">¥ {formatMoney(amount)}</p>
|
||||
<p className="shop-success-amount">¥ {formatAmount(amount)}</p>
|
||||
<p className="shop-success-sub">已入账到余额</p>
|
||||
<p className="shop-success-note">核销成功,账单通知已发送至老板手机</p>
|
||||
</section>
|
||||
|
||||
@@ -8,12 +8,15 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
import { formatMoney, toMoneyNumber } from '../lib/money';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
|
||||
|
||||
type StatusFilter = 'all' | StoreWithdrawStatus;
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function WithdrawPage() {
|
||||
useStorePageView('store_withdraw_view');
|
||||
const navigate = useNavigate();
|
||||
@@ -110,13 +113,13 @@ export default function WithdrawPage() {
|
||||
<div>
|
||||
<p className="shop-records-summary-label">可提未出账余额</p>
|
||||
<p className="shop-records-summary-value">
|
||||
¥ {formatMoney(toMoneyNumber(summary?.availableAmount))}
|
||||
¥ {formatMoney(summary?.availableAmount ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-records-summary-label">今日剩余额度</p>
|
||||
<p className="shop-records-summary-value">
|
||||
¥ {formatMoney(toMoneyNumber(summary?.remainingDailyLimit))}
|
||||
¥ {formatMoney(summary?.remainingDailyLimit ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,7 +209,7 @@ export default function WithdrawPage() {
|
||||
<div className="shop-record-amounts">
|
||||
<div>
|
||||
<p className="shop-record-amount-label">提现金额</p>
|
||||
<p className="shop-record-amount-value red">¥{formatMoney(toMoneyNumber(r.amount))}</p>
|
||||
<p className="shop-record-amount-value red">¥{formatMoney(Number(r.amount))}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-record-amount-label">明细笔数</p>
|
||||
|
||||
@@ -3190,26 +3190,3 @@ header:has(> .app-page-title:only-child),
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.shop-float-toast {
|
||||
position: fixed;
|
||||
top: 28%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10020;
|
||||
max-width: min(320px, calc(100vw - 40px));
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.shop-float-toast--error {
|
||||
background: rgba(166, 29, 36, 0.92);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user