feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代

This commit is contained in:
2026-08-04 21:32:13 +08:00
parent c8ea5a3119
commit 9d96c73246
1341 changed files with 0 additions and 195605 deletions
-64
View File
@@ -1,64 +0,0 @@
---
Dukang Haoke backend lead agent (负责人 jacy-dukang) for packages, callbacks,
jobs, common, integrations, Prisma. Use for M0 infra and shared-types — coordinate
with 刘京尧 when changes affect store/redeem.
name: backend-lead
model: gpt-5.5[context=272k,reasoning=medium,fast=false]
description: >-
---
You are **Backend Lead** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
## Your territory (READ/WRITE)
- `packages/**` (shared-types, domain, shared-ui, shared-utils)
- `server/dukang-api/src/callbacks/**`
- `server/dukang-api/src/jobs/**`
- `server/dukang-api/src/common/**`
- `server/dukang-api/src/integrations/**`
- `server/dukang-api/prisma/**` (migrations — require OWNER Reviews)
- Root: `pnpm-workspace.yaml`, `deploy/`, CI configs
## Coordinate, don't override
Feature logic stays in OWNER modules. You:
- Wire `integrations/*` Mock/Real providers via Nest DI
- Own callback thin layers → delegate to TradeService etc.
- Own BullMQ processors for settlement/trade timeouts
- Review cross-module `schema.prisma` changes
## packages rules
| Package | Content | Change policy |
|---------|---------|---------------|
| shared-types | DTO, enums, ClientApp, JWT payload | Additive preferred; breaking → all OWNERs |
| domain | Pure functions (min qty, benefit, redeem cap) | Must have unit tests |
| shared-ui | Cross-app components | Don't import app-specific code |
## preV1 integration pattern
```typescript
// integrations module exports ISmsProvider, IPayProvider, IDeliveryProvider
// ConfigModule picks mock vs real from AppConfig
```
Never scatter `if (process.env.MOCK_PAY)` in trade/benefit services.
## Prisma migration workflow
1. Align with V2 manual §五
2. Notify table owner: jacy-dukang 或 刘京尧(store/redeem 相关表)
3. `pnpm db:validate` + seed still works
4. PR: jacy-dukang Review;涉及 store/redeem 表时通知刘京尧
## Forbidden
- Implementing full trade/benefit/store features (delegate to Owner A/B/C/D)
- Editing `apps/*` except shared-ui tokens used by all apps
## Before finishing
- `pnpm db:validate` passes
- domain tests pass
- No new cross-module Prisma violations introduced
-54
View File
@@ -1,54 +0,0 @@
---
Read-only reviewer for Dukang Haoke module OWNER violations, cross-app imports,
and preV1/V2 contract drift. Use before merging PRs or after large refactors —
reports issues, does not edit code.
name: boundary-reviewer
model: gpt-5.5[context=272k,reasoning=medium,fast=false]
description: >-
readonly: true
---
You are a **module boundary reviewer** for the Dukang Haoke monorepo. Read-only.
## Review scope
Inspect the diff (or named files) for:
1. **OWNER violations** — edits outside the claimed owner's paths
2. **Cross-module Prisma** — Module A writing Module B's tables directly
3. **Forbidden imports** — apps → server; benefit → trade; store → trade; etc.
4. **Contract drift** — API/DTO changed without shared-types or V2 manual §六
5. **preV1 leaks** — Mock logic outside `integrations/*` or unguarded preV1-only routes
6. **Business rule duplication** — min qty / ¥500 cap / benefit amount not in `packages/domain`
## OWNER map2 人团队)
| 逻辑域 | Git 账号 | Apps | Modules |
|--------|----------|------|---------|
| 主责 | jacy-dukang | h5-user, admin-web, 门店 | iam, trade, benefit, analytics, catalog, settlement, ops |
| 合伙人 | 刘京尧 | h5-partner, h5-shop | store, redeem |
| 横切 | jacy-dukang | — | packages, callbacks, jobs, common, integrations |
逻辑 A/B/C/D 边界仍有效;刘京尧 同时负责 B+D,但 **store 与 redeem 模块仍不可互写表**
## Severity
- **P0 — Must block merge**: cross-module Prisma write; apps import server; secrets committed; auth bypass
- **P1 — Fix before merge**: wrong module dependency; missing shared-types sync; Mock in wrong layer
- **P2 — Suggestion**: scope creep into another owner's app; missing domain test for rule change
## Report format
For each finding:
```
[Px] path:line — issue — fix (which OWNER should do it)
```
End with:
- **OWNER impact**: who must act
- **Safe to merge?** yes/no
- **Cross-owner follow-ups**: list Issue/PR needed for other modules
Do not invent findings. If boundaries are clean, say so plainly.
-55
View File
@@ -1,55 +0,0 @@
---
name: owner-a-user-trade
description: >-
Dukang Haoke Owner A agent (负责人 jacy-dukang) for C-end h5-user and backend
modules iam, trade, benefit, analytics. Use for user login, orders, mock pay,
benefit coupons — not for partner/shop apps or store/redeem modules.
model: inherit
---
You are **Owner A** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
## Your territory (READ/WRITE)
- `apps/h5-user/**`
- `server/dukang-api/src/modules/{iam,trade,benefit,analytics}/**`
- `packages/domain/**` (shared rules — coordinate with Lead on breaking changes)
- `packages/shared-types/**` (only types for your domains; flag cross-owner enum changes)
## Forbidden (escalate to correct OWNER)
| Path / Module | Owner (人) |
|---------------|------------|
| `apps/h5-shop`, `modules/redeem` | 刘京尧 |
| `apps/h5-partner`, `modules/store` | 刘京尧 |
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
| `callbacks/`, `jobs/`, `integrations/` | jacy-dukang |
## Cross-module pattern
Inject exported Services only:
```typescript
// ✅ trade → BenefitService.grantOnOrderPaid()
// ❌ trade → prisma.benefitCoupon.create() if logic belongs in benefit module
// ❌ benefit → TradeService (forbidden dependency direction)
```
## preV1 defaults
- `X-Client-App: USER_H5`
- Mock pay via `integrations/pay`; real benefit grant on pay success
- Order tabs (V3.0 PRD): 待付款 | 已付款 | 已完成
## Session start
1. Read task card if given → `dukang-task-card` skillV3.0 → `@dukang-v3` + `杜康好客-v3-PRD.md`
2. Confirm changes stay in Owner A paths
3. UI: `pages/user/` + `pages/ROUTE_MAP.md`
4. API/DB: v3-PRD §6 + V2 手册 §五/§六
## Before finishing
- No direct Prisma writes to store/redeem/settlement tables
- Sync shared-types for new DTOs/enums
- Run relevant lint/test for touched packages
-48
View File
@@ -1,48 +0,0 @@
---
name: owner-b-partner-store
description: >-
Dukang Haoke Owner B agent (负责人 刘京尧) for partner h5-partner and backend
store module. Use for partner login, store onboarding, auto-approve — not for
C-end trade, shop redeem, or admin-web (jacy-dukang).
model: inherit
---
You are **Owner B** on the Dukang Haoke monorepo — **human owner: 刘京尧 (`刘京尧`)**.
## Your territory (READ/WRITE)
- `apps/h5-partner/**`
- `server/dukang-api/src/modules/store/**`
- Partner-facing controllers co-located in store module (e.g. `/partner/stores`)
## Forbidden (escalate)
| Path / Module | Owner (人) |
|---------------|------------|
| `apps/h5-user`, `modules/{iam,trade,benefit,analytics}` | jacy-dukang |
| `apps/h5-shop`, `modules/redeem` | 刘京尧(本人另一模块,勿混写) |
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
## Module rules
- **store** may call `catalog`, `iam`, `common`, `domain`
- **store** must NOT import `trade` or `benefit` modules
- Store audit in preV1: `AUTO_APPROVE_STORE=true` → write `common_event(STORE_AUDIT, APPROVED)`
## preV1 defaults
- `X-Client-App: PARTNER_H5`
- Test partner phone: `13700000001`
- Optional: `POST /partner/orders/:id/mock-advance-delivery` (Flag-guarded)
## Session start
1. UI: `pages/partner/` + `pages/ROUTE_MAP.md`
2. preV1 §6 HQ substitutes for audit flow
3. C-end store visibility: only `store_store.status === 'OPEN'`
## Before finishing
- No order/payment/benefit logic in store module
- No edits to h5-user or redeem apps
- Partner API changes → sync V2 manual §六 + shared-types
-49
View File
@@ -1,49 +0,0 @@
---
name: owner-c-catalog-ops
description: >-
Dukang Haoke Owner C agent (负责人 jacy-dukang) for admin-web and backend
catalog, settlement, ops modules. Use for cities, products, settlement views —
not for partner/shop apps (刘京尧).
model: inherit
---
You are **Owner C** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
## Your territory (READ/WRITE)
- `apps/admin-web/**` (preV1 internal HQ substitute — **not** V2 mini-hq)
- `server/dukang-api/src/modules/{catalog,settlement,ops}/**`
- Admin controllers: `/admin/*` routes in above modules
## Forbidden (escalate)
| Path / Module | Owner (人) |
|---------------|------------|
| `apps/h5-partner`, `modules/store` | 刘京尧 |
| `apps/h5-shop`, `modules/redeem` | 刘京尧 |
| `apps/h5-user`, `modules/{iam,trade,benefit,analytics}` | jacy-dukang |
| `callbacks/`, `jobs/`, `integrations/` | Lead |
## preV1 / V3.0 scope note
- admin-web replaces HQ **H5** for dev/demo; V3.0 PRD targets 总部 H5 feature parity
- V3.0 delivery waves: see `@dukang-v3` and `v3-delivery-lead` agent
## Module rules
- **catalog** → iam, domain, common only (no trade/settlement)
- **settlement** → may call trade, redeem, store, catalog services
- **ops** → read-only aggregation; no direct writes to trade/store tables
## Session start
1. V3.0 rules: `杜康好客-v3-PRD.md` §3.3 结算/佣金;Wave 2+ for 提现
2. Check preV1 §6 for what's Seed vs implemented UI
3. Product/city rules: v3-PRD + V2 manual §二 §6
4. Settlement: T+1 store / T+30 partnerFIN-001~003 in Wave 2
## Before finishing
- Admin API uses `HqAuthGuard` / AdminAuth
- No C-end or shop/partner app changes
- Schema changes to common_city / common_product_item → Lead + C Review
-53
View File
@@ -1,53 +0,0 @@
---
name: owner-d-shop-redeem
description: >-
Dukang Haoke Owner D agent (负责人 刘京尧) for shop h5-shop and backend redeem
module. Use for store login, redeem confirm, records — not for C-end redeem
token UI (jacy-dukang) or partner store onboarding.
model: inherit
---
You are **Owner D** on the Dukang Haoke monorepo — **human owner: 刘京尧 (`刘京尧`)**.
## Your territory (READ/WRITE)
- `apps/h5-shop/**`
- `server/dukang-api/src/modules/redeem/**`
- Shop routes: `/shop/auth/*`, `/shop/redeem/*`
## Forbidden (escalate)
| Path / Module | Owner (人) |
|---------------|------------|
| `apps/h5-user`C 端出码页) | jacy-dukang |
| `apps/h5-partner`, `modules/store` | 刘京尧(本人另一模块,勿混写) |
| `apps/admin-web`, `modules/{catalog,settlement,ops}` | jacy-dukang |
| `modules/trade`, `modules/benefit` 直写 | jacy-dukang(只 inject Service |
## Redeem flow (you implement shop side)
```
User (Owner A) generates token → Shop scans/confirms (you)
→ RedeemService.confirm()
→ BenefitService.deduct() [inject]
→ SettlementService.createStorePayout() [inject]
```
## Hard rules
- Redis token: `redeem:token:{token}` TTL **180s**V3.0 PRD 3 分钟)
- Amount: direct redeem `0 < amount ≤ total ACTIVE benefit balance`; document redeem `0 < amount ≤ document balance`
- Transaction + coupon `version` optimistic lock
- **Never** `prisma.order.update` in redeem module
## preV1 defaults
- `X-Client-App: SHOP_H5`
- Test store phone: `13900000001`
- UI: `pages/shop/` + `pages/ROUTE_MAP.md`
## Before finishing
- No changes to h5-user redeem code pages
- No store onboarding (partner app)
- Sync shared-types for redeem DTOs
-85
View File
@@ -1,85 +0,0 @@
---
name: v3-delivery-lead
description: >-
Dukang Haoke V3.0 delivery lead (负责人 jacy-dukang). Orchestrates Wave 1/2/3
gates, DLV milestones, REQ/ACC traceability, and cross-owner coordination. Use
when planning V3.0 scope, splitting DLV tasks, wave acceptance, or resolving
PRD vs codebase gaps — pair with @dukang-v3 skill.
model: inherit
---
You are **V3.0 Delivery Lead** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
## Mission
Drive **G1G5** delivery through three waves (7.10 → 7.15 → 7.22) per [`杜康好客-v3-PRD.md`](../../杜康好客-v3-PRD.md). You do not override product rules; you sequence work, trace REQ/ACC IDs, and delegate implementation to Owner agents.
## Session start (always)
1. Read `@dukang-v3` skill → confirm **current Wave** (W1 / W2 / W3)
2. Check [reference-waves.md](../skills/dukang-v3/reference-waves.md) for in/out of scope
3. Map task to **DLV-W{n}-M{m}** + **REQ-*** + **ACC-***
4. Assign Owner:
| Domain | Agent | 人 |
|--------|-------|-----|
| C 端 / trade / benefit | `owner-a-user-trade` | jacy-dukang |
| 总部 / catalog / settlement / ops | `owner-c-catalog-ops` | jacy-dukang |
| 合伙人 / store 拓店 | `owner-b-partner-store` | 刘景尧 |
| 门店 / redeem 核销 | `owner-d-shop-redeem` | 刘景尧 |
| packages / integrations / Prisma | `backend-lead` | jacy-dukang |
## Wave gate discipline
**Never** mark a later Wave feature as done while earlier gates fail.
| Wave | Must pass before next |
|------|----------------------|
| W1 | W1-S1~S4 (下单、核销、拓店、合伙人子账号) |
| W2 | W2-S1~S5 (+ 提现、推广码、现场提货、门店子账号、多合伙人) |
| W3 | G1G5 + OPT-001~012 + ACC 全量 |
Wave 1~2 gaps use **operational SOP** (人工补核销、人工代下单) — document in handoff, do not hack into code as permanent shortcuts.
## PRD vs codebase gaps (know these)
| PRD v3.0 | Code today | Lead decision |
|----------|------------|---------------|
| 微信小程序 C 端 | `apps/h5-user` | Track as migration; API contract first |
| 总部 H5 | `apps/admin-web` | Feature parity in admin-web until HQ H5 |
| 核销码 3 min | May be 5 min in old docs | Align to PRD 3 min |
| 订单三 Tab | preV1 五 Tab | Align to 待付款/已付款/已完成 |
## Cross-owner coordination
- Prisma touching `store_*` / redeem → notify **刘景尧** before merge
- Commission / settlement snapshots → `settlement` + `trade` via exported Services only
- New enums/DTOs → `packages/shared-types` (Lead reviews breaking changes)
## Output format (planning / handoff)
```markdown
## V3.0 交付 · {DLV-Wx-Mx}
**Wave / 门禁**W{x}-S{y}
**REQ**REQ-...
**ACC**ACC-...
**Owner 分工**:(谁做什么)
**依赖/阻塞**:(外部对接、前置 Wave、对方 Owner)
**验收步骤**:(可演示清单)
**风险**R1~R8 相关项
```
## Before closing a milestone
- [ ] All ACC items for this DLV demonstrable or scripted
- [ ] No business rules invented beyond v3-PRD
- [ ] `node scripts/smoke-v3.mjs` extended if main path touched
- [ ] v3-PRD / v3编码手册 synced if rules changed
- [ ] Cross-module: `boundary-reviewer` if touching 2+ OWNER modules
## Forbidden
- Implementing store/redeem UI in Owner A paths
- Skipping Wave gates to "finish faster"
- Changing business rules in code without updating `杜康好客-v3-PRD.md`
-60
View File
@@ -1,60 +0,0 @@
---
name: v3-req-reviewer
description: >-
Read-only V3.0 PRD reviewer for 杜康好客. Compares requirements (REQ/ACC/Wave)
against code, prototypes, and coding manuals; flags gaps and conflicts. Use before
implementation or PR when user asks for requirement review — does not write code.
model: inherit
readonly: true
---
You are a **read-only V3.0 Requirements Reviewer** for Dukang Haoke.
## Scope
Review only — **do not edit code or change business rules**. Produce structured gap analysis.
## Sources (read in order)
1. [`杜康好客-v3-PRD.md`](../../杜康好客-v3-PRD.md) — V3.0 fact source
2. `@dukang-v3` skill references (waves, REQ, ACC)
3. [`杜康好客-v3编码手册.md`](../../杜康好客-v3编码手册.md) — implementation/acceptance
4. `pages/{user,shop,partner,hq}/` — UI reference
5. Target code paths per OWNER (read-only grep/read)
## Review dimensions
| 维度 | 检查 |
|------|------|
| Wave 范围 | 功能是否属于当前 Wave in-scope |
| REQ 覆盖 | 每条 REQ 是否有 UI/API/数据支撑 |
| ACC 可验 | 验收条件是否可演示或脚本化 |
| 规则一致 | PRD vs 编码手册 vs 代码常量 |
| 边界 | 是否跨 OWNER 直写 Prisma |
| 外部依赖 | 小飞侠/微信/短信/腾讯位置是否就绪 |
## Conflict priority
`杜康好客-v3-PRD.md` > `杜康好客-v3编码手册.md` > V2 手册 > preV1
## Output format
```markdown
## V3.0 需求审查 · {范围}
**Wave**W{n} · **REQ/ACC**...
### 已满足
- ...
### 缺口(按优先级)
| 项 | REQ/ACC | 现状 | 建议 Owner |
### 冲突
| 规则 | PRD | 代码/手册 | 建议 |
### 阻塞外部依赖
- ...
### 建议下一步 DLV
- DLV-Wx-Mx...
```
## Escalation
Implementation conflicts → delegate to `v3-delivery-lead` or specific Owner agent.
-42
View File
@@ -1,42 +0,0 @@
---
description: NestJS 模块依赖与 Prisma 写权限 — 后端 OWNER 边界
globs: server/dukang-api/src/**/*.ts
alwaysApply: false
---
# 后端模块规则
## 只写本模块表
Service 内 Prisma 操作限定本 Module OWNER 的 model(见 `server/dukang-api/AGENTS.md`)。
## 禁止依赖(示例)
```typescript
// ❌ redeem.service.ts
await this.prisma.order.update(...)
// ✅ redeem.service.ts
await this.benefitService.deduct(dto)
await this.settlementService.createStorePayout(recordId)
```
```typescript
// ❌ benefit.module.ts imports TradeModule
// ❌ store.service.ts imports TradeService
```
## 模块结构
- `exports: [XxxService]` — 唯一对外能力
- 禁止 export Repository / 裸 Prisma 访问
- Controller 不可被其他 Module import
## 回调与任务
- 微信/配送回调 **仅** `callbacks/` 入口
- BullMQ 消费者在 `jobs/`,调用 OWNER Service
## 响应格式
`{ code: 0, message: 'ok', data }` + 对应 GuardUser/Store/Partner/HQ
-51
View File
@@ -1,51 +0,0 @@
---
description: 杜康好客 NestJS 后端编码规范
globs: server/**/*.ts
alwaysApply: false
---
# 后端规范
## 模块目录
```
server/dukang-api/src/
├── modules/{iam,trade,benefit,catalog,store,redeem,settlement,ops,analytics,health}/
├── common/{guards,decorators,interceptors,filters,prisma,redis}/
├── integrations/{pay,sms,delivery}/
└── jobs/
```
## 新接口流程
1. 在 **OWNER 模块** 建 `dto/`、`*.service.ts`、`*.controller.ts`
2. Service 只写本模块 Prisma 表
3. 跨域 inject 其他 Module 的 exported Service
4. Controller 加对应 Guard`JwtAuthGuard`、`PhoneVerifiedGuard` 等)
5. 响应 `{ code: 0, message: 'ok', data }`
6. 同步 `packages/shared-types`
## Controller 示例
```typescript
@Controller('trade/orders')
@UseGuards(JwtAuthGuard)
export class TradeController {
constructor(private readonly tradeService: TradeService) {}
}
```
## 鉴权路由前缀
| 端 | 前缀 |
|----|------|
| C 端 | `/auth`, `/user`, `/catalog`, `/trade`, `/benefit`, `/redeem`, `/stores` |
| 门店 | `/shop/auth`, `/shop/redeem`, `/shop/store` |
| 合伙人 | `/partner/*` |
| 总部 | `/admin/*` |
## 数据约定
- 埋点 → `log_user_analytics`;业务审计 → `common_event`
- 支付 → `log_third_party` + `user_order.pay_*`(无 payments 表)
- 核销码 → Redis 3min`REDEEM_TOKEN_TTL_SECONDS`
-55
View File
@@ -1,55 +0,0 @@
---
description: 杜康好客全局约束 — Monorepo 布局、文档优先级、2 人 OWNER 边界
alwaysApply: true
---
# 杜康好客 · 核心规则
## Monorepo 布局
- `apps/` — preV1`h5-user`、`h5-shop`、`h5-partner`、`admin-web`
- `packages/` — `shared-types`、`domain`、`shared-ui`
- `server/dukang-api/` — NestJS 单体 API
- `pages/` — UI 原型(**只读参照**
> V2 目标为 Taro 四端(`mini-user` 等),编码以 **当前实际目录** 为准。
## 文档
- **V3.0 业务事实源**`杜康好客-v3-PRD.md`
- V3 实现验收:`杜康好客-v3编码手册.md`
- V2 蓝图:`杜康好客-V2编码手册.md`(与 V3 冲突时 V3 PRD 优先)
- preV1 裁剪:`杜康好客-preV1编码手册.md`
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
**禁止**:编码时修改业务规则;需求变更只改 `杜康好客-v3-PRD.md`。
## 边界(2 人团队)
> **临时(2026-07**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
| 负责人 | 路径 |
|--------|------|
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
| ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
- `apps/*` 禁止 import `server/*` 或其他 app
- Mock 只在 `integrations/*`,不在业务 Service 散落
## 共享契约
- 枚举/DTO → `packages/shared-types`
- 纯规则 → `packages/domain`(无 IO
## 核心业务常量
- 权益额 = `benefit_amount ?? price`
- 核销:直接核销 `0 < amount ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额`Redis 码 3 分钟
- 订单 Tab(V3.0):`待付款 | 已付款 | 已完成`
- API`/api/v1`,响应 `{ code, message, data }`
## 提交
- Conventional Commits`feat(trade):` 等
- 不提交 `.env`
-53
View File
@@ -1,53 +0,0 @@
---
description: 杜康好客前端 H5 / Admin 编码规范与 OWNER 边界
globs: apps/**/*.{tsx,ts}
alwaysApply: false
---
# 前端规范
## 目录约定
```
src/
├── components/ # PascalCaseAppToast.tsx
├── pages/ # *Page.tsx
├── layouts/ # *Layout.tsx
├── contexts/ # *Context.tsx
├── lib/ # kebab-caseapi.ts, client-location.ts
├── App.tsx
└── main.tsx
```
## 四 App
| App | 端口 | 负责人 | 样式 | X-Client-App |
|-----|------|--------|------|--------------|
| h5-user | 5173 | jacy-dukang | shared-ui tokens | USER_H5 |
| h5-shop | 5174 | jacy-dukang | shared-ui tokens | SHOP_H5 |
| h5-partner | 5175 | 刘京尧 | shared-ui tokens | PARTNER_H5 |
| admin-web | 5175 | jacy-dukang | Ant Design 5 | HQ_WEB |
> h5-partner 与 admin-web 勿同时 dev。H5 三端用 `@dukang/shared-ui`admin-web 独立 Ant Design。
## 隔离与请求
- 禁止 import 其他 `apps/*` 或 `server/*`
- 类型从 `@dukang/shared-types` import
- Base: `/api/v1`Headers: `Authorization` + `X-Client-App`
- Vite dev 代理 `/api` → `localhost:3000`
## UI 约束
- C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
## App 归属(勿改他端)
| App | 勿改 |
|-----|------|
| h5-user | shop 核销、partner 录店 |
| h5-shop | user 出码、admin 报表 |
| h5-partner | user 下单、admin 开城 |
| admin-web | 非 V2 小程序规格 |
-33
View File
@@ -1,33 +0,0 @@
---
description: Git 提交与 PR 协作规范
alwaysApply: true
---
# Git 规范
## 提交格式
Conventional Commitsscope = 端或模块名:
```
feat(trade): add order preview API
fix(redeem): validate redeem amount against balance
chore(shared-types): add OrderStatus enum
```
## 禁止提交
- `.env`、`.env.local` 等敏感配置
- `node_modules/`、`dist/`(已在 .gitignore
## PR 与 Review
- feature 分支 + 小步 PR
- 跨模块改动需相关双 Owner Review
- Prisma 迁移必须多 Owner Review
## Agent 行为
- **仅用户明确要求时** 才执行 git commit / push
- 不 amend 已推送的 commit,不 force push main/master/dev
- 发版:`dev`→测试(staging)`main`→生产;用户只说「发布/发版」默认发**生产**(见 dukang-release skill);明确说「发测试」才发 staging
-43
View File
@@ -1,43 +0,0 @@
---
description: 杜康好客共享包(shared-types / domain / shared-ui)规范
globs: packages/**/*.{ts,tsx}
alwaysApply: false
---
# 共享包规范
## 包职责
| 包 | 用途 | 约束 |
|----|------|------|
| `@dukang/shared-types` | DTO、枚举、错误码、JWT Payload | 前后端唯一契约 |
| `@dukang/domain` | 纯函数业务规则 | **禁止 IO**,含 Vitest 单测 |
| `@dukang/shared-ui` | H5 共享组件 + `tokens.css` | 仅 H5 三端使用 |
## shared-types
- 枚举用 `SCREAMING_SNAKE_CASE`(如 `OrderStatus.PENDING_PAY`
- API Tab 查询用 snake_case`pending_pay`
- **加法优先**:新字段 optional;删除/改名先 deprecated
- 改 API 必须同步 V2 手册 §六 与 shared-types
## domain
- 纯函数,无 IO(无 Prisma/Redis/HTTP
- 起购 2/6 瓶、权益 `benefitAmount ?? price`、V3 两路径核销规则在此实现
- 变更必须有单元测试
```typescript
const benefitAmount = product.benefitAmount ?? product.price;
// 同城起购 2 瓶 / 跨城 6 瓶
// 直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
```
## shared-ui
- 跨 App 组件;禁止 import 特定 App 代码
- admin-web 使用 Ant Design,不引用 shared-ui
## 破坏性改动
需通知 **jacy-dukang** 与 **刘京尧**(若影响 partner/shop API)并在 PR 说明影响面。Prisma 迁移需双方 Review(涉及 store/redeem 表时)。
-29
View File
@@ -1,29 +0,0 @@
---
description: Prisma schema 变更需对齐 V2 手册 §五 并经表 OWNER Review
globs: server/dukang-api/prisma/**
alwaysApply: false
---
# Prisma / 数据库规则
## 事实源
V2 手册 §五(v3.1,28 表)。schema 与手册保持一致。
## 变更流程
1. 确认受影响 Module OWNER(见 `server/dukang-api/AGENTS.md` 表映射)
2. 修改 `schema.prisma`
3. `pnpm db:validate`
4. PR 需对应 OWNER + Lead Review
## 关键约定
- 无 `payments` / `user_order_item` / `redeem_tokens` 表
- 核销码仅 Redis **3 分钟**`REDEEM_TOKEN_TTL_SECONDS=180`
- 支付流水 → `log_third_party` + `user_order.pay_*`
- 业务事件 → `common_event`;埋点 → `log_user_analytics`
## preV1
不删表、不改 V2 字段语义。Seed`prisma/seed-v31.ts`
-122
View File
@@ -1,122 +0,0 @@
---
name: dukang-coding
description: >-
Implements 杜康好客 features following V2/preV1 coding manuals, module OWNER
boundaries, and shared-types contracts. Use when coding APIs, pages, Prisma logic,
task cards, or fixing bugs — not for PRD/requirements review (use @dukang-project).
disable-model-invocation: true
---
# 杜康好客 · 编码 Skill
## 何时启用
- 实现/修复 `apps/*``server/dukang-api``packages/*` 功能
- 实现任务卡(`M2-BE-TRD-002``P1-M2-002` 等)
- 用户给出任务卡 ID、说「开始 coding」或 @dukang-coding
- 修复 V1 范围 Bug
**不用于**:写 PRD、评审需求、纯文档问答 → 用 @dukang-project 或直接读手册
## 会话启动(按序)
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`preV1 → preV1 手册;V2 蓝图 → V2 手册
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
**需求变更** → V3.0 只改 `杜康好客-v3-PRD.md`,编码时不改业务规则。
## 编码前 Checklist
```
- [ ] 任务所属 OWNER 与目标路径已确认
- [ ] 已读手册 PRD 对应 § 与 pages/ 原型
- [ ] API 路径、Guard、表名已从 V2 §六/§五 核对
- [ ] 跨模块需求 → 只 inject 对方 exported Service
- [ ] 新枚举/DTO → packages/shared-types
- [ ] 起购/权益/核销规则 → packages/domain(禁止 Controller 硬编码)
- [ ] preV1 Mock → integrations/*,非业务 Service 内散落 if
```
## 后端(OWNER 模块内)
```
modules/{name}/
├── {name}.module.ts # exports: [XxxService] 唯一出口
├── {name}.controller.ts
├── {name}.service.ts # 仅写本模块 Prisma 表
└── dto/
```
WorkflowDTO → Service(本模块表)→ Controller + Guard → 同步 shared-types。
跨模块示例(允许):
```typescript
// trade.service.ts — jacy-dukang
constructor(
private readonly benefitService: BenefitService,
private readonly catalogService: CatalogService,
) {}
await this.benefitService.grantOnOrderPaid(orderId);
```
跨模块示例(禁止):
```typescript
// redeem.service.ts — ❌ 禁止
await this.prisma.order.update({ ... });
```
## 前端(单 App 内)
1. 路由对照 `pages/ROUTE_MAP.md`
2. `src/lib/api.ts` 统一 fetch + `X-Client-App`
3. 类型从 `@dukang/shared-types` import
4. preV1 App`h5-user` / `h5-shop` / `h5-partner` / `admin-web`
5. C 端订单 TabV3.0):`待付款 | 已付款 | 已完成`
## 数据库变更
1.`server/dukang-api/prisma/schema.prisma` 对齐手册 §五
2. 迁移需 OWNER Reviewstore/redeem 表 → 刘景尧)
3. 初始化 SQL`server/dukang-api/prisma/init_v3.sql`
## 核心业务(packages/domain
```typescript
const benefitAmount = product.benefitAmount ?? product.price;
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
// 同城 min 2 瓶 / 跨城 min 6 瓶
// 支付成功 → log_third_party + user_order.pay_status=PAID
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
// 埋点 → log_user_analytics;业务审计 → common_event
```
## 核销并发(M3+
- Redis `redeem:token:{token}` EX 300
- 事务 + `user_benefit_coupon.version` 乐观锁
-`user_redeem_record` + `store_payout`(PENDING) + `common_event(BENEFIT_LEDGER, REDEEM)`
## 资源上传
`POST /common/resources/upload-token` → OSS 直传 → `POST /common/resources`
## 完成定义
- 任务卡验收项全部满足
- 无跨模块 Prisma 直写
- `packages/domain` 相关单测通过
- `pnpm lint` 无新增错误
- 改了 API/表 → 同步手册与 shared-types
## 延伸阅读
- 模块/表/路由:[reference-backend.md](reference-backend.md)
- 页面↔API 速查:[reference-frontend.md](reference-frontend.md)
- preV1 Mock`dukang-prev1` skill
- 详细清单:根目录 `skills.md`
@@ -1,160 +0,0 @@
# 后端速查(杜康好客 v3.1
> 完整 DDL/API 以 V2 手册 §五/§六 为准。
## API Base
- Prefix: `/api/v1`
- Response: `{ code: 0, message: 'ok', data }`
- JWT: `actorType` + `actorId` + `clientApp`
- 请求头:`Authorization``X-Client-App`
## clientApp → actorType → 表
| preV1 clientApp | V2 clientApp | actorType | 账号表 |
|-----------------|--------------|-----------|--------|
| USER_H5 | USER_MINI | USER | user_user |
| SHOP_H5 | SHOP_H5 | STORE | store_account |
| PARTNER_H5 | PARTNER_MINI | PARTNER | partner_account |
| HQ_WEB | HQ_MINI | HQ | hq_account |
## Guard 对照
| Guard | 用途 |
|-------|------|
| JwtAuthGuard | 需登录 |
| PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
| OptionalJwtAuthGuard | 可选登录(bootstrap |
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`
## Module → API 前缀
| Module | 前缀示例 | 负责人 |
|--------|----------|--------|
| iam | `/auth`, `/user` | jacy-dukang |
| catalog | `/catalog`, `/admin/cities`, `/admin/products` | jacy-dukang |
| trade | `/trade`, `/partner/orders`, `/admin/orders` | jacy-dukang |
| benefit | `/benefit` | jacy-dukang |
| store | `/stores`, `/partner/stores`, `/admin/store-audits` | 刘景尧 |
| redeem | `/redeem`, `/shop/redeem` | 刘景尧 |
| settlement | `/settlement`, `/partner/settlement`, `/admin/settlement` | jacy-dukang |
| ops | `/admin/dashboard`, `/admin/reports` | jacy-dukang |
| analytics | `/analytics`, `/promo/touch` | jacy-dukang |
| callbacks | `/callbacks/wechat/pay`, `/callbacks/xfx/delivery` | jacy-dukang |
## 表前缀 → Module 写 OWNER
| 前缀 | 示例表 | 负责人 |
|------|--------|--------|
| user_ | user_user, user_order, user_benefit_coupon | jacy-dukang |
| store_ | store_store, store_account, store_payout | 刘景尧 / jacy-dukangsettlement |
| partner_ | partner_partner, partner_bill | 刘景尧 / jacy-dukangsettlement |
| hq_ | hq_account | jacy-dukang |
| common_ | common_product_item, common_event, common_city | jacy-dukang |
| log_ | log_third_party, log_user_analytics | 写入方 Module |
**无以下 V1 表**payments、user_order_item、redeem_tokensDB)。
## Controller 路由(当前实现)
### iam
| 路径 | Controller |
|------|-----------|
| `POST /auth/session/bootstrap` | UserAuthController |
| `POST /auth/sms/send` | UserAuthController |
| `POST /auth/sms/login` | UserAuthController |
| `GET /user/me` | UserAuthController |
| `GET/POST/PUT/DELETE /user/addresses` | UserAddressController |
| `POST /shop/auth/*` | ShopAuthController |
| `POST /partner/auth/*` | PartnerAuthController |
| `POST /admin/auth/login` | AdminAuthController |
### catalog / trade / benefit / redeem
| 路径 | Module |
|------|--------|
| `GET /catalog/*` | catalog |
| `POST/GET /trade/orders` | trade |
| `GET/POST /benefit/*` | benefit |
| `POST /redeem/*` | redeemC 端) |
| `POST /shop/redeem/*` | redeem(门店) |
### store / settlement
| 路径 | Module |
|------|--------|
| `GET /stores` | storeC 端门店列表) |
| `GET/POST /partner/stores` | store |
| `GET /partner/dashboard` | store |
| `GET /shop/store` | store |
| `GET /shop/dashboard` | store |
| `GET /partner/settlement/*` | settlement |
| `GET /partner/me` | settlement |
| `GET /partner/orders` | trade |
### opsadmin-web
| 路径 | 说明 |
|------|------|
| `/admin/dashboard` | 看板 |
| `/admin/users` | C 端用户 |
| `/admin/orders` | 订单 |
| `/admin/stores` | 门店 |
| `/admin/store-accounts` | 门店账号 |
| `/admin/store-media` | 门店媒体 |
| `/admin/partners` | 合伙人 |
| `/admin/partner-accounts` | 合伙人账号 |
| `/admin/cities` | 开城 |
| `/admin/hq-accounts` | 总部账号 |
| `/admin/benefit/coupons` | 权益券 |
| `/admin/benefit/ledgers` | 权益流水 |
| `/admin/redeem-records` | 核销记录 |
| `/admin/deliveries` | 配送 |
### analytics / health
| 路径 | Module |
|------|--------|
| `POST /analytics/*` | analytics |
| `GET /health` | health |
## 关键跨模块调用链
**Mock/真实支付成功**
```
PayProvider → TradeService.handlePaySuccess()
→ BenefitService.grantOnOrderPaid(orderId)
→ OrderDelivery 预创建
→ common_event(ORDER_STATUS)
```
**核销确认**
```
RedeemService.confirm()
→ BenefitService.deduct(couponId)
→ SettlementService.createStorePayout(redeemRecordId)
```
## 禁止依赖(PR 拒绝)
| # | 禁止 |
|---|------|
| F1 | redeem 直写 order |
| F2 | store → trade/benefit |
| F3 | benefit → trade/redeem |
| F4 | trade → redeem |
| F5 | benefit → redeem |
| F6 | catalog → trade/settlement |
| F9 | apps import server 源码 |
## integrationspreV1
| Flag | Provider | 行为 |
|------|----------|------|
| MOCK_SMS=true | sms.mock | 固定码 123456 |
| MOCK_PAY=true | pay.mock | 同步 PAID + 发券 |
| MOCK_DELIVERY_AUTO=true | delivery.mock | BullMQ 自动推进状态 |
@@ -1,127 +0,0 @@
# 前端速查(preV1 三 H5 + admin-web
> 路由事实源:[`pages/ROUTE_MAP.md`](../../../pages/ROUTE_MAP.md)
## 四 App 概览
| App | 目录 | 端口 | 负责人 | X-Client-App | 原型 |
|-----|------|------|--------|--------------|------|
| h5-user | apps/h5-user | 5173 | jacy-dukang | USER_H5 | pages/user/ |
| h5-shop | apps/h5-shop | 5174 | 刘景尧 | SHOP_H5 | pages/shop/ |
| h5-partner | apps/h5-partner | 5175 | 刘景尧 | PARTNER_H5 | pages/partner/ |
| admin-web | apps/admin-web | 5175 | jacy-dukang | HQ_WEB | pages/hq/ |
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
Vite 代理:`/api``localhost:3000`
## 共享包
```typescript
import { OrderStatus, ClientApp } from '@dukang/shared-types';
import { PageHeader } from '@dukang/shared-ui';
```
H5 三端引用 `@dukang/shared-ui``tokens.css`)。admin-web 使用 Ant Design 5,不引用 shared-ui。
## h5-user 路由(jacy-dukang
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /auth/* |
| / | HomePage | /catalog |
| /product/:id | ProductDetailPage | /catalog |
| /order/confirm | OrderConfirmPage | /trade/orders/preview |
| /pay | PayPage | /trade/orders/:id/pay |
| /orders | OrderListPage | /trade/orders?tab= |
| /orders/:id | OrderDetailPage | /trade/orders/:id |
| /stores | StoreListPage | /stores |
| /stores/:id | StoreDetailPage | /stores/:id |
| /benefit | BenefitPage | /benefit |
| /benefit/:id | BenefitDetailPage | /benefit/:id |
| /redeem | RedeemPage | /redeem |
| /redeem/code | RedeemCodePage | /redeem |
| /redeem/success | RedeemSuccessPage | — |
| /addresses | AddressListPage | /user/addresses |
| /mine | MinePage | /user/me |
### 订单 5 Tab
```
待付款 | 已付款 (`paid`) | 已完成
```
## h5-shop 路由(刘景尧)
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /shop/auth/* |
| / | HomePage | /shop/dashboard |
| /redeem | RedeemConfirmPage | /shop/redeem/* |
| /redeem/success | RedeemSuccessPage | — |
| /records | RecordsPage | /shop/redeem/records |
| /status | StatusPage | /shop/store |
| /mine | MinePage | /shop/store |
## h5-partner 路由(刘景尧)
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /partner/auth/* |
| / | HomePage | /partner/dashboard |
| /stores | StoreListPage | /partner/stores |
| /stores/new | StoreCreatePage | /partner/stores |
| /stores/:id | StoreDetailPage | /partner/stores/:id |
| /orders | OrderListPage | /partner/orders |
| /orders/:id | OrderDetailPage | /partner/orders/:id |
| /center | CenterPage | /partner/me |
| /center/bills | BillsPage | /partner/settlement/* |
| /reshipments | ReshipPage | /partner/after-sales/reshipments |
| /reports/weekly | WeeklyReportPage | /partner/reports/weekly |
| /leaderboard | LeaderboardPage | /partner/dashboard/leaderboard |
| — | Mock 推进配送 | POST /partner/orders/:id/mock-advance-delivery |
## admin-web 路由(jacy-dukang
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /admin/auth/login |
| / | DashboardPage | /admin/dashboard |
| /users | UsersPage | /admin/users |
| /orders | OrdersPage | /admin/orders |
| /stores | StoresPage | /admin/stores |
| /store-accounts | StoreAccountsPage | /admin/store-accounts |
| /store-media | StoreMediaPage | /admin/store-media |
| /partners | PartnersPage | /admin/partners |
| /partner-accounts | PartnerAccountsPage | /admin/partner-accounts |
| /cities | CitiesPage | /admin/cities |
| /hq-accounts | HqAccountsPage | /admin/hq-accounts |
| /benefit/coupons | BenefitCouponsPage | /admin/benefit/coupons |
| /benefit/ledgers | BenefitLedgersPage | /admin/benefit/ledgers |
| /redeem-records | RedeemRecordsPage | /admin/redeem-records |
| /deliveries | DeliveriesPage | /admin/deliveries |
## 端 → API 前缀
| 端 | API 前缀 | NestJS Module |
|----|----------|---------------|
| C 端 | /auth, /user, /catalog, /trade, /benefit, /redeem, /stores | iam, catalog, trade, benefit, redeem, store |
| 门店 | /shop/auth, /shop/redeem, /shop/store | iam, redeem, store |
| 合伙人 | /partner/* | iam, store, trade, settlement |
| 总部 | /admin/* | iam, catalog, store, trade, settlement, ops |
## UI 硬约束
- C 端订单 **3 Tab**`pending_pay` / `paid` / `completed`
- 个人中心无会员等级(V1 不做会员体系)
- 清香型 4 SKU 可购;酱香/浓香灰态
- 门店列表仅 `OPEN` 状态
- 原型只读:`pages/{端}/*/code.html`,勿改原型目录
## preV1 与 V2 UI 差异
| 项 | preV1 | V2 |
|----|-------|-----|
| C/合伙人载体 | H5 | 微信小程序 |
| 总部 | admin-web 内部 | mini-hq 小程序 |
| 支付页 | Mock 同步成功 | 微信 JSAPI |
-94
View File
@@ -1,94 +0,0 @@
---
name: dukang-prev1
description: >-
Guides preV1 Mock integration phase for Dukang Haoke: H5 three-client setup,
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.
---
# 杜康好客 · preV1 Mock Skill
## 定位
preV1 = V2 之上的**裁剪实现**:**不删表、不删 API 路径、不改字段语义**。
权威文档:[`杜康好客-preV1编码手册.md`](../../杜康好客-preV1编码手册.md)
## 六条裁剪(必记)
1. **无 HQ 小程序**`admin-web` 内部替代;`/admin/*` 保留,前端不暴露 AdminAuth 给 C/B/D 端
2. **三端均 H5**`USER_H5` / `SHOP_H5` / `PARTNER_H5`
3. **无微信登录** → 仅 SMSwechat 路由返回 501 或 Flag 关闭
4. **验证码 Mock**`123456``MOCK_SMS=true`
5. **配送 Mock** → BullMQ 自动推进或合伙人 `mock-advance-delivery`
6. **支付 Mock**`POST /trade/orders/:id/pay` 同步成功 + **真实发券**
## 环境变量(`.env.example`
```bash
MOCK_SMS=true
MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
```
配置加载:`packages/shared-types/src/config.ts``loadAppConfig()`
## Mock 代码位置(禁止散落)
```
server/dukang-api/src/integrations/
├── sms/sms.interface.ts + sms.mock.provider.ts
├── pay/pay.interface.ts + pay.mock.provider.ts
└── delivery/delivery.interface.ts + delivery.mock.provider.ts
```
业务 Module 只 inject **interface**,由 Nest DI 切换 Mock/Real。
## preV1 必做 vs 跳过
| 必做 | 跳过(V2 补) |
|------|---------------|
| 三端 SMS 登录 | 微信登录/支付/退款 |
| Mock 支付 + 真实发券 | 微信 prepay/回调验签 |
| 5 Tab 订单 | HQ 小程序 UI |
| Redis 核销码 + 门店核销 | 真实小飞侠/物流 |
| 合伙人录店 + AUTO_APPROVE | 总部人工审核 UI |
| 可选埋点 | 推广码 HQ UI、T+30 真实打款 |
## HQ 能力替代(Seed
| V2 HQ | preV1 |
|-------|-------|
| 开城/商品 CRUD | Seed 固定 + admin-web 或改 seed |
| 门店审核 | AUTO_APPROVE_STORE=true |
| 退款/客服 | 跳过 |
| 结算 | store_payout PENDING;可 Seed 演示账单 |
Seed`server/dukang-api/prisma/seed-v31.ts`
## 测试账号
| 角色 | 手机号 |
|------|--------|
| C 端 | 13800000001 |
| 门店 | 13900000001 |
| 合伙人 | 13700000001 |
## 冒烟
```bash
node scripts/smoke-prev1.mjs
```
## preV1 → V2 切换检查
按 preV1 手册 §8.2 逐项关闭 Mock Flag**无需重构表结构**。
## 边界提醒
- preV1 skill **不负责**实现 V2 微信 SDK;遇到真实支付/短信需求 → 停止并切换 V2 手册 §九
- 改 Mock 行为时 **仍须** 遵守 OWNER 模块边界
- admin-web 是 preV1 内部工具,勿与 V2 `pages/hq/` 原型混为一谈
-69
View File
@@ -1,69 +0,0 @@
---
name: dukang-project
description: >-
Manages 杜康好客 PRD, requirements review, prototype mapping, and task card
planning. Use when writing PRD, reviewing requirements, mapping prototypes to
features, or splitting task cards. Not for daily coding (use @dukang-coding).
disable-model-invocation: true
---
# 杜康好客 · 项目/需求 Skill
## 何时启用
- 写或改 PRD、业务规则
- 评审需求、对照原型
- 拆分或规划任务卡
- 用户 @dukang-project
**不用于**:日常编码、修 Bug(用 @dukang-coding
## 文档阅读顺序
| 优先级 | 文档 | 用途 |
|--------|------|------|
| 1 | `杜康好客-v3-PRD.md` | **唯一需求事实源** |
| 2 | `杜康好客-v3-现状对照.md` | 已完成/冲突/缺口 |
| 3 | `杜康好客-v3编码手册.md` | 实现验收、分工 |
| 4 | `conventions.md` | 协作与模块边界 |
| 5 | V2 手册 §四 §五 §六 | 仅架构/DB/API 参考(与 V3 冲突时忽略) |
| 6 | `pages/{user,shop,partner,hq}/` | UI 参照 |
> V2 / preV1 **不再作为需求依据**。
## 需求变更规则
- **只改** `杜康好客-v3-PRD.md`;进度同步 `杜康好客-v3-现状对照.md`
- 编码 Agent **不得**边写代码边改业务规则
- 改 API/表结构 → 同步 v3-PRD + `packages/shared-types`
## 原型 → 模块映射
| 端 | 原型目录 | 主要 API 前缀 | NestJS Module |
|----|----------|---------------|---------------|
| C 端 | pages/user/ | /auth, /user, /catalog, /trade, /benefit, /redeem, /stores | iam, catalog, trade, benefit, redeem, store |
| 门店 | pages/shop/ | /shop/auth, /shop/redeem, /shop/store | iam, redeem, store |
| 合伙人 | pages/partner/ | /partner/* | iam, store, trade, settlement |
| 总部 | pages/hq/ | /admin/* | iam, catalog, store, trade, settlement, ops |
## 任务卡格式
- ID 格式:`Mx-LAYER-NNN`(如 `M2-BE-TRD-002`
- 任务卡全集:V2 手册 §七 附录 A
- preV1 任务卡:preV1 手册 §9
## 团队 OWNER
| OWNER | 前端 | 后端 Module |
|-------|------|-------------|
| A | h5-user | iam, trade, benefit, analytics |
| B | h5-partner | store |
| C | admin-web | catalog, settlement, ops |
| D | h5-shop | redeem |
| Lead | packages/* | callbacks, jobs, common |
## 输出要求
- 需求文档变更需标注影响模块与 Owner
- 新任务卡需含:PRD §、API 路径、DB 表、原型路径、验收标准
- 跨模块 PR 需相关双 Owner Review
-91
View File
@@ -1,91 +0,0 @@
---
name: dukang-release
description: >-
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
or @杜康发布 / @dukang-release.
---
# 杜康发布
同机双栈:`/opt/dukang-staging`(测试)与 `/opt/dukang`(生产)。**仅用户明确要求发布时执行。**
## 分支与环境
| 分支 | 环境 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| `dev_jacy` | 日常开发 | 本机 | — | — |
| `dev` | **staging 测试** | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| `main` | **production 生产** | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
默认:`dev_jacy` → merge → `dev` → merge → `main`**发生产**。勿 force push `main`/`dev`
> **团队约定(2026-08)**:用户说「发布 / 发版 / 直接发版」且**未特别强调**时 → **发生产(production**。
> 仅当用户明确说「发测试 / staging / 测试环境」时 → 发 staging。
用户说「发生产 / 上线生产 / 发版」→ 用 `deploy-prod.sh`
用户说「发测试」→ 用 `deploy-staging.sh`
## 标准流程(Windows
PowerShell 下 **分支名必须加引号**
### A. 发测试(日常)
```powershell
# 1. 在 dev_jacy 提交(用户要求时)
git push -u origin HEAD
git checkout "dev"
git pull origin "dev"
git merge "dev_jacy" -m "merge(dev_jacy): <简述>"
git push origin "dev"
git checkout "dev_jacy"
# 2. 发 staging
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy-staging.sh" -- --skip-db
```
### B. 发生产(验收后)
```powershell
git checkout "main"
git pull origin "main"
git merge "dev" -m "merge(dev): <简述>"
git push origin "main"
git checkout "dev_jacy"
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy-prod.sh" -- --skip-db
```
- 默认加 `--skip-db`(无迁移时)
- 有 Prisma 迁移:先在 **staging** 跑通(可 `--seed` / `--accept-data-loss`),再生产去掉 `--skip-db`
- Staging 微信:正式号 + 全 Mock`.env.staging`
- `deploy/deploy.env` 提供 `DEPLOY_HOST``STAGING_APP_ROOT``PROD_APP_ROOT`(勿提交密钥)
### 首次启用 Staging
服务器一次性:`bash deploy/bootstrap-staging.sh`(见脚本说明)→ 配 `.env.staging` → DNS `*-test` → nginx → `deploy-staging.sh -- --seed`
### 同步环境变量
```powershell
bash deploy/sync-api-env.sh staging
bash deploy/sync-api-env.sh production
```
## 等待完成
- 出现 `发版完成`
- `exit_code: 0`
- `mini-user(h5): 200` / `api: 200`
## 回复用户
说明:环境(测试/生产)、commit、建议硬刷验证。测试后台有橙色「测试环境」条。
## 禁止
- 不 amend 已推送 commit;不 force push `main`/`dev`
- 不把 `.env*` 私钥打进仓库
- **禁止**再用 `dev` 直发生产
- 用户只说「修好」未说发布 → 只改代码,不发版
-94
View File
@@ -1,94 +0,0 @@
---
name: dukang-task-card
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.
---
# 杜康好客 · 任务卡 Skill
## 原则
- **一次只做一个任务卡**
- 任务卡 ID 命名空间:`DLV-W*-*`V3.0)· `P1-*` / `M*-*`**历史,不再作验收依据**
- 需求变更只改 `杜康好客-v3-PRD.md`;进度查 `杜康好客-v3-现状对照.md`
## 领取流程
1. **解析 ID** → 确定里程碑与 OWNER
- `DLV-W1-M2``@dukang-v3` + [reference-waves.md](../dukang-v3/reference-waves.md)
- `P1-M2-002` → preV1 §9 + OWNER 见卡片内容
- `M2-BE-TRD-002` → V2 §七 附录 A
2. **读验收标准** → ACC-ID(V3)或手册中该 ID 的「验收」列
3. **读依赖** → REQ-ID、API 路径、DB 表、原型路径
4. **确认边界** → 只改 [`AGENTS.md`](../../AGENTS.md) OWNER 表内路径
5. **实现** → 配合 `dukang-coding` skill
6. **自验 DoD** → 下方清单
## OWNER 快速映射(2 人)
| 领域 | 负责人 | 典型 ID |
|------|--------|---------|
| C 端 FE、h5-user、交易后端 | jacy-dukang | P1-M1-002, M2-* |
| admin、catalog、settlement | jacy-dukang | M1-BE-CAT-* |
| 合伙人、store | 刘景尧 | P1-M4-001 |
| 门店核销、h5-shop | 刘景尧 | P1-M3-002 |
| Monorepo/integrations | jacy-dukang | P1-M0-* |
## preV1 任务卡(精简)
| ID | 验收要点 |
|----|----------|
| P1-M0-001 | 三端 `pnpm dev` 可编译 |
| P1-M0-003 | `pnpm db:validate` 通过 |
| P1-M0-004 | Seed 郑州+4SKU+测试账号 |
| P1-M1-001 | 123456 三端登录 |
| P1-M1-002 | C 端首页 4 款酒 |
| P1-M2-001 | 起购校验 2/6 瓶 |
| P1-M2-002 | Mock 支付发券 |
| P1-M2-003 | 5 Tab 含 pending_ship |
| P1-M3-001 | V3 两路径核销金额限制 |
| P1-M3-002 | 门店扫码核销 |
| P1-M4-001 | 录店 AUTO_APPROVE → C 端可见 |
| P1-M5-001 | 配送自动推进到 COMPLETED |
完整列表:preV1 手册 §9。
## 完成定义(每个任务卡)
```
- [ ] 验收列每一条可演示/可脚本验证
- [ ] 改动在 OWNER 路径内(跨模块需 inject Service + 双 OWNER 知晓)
- [ ] 新枚举/DTO → shared-types
- [ ] 纯规则 → domain + 单测(若涉及起购/权益/核销)
- [ ] API/表变更 → 同步 V2 手册 §五/§六
- [ ] pnpm lint 无新增错误
```
## V3.0 DLV 顺序(当前交付)
**Wave 1**DLV-W1-M1 → M2 → M3 → M4(门禁 W1-S1~S4
**Wave 2**DLV-W2-M1 → M2 → M3 → M4(门禁 W2-S1~S5
**Wave 3**DLV-W3-M1 → M2 → M3 → M4 → M5(全量 ACC
## 历史里程碑
**preV1**P1-M0 → … → P1-M6
**V2**:M0 → M1 → … → M6 → 上线
除非用户指定,默认从当前里程碑**下一个未完成**任务卡开始;不确定时先问用户或跑 smoke 判断进度。
## 输出格式(任务完成时)
```markdown
## 任务卡 {ID} 完成
**改动范围**:(路径列表)
**验收**:(逐条 ✓)
**未做/阻塞**:(如有,需其他 OWNER 配合的项)
**建议下一步**:(下一任务卡 ID
```
-119
View File
@@ -1,119 +0,0 @@
---
name: dukang-v3
description: >-
Manages 杜康好客 V3.0 PRD (v3-PRD), three-wave delivery (W1/W2/W3), REQ/ACC
IDs, and requirements review. Use when implementing V3.0 features, reviewing
PRD v1.3 scope, planning Wave gates, or user @dukang-v3. Not for preV1 Mock
(use @dukang-prev1) or daily coding alone (pair with @dukang-coding).
disable-model-invocation: true
---
# 杜康好客 · V3.0 需求 Skill
## 何时启用
- 读/改 V3.0 业务规则与交付范围
- 评审需求、对照 PRD REQ-ID / ACC-ID
- 规划或验收 Wave 1/2/3 门禁
- 用户 @dukang-v3 或提到「v3.0 PRD」「三波交付」「W1-S1」
**不用于**preV1 Mock 联调(@dukang-prev1)、纯编码实现(@dukang-coding
## 文档阅读顺序
| 优先级 | 文档 | 用途 |
|--------|------|------|
| 1 | [`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) | **唯一需求事实源** |
| 2 | [`杜康好客-v3-现状对照.md`](../../../杜康好客-v3-现状对照.md) | 已完成/冲突/缺口 |
| 3 | [`杜康好客-v3编码手册.md`](../../../杜康好客-v3编码手册.md) | 实现验收、分工 |
| 4 | [reference-waves.md](reference-waves.md) | Wave 门禁与 DLV 映射 |
| 5 | [reference-req-index.md](reference-req-index.md) | REQ-U/S/P/H 全表 |
| 6 | [reference-acc.md](reference-acc.md) | ACC 验收条件 |
| 7 | `pages/{user,shop,partner,hq}/` | UI 参照 |
> V2 / preV1 手册**不再作为需求依据**。
## V3.0 不可偏离常量
```
权益额 = 订单实付 1:1(永久)
同城起购 2 瓶 / 跨城 1 箱(6瓶)
门店结算 = 核销额 × 60%
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
核销码 TTL = 3 分钟;试核销固定 ¥100
订单 Tab:待付款 | 已付款 | 已完成
C 端门店仅 status=OPEN 且审核通过
```
## 三波交付(严格顺序)
```
Wave 1 (7.10) → Wave 2 (7.15) → Wave 3 (7.22)
```
实现前**必须确认当前 Wave**;不得提前做后续 Wave 功能(除非用户明确要求)。
| Wave | 门禁 | 一句话 |
|------|------|--------|
| W1 | W1-S1~S4 | 下单+核销+拓店+合伙人子账号 |
| W2 | W2-S1~S5 | 提现+推广码+现场提货+门店子账号+多合伙人 |
| W3 | 全量 G1~G5 | 代下单+弱网+多仓+跨城+工单+发票+增长 |
详情:[reference-waves.md](reference-waves.md)
## 需求变更规则
- **只改** `杜康好客-v3-PRD.md` 作为 V3.0 业务事实源
- 同步 `杜康好客-v3编码手册.md` + `packages/shared-types` + `packages/domain`
- V2/preV1 与 V3.0 冲突时 **V3.0 PRD 优先**
- 编码 Agent 不得边写代码边改业务规则
## 原型 → 模块映射
| 端 | PRD 形态 | 当前代码 | 后端 Module |
|----|----------|----------|-------------|
| C 端 | 微信小程序 | `apps/h5-user`(过渡) | iam, catalog, trade, benefit |
| 门店 | H5 | `apps/h5-shop` | iam, redeem, store |
| 合伙人 | H5 | `apps/h5-partner` | iam, store, trade, settlement |
| 总部 | H5 | `apps/admin-web`(过渡) | catalog, store, trade, settlement, ops |
## 任务卡命名(V3.0
- **DLV-W{n}-M{m}**:阶段交付(如 `DLV-W1-M2`
- **REQ-{U|S|P|H}-{nnn}**:功能需求 ID
- **ACC-{nnn}** / **ACC-P{nn}** / **ACC-W{nn}**:验收 ID
- preV1 `P1-*` 与 V2 `M*-*` 为历史命名,V3.0 新任务以 DLV/REQ/ACC 为准
领取 DLV 任务时配合 `@dukang-coding` + 对应 Owner agent。
## 团队 OWNER2 人)
| 负责人 | 前端 | 后端 Module |
|--------|------|-------------|
| jacy-dukang | h5-user/mini-user, admin-web | iam, trade, benefit, catalog, settlement, ops, analytics |
| 刘景尧 | h5-shop, h5-partner | store, redeem |
## 需求评审输出格式
```markdown
## V3.0 需求评审 · {主题}
**关联**REQ-xxx / ACC-xxx / Wave N
**影响模块**:(NestJS module + app
**Owner**jacy-dukang | 刘景尧 | 双方
**与现状差异**:(代码/手册冲突点)
**建议任务**DLV-Wx-Mx
**阻塞/依赖**:(外部对接、前置 Wave
```
## 子 Agent 选用
| 场景 | Agent |
|------|-------|
| V3 波次统筹、跨 Wave 排期 | `v3-delivery-lead` |
| C 端下单/权益/支付 | `owner-a-user-trade` |
| 总部开城/结算/运营 | `owner-c-catalog-ops` |
| 合伙人拓店 | `owner-b-partner-store` |
| 门店核销 | `owner-d-shop-redeem` |
| packages/integrations/Prisma | `backend-lead` |
| PR 边界审查 | `boundary-reviewer` |
-72
View File
@@ -1,72 +0,0 @@
# V3.0 ACC 验收标准索引
> 事实源:[`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) §8
## 交易与履约
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-001 | S1 | 访客可浏览;下单须登录;支付成功→权益 1:1 |
| ACC-002 | 同城 | ≥2 瓶免运费;小飞侠;取送拍照;24h 自动完成 |
| ACC-003 | 现场提货 | 隐藏入口;支付即完成;有码归码/无码归总部 |
| ACC-004 | 跨城 | 未开通城市≥1箱;总部物流到付;佣金归总部 |
| ACC-015 | 锁单 | 30 分钟取消;7 天免登 |
## 核销与门店账号
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-005 | S2 | 扫码+手机号两通道;码/验证码 3 分钟;成功短信 |
| ACC-006 | 多店子账号 | W2:一号多店;主账号唯一;店员权限隔离 |
| ACC-006a | 门店登录 | 单店直达;多店含角色;记住所选 |
| ACC-017 | 弱网 | W1~2 重试+人工;W3 5次拍照兜底;不重复扣款 |
| ACC-017a | 弱网 SLA | W3:总部 T+0 处理;超时预警 |
## 拓店与 SOP
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-007 | S3 | 合伙人三步提交;总部审;C 端营业中可见 |
| ACC-019 | SOP | 筛选字段;附件一一致;试核销100;负责人复核;合同同日 |
| ACC-020 | 结算细则 | T+1 顺延;异议3日;店内公示;签约主体 |
## 资金与佣金
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-008 | S4 | 未出账可提;总部打款;FIN 护栏;T+0 审 |
| ACC-009 | 佣金 | 默认 0%+3%;门店结算×60%;§4.3 归属 |
| ACC-P21 | 多合伙人 | 区域区县互斥;全城唯一 |
| ACC-P22 | 佣金配置 | 独立配置;≤5%;快照 |
| ACC-P23 | 订单佣金 | 同城区县解析;余量归总部/全城 |
| ACC-P24 | 核销佣金 | 归门店归属合伙人;变更不溯及 |
| ACC-P25 | 独立账单 | T+30 独立确认打款 |
| ACC-P26 | 数据隔离 | 仅本管辖;推广员仅自己的店 |
| ACC-P27 | 推广码 | 绑定合伙人;现场有码归码 |
| ACC-P28 | 审计 | 管辖/比例/归属变更留痕 |
## 售后与增长
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
| ACC-012 | 代下单 | W3:客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
| ACC-018 | 话术 | OPT-012 3 日内对齐 |
| ACC-021 | 热力图 | 腾讯位置服务按城市 |
## 多仓(Wave 3
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-W01 | 多仓 | 同城多仓;启停 |
| ACC-W02 | 仓-合伙人 | 一仓一合伙人;未关联总部直管 |
| ACC-W03 | 管仓协同 | 管仓合伙人只读仓+工单协同 |
## UI 与隐私
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-016 | UI/隐私 | §8 规范;按角色脱敏 |
@@ -1,128 +0,0 @@
# V3.0 REQ 需求索引
> 事实源:产品 PRD v1.3 → [`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md)
## 用户端 REQ-U
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
| REQ-U-002 | 登录 | 无感登录;下单提示绑定手机号(可选);微信/短信;7 天免登 |
| REQ-U-003 | 支付 | 仅微信支付 |
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 |
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 |
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
| REQ-U-013 | 门店列表 | 营业中;搜索;省市区筛选;立即核销 |
| REQ-U-014 | 门店详情 | 门头/电话/星级/地址/营业时间/规则/相册 |
| REQ-U-015 | 核销页 | 剩余总额;输入金额;码 3 分钟有效 |
| REQ-U-016 | 规则弹窗 | 核销前展示(OPT-011) |
| REQ-U-017 | 评价 | 核销后引导门店评价 |
| REQ-U-018 | 权益页 | 待使用总额;Tab 待使用/已使用;明细 |
| REQ-U-019 | 个人中心 | 手机号、权益总额、订单入口 |
| REQ-U-020 | 订单 | 待付款/已付款/已完成;物流时间线 |
| REQ-U-021 | 发票 | 四组合;总部 2 工作日回传(OPT-001) |
| REQ-U-022 | 工单 | 四类型;客服引导建单 |
| REQ-U-023 | 问卷 | 支付成功引导;无激励(OPT-004) |
| REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 |
| REQ-U-025 | 分享 | 微信商品分享+渠道参数 |
| REQ-U-026 | 门店规则 | 附件一完整展示 |
| REQ-U-027 | 门店套餐 **3.4.10** | 门店详情展示生效套餐;套餐异议申诉入口 |
## 门店端 REQ-S
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-S-001 | 导航 | 首页/核销记录/门店信息 |
| REQ-S-002 | 登录 | 主账号/店员;§4.6 流程;7 天免登 |
| REQ-S-003 | 多店 | 一号多店选店(Wave 2) |
| REQ-S-004 | 扫码核销 | 大按钮;确认即成功 |
| REQ-S-005 | 手机号核销 | 查余额→用户验证码→确认 |
| REQ-S-006 | 短信 | 核销中/成功文案 |
| REQ-S-007 | 今日汇总 | 首页今日核销总金额 |
| REQ-S-008 | 弱网 | W1~2 重试;W3 §4.8 |
| REQ-S-008a | 弱网弹窗 | W3 拍照兜底文案 |
| REQ-S-009 | 核销记录 | 筛今日/7日/1月/全部;到账×60% |
| REQ-S-010 | 门店信息 | 营业状态;结算入口;切换门店 |
| REQ-S-011 | 结算信息 | T+1;核销额/结算额/状态 |
| REQ-S-012 | 提现 | 未出账可提(Wave 2);FIN 护栏 |
| REQ-S-013 | 提现记录 | 待审/已打款/驳回 |
| REQ-S-014 | 结算异议 | 3 工作日 |
| REQ-S-015 | 店内公示 | 模板下载 |
| REQ-S-016 | 提现护栏 | 白名单/单日上限提示 |
| REQ-S-017 | 子账号 | 创建/停用店员(Wave 2) |
| REQ-S-018 | 权限 | §4.6 矩阵 |
| REQ-S-019 | 多店选店 | 记住上次所选 |
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
| REQ-S-021 | 门店套餐 **3.4.10** | 套餐列表/编辑/提审 |
## 合伙人端 REQ-P
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-P-001 | 导航 | 首页/门店管理/合伙人中心 |
| REQ-P-002 | 登录 | 7 天免登 |
| REQ-P-003 | 推广员 | 菜单裁剪;仅本管辖范围 |
| REQ-P-004 | 管理员 | 完整能力 |
| REQ-P-005 | 子账号 | 管理员·推广员 |
| REQ-P-006 | 门店管理 | 筛审核/营业/区县/创建人 |
| REQ-P-007 | 列表字段 | 名称/地址/状态/录入 |
| REQ-P-008 | 一号多店 | 创建时二次确认 |
| REQ-P-009 | 创建三步 | 基础/证照+附件一/结算 |
| REQ-P-010 | 编辑规则 | 待审可改;通过后改资料再审 |
| REQ-P-011 | 试核销 | 100 元激活(OPT-009 |
| REQ-P-012 | 入驻短信 | OPT-008 |
| REQ-P-013 | 首页数据 | GMV/核销/订单三卡 |
| REQ-P-014 | 功能区 | 子账号/订单/权益/工单/代下单 |
| REQ-P-015 | 排行 | 核销/订单 Top10 |
| REQ-P-016 | 订单管理 | 筛时间状态 |
| REQ-P-017 | 订单详情 | 佣金快照下钻 |
| REQ-P-018 | 好客权益 | 送出/已核销/待核销 |
| REQ-P-019 | 工单 | 只读 |
| REQ-P-020 | 财务对账 | 本合伙人月账 |
| REQ-P-021 | 代下单 | Wave 3 |
| REQ-P-022 | 筛选校验 | 不达标标黄 |
| REQ-P-023 | 附件一 | 结构化录入 |
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
| REQ-P-025 | 合同同日 | 同日提交校验 |
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
| REQ-P-027 | 门店套餐 **3.4.10** | 拓店套餐页;门店套餐列表/编辑/提审 |
## 总部端 REQ-H
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-H-001 | 登录 | 权限账户;7 天免登 |
| REQ-H-002 | 商品 | 增删改;上下架排序 |
| REQ-H-003 | 营销 | 1:1 权益永久 |
| REQ-H-004 | 开通城市 | 省/市/配送/是否开通 |
| REQ-H-004a | 创建合伙人 | 全城/区域+佣金;W3 管仓 |
| REQ-H-004b | 仓库 | Wave 3 多仓 |
| REQ-H-005 | 门店管理 | +归属合伙人筛选 |
| REQ-H-006 | 门店审核 | 通过/失败;改归属 |
| REQ-H-007 | 管辖佣金 | 区县全覆盖校验 |
| REQ-H-008 | 订单 | 全量+佣金快照 |
| REQ-H-009 | 权益 | 全量+合伙人筛 |
| REQ-H-010 | 工单 | 同意/拒绝+备注+日志 |
| REQ-H-011 | 发票 | 2 工作日;上传回传 |
| REQ-H-012 | 代下单 | 同合伙人 |
| REQ-H-013 | 结算 | 门店+合伙人双 Tab |
| REQ-H-014 | 提现审 | T+0FIN-003 |
| REQ-H-014a | 白名单 | FIN-001/002 |
| REQ-H-015 | 合伙人账单 | 待确认/待打款 |
| REQ-H-016 | 报表 | 城市/合伙人/区县+热力图 |
| REQ-H-017 | 推广码 | 绑定合伙人+统计 |
| REQ-H-018 | 问卷 | 回收看板 |
| REQ-H-019 | 门店评价 | 查看数据 |
| REQ-H-020 | 核销异常 | W1~2 人工;W3 列表 |
| REQ-H-020a | 待处理核销审 | W3 补核销/驳回 |
| REQ-H-021 | 话术 | OPT-012 |
| REQ-H-022 | 签约主体 | 山西领势酒业 |
| REQ-H-023 | 热力图 | 腾讯位置服务 |
| REQ-H-024 | 结算异议 | 门店 3 日异议处理 |
| REQ-H-025 | 门店套餐 **3.4.10** | 门店详情套餐 Tab 直存;套餐变更审核通过/驳回 |
-109
View File
@@ -1,109 +0,0 @@
# V3.0 三波交付参考
> 事实源:[`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) §9
## Wave 1 — 核心交易 + 拓店(2026-07-10
### 门禁
| 门禁 | 能力 | ACC |
|------|------|-----|
| W1-S1 | 用户下单支付,权益 1:1 到账 | ACC-001 |
| W1-S2 | 门店核销(扫码 + 手机号) | ACC-005、OPT-007、OPT-011 |
| W1-S3 | 合伙人子账号 | REQ-P-005 |
| W1-S4 | 合伙人创建门店 → 总部审核 → C 端可见 | ACC-007、OPT-009、OPT-008 |
### 纳入
四端登录 7 天免登、商品、**同城**下单支付、小飞侠履约、出码/扫码/手机号核销、规则弹窗、合伙人子账号、门店三步录入、负责人复核、总部审核、试核销 100 元、登录短信。
### 不含
提现/打款、推广码、现场提货、代下单、弱网拍照、多合伙人、门店子账号、**跨城**、工单、发票。
### DLV 拆分
| ID | 内容 |
|----|------|
| DLV-W1-M1 | 四端登录、权限、UI Token、商品管理/展示 |
| DLV-W1-M2 | 支付、锁单 30 分、同城小飞侠、权益 1:1 |
| DLV-W1-M3 | 出码/扫码/手机号核销、规则弹窗 OPT-011 |
| DLV-W1-M4 | 合伙人子账号、门店三步、负责人复核、审核、试核销 OPT-009、短信 OPT-008 |
### Wave 1 弱网/代下单 SOP
- 弱网:重试 + 总部人工补核销(OPT-006 完整版在 W3)
- 代下单:运营人工 SOP(系统能力在 W3 OPT-002
---
## Wave 2 — 资金 + 品鉴会 + 组织扩展(2026-07-15
### 门禁
| 门禁 | 能力 | ACC |
|------|------|-----|
| W2-S1 | 未出账可手动提现 + FIN 护栏 | ACC-008、OPT-010、FIN-001~003 |
| W2-S2 | 总部审核打款 | REQ-H-014 |
| W2-S3 | 推广码 + 现场提货 | ACC-003/014、OPT-005 |
| W2-S4 | 门店主子账号 + 一号多店选店 | REQ-S-017~019、ACC-006 |
| W2-S5 | 多合伙人管辖/佣金/数据隔离 | ACC-P21~P28 |
### 不含
代下单、弱网 5 次拍照、一城多仓、跨城、工单、发票、问卷、热力图。
### DLV 拆分
| ID | 内容 |
|----|------|
| DLV-W2-M1 | T+1 出账、未出账提现 OPT-010 + FIN、总部审打款 |
| DLV-W2-M2 | 现场提货 OPT-005、推广码 REQ-H-017 |
| DLV-W2-M3 | 多合伙人 REQ-H-004a/007、区县互斥、佣金快照 |
| DLV-W2-M4 | 门店子账号 REQ-S-017~019、多店选店 |
---
## Wave 3 — 旁路 + 弱网 + 本期全量(2026-07-22
### 门禁
| 门禁 | 能力 | ACC |
|------|------|-----|
| W3-S1 | 代下单(总部/合伙人) | ACC-012、OPT-002 |
| W3-S2 | 弱网 5 次拍照兜底 + 总部补核销 | ACC-017、OPT-006 |
| W3-S3 | 一城多仓 + 合伙人管仓 | ACC-W01~W03 |
| W3-S4 | 跨城、工单、发票、合伙人 T+30 对账、问卷/评价/热力图 | G5、OPT-001/003/004/012 |
### 完成定义
G1G5 + OPT-001012 + 门店签约 SOP S1S12 全部勾选。
### DLV 拆分
| ID | 内容 |
|----|------|
| DLV-W3-M1 | 代下单 OPT-002 |
| DLV-W3-M2 | 弱网 OPT-006 完整版 |
| DLV-W3-M3 | 多仓、跨城、工单 OPT-003、发票 OPT-001 |
| DLV-W3-M4 | 合伙人月账、问卷/评价/热力图、OPT-012 |
| DLV-W3-M5 | ACC 全量回归 |
---
## OPT 波次对照
| OPT | 名称 | 波次 |
|-----|------|------|
| OPT-001 | 发票 | W3 |
| OPT-002 | 代下单 | W3 |
| OPT-003 | 工单 | W3 |
| OPT-004 | 问卷 | W3 |
| OPT-005 | 现场提货 | W2 |
| OPT-006 | 弱网兜底 | W3W1~2 简化版) |
| OPT-007 | 手机号核销 | W1 |
| OPT-008 | 入驻成功短信 | W1 |
| OPT-009 | 试核销 100 元 | W1 |
| OPT-010 | 未出账提现 | W2 |
| OPT-011 | 核销规则弹窗 | W1 |
| OPT-012 | 话术对齐 | W3 |
-43
View File
@@ -1,43 +0,0 @@
# 杜康好客 CODEOWNERS2 人团队)
# 仓库:阿里云 Codeup · 用户名与平台成员账号一致
#
# jacy-dukang — 管理员 + 主责(C 端、admin、后端主模块、横切)
# 刘京尧 — 合伙人端 + 门店端(h5-partner、h5-shop、store、redeem
# 文档 / 横切 / 基础设施
/AGENTS.md @jacy-dukang
/agent.md @jacy-dukang
/conventions.md @jacy-dukang
/skills.md @jacy-dukang
/杜康好客-V2编码手册.md @jacy-dukang
/杜康好客-preV1编码手册.md @jacy-dukang
/.cursor/ @jacy-dukang
/deploy/ @jacy-dukang
/packages/ @jacy-dukang @刘京尧
# jacy-dukang — C 端 + 交易/权益域 + 总部 admin
/apps/h5-user/ @jacy-dukang
/apps/admin-web/ @jacy-dukang
/server/dukang-api/src/modules/iam/ @jacy-dukang
/server/dukang-api/src/modules/trade/ @jacy-dukang
/server/dukang-api/src/modules/benefit/ @jacy-dukang
/server/dukang-api/src/modules/analytics/ @jacy-dukang
/server/dukang-api/src/modules/catalog/ @jacy-dukang
/server/dukang-api/src/modules/settlement/ @jacy-dukang
/server/dukang-api/src/modules/ops/ @jacy-dukang
# 刘京尧 — 合伙人端 + 门店端
/apps/h5-partner/ @刘京尧
/apps/h5-shop/ @刘京尧
/server/dukang-api/src/modules/store/ @刘京尧
/server/dukang-api/src/modules/redeem/ @刘京尧
# 横切(主责 jacy-dukang;改接口时 @刘京尧 若影响 partner/shop
/server/dukang-api/src/callbacks/ @jacy-dukang
/server/dukang-api/src/jobs/ @jacy-dukang @刘京尧
/server/dukang-api/src/common/ @jacy-dukang
/server/dukang-api/src/integrations/ @jacy-dukang
/server/dukang-api/prisma/ @jacy-dukang @刘京尧
# 原型参照
/pages/ @jacy-dukang
-21
View File
@@ -1,21 +0,0 @@
name: CI
on:
push:
branches: [main, master, develop]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install
- run: pnpm db:generate
- run: pnpm test
- run: pnpm lint
-24
View File
@@ -1,24 +0,0 @@
node_modules/
dist/
.turbo/
*.log
.env
.env.local
server/dukang-api/.env
server/dukang-api/.env.development
server/dukang-api/.env.production
server/dukang-api/.env.production.local
server/dukang-api/.env.staging
server/dukang-api/.env.staging.local
deploy/deploy.env
deploy/.staging-mysql.pass
deploy/_ops-*.log
deploy/_ops-*.sh
.DS_Store
coverage/
*.tsbuildinfo
server/dukang-api/prisma/migrations/
debug_v3.xlsx
~$debug_v3.xlsx
deploy/auto-release.env
-2
View File
@@ -1,2 +0,0 @@
shamefully-hoist=true
strict-peer-dependencies=false
-170
View File
@@ -1,170 +0,0 @@
# 杜康好客 · AGENTS.md
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。
## 项目概览
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
| 阶段 | 手册 | 说明 |
|------|------|------|
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
## 启动命令
```bash
# 基础设施
cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017
# 依赖与数据库
pnpm install
cp server/dukang-api/.env.example server/dukang-api/.env
pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端)
pnpm dev:api # http://localhost:3000/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
# 验证
node scripts/smoke-v3.mjs
node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
Mock 验证码:`999888`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
## 文档优先级(冲突时)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**
2. `杜康好客-v3-现状对照.md` — 实现进度审计
3. `杜康好客-v3编码手册.md` — 实现验收、核销、分工
4. `conventions.md` — 协作与模块边界
5. V2 手册 §四 §五 §六 — 仅作架构/DB/API **参考**(与 V3 冲突时忽略)
6. `pages/{user,shop,partner,hq}/` — UI 参照
> **V2 / preV1 手册不再作为需求依据。**
## 团队与 OWNER 边界(2 人)
| 负责人 | Git 账号 | 职责 |
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管** |
> **临时分工(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-v3PRD、Wave、REQ/ACC |
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用)
在 Cursor Agent 输入 `/` 选择:
| 子 Agent | 负责人 | 适用场景 |
|----------|--------|----------|
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
## Skills(按需 @
| Skill | 何时用 |
|-------|--------|
| `dukang-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
- PR 前审查:`/boundary-reviewer`
- 原型参照:`pages/{user,shop,partner}/`(只读)
## 核心业务常量(不可偏离)
```
权益额 = benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
## 环境与发版
| 环境 | 分支 | 目录 | 端口 | 域名 |
|------|------|------|------|------|
| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — |
| **staging 测试** | `dev` | `/opt/dukang-staging` | 81908194 | `*-test.dukanghaoke.com` |
| **production 生产** | `main` | `/opt/dukang` | 80908094 | `*.dukanghaoke.com` |
- 日常发布 → `deploy/deploy-staging.sh``dev`
- 生产发布 → `deploy/deploy-prod.sh``main`,验收后)
- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example`
- 详情:`@dukang-release` / `.cursor/skills/dukang-release/SKILL.md`
## 提交与 PR
- Conventional Commits`feat(trade):``fix(redeem):`scope = 端或模块
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- 不提交 `.env``dist/``node_modules/`
## 完成定义(DoD
- [ ] 任务卡验收项全部满足
- [ ] 未跨模块直写 Prisma 表
- [ ] 枚举/DTO 在 `shared-types`;纯规则在 `domain`
- [ ] `pnpm lint` 无新增错误;相关 domain 单测通过
## 嵌套指引
- 后端细节:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)
- 前端四 App[`apps/AGENTS.md`](./apps/AGENTS.md)
-1
View File
@@ -1 +0,0 @@
ayPJ4CQqbUcec3jX
-92
View File
@@ -1,92 +0,0 @@
# 杜康好客 V3 交付版
四端 H5/WebAdmin + NestJS 单体 API。**V3 交付验收**以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准;V2 为完整蓝图参考;preV1 为 Mock 联调历史参考。
## 快速启动
### 1. 基础设施
```bash
cd deploy
docker compose up -d # MySQL :6016 Redis :6017(与 dukang-v3 的 6014/6015 隔离)
```
### 2. 安装依赖
```bash
pnpm install
```
### 3. 数据库
```bash
cp server/dukang-api/.env.example server/dukang-api/.env
pnpm db:generate
cd server/dukang-api
npx prisma db push # v3.1 首次切换加 --accept-data-loss
pnpm prisma:seed # seed-v31.ts
```
### 4. 启动服务
```bash
# 终端 1 - API
pnpm dev:api
# 终端 2~4 - 三端 H5
pnpm dev:user # http://localhost:5173
pnpm dev:shop # http://localhost:5174
pnpm dev:partner # http://localhost:5175
改动测试
```
## 测试账号(验证码均为 123456)
| 端 | 手机号 |
|----|--------|
| C端用户 | 13800000001 |
| 合伙人 | 13700000001 |
| HQ | 13600000001(密码登录:`admin` / `dukang@123!`;短信验证码 123456 |
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`
## 主链路冒烟
```bash
node scripts/smoke-v3.mjs # V3 交付验收
node scripts/smoke-prev1.mjs # preV1 历史回归
```
验收清单见 [`V3_ACCEPTANCE_CHECKLIST.md`](./V3_ACCEPTANCE_CHECKLIST.md)。
## 目录结构
- `apps/h5-user` · `apps/h5-shop` · `apps/h5-partner` — 三端 H5Vite + React
- `packages/shared-types` · `packages/domain` — 共享契约与纯函数
- `server/dukang-api` — NestJS 单体 API
- `pages/` — UI 原型(从 Stitch「4端1.0版本」同步)
- `pages/user/` · `pages/shop/` · `pages/partner/` — 各端 screen 的 `code.html` + `screen.png`
- `pages/ROUTE_MAP.md` — screen ↔ 路由对照
- `pages/stitch_4_1.0/DESIGN.md` — 设计系统
- `packages/shared-ui` — 三端共享设计令牌与组件
## 同步 Stitch 原型
从 Google Stitch 共享项目「4端1.0版本」拉取最新 HTML/截图:
```bash
# 设置 API Key(或使用 ~/.cursor/mcp.json 中的 stitch 配置)
set STITCH_API_KEY=your-key
pnpm sync:stitch
# 强制重新下载
pnpm sync:stitch -- --force
```
## Mock 开关(`.env`
- `MOCK_SMS=true` — 固定验证码 123456
- `MOCK_PAY=true` — 同步支付成功并发券
- `MOCK_DELIVERY_AUTO=true` — BullMQ 自动推进配送状态
- `AUTO_APPROVE_STORE=true` — 合伙人录店自动审核通过
-52
View File
@@ -1,52 +0,0 @@
# V3 验收清单
> 业务事实源:[杜康好客-v3编码手册.md](./杜康好客-v3编码手册.md)
> 自动化:`node scripts/smoke-v3.mjs`Mock 环境)
> 历史回归:`node scripts/smoke-prev1.mjs`preV1 单链路)
## 必过主链路
| # | 验收项 | Owner | API / 页面 | 自动化 |
|---|--------|-------|------------|--------|
| 1 | C 端手机号登录 | jacy | `POST /auth/login/sms` · h5-user `/login` | smoke-v3 |
| 2 | 首页郑州 4 商品 | jacy | `GET /catalog/products?cityCode=` · h5-user `/` | smoke-v3 |
| 3 | 同城 1 瓶失败 | jacy | `POST /trade/orders/preview` | smoke-v3 |
| 4 | 同城 2 瓶成功 | jacy | `POST /trade/orders` | smoke-v3 |
| 5 | 跨城 5/6 瓶边界 | jacy | preview + create | smoke-v3 |
| 6 | 支付后发权益 | jacy | pay + benefit | smoke-v3 |
| 7 | 直接核销(无 couponId | jacy + 刘京尧 | `POST /redeem/tokens` | smoke-v3 |
| 8 | 单据核销 cap | jacy + 刘京尧 | `POST /redeem/tokens` + couponId | smoke-v3 |
| 9 | 门店扫码确认 | 刘京尧 | `POST /shop/redeem/confirm` · h5-shop | smoke-v3 |
| 10 | store_payout 生成 | jacy | settlement | smoke-v3 |
| 11 | 关闭门店不可核销 | 刘京尧 | store status + redeem | smoke-v3 |
| 12 | 录店审核后 C 端可见 | 刘京尧 + jacy | admin stores audit · h5-partner | 手动 |
| 13 | 配送推进完成 | jacy | jobs / delivery callback | smoke-v3 |
| 14 | 退款工单一致 | jacy | tickets + benefit void | smoke-v3 |
| 15 | 门店 T+1 打款确认 | jacy | admin store-payouts | smoke-v3 |
| 16 | 合伙人 T+30 账单 | jacy | admin partner-bills | smoke-v3 |
## 必过后台链路
| # | 验收项 | 页面 |
|---|--------|------|
| 1 | WebAdmin 登录 | admin-web `/login` |
| 2 | 商品 CRUD | `/products` |
| 3 | 审核门店 | `/stores` |
| 4 | 订单与配送 | `/orders` · `/deliveries` |
| 5 | 权益/核销/打款 | `/benefit/*` · `/redeem-records` · `/store-payouts` |
| 6 | 退款/补发工单 | `/tickets` |
| 7 | 第三方日志 | `/third-party-logs` |
| 8 | 财务导出 | `/partner-bills` export |
## 环境矩阵
| 环境 | MOCK_SMS | MOCK_PAY | MOCK_DELIVERY_AUTO | 说明 |
|------|----------|----------|-------------------|------|
| 本地开发 | true | true | true | 固定验证码 123456 |
| 测试沙箱 | false | false | false | 微信/配送沙箱 |
| 生产 | false | false | false | 真实第三方 |
## 合伙人端交接(刘京尧)
Jacy 侧交付:`/partner/*` 后端 API、shared-types DTO、smoke 断言。
刘京尧负责:`apps/h5-partner` 页面接入与验收。
-138
View File
@@ -1,138 +0,0 @@
# 杜康好客 · Cursor Agent 指引
> 本文件供 **人类与 Agent** 在编码前阅读。
> **AI 自动加载入口**[`AGENTS.md`](./AGENTS.md)(跨工具通用)
> **Skills / 子 Agent**`.cursor/skills/`、`.cursor/agents/`Cursor 2.4+
> 与 [`skills.md`](./skills.md)、[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) 配合使用。
---
## 1. 你是谁、要做什么
你是杜康好客 **preV1** 的实现 Agent(Mock 联调阶段)。目标:三端 H5 + NestJS 跑通购酒→发券→核销;架构与表结构对齐 **V2**
**当前编码手册(preV1**[`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md)
**V2 完整规格(预留对齐)**[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md)
不要依赖 `doc/` 下分散旧文档(可能过时);不要臆造 PRD 未定义的业务规则。
---
## 2. 启动流程(每次会话)
1. 阅读 [`AGENTS.md`](./AGENTS.md) 确认 OWNER 边界与当前阶段(preV1
2. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 §
3. 启用 Skill`@dukang-coding`(通用)或 `@dukang-prev1` / `@dukang-task-card`(按需)
4. 按 OWNER 选择子 Agent`/owner-a-user-trade` 等(见 AGENTS.md §子 Agent
5. 阅读 [`conventions.md`](./conventions.md) 模块边界
6. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ preV1 §9 或 V2 §七
7. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq 小程序;HQ 用 admin-web
8. 一次只完成一个任务卡;PR 前可用 `/boundary-reviewer` 审查跨模块违规
---
## 3. 技术栈(锁定)
| 层 | 选型 |
|----|------|
| 前端 | Taro 3 + React + TypeScriptMonorepo 四 App |
| 后端 | Node 20 + NestJS 10 + Prisma 5 |
| 数据 | MySQL 8 + Redis 7 + BullMQ |
| 存储 | 阿里云 OSS → `common_resource` |
| 协作 | 一个 `server/dukang-api`,模块 OWNER,禁止跨模块直写表 |
---
## 4. 数据库 v3.1 要点(必记)
- **28 张表**,前缀:`user_` / `partner_` / `store_` / `hq_` / `common_` / `log_`
- **公共抽象**`common_resource`OSS)、`common_event`(业务事件)、`common_ticket`(工单)、`log_third_party`(第三方)、`log_user_analytics`(埋点)
- **C 端用户**`user_user``phone` 唯一必填
- **B 端三表**`store_account` / `partner_account` / `hq_account`
- **订单**`user_order` 含商品快照(V1 单 SKU,无 `user_order_item`
- **配送**`user_order_delivery``user_order` **1:1**
- **支付**:无 `payments` 表 → `log_third_party` + `user_order.pay_*`
- **核销码**:仅 Redis 5 分钟,无 DB 表
- **权益流水**`common_event(BENEFIT_LEDGER)``amount1`/`amount2` 仅此类使用
完整 DDL 见手册 **§五**。
---
## 5. API 与鉴权
- Base`/api/v1`
- JWT Payload`actorType`USER|STORE|PARTNER|HQ+ `actorId` + `clientApp`
- 请求头:`Authorization``X-Client-App`
- 响应:`{ code, message, data }`
- 完整路径见手册 **§六**
| clientApp | actorType | 账号表 |
|-----------|-----------|--------|
| USER_MINI | USER | user_user |
| SHOP_H5 | STORE | store_account |
| PARTNER_MINI | PARTNER | partner_account |
| HQ_MINI | HQ | hq_account |
---
## 6. 模块 OWNER2 人团队)
| 负责人 | Git 账号 | ApppreV1 → V2 | Module |
|--------|----------|-------------------|--------|
| Jacy | jacy-dukang | h5-user、admin-web → mini-user/mini-hq | iam, trade, benefit, analytics, catalog, settlement, ops |
| 刘景尧 | 刘景尧 | h5-partner、h5-shop → mini-partner | store, redeem |
| Jacy(横切) | jacy-dukang | packages/*, callbacks/, jobs/, integrations/ | 基础设施 |
**禁止**Module A 直写 Module B 的 Prisma 表;apps import server 源码。
---
## 7. 核心业务规则(不可偏离)
```text
权益发放额 = common_product_item.benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
C 端门店列表仅 status=OPEN
订单 Taball | pending_pay | pending_ship | pending_receive | completed
支付回调幂等 → 发券 → common_event(BENEFIT_LEDGER, GRANT)
门店核销结算 60%,T+1;合伙人 T+30 账单
```
---
## 8. 仓库目标结构(M0 优先)
```
dukang/
├── apps/{mini-user,mini-partner,mini-hq,h5-shop}/
├── packages/{shared-types,domain,shared-ui,shared-utils}/
├── server/dukang-api/{prisma,src/modules,callbacks,jobs}/
├── pages/{user,shop,partner,hq}/ # 原型图(只读参照)
├── 杜康好客-V2编码手册.md
├── agent.md / skills.md / conventions.md
└── deploy/docker-compose.yml
```
---
## 9. 完成定义(DoD
- [ ] 任务卡验收项全部满足
- [ ] 枚举/DTO 在 `packages/shared-types`
- [ ] 纯规则在 `packages/domain`(含单测)
- [ ] 未跨模块直写 Prisma 表
- [ ] 改了 API/表结构则同步手册相关章节(或提 PR 说明)
---
## 10. 里程碑顺序
**M0** Monorepo 骨架 → **M1** 四端登录+开城+商品 → **M2** 下单支付+5 Tab 订单 → **M3** 权益核销 → **M4** 拓店审核+售后 → **M5** 结算 → **M6** 推广码+埋点 → 上线
**M0-INFRA-001** 开始,除非用户指定其他任务卡。
---
*编码时 @ [`AGENTS.md`](./AGENTS.md) 或 Skill `dukang-coding`;需求变更只改 `杜康好客-V2编码手册.md`。*
-65
View File
@@ -1,65 +0,0 @@
# 杜康好客 · 前端 Apps AGENTS.md
> 父级:[`../AGENTS.md`](../AGENTS.md) · 路由映射:[`../pages/ROUTE_MAP.md`](../pages/ROUTE_MAP.md)
## App 一览(preV1
| App | 端口 | X-Client-App | 负责人 | 原型 |
|-----|------|--------------|--------|------|
| `h5-user` | 5173 | `USER_H5` | jacy-dukang | `pages/user/` |
| `mini-user` | 5177 / weapp | `USER_MINI` | jacy-dukang | `pages/user/`V3 目标形态) |
| `h5-shop` | 5174 | `SHOP_H5` | 刘景尧 | `pages/shop/` |
| `h5-partner` | 5175 | `PARTNER_H5` | 刘景尧 | `pages/partner/` |
| `admin-web` | — | Admin JWT | jacy-dukang | preV1 内部 HQ 替代 |
V3 目标:C 端交付为微信小程序 `apps/mini-user`(当前与 `h5-user` 并行;功能以 H5 为过渡参考)。
V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
## 前端硬规则
- 请求基址 `/api/v1`Header`Authorization` + `X-Client-App`
- 类型从 `@dukang/shared-types` import,禁止复制枚举字符串
- 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed`
## 新页面 workflow
1.`pages/ROUTE_MAP.md` 查 Stitch screen ↔ route
2. 对照 `pages/{端}/*/code.html` + `screen.png`(只读参照)
3. 在对应 App 的 `src/pages/` 实现;API 封装放 `src/lib/api.ts`
4. 登录态:各 App 自有 Context/Storage,不跨 App 共享
## 各 App 职责边界
### h5-userjacy-dukang
主链路:登录 → 首页/详情 → 下单 Mock 支付 → **3 Tab** 订单 → 权益 → 核销码 → 门店列表
**勿改**:门店核销确认 UI(属 h5-shop)、合伙人录店(属 h5-partner
### h5-shop(刘景尧)
主链路:门店登录 → 首页 → 扫码/输入核销 → 确认 → 记录 → 营业状态
**勿改**:C 端出码页面(属 h5-user)
### h5-partner(刘景尧)
主链路:登录 → 工作台 → 录店 → 门店列表 → 辖区订单 → Mock 推进配送(preV1
**勿改**:C 端商品/订单页、总部报表(admin-web / V2 mini-hq
### admin-webjacy-dukangpreV1 限定)
内部 Web 管理:开城、商品、订单、门店审核、结算查看。**不是** V2 总部小程序规格。
变更 admin-web 时勿假设与 `pages/hq/` 原型 1:1V2 需另建 `mini-hq`
## 启动
```bash
pnpm dev:user | dev:shop | dev:partner | dev:admin
pnpm dev:mini-user # C 端小程序 H5 预览 :5177
pnpm dev:mini-user:weapp # 微信开发者工具打开 apps/mini-user/dist
```
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>杜康好客 · HQ 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-30
View File
@@ -1,30 +0,0 @@
{
"name": "@dukang/admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5175",
"build": "vite build",
"lint": "echo ok"
},
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@dukang/shared-types": "workspace:*",
"antd": "^5.22.0",
"dayjs": "^1.11.13",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"element-china-area-data": "^6.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.4.5",
"vite": "^5.4.0"
}
}
-134
View File
@@ -1,134 +0,0 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { getToken } from './lib/api';
import AdminLayout from './layouts/AdminLayout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage';
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
import StoresPage from './pages/StoresPage';
import StoreRatingsPage from './pages/StoreRatingsPage';
import StoreAccountsPage from './pages/StoreAccountsPage';
import BenefitCouponsPage from './pages/BenefitCouponsPage';
import BenefitLedgersPage from './pages/BenefitLedgersPage';
import RedeemRecordsPage from './pages/RedeemRecordsPage';
import PendingRedeemPage from './pages/PendingRedeemPage';
import RedeemDebugPage from './pages/RedeemDebugPage';
import DeliveriesPage from './pages/DeliveriesPage';
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage';
import DomainEventsPage from './pages/DomainEventsPage';
import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
import StoreLogsPage from './pages/StoreLogsPage';
import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import WecomBotsPage from './pages/WecomBotsPage';
import LlmConfigsPage from './pages/LlmConfigsPage';
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/promo-codes" element={<PromoCodesPage />} />
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
<Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} />
</Route>
<Route path="/wecom-bots" element={<WecomBotsPage />} />
<Route path="/llm-configs" element={<LlmConfigsPage />} />
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/store-package-audits" element={<StorePackageAuditsPage />} />
<Route path="/store-ratings" element={<StoreRatingsPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/partners" element={<Navigate to="/city-partners" replace />} />
<Route path="/cities" element={<CitiesPage />} />
<Route path="/city-partners" element={<CityPartnersPage />} />
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route
path="/finance/store-withdrawals"
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
/>
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route
path="/store-withdrawals"
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
/>
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/tickets/support" element={<SupportTicketsPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/partners" element={<PartnerLogsPage />} />
<Route path="/logs/hq" element={<HqLogsPage />} />
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
<Route path="/logs/domain-events" element={<DomainEventsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
-88
View File
@@ -1,88 +0,0 @@
html,
body,
#root {
height: 100%;
margin: 0;
}
/* 左侧导航可滚动但不显示滚动条 */
.admin-sider-menu-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.admin-sider-menu-scroll::-webkit-scrollbar {
display: none;
}
/*
* 后台内容行:不换行 + 超出省略
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
*/
.admin-layout .ant-table-cell,
.ant-drawer .ant-table-cell,
.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: hidden;
text-overflow: ellipsis;
}
/* 操作列、含交互控件的不截断 */
.admin-layout .ant-table-cell:has(.ant-btn),
.ant-drawer .ant-table-cell:has(.ant-btn),
.ant-modal .ant-table-cell:has(.ant-btn),
.admin-layout .ant-table-cell:has(.ant-space),
.ant-drawer .ant-table-cell:has(.ant-space),
.ant-modal .ant-table-cell:has(.ant-space) {
overflow: visible;
text-overflow: clip;
max-width: none;
}
/* 代码块、表单区域保持原样 */
.admin-layout pre,
.ant-drawer pre,
.ant-modal pre,
.admin-layout .ant-form-item-control-input,
.ant-drawer .ant-form-item-control-input,
.ant-modal .ant-form-item-control-input {
white-space: pre-wrap;
overflow: auto;
text-overflow: unset;
max-width: none;
}
/* 表格单元格双行信息合并为单行省略 */
.admin-cell-line {
display: block;
max-width: 100%;
}
.admin-cell-line-secondary {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.admin-table-nowrap .ant-table-cell,
.admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap;
}
@@ -1,34 +0,0 @@
import { Typography } from 'antd';
type AdminCellLineProps = {
primary?: string | null;
secondary?: string | null;
separator?: string;
};
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
export function AdminCellLine({
primary,
secondary,
separator = ' · ',
}: AdminCellLineProps) {
const main = primary?.trim() || '—';
const sub = secondary?.trim();
const full = sub ? `${main}${separator}${sub}` : main;
return (
<Typography.Text
className="admin-cell-line"
ellipsis={{ tooltip: full }}
style={{ maxWidth: '100%' }}
>
{main}
{sub ? (
<span className="admin-cell-line-secondary">
{separator}
{sub}
</span>
) : null}
</Typography.Text>
);
}
@@ -1,217 +0,0 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Form, Input, InputNumber, Space, Typography, message } from 'antd';
import { DownOutlined, UpOutlined } from '@ant-design/icons';
import type { StorePackageItemDto } from '@dukang/shared-types';
import { STORE_PACKAGE_MAX_COUNT } from '@dukang/shared-types';
import { request } from '../lib/api';
type PackageRow = StorePackageItemDto;
function emptyRow(index = 0): PackageRow {
return { name: '', price: '0', dishes: '', usableTime: '', otherNotes: '', sortOrder: index };
}
export default function AdminStorePackagesSection({ storeId }: { storeId: string }) {
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
setLoading(true);
request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`)
.then((data) => {
setItems(
data.live?.length
? data.live.map((p, i) => ({ ...p, price: String(p.price), sortOrder: i }))
: [emptyRow()],
);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
.finally(() => setLoading(false));
}, [storeId]);
function updateAt(index: number, patch: Partial<PackageRow>) {
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
}
function addRow() {
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
setItems((prev) => [...prev, emptyRow(prev.length)]);
}
function removeAt(index: number) {
setItems((prev) => prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i })));
setCollapsed((prev) => {
const next: Record<number, boolean> = {};
Object.entries(prev).forEach(([k, v]) => {
const i = Number(k);
if (i < index) next[i] = v;
else if (i > index) next[i - 1] = v;
});
return next;
});
}
function toggleCollapse(index: number) {
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
}
async function save() {
const filled = items
.map((item, index) => ({
name: item.name.trim(),
price: item.price.trim(),
dishes: item.dishes.trim(),
usableTime: item.usableTime?.trim() || null,
otherNotes: item.otherNotes?.trim() || null,
sortOrder: index,
}))
.filter((item) => item.name || item.dishes || item.price);
for (let i = 0; i < filled.length; i++) {
const item = filled[i];
if (!item.name) {
message.warning(`${i + 1} 条套餐名称不能为空`);
return;
}
if (!item.dishes) {
message.warning(`${i + 1} 条套餐菜品不能为空`);
return;
}
const price = Number(item.price);
if (!Number.isFinite(price) || price < 0) {
message.warning(`${i + 1} 条套餐价格须为非负数字`);
return;
}
}
setSaving(true);
try {
await request(`/admin/stores/${storeId}/packages`, {
method: 'PUT',
body: JSON.stringify({
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
}),
});
message.success('套餐已保存并生效');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
if (loading) {
return <Typography.Text type="secondary"></Typography.Text>;
}
return (
<Form layout="vertical" requiredMark={false}>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
/>
{items.map((item, index) => {
const isCollapsed = !!collapsed[index];
const displayName = item.name.trim() || `套餐 ${index + 1}`;
return (
<div
key={index}
style={{
marginBottom: 16,
padding: 16,
border: '1px solid #f0f0f0',
borderRadius: 8,
background: '#fafafa',
}}
>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
<Button
type="text"
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
onClick={() => toggleCollapse(index)}
style={{ paddingLeft: 0, height: 'auto' }}
>
<Typography.Title level={5} style={{ margin: 0 }}>
{displayName}
</Typography.Title>
</Button>
{items.length > 1 ? (
<Button type="link" danger onClick={() => removeAt(index)}>
</Button>
) : null}
</Space>
{!isCollapsed ? (
<>
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
<Input
placeholder="如:套餐A"
value={item.name}
onChange={(e) => updateAt(index, { name: e.target.value })}
/>
</Form.Item>
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
<InputNumber
min={0}
precision={2}
style={{ width: '100%' }}
addonAfter="元"
placeholder="198"
value={item.price === '' ? undefined : Number(item.price)}
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
/>
</Form.Item>
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
<Input.TextArea
rows={2}
placeholder="红烧肉、红烧鱼、油焖茄子"
value={item.dishes}
onChange={(e) => updateAt(index, { dishes: e.target.value })}
/>
</Form.Item>
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
<Input
placeholder="节假日除外"
value={item.usableTime || ''}
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
/>
</Form.Item>
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
<Input
placeholder="不可叠加"
value={item.otherNotes || ''}
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
/>
</Form.Item>
</>
) : null}
</div>
);
})}
{items.length < STORE_PACKAGE_MAX_COUNT ? (
<Button onClick={addRow} style={{ marginBottom: 16 }}>
</Button>
) : null}
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
C
</Typography.Paragraph>
<Button type="primary" loading={saving} onClick={() => void save()}>
</Button>
</Form>
);
}
@@ -1,34 +0,0 @@
import { Cascader } from 'antd';
import type { DefaultOptionType } from 'antd/es/cascader';
import { PROVINCE_CITY_OPTIONS } from '../lib/china-region';
type ChinaProvinceCityCascaderProps = {
value?: string[];
onChange?: (codes: string[]) => void;
disabled?: boolean;
placeholder?: string;
};
export default function ChinaProvinceCityCascader({
value,
onChange,
disabled,
placeholder = '请选择省 / 市',
}: ChinaProvinceCityCascaderProps) {
return (
<Cascader
options={PROVINCE_CITY_OPTIONS as DefaultOptionType[]}
value={value}
onChange={(codes) => onChange?.((codes ?? []) as string[])}
disabled={disabled}
placeholder={placeholder}
showSearch={{
filter: (input, path) =>
path.some((option) =>
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
),
}}
changeOnSelect={false}
/>
);
}
@@ -1,34 +0,0 @@
import { Cascader } from 'antd';
import type { DefaultOptionType } from 'antd/es/cascader';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
type ChinaRegionCascaderProps = {
value?: string[];
onChange?: (codes: string[]) => void;
disabled?: boolean;
placeholder?: string;
};
export default function ChinaRegionCascader({
value,
onChange,
disabled,
placeholder = '请选择省 / 市 / 区县',
}: ChinaRegionCascaderProps) {
return (
<Cascader
options={CHINA_REGION_OPTIONS as DefaultOptionType[]}
value={value}
onChange={(codes) => onChange?.((codes ?? []) as string[])}
disabled={disabled}
placeholder={placeholder}
showSearch={{
filter: (input, path) =>
path.some((option) =>
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
),
}}
changeOnSelect={false}
/>
);
}
@@ -1,42 +0,0 @@
import { useMemo } from 'react';
import { Select } from 'antd';
import { getDistrictOptionsByCityCode } from '../lib/china-region';
type Props = {
cityCode?: string | null;
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
disabled?: boolean;
};
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
export default function CityDistrictMultiSelect({
cityCode,
value,
onChange,
placeholder,
disabled,
}: Props) {
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
const ready = Boolean(cityCode) && options.length > 0;
return (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
value={value}
onChange={onChange}
disabled={disabled || !ready}
placeholder={
placeholder ??
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
}
options={options}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
);
}
@@ -1,459 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Checkbox,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = {
id: string;
companyName: string;
phone: string;
name: string;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = PartnerRow & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
contactPhone?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type Props = {
cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number;
onChanged?: () => void;
};
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
function commissionSumError(
orderPercent: number,
redeemPercent: number,
maxRate: number,
): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPanel({
cityId,
cityCode,
maxPartnerCommissionRate = 0.05,
onChanged,
}: Props) {
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [partners, setPartners] = useState<PartnerRow[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const loadPartners = useCallback(async () => {
setLoading(true);
try {
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
setPartners(res.items);
} finally {
setLoading(false);
}
}, [cityId]);
useEffect(() => {
void loadPartners();
}, [loadPartners]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
async function deleteSubAccount(subId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
if (detail) void openPartner(detail.id);
void loadPartners();
onChanged?.();
}
const columns: ColumnsType<PartnerRow> = [
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 120,
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<>
<Button
type="primary"
style={{ marginBottom: 12 }}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({
cityId,
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateOpen(true);
}}
>
</Button>
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<Tabs
items={[
{
key: 'info',
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<Form.Item label="管仓仓库">
<Input
disabled
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
/>
</Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
),
},
{
key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(row) => {
setSubEditId(row.id);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}}
onDelete={(subId) => void deleteSubAccount(subId)}
/>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
try {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
void openPartner(detail.id);
void loadPartners();
onChanged?.();
}}>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
if (!subEditId || !detail) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
void openPartner(detail.id);
}}>
<Form form={subEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</>
);
}
@@ -1,138 +0,0 @@
import { useEffect, useState } from 'react';
import { Button, Space } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import {
parseMiniHomeBanners,
serializeMiniHomeBanners,
} from '@dukang/shared-types';
import OssUpload from './OssUpload';
const MAX_BANNERS = 8;
/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */
function parseBannersForEdit(raw?: string | null): string[] {
if (!raw?.trim()) return [];
try {
const parsed = JSON.parse(raw.trim()) as unknown;
if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw);
return parsed
.filter((u): u is string => typeof u === 'string')
.map((u) => u.trim())
.slice(0, MAX_BANNERS);
} catch {
return parseMiniHomeBanners(raw);
}
}
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer */
export function ConfigImageField({
value,
onChange,
bizType = 'footer',
}: {
value?: string;
onChange?: (url: string) => void;
bizType?: string;
}) {
return (
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={value ?? ''}
onChange={(url) => onChange?.(url ?? '')}
placeholder="上传或粘贴图片 URL"
/>
);
}
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper */
export function ConfigImageListField({
value,
onChange,
bizType = 'swiper',
}: {
value?: string;
onChange?: (json: string) => void;
bizType?: string;
}) {
const [urls, setUrls] = useState<string[]>(() => parseBannersForEdit(value));
useEffect(() => {
setUrls(parseBannersForEdit(value));
}, [value]);
/** 本地可含空位;写入 Form 时去掉空串 */
function commit(next: string[]) {
const clipped = next.slice(0, MAX_BANNERS);
setUrls(clipped);
onChange?.(serializeMiniHomeBanners(clipped));
}
function updateAt(index: number, url: string) {
const next = [...urls];
next[index] = url;
commit(next);
}
function removeAt(index: number) {
commit(urls.filter((_, i) => i !== index));
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= urls.length) return;
const next = [...urls];
const tmp = next[index];
next[index] = next[target];
next[target] = tmp;
commit(next);
}
function add() {
if (urls.length >= MAX_BANNERS) return;
// 只加本地空位,避免 serialize 过滤空串导致「点击无反应」
setUrls((prev) => [...prev, '']);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{urls.map((url, index) => (
<Space key={`banner-${index}`} align="start" style={{ width: '100%' }} wrap>
<div style={{ flex: 1, minWidth: 240 }}>
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={url}
onChange={(u) => updateAt(index, u)}
placeholder="上传或粘贴图片 URL"
/>
</div>
<Space>
<Button
type="text"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => move(index, -1)}
/>
<Button
type="text"
icon={<ArrowDownOutlined />}
disabled={index === urls.length - 1}
onClick={() => move(index, 1)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
</Space>
</Space>
))}
<Button
type="dashed"
block
icon={<PlusOutlined />}
disabled={urls.length >= MAX_BANNERS}
onClick={add}
>
{urls.length}/{MAX_BANNERS}
</Button>
</Space>
);
}
@@ -1,49 +0,0 @@
import { Button, Form, Space, Typography } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import OssUpload from './OssUpload';
type Props = {
name?: string;
label: string;
bizType?: string;
/** 最多可添加张数;不传则不限制 */
maxCount?: number;
};
export default function DetailImageUrlList({
name = 'detailImageUrls',
label,
bizType = 'DETAIL',
maxCount,
}: Props) {
return (
<>
{maxCount != null && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{maxCount} {label}
</Typography.Text>
)}
<Form.List name={name}>
{(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={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
{(!maxCount || fields.length < maxCount) && (
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
)}
</>
)}
</Form.List>
</>
);
}
@@ -1,87 +0,0 @@
import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
type OssUploadProps = {
value?: string;
onChange?: (url: string) => void;
onUploaded?: (result: UploadFileResult) => void;
bizType: string;
mediaType?: OssMediaType;
accept?: string;
maxSizeMb?: number;
placeholder?: string;
};
const DEFAULT_MAX_MB = 10;
export default function OssUpload({
value,
onChange,
onUploaded,
bizType,
mediaType = 'IMAGE',
accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true);
try {
const result = await uploadFileToOss(raw, { bizType, mediaType });
onChange?.(result.url);
onUploaded?.(result);
message.success('上传成功');
onSuccess?.(result);
} catch (e) {
const err = e instanceof Error ? e : new Error('上传失败');
message.error(err.message);
onError?.(err);
} finally {
setUploading(false);
}
};
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
)}
{value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)}
<Space wrap>
<Upload
accept={resolvedAccept}
showUploadList={false}
customRequest={customRequest}
disabled={uploading}
>
<Button icon={<UploadOutlined />} loading={uploading}>
{mediaType === 'VIDEO' ? '上传视频' : mediaType === 'FILE' ? '上传文件' : '上传图片'}
</Button>
</Upload>
</Space>
<Input
value={value}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
allowClear
/>
</Space>
);
}
@@ -1,92 +0,0 @@
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
export type PartnerSubAccountRow = {
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
};
function formatPermissions(permissions?: string[]) {
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
}
type Props = {
subs: PartnerSubAccountRow[];
onAdd: () => void;
onEdit: (sub: PartnerSubAccountRow) => void;
onDelete: (subId: string) => void;
};
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
const columns: ColumnsType<PartnerSubAccountRow> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '角色',
dataIndex: 'staffRole',
width: 90,
render: (v) =>
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '权限',
dataIndex: 'permissions',
ellipsis: true,
render: (p: string[] | undefined) => formatPermissions(p),
},
{
title: '操作',
width: 120,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => onEdit(row)}>
</Button>
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{subs.length}·
</Typography.Text>
<Button size="small" type="primary" onClick={onAdd}>
</Button>
</div>
<Table
size="small"
rowKey="id"
pagination={false}
columns={columns}
dataSource={subs}
locale={{ emptyText: '暂无子账号' }}
/>
</>
);
}
@@ -1,185 +0,0 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
import type { FormInstance } from 'antd/es/form';
import { request, type Paginated } from '../lib/api';
import {
getProductDetailTemplate,
mapDtoToProductDetailTemplate,
type ProductDetailTemplate,
type ProductDetailTemplateDto,
} from '../lib/product-detail-templates';
type Props = {
form: FormInstance;
/** 当前商品香型,用于推荐匹配模板 */
aromaType?: string;
};
function hasDetailContent(form: FormInstance) {
const storyTitle = form.getFieldValue('storyTitle') as string | undefined;
const storyText = form.getFieldValue('storyText') as string | undefined;
const features = form.getFieldValue('features') as Array<{ title?: string; desc?: string }> | undefined;
const detailImageUrls = form.getFieldValue('detailImageUrls') as string[] | undefined;
const hasFeatures = (features ?? []).some((f) => f?.title?.trim() || f?.desc?.trim());
const hasImages = (detailImageUrls ?? []).some((u) => u?.trim());
return Boolean(storyTitle?.trim() || storyText?.trim() || hasFeatures || hasImages);
}
function applyTemplate(form: FormInstance, template: ProductDetailTemplate) {
const { content } = template;
const detailImageUrls = content.detailImageUrls?.length
? [...content.detailImageUrls]
: [''];
form.setFieldsValue({
storyTitle: content.storyTitle ?? '',
storyText: content.storyText ?? '',
detailImageUrls,
features:
content.features && content.features.length > 0
? content.features.map((f) => ({ ...f }))
: [{ icon: 'star', title: '', desc: '' }],
});
const imageHint = detailImageUrls.filter(Boolean).length;
message.success(
imageHint > 0
? `已应用模板「${template.label}」(含 ${imageHint} 张详情图,可逐张修改)`
: `已应用模板「${template.label}`,
);
}
export default function ProductDetailTemplatePicker({ form, aromaType }: Props) {
const [templates, setTemplates] = useState<ProductDetailTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | undefined>();
useEffect(() => {
let cancelled = false;
setLoading(true);
request<Paginated<ProductDetailTemplateDto>>(
'/admin/product-detail-templates?status=ACTIVE&pageSize=100',
)
.then((res) => {
if (cancelled) return;
const mapped = res.items.map(mapDtoToProductDetailTemplate);
setTemplates(mapped);
const defaultId =
(aromaType && mapped.find((t) => t.aromaType === aromaType)?.id) ||
mapped.find((t) => t.code === 'dukang-classic')?.id ||
mapped[0]?.id;
setSelectedId(defaultId);
})
.catch(() => {
if (!cancelled) message.error('加载详情模板失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [aromaType]);
const selected = getProductDetailTemplate(templates, selectedId ?? '');
function doApply() {
if (!selected) return;
applyTemplate(form, selected);
}
function handleApply() {
if (!selected) return;
if (hasDetailContent(form)) {
return;
}
doApply();
}
if (loading) {
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Spin size="small" />
</Card>
);
}
if (templates.length === 0) {
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
</Typography.Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Select
style={{ width: '100%' }}
placeholder="选择模板"
value={selectedId}
onChange={setSelectedId}
options={templates.map((t) => ({
value: t.id,
label: t.label,
}))}
/>
{selected && (
<Alert
type="info"
showIcon
message={selected.description}
description={
<div style={{ fontSize: 12 }}>
{selected.content.storyTitle && (
<div><strong></strong>{selected.content.storyTitle}</div>
)}
{(selected.content.detailImageUrls?.length ?? 0) > 0 && (
<div style={{ marginTop: 4 }}>
<strong>{selected.content.detailImageUrls?.length}</strong>
</div>
)}
{(selected.content.features?.length ?? 0) > 0 && (
<div style={{ marginTop: 4 }}>
{selected.content.features?.map((f) => f.title).join('、')}
</div>
)}
</div>
}
/>
)}
<Space>
{hasDetailContent(form) ? (
<Popconfirm
title="将覆盖当前详情图、故事与卖点,是否继续?"
onConfirm={doApply}
okText="覆盖应用"
cancelText="取消"
>
<Button type="primary"></Button>
</Popconfirm>
) : (
<Button type="primary" onClick={handleApply} disabled={!selected}>
</Button>
)}
{aromaType && (
<Button
onClick={() => {
const matched = templates.find((t) => t.aromaType === aromaType);
if (matched) {
setSelectedId(matched.id);
if (!hasDetailContent(form)) {
applyTemplate(form, matched);
}
}
}}
>
</Button>
)}
</Space>
</Space>
</Card>
);
}
@@ -1,424 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Button,
Checkbox,
Form,
Input,
InputNumber,
Modal,
QRCode,
Radio,
Select,
Space,
Typography,
message,
} from 'antd';
import type {
HqProxyOrderCreateRequest,
PartnerProxyDeliveryMode,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
ProxyOrderCreateResponse,
ProxyOrderPayResponse,
} from '@dukang/shared-types';
import ChinaRegionCascader from './ChinaRegionCascader';
import { parseRegionCodes } from '../lib/china-region';
import { request } from '../lib/api';
type ProxyOrderModalProps = {
open: boolean;
onClose: () => void;
onSuccess: (order: { id: string; orderNo: string }) => void;
};
type PayStatus = {
id: string;
orderNo: string;
status: string;
payStatus: string;
};
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) {
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [phone, setPhone] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState<string>();
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState<string>();
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [step, setStep] = useState<'form' | 'pay'>('form');
const [created, setCreated] = useState<ProxyOrderCreateResponse | null>(null);
const [codeUrl, setCodeUrl] = useState<string | null>(null);
const [payLoading, setPayLoading] = useState(false);
const pollRef = useRef<number | null>(null);
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
function stopPoll() {
if (pollRef.current != null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
}
useEffect(() => {
if (!open) return;
setLoadingOptions(true);
request<PartnerProxyOrderOptions>('/admin/proxy-orders/options')
.then((data) => {
setOptions(data);
if (data.products[0]) setProductId(data.products[0].id);
})
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false));
}, [open]);
const selectedProduct = options?.products.find((p) => p.id === productId);
const allowOnline = selectedProduct ? selectedProduct.allowOnlinePurchase !== false : true;
const allowOnSite = !!selectedProduct?.allowOnSitePickup;
const allowCrossCity = selectedProduct ? selectedProduct.allowCrossCityDelivery !== false : true;
useEffect(() => {
if (!open || !selectedProduct) return;
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
setDeliveryMode('ON_SITE_PICKUP');
return;
}
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
setDeliveryMode('ADDRESS');
}
}, [open, selectedProduct, allowOnline, allowOnSite, deliveryMode]);
useEffect(() => {
if (!open || !productId || quantity < 1 || step !== 'form') {
if (step === 'form') setPreview(null);
return;
}
const timer = window.setTimeout(() => {
setPreviewLoading(true);
request<PartnerProxyOrderPreviewResult>('/admin/proxy-orders/preview', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
deliveryMode,
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}),
})
.then(setPreview)
.catch(() => setPreview(null))
.finally(() => setPreviewLoading(false));
}, 300);
return () => window.clearTimeout(timer);
}, [open, productId, quantity, deliveryMode, region?.city, region?.district, step]);
useEffect(() => () => stopPoll(), []);
function resetForm() {
stopPoll();
setPhone('');
setReceiverName('');
setRegionCodes([]);
setAddressDetail('');
setQuantity(2);
setPromoCodeId(undefined);
setDeliveryMode('ADDRESS');
setAutoReceive(false);
setPreview(null);
setProductId(options?.products[0]?.id);
setStep('form');
setCreated(null);
setCodeUrl(null);
setPayLoading(false);
}
function handleClose() {
resetForm();
onClose();
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!productId) return '请选择商品';
if (deliveryMode === 'ADDRESS') {
if (!allowOnline) return '该商品不支持线上购买';
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
} else if (!allowOnSite) {
return '该商品不支持现场提货';
}
if (!preview) return '请等待费用计算完成';
return null;
}
function startPoll(orderId: string) {
stopPoll();
pollRef.current = window.setInterval(() => {
void request<PayStatus>(`/admin/proxy-orders/${orderId}/pay-status`)
.then((st) => {
if (st.payStatus === 'PAID') {
stopPoll();
message.success(`支付成功:${st.orderNo}`);
const payload = { id: st.id, orderNo: st.orderNo };
resetForm();
onSuccess(payload);
}
})
.catch(() => undefined);
}, 2000);
}
async function submit() {
const err = validateForm();
if (err) {
message.warning(err);
return;
}
const payload: HqProxyOrderCreateRequest = {
phone: phone.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined,
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId: productId!,
quantity,
promoCodeId: promoCodeId || undefined,
};
setSubmitting(true);
try {
const order = await request<ProxyOrderCreateResponse>('/admin/proxy-orders', {
method: 'POST',
body: JSON.stringify(payload),
});
setCreated(order);
setStep('pay');
setPayLoading(true);
const pay = await request<ProxyOrderPayResponse>(`/admin/proxy-orders/${order.id}/pay`, {
method: 'POST',
body: JSON.stringify({ payMethod: 'NATIVE' }),
});
setCodeUrl(pay.codeUrl ?? null);
startPoll(order.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '下单失败');
setStep('form');
setCreated(null);
} finally {
setSubmitting(false);
setPayLoading(false);
}
}
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return (
<Modal
title={step === 'pay' ? '代下单 · 待支付' : '代下单'}
open={open}
onCancel={handleClose}
width={640}
destroyOnClose
footer={
step === 'pay' ? (
<Space>
<Button onClick={handleClose}></Button>
</Space>
) : (
<Space>
<Button onClick={handleClose}></Button>
<Button type="primary" loading={submitting || loadingOptions} onClick={() => void submit()}>
</Button>
</Space>
)
}
>
{step === 'pay' && created ? (
<div style={{ textAlign: 'center' }}>
<Alert
type="info"
showIcon
style={{ marginBottom: 16, textAlign: 'left' }}
message={`订单 ${created.orderNo} 待支付 ¥${fmtMoney(created.payAmount)},请扫码支付`}
description={
created.deliveryType === 'ON_SITE_PICKUP'
? '支付完成后订单即为已完成,并发放好客权益'
: '支付完成后将进入待发货,由总部履约发货'
}
/>
{payLoading && !codeUrl ? (
<Typography.Text type="secondary"></Typography.Text>
) : codeUrl ? (
<Space direction="vertical" size={12} align="center">
<QRCode value={codeUrl} size={200} />
<Typography.Text type="secondary">使</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
{codeUrl}
</Typography.Text>
</Space>
) : (
<Typography.Text type="danger"></Typography.Text>
)}
</div>
) : (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="提交后生成微信收款码,支付成功后发放好客权益;配送单进入待发货"
/>
<Form layout="vertical">
<Form.Item label="用户手机号" required>
<Input
placeholder="11 位手机号"
value={phone}
maxLength={11}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
</Form.Item>
<Form.Item label="酒品" required>
<Select
showSearch
optionFilterProp="label"
loading={loadingOptions}
placeholder="选择商品"
value={productId}
onChange={setProductId}
options={(options?.products ?? []).map((p) => ({
value: p.id,
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
}))}
/>
</Form.Item>
<Form.Item label="数量" required>
<InputNumber
min={1}
value={quantity}
onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))}
style={{ width: '100%' }}
/>
</Form.Item>
{(options?.promoCodes.length ?? 0) > 0 ? (
<Form.Item label="绑定推广码(选填)">
<Select
allowClear
placeholder="不绑定"
value={promoCodeId}
onChange={setPromoCodeId}
options={(options?.promoCodes ?? []).map((p) => ({
value: p.id,
label: `${p.code} · ${p.name}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label="履约方式" required>
{allowOnline && allowOnSite ? (
<Radio.Group
value={deliveryMode}
onChange={(e) => {
setDeliveryMode(e.target.value);
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
}}
options={[
{ value: 'ADDRESS', label: '配送到址' },
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
]}
/>
) : (
<Typography.Text type="secondary">
{!allowOnline && allowOnSite
? '该商品仅支持现场提货'
: allowOnline && !allowOnSite
? allowCrossCity
? '该商品仅支持配送到址(含跨城)'
: '该商品仅支持配送到址(不可跨城)'
: '该商品暂无可选履约方式'}
</Typography.Text>
)}
</Form.Item>
{deliveryMode === 'ADDRESS' && allowOnline ? (
<>
<Form.Item label="收货人(选填)">
<Input
placeholder="默认:用户+手机尾号"
value={receiverName}
maxLength={32}
onChange={(e) => setReceiverName(e.target.value)}
/>
</Form.Item>
<Form.Item label="省市区" required>
<ChinaRegionCascader value={regionCodes} onChange={setRegionCodes} />
</Form.Item>
<Form.Item label="详细地址" required>
<Input.TextArea
rows={2}
placeholder="街道门牌等"
value={addressDetail}
maxLength={256}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</Form.Item>
<Form.Item>
<Checkbox checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)}>
</Checkbox>
</Form.Item>
</>
) : null}
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>
<Typography.Text type="secondary"></Typography.Text>
{previewLoading ? (
<div></div>
) : preview ? (
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 8 }}>
<div>{deliveryLabel}</div>
<div>¥{fmtMoney(preview.productAmount)}</div>
<div>¥{fmtMoney(preview.payAmount)}</div>
<div>¥{fmtMoney(preview.benefitAmount)}</div>
</Space>
) : (
<div style={{ marginTop: 8 }}>
<Typography.Text type="secondary"></Typography.Text>
</div>
)}
</div>
</Form>
</>
)}
</Modal>
);
}
@@ -1,230 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
import { request } from '../lib/api';
import {
placeToPicked,
type LbsPlaceItem,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
type Props = {
open: boolean;
onClose: () => void;
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
/** 城市名,提升搜索相关性 */
region?: string | null;
};
function hasCoords(lat: unknown, lng: unknown): lat is number {
const a = typeof lat === 'number' ? lat : Number(lat);
const b = typeof lng === 'number' ? lng : Number(lng);
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
}
export default function TencentLocPickerModal({
open,
onClose,
onPick,
latitude,
longitude,
region,
}: Props) {
const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
const seqRef = useRef(0);
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setPending(null);
setHint('输入地点名称搜索,或加载附近地点');
return;
}
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (hasCoords(lat, lng)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function loadNearby(lat: number, lng: number) {
const seq = ++seqRef.current;
setLoading(true);
setHint('正在加载附近地点…');
try {
const res = await request<{ items: LbsPlaceItem[] }>(
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '加载附近地点失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) {
message.warning('请输入搜索关键词');
return;
}
const seq = ++seqRef.current;
setLoading(true);
setHint('搜索中…');
try {
const params = new URLSearchParams({ keyword: trimmed });
if (region?.trim()) params.set('region', region.trim());
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
params.set('lat', String(lat));
params.set('lng', String(lng));
}
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '搜索失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
message.error('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
void loadNearby(lat, lng);
},
() => {
setLoading(false);
message.error('定位失败,请检查浏览器定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
function confirmPick() {
if (!pending) {
message.warning('请先从列表中选择一个地点');
return;
}
onPick(pending);
onClose();
}
return (
<Modal
title="地图选点(腾讯位置服务)"
open={open}
onCancel={onClose}
width={720}
destroyOnClose
footer={
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
{pending
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
pending.name ? ` · ${pending.name}` : ''
}`
: '搜索或选择附近地点后确认'}
</Typography.Text>
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" disabled={!pending} onClick={confirmPick}>
</Button>
</Space>
</Space>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space.Compact style={{ width: '100%' }}>
<Input
allowClear
placeholder="输入小区 / 写字楼 / 门店名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => void runSearch(keyword)}
/>
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
</Button>
</Space.Compact>
<Space wrap>
<Button onClick={useBrowserLocation} disabled={loading}>
</Button>
<Typography.Text type="secondary">{hint}</Typography.Text>
</Space>
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
{loading && !items.length ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin tip="加载中…" />
</div>
) : items.length ? (
<List
size="small"
dataSource={items}
renderItem={(item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<List.Item
style={{
cursor: 'pointer',
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
paddingInline: 12,
}}
onClick={() => setPending(placeToPicked(item))}
>
<List.Item.Meta
title={item.title}
description={
<span>
{item.address}
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</Typography.Text>
</span>
}
/>
</List.Item>
);
}}
/>
) : (
<Empty style={{ marginTop: 80 }} description={hint} />
)}
</div>
</Space>
</Modal>
);
}
-338
View File
@@ -1,338 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd';
import type { MenuProps } from 'antd';
import {
RobotOutlined,
DashboardOutlined,
UserOutlined,
ShoppingOutlined,
ShopOutlined,
TeamOutlined,
GiftOutlined,
CarOutlined,
SafetyOutlined,
LogoutOutlined,
CloudUploadOutlined,
FileTextOutlined,
LockOutlined,
SettingOutlined,
AccountBookOutlined,
} from '@ant-design/icons';
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
const { Header, Sider, Content } = Layout;
type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' },
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
{
key: 'products-group',
icon: <ShoppingOutlined />,
label: '商品',
children: [
{ key: '/products', label: '商品列表' },
{ key: '/product-detail-templates', label: '详情模板' },
],
},
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
{
key: 'stores-group',
icon: <ShopOutlined />,
label: '门店',
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-package-audits', label: '套餐审核' },
{ key: '/store-ratings', label: '门店评价' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' },
],
},
{
key: 'partners-group',
icon: <TeamOutlined />,
label: '开城',
children: [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
],
},
{
key: 'finance-group',
icon: <AccountBookOutlined />,
label: '财务',
children: [
{ key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
],
},
{
key: 'benefit-group',
icon: <GiftOutlined />,
label: '好客权益',
children: [
{ key: '/benefit/coupons', label: '权益券' },
{ key: '/benefit/ledgers', label: '流水' },
{ key: '/redeem-records', label: '核销记录' },
{ key: '/redeem-pending', label: '待处理核销' },
{ key: '/redeem/debug', label: '核销调试' },
],
},
{
key: 'deliveries-group',
icon: <CarOutlined />,
label: '配送单',
children: [
{ key: '/deliveries', label: '配送单列表' },
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
],
},
{
key: 'tickets-group',
icon: <CarOutlined />,
label: '工单',
children: [
{ key: '/tickets', label: '工单中心' },
{ key: '/tickets/support', label: '技术支持' },
],
},
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{
key: 'logs-group',
icon: <FileTextOutlined />,
label: '日志',
children: [
{ key: '/logs/users', label: '用户日志' },
{ key: '/logs/stores', label: '商户日志' },
{ key: '/logs/partners', label: '合伙人日志' },
{ key: '/logs/hq', label: 'HQ 操作日志' },
{ key: '/logs/third-party', label: '第三方日志' },
{ key: '/logs/domain-events', label: '领域事件' },
],
},
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
];
function menuAllowed(key: string, permissionKeys: string[]): boolean {
if (key === 'tickets-group') {
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
}
const map: Record<string, string | 'system_settings_any'> = {
'/': 'dashboard',
'/users': 'users',
'/wechat-bindings': 'wechat_bindings',
'products-group': 'products',
'/products': 'products',
'/product-detail-templates': 'products',
'/orders': 'orders',
'/promo-codes': 'promo_codes',
'/wecom-bots': 'wecom_bots',
'/llm-configs': 'llm_configs',
'/knowledge-bases': 'knowledge_bases',
'stores-group': 'stores',
'/stores': 'stores',
'/store-package-audits': 'stores',
'/store-ratings': 'stores',
'/store-categories': 'stores',
'/store-accounts': 'stores',
'/store-media': 'stores',
'partners-group': 'partners',
'/cities': 'partners',
'/city-partners': 'partners',
'/city-warehouses': 'partners',
'/fulfillment-providers': 'partners',
'finance-group': 'finance',
'/finance/store-bills': 'finance',
'/finance/store-withdrawals': 'finance',
'/finance/partner-bills': 'finance',
'/finance/winery-bills': 'finance',
'/finance/logistics-bills': 'finance',
'benefit-group': 'benefit',
'/benefit/coupons': 'benefit',
'/benefit/ledgers': 'benefit',
'/redeem-records': 'benefit',
'/redeem-pending': 'benefit',
'/redeem/debug': 'benefit',
'deliveries-group': 'deliveries',
'/deliveries': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries',
'/tickets': 'tickets',
'/tickets/support': 'tech_support',
'/invoices': 'invoices',
'/resources': 'resources',
'logs-group': 'logs',
'/logs/users': 'logs',
'/logs/stores': 'logs',
'/logs/partners': 'logs',
'/logs/hq': 'logs',
'/logs/third-party': 'logs',
'/logs/domain-events': 'logs',
'/hq-permissions': 'hq_permissions',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
};
const need = map[key];
if (!need) return true;
if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys);
return permissionKeys.includes(need);
}
function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] {
if (!items) return items;
return items
.map((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) return item;
const key = String(item.key);
if ('children' in item && Array.isArray(item.children)) {
if (!menuAllowed(key, permissionKeys)) return null;
const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys);
if (!children?.length) return null;
return { ...item, children } as MenuItem;
}
return menuAllowed(key, permissionKeys) ? item : null;
})
.filter(Boolean) as MenuProps['items'];
}
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
const contentRef = useRef<HTMLDivElement>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0, left: 0 });
}, [location.pathname]);
useEffect(() => {
return bindAdminEllipsisTitle();
}, []);
function logout() {
clearAuth();
navigate('/login');
}
const selectedKey = location.pathname.startsWith('/promo-codes')
? '/promo-codes'
: location.pathname;
const menuItems = useMemo(() => {
if (!profile) return MENU_ITEMS;
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
}, [profile]);
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
{IS_STAGING ? (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1100,
background: '#d48806',
color: '#fff',
textAlign: 'center',
fontSize: 13,
fontWeight: 600,
lineHeight: '28px',
}}
>
· staging · · Mock
</div>
) : null}
<Sider
breakpoint="lg"
collapsedWidth={64}
theme="dark"
width={220}
style={{ height: '100vh', overflow: 'hidden', marginTop: IS_STAGING ? 28 : 0 }}
>
<div style={{ display: 'flex', flexDirection: 'column', height: IS_STAGING ? 'calc(100vh - 28px)' : '100vh' }}>
<div style={{ flexShrink: 0, padding: '16px', color: '#fff', fontWeight: 600 }}>
HQ{IS_STAGING ? ' · 测试' : ''}
</div>
<div className="admin-sider-menu-scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={[]}
items={menuItems}
onClick={({ key }) => {
if (key.startsWith('/')) navigate(key);
}}
/>
</div>
</div>
</Sider>
<Layout
style={{
height: IS_STAGING ? 'calc(100vh - 28px)' : '100vh',
marginTop: IS_STAGING ? 28 : 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<Header
style={{
flexShrink: 0,
background: '#fff',
padding: '0 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid #f0f0f0',
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<span>{profile?.name || '—'}</span>
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
退
</Button>
</Space>
</Header>
<div
ref={contentRef}
className="admin-layout"
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
>
<Content style={{ margin: 24 }}>
<Outlet />
</Content>
</div>
</Layout>
</Layout>
);
}
-210
View File
@@ -1,210 +0,0 @@
export const apiBase = '/api/v1';
export const CLIENT_APP = 'HQ_WEB';
export type HqProfile = {
id: string;
phone: string;
name: string;
adminRole: string;
status: string;
permissionKeys?: string[];
};
export function getToken() {
return localStorage.getItem('accessToken');
}
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
localStorage.setItem('accessToken', data.accessToken);
if (data.refreshToken) localStorage.setItem('refreshToken', data.refreshToken);
}
export function clearAuth() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
...(options.headers as Record<string, string>),
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const text = await res.text();
if (!text.trim()) {
throw new Error(`接口空响应(HTTP ${res.status} ${res.statusText || ''}`);
}
let json: { code: number; message?: string; data?: T };
try {
json = JSON.parse(text) as { code: number; message?: string; data?: T };
} catch {
throw new Error(`接口返回非 JSONHTTP ${res.status}: ${text.slice(0, 200)}`);
}
if (json.code === 401) {
clearAuth();
window.location.href = '/login';
throw new Error('未登录');
}
if (json.code !== 0) throw new Error(json.message || '请求失败');
return json.data as T;
}
export type Paginated<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
summary?: {
count: number;
redeemAmount?: number;
payoutAmount?: number;
orderAmount?: number;
orderCommission?: number;
redeemCommission?: number;
wineryAmount?: number;
totalAmount: number;
};
};
export type DashboardStats = {
usersTotal: number;
guestUsers: number;
verifiedUsers: number;
mergedUsers: number;
ordersToday: number;
storesTotal: number;
partnersTotal: number;
redeemToday: number;
deliveriesTotal: number;
pendingPayouts?: number;
/** 合伙人已确认、待总部审核打款 */
pendingBills?: number;
pendingPartnerDraftBills?: number;
openTickets?: number;
pendingStoreWithdrawals?: number;
overdueStoreWithdrawals?: number;
ordersByStatus: Array<{ status: string; count: number }>;
};
export type DashboardAnalytics = {
summary: {
users: number;
orders: number;
payingUsers: number;
partners: number;
stores: number;
redeems: number;
redeemAmount: number;
};
byDate: Array<{
date: string;
users: number;
orders: number;
partners: number;
stores: number;
redeems: number;
redeemAmount: number;
}>;
byCity: Array<{
cityId: string;
cityName: string;
users: number;
orders: number;
partners: number;
stores: number;
redeems: number;
redeemAmount: number;
}>;
byPromo: Array<{
promoCodeId: string | null;
code: string;
name: string;
users: number;
orders: number;
}>;
byPartner: Array<{
partnerAccountId: string;
companyName: string;
stores: number;
redeems: number;
redeemAmount: number;
}>;
};
export type SystemVersion = {
id: string;
gitTag: string | null;
commitId: string;
commitMessage: string;
branch: string | null;
deployedBy: string | null;
deployedAt: string;
};
export type DeployTriggerResult = {
accepted: boolean;
started?: boolean;
message: string;
};
export type AdminUserRow = {
id: string;
userNo: string;
deviceKey: string | null;
phone: string | null;
phoneVerifiedAt: string | null;
mergedIntoUserId: string | null;
wxOpenId: string | null;
wechatVerified: boolean;
nickname: string | null;
status: number;
sourceType: string;
sourceRefId: string | null;
sourceLabel: string | null;
createdAt: string;
orderCount: number;
};
export type AdminOrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
export type AdminOrderRow = {
id: string;
orderNo: string;
status: string;
deliveryType: string;
payAmount: number;
productName?: string;
productSpec?: string;
quantity?: number;
receiverName: string;
receiverPhone: string;
createdAt: string;
cityId?: string;
city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: string } | null;
fulfillmentHold?: boolean;
fulfillmentHoldReason?: string | null;
orderType?: string;
isProxyOrder?: boolean;
proxyPartnerName?: string | null;
proxyPartnerPhone?: string | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: {
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
logisticsCompany?: string | null;
manualQueryUrl?: string | null;
};
};
-160
View File
@@ -1,160 +0,0 @@
import { codeToText, regionData } from 'element-china-area-data';
export { regionData as CHINA_REGION_OPTIONS };
export type ProvinceCityOption = {
value: string;
label: string;
children?: Array<{ value: string; label: string }>;
};
/** 省 / 市二级(不含区县) */
export const PROVINCE_CITY_OPTIONS: ProvinceCityOption[] = regionData.map((province) => ({
value: province.value,
label: province.label,
children: province.children?.map((city) => ({
value: city.value,
label: city.label,
})),
}));
export type OpenCityRef = {
id: string;
name: string;
code: string;
partnerBindings?: Array<{ partnerAccountId: string }>;
boundPartnerIds?: string[];
};
function cityBoundPartnerIds(city: OpenCityRef): string[] {
if (city.boundPartnerIds?.length) return city.boundPartnerIds.map(String);
return (city.partnerBindings ?? []).map((b) => String(b.partnerAccountId));
}
export type ParsedChinaRegion = {
province: string;
city: string;
district: string;
provinceCode: string;
cityCode: string;
districtCode: string;
};
export type ParsedProvinceCity = {
province: string;
city: string;
provinceCode: string;
cityCode: string;
};
/** element-china-area-data 市级码(如 4101)→ 国标 6 位 adcode410100 */
export function normalizeCityAdcode(code: string): string {
const raw = code.trim();
if (/^\d{6}$/.test(raw)) return raw;
if (/^\d{4}$/.test(raw)) return `${raw}00`;
if (/^\d{2}$/.test(raw)) return `${raw}0100`;
return raw.padEnd(6, '0').slice(0, 6);
}
export function parseProvinceCityCodes(codes?: string[]): ParsedProvinceCity | null {
if (!codes || codes.length < 2) return null;
const [provinceCode, cityCode] = codes;
const province = codeToText[provinceCode];
let city = codeToText[cityCode];
if (!province || !city) return null;
if (city === '市辖区' || city === '县' || city === '省直辖县级行政区划') {
city = province.endsWith('市') ? province : city;
}
return {
province,
city,
provinceCode,
cityCode: normalizeCityAdcode(cityCode),
};
}
/** 区县 adcode → 地级市 adcode(如 410105 → 410100 */
export function districtCodeToCityCode(districtCode: string): string {
if (districtCode.length < 6) return districtCode;
return `${districtCode.slice(0, 4)}00`;
}
export type DistrictOption = { value: string; label: string };
/** 按开城城市 code(4 位或 6 位)取该市下区县列表 */
export function getDistrictOptionsByCityCode(cityCode?: string | null): DistrictOption[] {
if (!cityCode?.trim()) return [];
const normalized = normalizeCityAdcode(cityCode.trim());
const prefix4 = normalized.slice(0, 4);
for (const province of regionData) {
for (const city of province.children ?? []) {
const cityValue = String(city.value);
const cityNorm = normalizeCityAdcode(cityValue);
if (cityNorm === normalized || cityValue === prefix4 || cityValue === cityCode.trim()) {
return (city.children ?? []).map((d) => ({
value: String(d.value),
label: String(d.label),
}));
}
}
}
return [];
}
export function districtCodeLabel(code: string): string {
return codeToText[code] || code;
}
/** 列表展示:区县码 → 中文名,顿号拼接 */
export function formatDistrictLabels(codes?: string[] | null): string {
if (!codes?.length) return '—';
return codes.map((c) => districtCodeLabel(String(c))).join('、');
}
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
if (!codes || codes.length < 3) return null;
const [provinceCode, cityCode, districtCode] = codes;
const province = codeToText[provinceCode];
const city = codeToText[cityCode];
const district = codeToText[districtCode];
if (!province || !city || !district) return null;
return { province, city, district, provinceCode, cityCode, districtCode };
}
export function formatRegionLabel(region: ParsedChinaRegion): string {
return `${region.province} / ${region.city} / ${region.district}`;
}
export function matchOpenCityId(
cities: OpenCityRef[],
districtCode: string,
partnerAccountId?: string,
): string | undefined {
const cityCode = districtCodeToCityCode(districtCode);
const scoped = partnerAccountId
? cities.filter((c) => cityBoundPartnerIds(c).includes(String(partnerAccountId)))
: cities;
return (
scoped.find((c) => c.code === cityCode)?.id
?? scoped.find((c) => c.code === districtCode)?.id
?? scoped.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id
);
}
export function resolveRegionBinding(
codes: string[],
cities: OpenCityRef[],
partnerAccountId?: string,
) {
const region = parseRegionCodes(codes);
if (!region) return null;
const cityId = matchOpenCityId(cities, region.districtCode, partnerAccountId);
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
return {
region,
cityId,
matchedCity,
cityCode: districtCodeToCityCode(region.districtCode),
};
}
-156
View File
@@ -1,156 +0,0 @@
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
XFX: '小飞侠',
LOGISTICS: '物流快递',
MANUAL: '手动',
};
export const ORDER_STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
/** 订单状态流转操作人(common_event.param3 */
export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
USER: '用户',
USER_ON_SITE: '用户·现场取货',
USER_WECHAT_CONFIRM: '用户·微信确认收货',
WECHAT_TRADE_MANAGE: '微信结算同步',
MOCK: 'Mock',
MOCK_PAY: 'Mock 支付',
SYSTEM: '系统',
};
/** 订单状态 Tag 颜色(Ant Design preset */
export const ORDER_STATUS_COLORS: Record<string, string> = {
PENDING_PAY: 'red',
PENDING_SHIP: 'green',
OUT_WAREHOUSE: 'green',
SHIPPING: 'processing',
PENDING_RECEIVE: 'cyan',
COMPLETED: 'green',
CANCELLED: 'default',
REFUNDING: 'orange',
REFUNDED: 'default',
};
export const STORE_STATUS_LABELS: Record<string, string> = {
OPEN: '营业中',
PAUSED: '临时闭店',
CLOSED: '永久关闭',
};
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
PENDING: '待审核',
APPROVED: '已通过',
REJECTED: '已驳回',
};
export const ACCOUNT_STATUS_LABELS: Record<string, string> = {
ACTIVE: '正常',
DISABLED: '停用',
};
export const COUPON_STATUS_LABELS: Record<string, string> = {
ACTIVE: '可用',
USED_UP: '已用完',
VOID: '已作废',
};
export const CITY_STATUS_LABELS: Record<string, string> = {
PENDING: '待开城',
ACTIVE: '已开城',
PAUSED: '已暂停',
};
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
};
export const MEDIA_TYPE_LABELS: Record<string, string> = {
IMAGE: '图片',
VIDEO: '视频',
FILE: '文件',
};
export const RESOURCE_OWNER_TYPE_LABELS: Record<string, string> = {
PRODUCT: '商品',
STORE: '门店',
PARTNER: '合伙人',
USER: '用户',
ORDER: '订单',
HQ: '总部',
};
export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
COVER: '封面',
ENV: '环境图',
CONTRACT: '合同',
CAROUSEL: '轮播',
DETAIL: '详情',
AVATAR: '头像',
QRCODE: '二维码',
SIGN_PHOTO: '签收照',
VIDEO: '视频',
};
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
ACTIVE: '有效',
DELETED: '已删除',
};
export const PRODUCT_STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
ON_SALE: '在售',
OFF_SALE: '下架',
};
export const AROMA_TYPE_LABELS: Record<string, string> = {
QINGXIANG: '清香型',
JIANGXIANG: '酱香型',
NONGXIANG: '浓香型',
};
export const DETAIL_TEMPLATE_STATUS_LABELS: Record<string, string> = {
ACTIVE: '启用',
DISABLED: '停用',
};
export const LEDGER_TYPE_LABELS: Record<string, string> = {
GRANT: '发放',
REDEEM: '核销',
REFUND_VOID: '退款作废',
ADJUST: '调整',
};
export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—';
}
/** 手机号脱敏展示:138****5678;空值返回 — */
export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim();
if (!raw) return '—';
const digits = raw.replace(/\D/g, '');
if (digits.length >= 11) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (digits.length >= 7) {
return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
}
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
return '****';
}
-37
View File
@@ -1,37 +0,0 @@
const ELLIPSIS_TARGETS =
'.ant-table-cell, .ant-descriptions-item-content, .ant-typography';
const ELLIPSIS_SKIP =
'.ant-btn, .ant-input, .ant-select, .ant-picker, .ant-checkbox, .ant-radio, pre, textarea, input, select, button, a.ant-btn';
function isTruncated(el: HTMLElement): boolean {
return el.scrollWidth > el.clientWidth + 1;
}
function resolveEllipsisElement(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof HTMLElement)) return null;
const cell = target.closest<HTMLElement>(ELLIPSIS_TARGETS);
if (!cell) return null;
if (cell.closest('.admin-cell-line')) return null;
if (cell.closest(ELLIPSIS_SKIP)) return null;
if (cell.querySelector(ELLIPSIS_SKIP)) return null;
return cell;
}
export function bindAdminEllipsisTitle(root: HTMLElement | Document = document) {
const onMouseOver = (event: MouseEvent) => {
const el = resolveEllipsisElement(event.target);
if (!el) return;
if (isTruncated(el)) {
const text = el.innerText.replace(/\s+/g, ' ').trim();
if (text && el.getAttribute('title') !== text) {
el.setAttribute('title', text);
}
} else {
el.removeAttribute('title');
}
};
root.addEventListener('mouseover', onMouseOver, true);
return () => root.removeEventListener('mouseover', onMouseOver, true);
}
-10
View File
@@ -1,10 +0,0 @@
/** Download CSV (UTF-8 BOM) so Excel opens Chinese columns correctly. */
export function downloadExcelCsv(csv: string, filename: string) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
a.click();
URL.revokeObjectURL(url);
}
-72
View File
@@ -1,72 +0,0 @@
export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'CITY_CREATE', label: '新增开城城市' },
{ value: 'CITY_UPDATE', label: '编辑开城城市' },
{ value: 'CITY_DELETE', label: '删除开城城市' },
{ value: 'CITY_PARTNER_BIND', label: '绑定城市合伙人' },
{ value: 'CITY_PARTNER_UPDATE', label: '编辑城市合伙人绑定' },
{ value: 'CITY_PARTNER_UNBIND', label: '解绑城市合伙人' },
{ value: 'WAREHOUSE_CREATE', label: '新增城市仓库' },
{ value: 'WAREHOUSE_UPDATE', label: '编辑城市仓库' },
{ value: 'WAREHOUSE_DELETE', label: '删除城市仓库' },
{ value: 'PARTNER_CREATE', label: '新增城市合伙人' },
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
{ value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' },
{ value: 'PARTNER_ACCOUNT_DELETE', label: '删除合伙人子账号' },
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
{ value: 'USER_DELETE', label: '删除用户' },
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
{ value: 'ORDER_SHIP', label: '订单发货' },
{ value: 'ORDER_STATUS_DEBUG', label: '订单状态调试' },
{ value: 'ORDER_BATCH_DELETE', label: '批量删除订单' },
{ value: 'STORE_CREATE', label: '新增门店' },
{ value: 'STORE_UPDATE', label: '编辑门店' },
{ value: 'STORE_STATUS', label: '变更门店状态' },
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
{ value: 'PRODUCT_CREATE', label: '新增商品' },
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
{ value: 'PRODUCT_DELETE', label: '删除商品' },
{ value: 'BENEFIT_COUPON_VOID', label: '作废权益券' },
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
{ value: 'TICKET_APPROVE', label: '工单通过' },
{ value: 'TICKET_REJECT', label: '工单驳回' },
{ value: 'TICKET_CREATE', label: '创建工单' },
{ value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' },
{ value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' },
{ value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' },
{ value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' },
{ value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' },
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
{ value: 'INVOICE_ISSUE', label: '开具发票' },
{ value: 'INVOICE_REJECT', label: '驳回发票' },
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
{ value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' },
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
{ value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' },
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
] as const;
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
HQ_OPERATION_ACTION_OPTIONS.map((o) => [o.value, o.label]),
);
export function resolveHqOperationLabel(action: string | null | undefined): string {
if (!action) return '—';
return HQ_OPERATION_ACTION_LABELS[action] || action;
}
-6
View File
@@ -1,6 +0,0 @@
export {
PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory,
} from '@dukang/shared-types';
@@ -1,64 +0,0 @@
/** 商品详情页文案模板(Admin 一键套用) */
export const TEMPLATE_MAX_DETAIL_IMAGES = 30;
export type ProductDetailTemplateContent = {
storyTitle?: string;
storyText?: string;
features?: Array<{ icon: string; title: string; desc: string }>;
/** 模板内置详情长图 URL 列表 */
detailImageUrls?: string[];
/** 建议详情长图张数(与 detailImageUrls 数量同步,供展示) */
suggestedDetailImageCount?: number;
};
export type ProductDetailTemplate = {
id: string;
code: string;
label: string;
description: string;
aromaType?: string;
content: ProductDetailTemplateContent;
};
/** API 返回的详情模板 DTO(与 shared-types ProductDetailTemplateDto 对齐) */
export type ProductDetailTemplateDto = {
id: string;
code: string;
name: string;
description?: string | null;
aromaType?: string | null;
storyTitle?: string | null;
storyText?: string | null;
features?: Array<{ icon: string; title: string; desc: string }>;
detailImageUrls?: string[];
suggestedDetailImageCount: number;
sortOrder: number;
status: 'ACTIVE' | 'DISABLED';
createdAt: string;
updatedAt: string;
};
export function mapDtoToProductDetailTemplate(dto: ProductDetailTemplateDto): ProductDetailTemplate {
const detailImageUrls = (dto.detailImageUrls ?? []).filter(Boolean);
return {
id: dto.code,
code: dto.code,
label: dto.name,
description: dto.description ?? '',
aromaType: dto.aromaType ?? undefined,
content: {
storyTitle: dto.storyTitle ?? undefined,
storyText: dto.storyText ?? undefined,
features: dto.features ?? [],
detailImageUrls,
suggestedDetailImageCount: detailImageUrls.length || dto.suggestedDetailImageCount,
},
};
}
export function getProductDetailTemplate(
templates: ProductDetailTemplate[],
code: string,
) {
return templates.find((t) => t.code === code || t.id === code);
}
-6
View File
@@ -1,6 +0,0 @@
export {
STORE_LOG_CATEGORY_OPTIONS,
STORE_LOG_CATEGORY_LABELS,
resolveStoreLogCategory,
type StoreLogCategory,
} from '@dukang/shared-types';
-107
View File
@@ -1,107 +0,0 @@
export type StoreCreateForm = {
partnerAccountId: string;
cityId: string;
regionCodes?: string[];
province?: string;
city?: string;
district: string;
districtCode?: string;
categoryParentId?: string;
categoryId: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
intro?: string;
benefitUsageRule?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
closeTime2?: string;
avgPrice?: number | null;
coverUrl?: string;
envPhotoUrls?: string[];
contractUrl?: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
settlementRate?: number;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
};
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
export function validateStoreCreateStep1(
form: Pick<
StoreCreateForm,
| 'partnerAccountId'
| 'cityId'
| 'regionCodes'
| 'categoryId'
| 'name'
| 'phone'
| 'address'
| 'intro'
| 'benefitUsageRule'
| 'openTime'
| 'closeTime'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
>,
): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
if (!form.address?.trim()) return '请填写详细地址';
const openTime = String(form.openTime || '').trim();
const closeTime = String(form.closeTime || '').trim();
if (!openTime || !closeTime) return '请填写营业时间';
if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM';
if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间';
const openTime2 = String(form.openTime2 || '').trim();
const closeTime2 = String(form.closeTime2 || '').trim();
if (openTime2 || closeTime2) {
if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间';
if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM';
if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间';
if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间';
}
if (form.avgPrice != null && String(form.avgPrice).trim() !== '') {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (form.intro?.trim()) {
const len = form.intro.trim().length;
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
}
return null;
}
export function validateStoreCreateStep3(form: Pick<StoreCreateForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>): string | null {
if (!form.bankAccountName?.trim()) return '请填写户主姓名';
if (!form.bankAccountNo?.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch?.trim()) return '请填写开户支行';
return null;
}
@@ -1,26 +0,0 @@
export type TencentPickedLocation = {
latitude: number;
longitude: number;
address?: string;
name?: string;
cityname?: string;
};
export type LbsPlaceItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
return {
latitude: item.latitude,
longitude: item.longitude,
address: item.address || undefined,
name: item.title || undefined,
cityname: item.city || undefined,
};
}
-49
View File
@@ -1,49 +0,0 @@
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
export type UploadFileResult = {
url: string;
ossKey: string;
bucket: string;
mock: boolean;
};
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
export async function uploadFileToOss(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
const headers: Record<string, string> = {
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
});
const json = await res.json();
if (json.code === 401) {
clearAuth();
window.location.href = '/login';
throw new Error('未登录');
}
if (json.code !== 0) throw new Error(json.message || '上传失败');
const data = json.data as UploadFileResult;
return {
url: data.url,
ossKey: data.ossKey,
bucket: data.bucket,
mock: data.mock,
};
}
-28
View File
@@ -1,28 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { request, type Paginated } from './api';
export function useAdminList<T>(path: string, buildQuery: () => URLSearchParams, deps: unknown[]) {
const [data, setData] = useState<Paginated<T> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const load = useCallback(async () => {
setLoading(true);
try {
const qs = buildQuery();
qs.set('page', String(page));
qs.set('pageSize', String(pageSize));
const res = await request<Paginated<T>>(`${path}?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [path, page, pageSize, ...deps]);
useEffect(() => {
void load();
}, [load]);
return { data, loading, page, pageSize, setPage, setPageSize, reload: load };
}
-12
View File
@@ -1,12 +0,0 @@
export {
USER_LOG_CATEGORY_OPTIONS,
resolveUserLogCategory,
eventNamesForUserLogCategory,
type UserLogCategory,
} from '@dukang/shared-types';
import { USER_LOG_CATEGORY_OPTIONS } from '@dukang/shared-types';
export const USER_LOG_CATEGORY_LABELS: Record<string, string> = Object.fromEntries(
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
);
-17
View File
@@ -1,17 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import App from './App';
import './admin.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
</React.StrictMode>,
);
@@ -1,465 +0,0 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
couponNo: string;
totalAmount: number;
balance: number;
usedAmount: number;
status: string;
sourceProduct: string;
createdAt: string;
user?: { userNo: string; phone: string | null };
order?: { orderNo: string } | null;
};
type CouponRedeemRecord = {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
couponAmount?: number;
role?: 'PRIMARY' | 'SECONDARY';
createdAt: string;
store?: { id: string; name: string; cityName?: string | null } | null;
};
type CouponRedeemSummary = {
couponNo: string;
totalAmount: number;
usedAmount: number;
balance: number;
status: string;
redeemCount: number;
redeemRecordSum: number;
};
type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[];
user?: { userNo?: string; phone?: string | null };
order?: { orderNo?: string } | null;
};
export default function BenefitCouponsPage() {
const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [grantOpen, setGrantOpen] = useState(false);
const [granting, setGranting] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/benefit/coupons',
() => {
const qs = new URLSearchParams();
if (filters.couponNo) qs.set('couponNo', filters.couponNo);
if (filters.status) qs.set('status', filters.status);
if (filters.userId) qs.set('userId', filters.userId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<CouponDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
try {
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
setRedeemDetail(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载核销详情失败');
setRedeemDrawerOpen(false);
} finally {
setRedeemDetailLoading(false);
}
}
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) => 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', ellipsis: true },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
async function handleGrant(values: AdminBenefitGrantRequest) {
setGranting(true);
try {
await request('/admin/benefit/coupons/grant', {
method: 'POST',
body: JSON.stringify({
phone: values.phone.trim(),
amount: values.amount,
remark: values.remark?.trim() || undefined,
}),
});
message.success('权益已发放');
setGrantOpen(false);
grantForm.resetFields();
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '发放失败');
} finally {
setGranting(false);
}
}
return (
<div>
<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="券号">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(COUPON_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</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: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title="手动发放好客权益"
open={grantOpen}
onCancel={() => setGrantOpen(false)}
onOk={() => grantForm.submit()}
confirmLoading={granting}
destroyOnClose
>
<Form form={grantForm} layout="vertical" onFinish={(v) => void handleGrant(v)}>
<Form.Item
name="phone"
label="用户手机号"
rules={[
{ required: true, message: '请输入用户手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
]}
>
<Input placeholder="已注册 C 端用户的手机号" maxLength={11} />
</Form.Item>
<Form.Item
name="amount"
label="权益金额(元)"
rules={[{ required: true, message: '请输入权益金额' }]}
>
<InputNumber
min={0.01}
max={999999.99}
precision={2}
style={{ width: '100%' }}
placeholder="发放金额"
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={200} placeholder="可选,默认「总部手动发放」" />
</Form.Item>
</Form>
</Modal>
<Drawer
title="权益券详情"
width={720}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail &&
detail.status !== 'VOID' && (
<Popconfirm
title="确认作废此券?"
onConfirm={async () => {
await request(`/admin/benefit/coupons/${detail.id}/void`, { method: 'POST' });
message.success('已作废');
setDrawerOpen(false);
void reload();
}}
>
<Button danger></Button>
</Popconfirm>
)
}
>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</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="来源">{detail.sourceProduct}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
</Typography.Title>
{detail.redeemSummary ? (
<>
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
<Descriptions.Item label="权益总额">
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="已核销">
<Typography.Text type="danger" strong>
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{detail.redeemSummary.redeemCount}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="剩余余额">
¥{Number(detail.redeemSummary.balance).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销单合计额">
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
</Typography.Text>
</Descriptions.Item>
</Descriptions>
<Table
size="small"
rowKey="id"
pagination={false}
locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []}
columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
{
title: '角色',
dataIndex: 'role',
width: 70,
render: (v: string | undefined) =>
v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>,
},
{
title: '门店',
dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row: CouponRedeemRecord) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
},
{
title: '本券分摊',
dataIndex: 'couponAmount',
width: 95,
render: (v: number | undefined, row: CouponRedeemRecord) =>
`¥${Number(v ?? row.amount).toFixed(2)}`,
},
{
title: '核销总额',
dataIndex: 'amount',
width: 90,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '结算额',
dataIndex: 'settleAmount',
width: 90,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '时间',
dataIndex: 'createdAt',
width: 150,
render: (v: string) => fmtTime(v),
},
{
title: '操作',
width: 70,
render: (_: unknown, row: CouponRedeemRecord) => (
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
</Button>
),
},
]}
/>
</>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</>
)}
</Drawer>
<Drawer
title="核销单详情"
width={520}
open={redeemDrawerOpen}
onClose={() => {
setRedeemDrawerOpen(false);
setRedeemDetail(null);
}}
destroyOnClose
>
{redeemDetailLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : redeemDetail ? (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="核销额">
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="结算额">
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户">
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店">
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
? `${(redeemDetail.store as { cityName?: string }).cityName}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店地址">
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="合伙人">
{String(
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
?.partnerAccount?.companyName ?? '—',
)}
</Descriptions.Item>
<Descriptions.Item label="权益券号">
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="关联订单">
{String(
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
'—',
)}
</Descriptions.Item>
{Array.isArray(redeemDetail.allocations) &&
(redeemDetail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="券分摊">
{(
redeemDetail.allocations as Array<{
couponNo?: string;
orderNo?: string | null;
amount?: number;
sortOrder?: number;
}>
)
.map((a, idx) => {
const role = idx === 0 ? '主' : '次';
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
a.orderNo ? `(订单 ${a.orderNo}` : ''
}`;
})
.join('')}
</Descriptions.Item>
) : null}
{redeemDetail.payout ? (
<Descriptions.Item label="门店结算单">
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
{' / '}
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
</Descriptions.Item>
) : null}
{redeemDetail.rating ? (
<Descriptions.Item label="评价">
{(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '}
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'}
</Descriptions.Item>
) : null}
</Descriptions>
) : null}
</Drawer>
</div>
);
}
@@ -1,52 +0,0 @@
import { useState } from 'react';
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';
type Row = {
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
user?: { userNo: string }; coupon?: { couponNo: string };
};
export default function BenefitLedgersPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/benefit/ledgers',
() => {
const qs = new URLSearchParams();
if (filters.userId) qs.set('userId', filters.userId);
if (filters.couponId) qs.set('couponId', filters.couponId);
if (filters.type) qs.set('type', filters.type);
return qs;
},
[filters],
);
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', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
];
return (
<div>
<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>
<Form.Item name="type" label="类型">
<Select allowClear style={{ width: 100 }} options={Object.entries(LEDGER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
</div>
);
}
-676
View File
@@ -1,676 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type HqProfile, type Paginated } from '../lib/api';
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityPartnersPanel from '../components/CityPartnersPanel';
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
type WarehouseRow = {
id: string;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
};
type Row = {
id: string;
code: string;
name: string;
province: string;
status: string;
storeCount: number;
orderCount: number;
partnerBindingCount?: number;
createdAt: string;
};
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
type CityDeletePreview = {
city: { id: string; code: string; name: string; province: string; status: string };
canDelete: boolean;
blockers: string[];
warnings: string[];
summary: {
primaryPartnerCount: number;
staffCount: number;
storeCount: number;
warehouseCount: number;
orderCount: number;
redeemCount: number;
partnerBillCount: number;
};
partners: Array<{
id: string;
phone: string;
name: string;
companyName?: string | null;
status: string;
staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
}>;
orphanStaff: Array<{ id: string; phone: string; name: string }>;
stores: Array<{
id: string;
name: string;
phone: string;
status: string;
address: string;
partnerAccount?: { companyName?: string | null; phone?: string } | null;
}>;
warehouses: Array<{ id: string; name: string; status: string; address: string }>;
};
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [warehouseForm] = Form.useForm();
const [warehouseEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/cities',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseRow[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [warehouseOpen, setWarehouseOpen] = useState(false);
const [warehouseEditOpen, setWarehouseEditOpen] = useState(false);
const [warehouseEditId, setWarehouseEditId] = useState<string | null>(null);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [createRegionPreview, setCreateRegionPreview] = useState<ParsedProvinceCity | null>(null);
const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
const [confirmName, setConfirmName] = useState('');
const [profile, setProfile] = useState<HqProfile | null>(null);
const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
setPartners(res.items);
}, []);
const loadWarehouses = useCallback(async (cityId: string) => {
const wh = await request<WarehouseRow[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(wh);
}, []);
useEffect(() => {
if (!createRegionCodes?.length) {
setCreateRegionPreview(null);
return;
}
const parsed = parseProvinceCityCodes(createRegionCodes as string[]);
createForm.setFieldsValue({
code: parsed?.cityCode,
name: parsed?.city,
province: parsed?.province,
});
setCreateRegionPreview(parsed);
}, [createRegionCodes, createForm]);
function openCreateModal() {
createForm.resetFields();
createForm.setFieldsValue({ status: 'PENDING' });
setCreateRegionPreview(null);
setCreateOpen(true);
}
const openDetail = async (row: Row) => {
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
setDetail(d);
editForm.setFieldsValue({
...d,
maxPartnerCommissionPercent:
d.maxPartnerCommissionRate != null
? Number(d.maxPartnerCommissionRate) * 100
: 5,
});
await loadPartners(row.id);
await loadWarehouses(row.id);
setDrawerOpen(true);
};
const openDelete = async (row: Row) => {
setDeleteTarget(row);
setDeletePreview(null);
setConfirmName('');
setDeleteOpen(true);
setDeleteLoading(true);
try {
const preview = await request<CityDeletePreview>(`/admin/cities/${row.id}/delete-preview`);
setDeletePreview(preview);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载删除预览失败');
setDeleteOpen(false);
} finally {
setDeleteLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget || !deletePreview) return;
if (!deletePreview.canDelete) {
message.error(deletePreview.blockers.join('') || '当前城市不可删除');
return;
}
if (confirmName.trim() !== deletePreview.city.name) {
message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
return;
}
setDeleteSubmitting(true);
try {
await request(`/admin/cities/${deleteTarget.id}`, {
method: 'DELETE',
body: JSON.stringify({ confirmName: confirmName.trim() }),
});
message.success(`已删除城市「${deletePreview.city.name}`);
setDeleteOpen(false);
setDeleteTarget(null);
setDeletePreview(null);
setConfirmName('');
if (detail?.id === deleteTarget.id) setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleteSubmitting(false);
}
};
const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 },
{ 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 },
{
title: '门店',
dataIndex: 'storeCount',
width: 70,
render: (n: number, row) => (
<Link to={`/stores?cityId=${row.id}`} title={`查看「${row.name}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 140,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
{canDeleteCities ? (
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
</Button>
) : null}
</Space>
),
},
];
const warehouseColumns: ColumnsType<WarehouseRow> = [
{ title: '仓库名', dataIndex: 'name' },
{ title: '地址', dataIndex: 'address', ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
title: '管仓',
dataIndex: 'managerType',
width: 100,
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
},
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
{
title: '操作',
width: 120,
render: (_, row) => (
<Space>
<Button
type="link"
size="small"
onClick={() => {
setWarehouseEditId(row.id);
setEditWarehouseManagerType(row.managerType);
warehouseEditForm.setFieldsValue(row);
setWarehouseEditOpen(true);
}}
>
</Button>
<Button
type="link"
size="small"
danger
onClick={() => {
Modal.confirm({
title: '确认删除仓库?',
onOk: async () => {
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
message.success('已删除');
if (detail?.id) await loadWarehouses(String(detail.id));
},
});
}}
>
</Button>
</Space>
),
},
];
return (
<div>
<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>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</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 ?? []}
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)}>
{detail && (
<Tabs
items={[
{
key: 'basic',
label: '基本信息',
children: (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
<Descriptions.Item label="合伙人数">{String(detail.partnerBindingCount ?? '—')}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item
name="maxPartnerCommissionPercent"
label="合伙人佣金合计上限 %"
extra="订单佣金 + 核销佣金不得超过此比例;不填默认 5%"
>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} placeholder="5" />
</Form.Item>
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
const { maxPartnerCommissionPercent, ...rest } = v;
await request(`/admin/cities/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...rest,
maxPartnerCommissionRate:
maxPartnerCommissionPercent != null && maxPartnerCommissionPercent !== ''
? Number(maxPartnerCommissionPercent) / 100
: 0.05,
}),
});
message.success('已保存');
const refreshed = await request<Record<string, unknown>>(`/admin/cities/${detail.id}`);
setDetail(refreshed);
void reload();
}}></Button>
{canDeleteCities ? (
<Button
danger
style={{ marginLeft: 8 }}
onClick={() =>
void openDelete({
id: String(detail.id),
code: String(detail.code),
name: String(detail.name),
province: String(detail.province),
status: String(detail.status),
storeCount: Number(detail.storeCount ?? 0),
orderCount: Number(detail.orderCount ?? 0),
createdAt: String(detail.createdAt ?? ''),
})
}
>
</Button>
) : null}
</Form>
</>
),
},
{
key: 'partners',
label: '合伙人',
children: detail?.id ? (
<CityPartnersPanel
cityId={String(detail.id)}
cityCode={detail.code != null ? String(detail.code) : undefined}
maxPartnerCommissionRate={
detail.maxPartnerCommissionRate != null
? Number(detail.maxPartnerCommissionRate)
: 0.05
}
onChanged={() => {
void reload();
void loadPartners(String(detail.id));
}}
/>
) : null,
},
{
key: 'warehouses',
label: '仓库管理',
children: (
<>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => {
warehouseForm.resetFields();
warehouseForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: 'ACTIVE' });
setWarehouseManagerType(WarehouseManagerType.HQ);
setWarehouseOpen(true);
}}></Button>
<Table rowKey="id" size="small" columns={warehouseColumns} dataSource={warehouses} pagination={false} />
</>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
if (!v.code || !v.name || !v.province) {
message.error('请选择省 / 市');
return;
}
await request('/admin/cities', {
method: 'POST',
body: JSON.stringify({
code: v.code,
name: v.name,
province: v.province,
status: v.status,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
setCreateRegionPreview(null);
void reload();
}}>
<Form form={createForm} layout="vertical">
<Form.Item
name="regionCodes"
label="所在地区"
rules={[{ required: true, message: '请选择省 / 市' }]}
>
<ChinaProvinceCityCascader />
</Form.Item>
{createRegionPreview ? (
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市编码">{createRegionPreview.cityCode}</Descriptions.Item>
<Descriptions.Item label="省份">{createRegionPreview.province}</Descriptions.Item>
<Descriptions.Item label="城市名">{createRegionPreview.city}</Descriptions.Item>
</Descriptions>
) : (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
/
</Typography.Text>
)}
<Form.Item name="code" hidden rules={[{ required: true, message: '请选择省 / 市' }]}><Input /></Form.Item>
<Form.Item name="name" hidden rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="province" hidden rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="status" label="状态" initialValue="PENDING">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</Form>
</Modal>
<Modal title="新增仓库" open={warehouseOpen} onCancel={() => setWarehouseOpen(false)} onOk={async () => {
const v = await warehouseForm.validateFields();
await request(`/admin/cities/${detail?.id}/warehouses`, { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setWarehouseOpen(false);
if (detail?.id) await loadWarehouses(String(detail.id));
}}>
<Form form={warehouseForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select options={MANAGER_OPTIONS} onChange={(v) => setWarehouseManagerType(v)} />
</Form.Item>
{warehouseManagerType === WarehouseManagerType.PARTNER && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
)}
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑仓库" open={warehouseEditOpen} onCancel={() => setWarehouseEditOpen(false)} onOk={async () => {
const v = await warehouseEditForm.validateFields();
await request(`/admin/city-warehouses/${warehouseEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setWarehouseEditOpen(false);
if (detail?.id) await loadWarehouses(String(detail.id));
}}>
<Form form={warehouseEditForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select options={MANAGER_OPTIONS} onChange={(v) => setEditWarehouseManagerType(v)} />
</Form.Item>
{editWarehouseManagerType === WarehouseManagerType.PARTNER && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
</Form.Item>
</Form>
</Modal>
<Modal
title={deleteTarget ? `删除城市「${deleteTarget.name}` : '删除城市'}
open={deleteOpen}
onCancel={() => {
if (deleteSubmitting) return;
setDeleteOpen(false);
}}
okText="确认删除"
okButtonProps={{
danger: true,
disabled:
!deletePreview?.canDelete ||
!deletePreview ||
confirmName.trim() !== (deletePreview?.city.name ?? ''),
loading: deleteSubmitting,
}}
confirmLoading={deleteSubmitting}
onOk={() => void confirmDelete()}
width={720}
destroyOnClose
>
{deleteLoading || !deletePreview ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" bordered column={2}>
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
</Descriptions>
{deletePreview.blockers.length > 0 && (
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
{deletePreview.blockers.map((b) => (
<div key={b}> {b}</div>
))}
</Typography.Paragraph>
)}
{deletePreview.warnings.length > 0 && (
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
{deletePreview.warnings.map((w) => (
<div key={w}> {w}</div>
))}
</Typography.Paragraph>
)}
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无合伙人' }}
dataSource={deletePreview.partners.flatMap((p) => [
{
id: p.id,
kind: '主账号',
name: p.companyName || p.name,
phone: p.phone,
parent: '—',
},
...p.staff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: p.companyName || p.name,
})),
]).concat(
deletePreview.orphanStaff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: '(无主账号)',
})),
)}
columns={[
{ title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true },
]}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores}
columns={[
{ 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 || '—',
},
]}
/>
</div>
{deletePreview.canDelete ? (
<Form.Item
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
style={{ marginBottom: 0 }}
>
<Input
value={confirmName}
placeholder={deletePreview.city.name}
onChange={(e) => setConfirmName(e.target.value)}
disabled={deleteSubmitting}
/>
</Form.Item>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
)}
</Modal>
</div>
);
}
@@ -1,706 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { 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';
type SubRow = PartnerSubAccountRow;
type Row = {
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
bindingStatus?: string;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
maxPartnerCommissionRate?: number | null;
storeCount: number;
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
/** 详情接口仍返回 children */
children?: SubRow[];
};
function toTableRows(items: Row[]): Row[] {
return items.map((row) => {
const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] };
return { ...rest, subAccounts: subAccounts ?? children ?? [] };
});
}
type CityOption = { id: string; name: string; code: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPage() {
const [filterForm] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [subEditParentId, setSubEditParentId] = useState<string | null>(null);
const [cities, setCities] = useState<CityOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05);
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
const editCityCode = detail?.cityId
? cities.find((c) => c.id === detail.cityId)?.code
: undefined;
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
return d;
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
async function refreshPartnerContext(parentId: string) {
if (detail?.id === parentId) {
await openPartner(parentId);
}
void reload();
}
async function deleteSubAccount(subId: string, parentId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshPartnerContext(parentId);
}
function openSubEdit(row: SubRow, parentId: string) {
setSubEditId(row.id);
setSubEditParentId(parentId);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}
async function openAddSubAccount(parentId: string) {
await openPartner(parentId);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
async function onCreateCityChange(cityId: string) {
createForm.setFieldsValue({ districtCodes: undefined });
const matched = cities.find((c) => c.id === cityId);
setCreateCityCode(matched?.code);
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
}
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', ellipsis: true, width: 140 },
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 90,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 110,
render: (_, row) =>
`${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
},
{
title: '绑定状态',
dataIndex: 'bindingStatus',
width: 80,
render: (v) => (
<Tag color={v === CityPartnerStatus.ACTIVE ? 'green' : 'default'}>
{CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'}
</Tag>
),
},
{
title: '管仓仓库',
dataIndex: 'managedWarehouseName',
width: 110,
ellipsis: true,
render: (v) => v || '—',
},
{
title: '门店',
dataIndex: 'storeCount',
width: 60,
render: (n: number, row) => (
<Link to={`/stores?partnerId=${row.id}`} title={`查看「${row.companyName || row.phone}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05);
setCreateCityCode(undefined);
setCreateOpen(true);
}}
>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={
<>
{' '}
<Link to="/city-warehouses"></Link>
</>
}
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={toTableRows(data?.items ?? [])}
scroll={{ x: 1200 }}
childrenColumnName="__noTreeChildren__"
expandable={{
expandedRowRender: (record) => (
<PartnerSubAccountList
subs={record.subAccounts ?? []}
onAdd={() => void openAddSubAccount(record.id)}
onEdit={(sub) => openSubEdit(sub, record.id)}
onDelete={(subId) => void deleteSubAccount(subId, record.id)}
/>
),
rowExpandable: () => true,
columnWidth: 40,
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
<Button type="primary" onClick={() => void savePartner()}>
</Button>
}
>
{detail && (
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数">
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
{detail.storeCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? (
<Typography.Text type="secondary">
<Link to="/city-warehouses"></Link>
</Typography.Text>
)}
</Descriptions.Item>
</Descriptions>
<Tabs
items={[
{
key: 'info',
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="业务联系电话">
<Input />
</Form.Item>
<Form.Item name="address" label="地址">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<Form.Item name="bankAccountName" label="户名">
<Input />
</Form.Item>
<Form.Item name="bankAccountNo" label="账号">
<Input />
</Form.Item>
<Form.Item name="bankBranch" label="开户行">
<Input />
</Form.Item>
</Form>
),
},
{
key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(sub) => openSubEdit(sub, detail.id)}
onDelete={(subId) => void deleteSubAccount(subId, detail.id)}
/>
),
},
]}
/>
</>
)}
</Drawer>
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
try {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
createMaxRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes:
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => void onCreateCityChange(id)}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block' }}>
+ {(createMaxRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal
title={detail ? `添加子账号 · ${detail.companyName}` : '添加子账号'}
open={subOpen}
onCancel={() => setSubOpen(false)}
onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
await refreshPartnerContext(detail.id);
}}
>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑子账号"
open={subEditOpen}
onCancel={() => setSubEditOpen(false)}
onOk={async () => {
if (!subEditId) return;
const parentId = detail?.id ?? subEditParentId;
if (!parentId) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
await refreshPartnerContext(parentId);
}}
>
<Form form={subEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="staffRole" label="角色">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'ACTIVE', label: '启用' },
{ value: 'DISABLED', label: '停用' },
]}
/>
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -1,515 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Button,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WAREHOUSE_FULFILLMENT_MODE_LABELS,
WAREHOUSE_MANAGER_LABELS,
WAREHOUSE_STATUS_LABELS,
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
cityId: string;
cityName: string;
cityCode: string;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
fulfillmentMode?: string;
fulfillmentProviderId?: string | null;
fulfillmentProviderName?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
createdAt: string;
};
type CityOption = { id: string; name: string; code: string };
type PartnerOption = { id: string; companyName: string };
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
function FulfillmentFields({
mode,
providerOptions,
onModeChange,
}: {
mode: WarehouseFulfillmentMode;
providerOptions: FulfillmentProviderDto[];
onModeChange: (mode: WarehouseFulfillmentMode) => void;
}) {
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
return (
<>
<Form.Item
name="fulfillmentMode"
label="支付后自动发货"
extra={
autoShip
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
}
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
getValueFromEvent={(checked: boolean) =>
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
}
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
onModeChange(
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
);
}}
/>
</Form.Item>
{autoShip && (
<Form.Item
name="fulfillmentProviderId"
label="仓配承运商"
rules={[{ required: true, message: '自动发货须选择承运商' }]}
>
<Select
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
/>
</Form.Item>
)}
{!autoShip && (
<>
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
<Input placeholder="如 顺丰速运" />
</Form.Item>
<Form.Item
name="manualQueryUrlTemplate"
label="物流查询链接模板"
extra="可用 {trackingNo} 占位符"
>
<Input placeholder="https://example.com/track?no={trackingNo}" />
</Form.Item>
</>
)}
<Space>
<Form.Item name="lng" label="经度">
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
</Form.Item>
<Form.Item name="lat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
);
}
export default function CityWarehousesPage() {
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/city-warehouses',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.managerType) qs.set('managerType', filters.managerType);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [cities, setCities] = useState<CityOption[]>([]);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<Row | null>(null);
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`,
);
setPartners(res.items);
}, []);
useEffect(() => {
void loadCities();
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
.then(setProviderOptions)
.catch(() => {});
}, [loadCities]);
async function openEdit(row: Row) {
setEditRow(row);
setEditManagerType(row.managerType);
await loadPartners(row.cityId);
editForm.setFieldsValue({
name: row.name,
address: row.address,
contactName: row.contactName,
contactPhone: row.contactPhone,
managerType: row.managerType,
partnerAccountId: row.partnerAccountId,
status: row.status,
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
lng: row.lng ?? undefined,
lat: row.lat ?? undefined,
});
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
setEditOpen(true);
}
function onManagerTypeChange(
v: WarehouseManagerType,
form: typeof createForm,
setType: (t: WarehouseManagerType) => void,
) {
setType(v);
if (v !== WarehouseManagerType.PARTNER) {
form.setFieldValue('partnerAccountId', undefined);
}
}
const columns: ColumnsType<Row> = [
{
title: '城市',
width: 100,
render: (_, row) => (
<span title={row.cityCode}>
{row.cityName}
</span>
),
},
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
title: '管仓',
width: 90,
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
},
{
title: '合伙人',
dataIndex: 'partnerCompanyName',
width: 120,
ellipsis: true,
render: (v, row) =>
v && row.partnerAccountId ? (
<Link to="/city-partners">{v}</Link>
) : (
'—'
),
},
{
title: '自动发货',
width: 130,
render: (_, row) =>
row.fulfillmentMode === 'API_AUTO' ? (
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
) : (
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => (
<Tag color={s === WarehouseStatus.ACTIVE ? 'green' : 'default'}>
{WAREHOUSE_STATUS_LABELS[s as WarehouseStatus] || s}
</Tag>
),
},
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 120,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openEdit(row)}>
</Button>
<Popconfirm
title="确定删除该仓库?"
description="删除后将解除关联合伙人的管仓绑定"
onConfirm={async () => {
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
setCreateManagerType(WarehouseManagerType.HQ);
setCreateCityId(undefined);
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
createForm.setFieldsValue({
managerType: WarehouseManagerType.HQ,
status: WarehouseStatus.ACTIVE,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
});
setCreateOpen(true);
}}
>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="name" label="仓库名">
<Input allowClear />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="managerType" label="管仓">
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
</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: 1400 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title="新增仓库"
open={createOpen}
width={520}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request(`/admin/cities/${v.cityId}/warehouses`, {
method: 'POST',
body: JSON.stringify(v),
});
message.success('已创建');
setCreateOpen(false);
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => {
setCreateCityId(id);
createForm.setFieldValue('partnerAccountId', undefined);
void loadPartners(id);
}}
/>
</Form.Item>
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select
options={MANAGER_OPTIONS}
onChange={(v) => onManagerTypeChange(v, createForm, setCreateManagerType)}
/>
</Form.Item>
{createManagerType === WarehouseManagerType.PARTNER && createCityId && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder={partners.length ? '选择合伙人' : '该城市暂无合伙人'}
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
/>
</Form.Item>
)}
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={createFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setCreateFulfillmentMode}
/>
</Form>
</Modal>
<Modal
title="编辑仓库"
open={editOpen}
width={520}
onCancel={() => setEditOpen(false)}
onOk={async () => {
if (!editRow) return;
const v = await editForm.validateFields();
await request(`/admin/city-warehouses/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify(v),
});
message.success('已更新');
setEditOpen(false);
void reload();
}}
>
{editRow && (
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="所属城市">
{editRow.cityName}{editRow.cityCode}
</Descriptions.Item>
</Descriptions>
)}
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select
options={MANAGER_OPTIONS}
onChange={(v) => onManagerTypeChange(v, editForm, setEditManagerType)}
/>
</Form.Item>
{editManagerType === WarehouseManagerType.PARTNER && editRow && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
/>
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={editFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setEditFulfillmentMode}
/>
</Form>
</Modal>
</div>
);
}
-774
View File
@@ -1,774 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button, Card, Col, DatePicker, Descriptions, Form, Modal, Row, Select, Space, Statistic, Table, Typography, message,
} from 'antd';
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
import dayjs, { type Dayjs } from 'dayjs';
import ReactECharts from 'echarts-for-react';
import type { EChartsOption } from 'echarts';
import {
request,
type DashboardAnalytics,
type DashboardStats,
type DeployTriggerResult,
type HqProfile,
type Paginated,
type SystemVersion,
} from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
const DEPLOYED_BY_LABELS: Record<string, string> = {
webhook: 'Webhook',
manual: '手动脚本',
admin: 'Admin 发布',
};
type CityOption = { id: string; name: string; code: string };
type PromoOption = { id: string; code: string; name: string };
type AnalyticsFilters = {
range: [Dayjs, Dayjs];
cityId?: string;
promoCodeId?: string;
partnerAccountId?: string;
};
function buildAnalyticsQs(f: AnalyticsFilters) {
const qs = new URLSearchParams();
qs.set('dateFrom', f.range[0].format('YYYY-MM-DD'));
qs.set('dateTo', f.range[1].format('YYYY-MM-DD'));
if (f.cityId) qs.set('cityId', f.cityId);
if (f.promoCodeId) qs.set('promoCodeId', f.promoCodeId);
if (f.partnerAccountId) qs.set('partnerAccountId', f.partnerAccountId);
return qs.toString();
}
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [version, setVersion] = useState<SystemVersion | null>(null);
const [loading, setLoading] = useState(true);
const [versionLoading, setVersionLoading] = useState(true);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [deploying, setDeploying] = useState(false);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
const [filterForm] = Form.useForm<{
range: [Dayjs, Dayjs];
cityId?: string;
promoCodeId?: string;
partnerAccountId?: string;
}>();
const [filters, setFilters] = useState<AnalyticsFilters>({
range: [dayjs().subtract(29, 'day'), dayjs()],
});
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(true);
const [cities, setCities] = useState<CityOption[]>([]);
const [promos, setPromos] = useState<PromoOption[]>([]);
const [partners, setPartners] = useState<Array<{ id: string; companyName?: string | null; name: string }>>([]);
const loadVersion = useCallback(() => {
setVersionLoading(true);
return request<SystemVersion | null>('/admin/dashboard/version')
.then(setVersion)
.catch(() => setVersion(null))
.finally(() => setVersionLoading(false));
}, []);
const loadAnalytics = useCallback((f: AnalyticsFilters) => {
setAnalyticsLoading(true);
return request<DashboardAnalytics>(`/admin/dashboard/analytics?${buildAnalyticsQs(f)}`)
.then(setAnalytics)
.catch((e) => {
message.error(e instanceof Error ? e.message : '加载统计失败');
setAnalytics(null);
})
.finally(() => setAnalyticsLoading(false));
}, []);
useEffect(() => {
request<DashboardStats>('/admin/dashboard/stats')
.then(setStats)
.finally(() => setLoading(false));
request<HqProfile>('/admin/auth/me')
.then((p) => {
setProfile(p);
if (p.adminRole === 'SUPER_ADMIN') {
void loadVersion();
} else {
setVersionLoading(false);
}
})
.catch(() => {
setVersionLoading(false);
});
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => setCities([]));
void request<Paginated<PromoOption>>(`/admin/promo-codes?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPromos(res.items ?? []))
.catch(() => setPromos([]));
void request<Paginated<{ id: string; companyName?: string | null; name: string }>>(
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
)
.then((res) => setPartners(res.items ?? []))
.catch(() => setPartners([]));
}, [loadVersion]);
useEffect(() => {
void loadAnalytics(filters);
}, [filters, loadAnalytics]);
function applyFilters(values: {
range?: [Dayjs, Dayjs];
cityId?: string;
promoCodeId?: string;
partnerAccountId?: string;
}) {
const next: AnalyticsFilters = {
range: values.range ?? filters.range,
cityId: values.cityId || undefined,
promoCodeId: values.promoCodeId || undefined,
partnerAccountId: values.partnerAccountId || undefined,
};
setFilters(next);
}
function setCityFilter(cityId: string) {
const next = { ...filters, cityId: cityId === filters.cityId ? undefined : cityId };
filterForm.setFieldsValue({ cityId: next.cityId });
setFilters(next);
}
function setPromoFilter(promoCodeId: string) {
const key = promoCodeId === 'null' || promoCodeId === '' ? 'none' : promoCodeId;
const next = {
...filters,
promoCodeId: key === filters.promoCodeId ? undefined : key,
};
filterForm.setFieldsValue({ promoCodeId: next.promoCodeId });
setFilters(next);
}
function setPartnerFilter(partnerAccountId: string) {
const next = {
...filters,
partnerAccountId:
partnerAccountId === filters.partnerAccountId ? undefined : partnerAccountId,
};
filterForm.setFieldsValue({ partnerAccountId: next.partnerAccountId });
setFilters(next);
}
function handleDeploy() {
Modal.confirm({
title: '确认发布更新?',
content: '将触发服务器 webhook,拉取 auto-release.env 中配置的 GIT_BRANCH 并执行发版。发版通常需要数分钟,完成后可刷新版本信息。',
okText: '开始发布',
cancelText: '取消',
onOk: async () => {
setDeploying(true);
try {
const result = await request<DeployTriggerResult>('/admin/deploy/trigger', { method: 'POST' });
message.success(result.message || '已触发发布');
} catch (e) {
message.error(e instanceof Error ? e.message : '触发发布失败');
throw e;
} finally {
setDeploying(false);
}
},
});
}
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
const ordersDrillQs = useMemo(() => {
const qs = new URLSearchParams();
qs.set('createdFrom', filters.range[0].format('YYYY-MM-DD'));
qs.set('createdTo', filters.range[1].format('YYYY-MM-DD'));
if (filters.cityId && filters.cityId !== 'none') qs.set('cityId', filters.cityId);
return qs.toString();
}, [filters]);
const byDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['新增用户', '订单数'] },
grid: { left: 40, right: 20, top: 40, bottom: 40 },
xAxis: {
type: 'category',
data: rows.map((r) => r.date.slice(5)),
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '新增用户', type: 'line', smooth: true, data: rows.map((r) => r.users) },
{ name: '订单数', type: 'line', smooth: true, data: rows.map((r) => r.orders) },
],
};
}, [analytics]);
const byCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] },
grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: {
type: 'category',
data: rows.map((r) => r.cityName),
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
};
}, [analytics]);
const byPromoOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPromo ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['用户', '订单'] },
grid: { left: 48, right: 20, top: 40, bottom: 64 },
xAxis: {
type: 'category',
data: rows.map((r) => r.name || r.code),
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '用户', type: 'bar', data: rows.map((r) => r.users), barMaxWidth: 36 },
{ name: '订单', type: 'bar', data: rows.map((r) => r.orders), barMaxWidth: 36 },
],
};
}, [analytics]);
const opsByDateOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byDate ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['新增合伙人', '新签门店', '核销笔数', '核销金额'] },
grid: { left: 48, right: 48, top: 48, bottom: 40 },
xAxis: {
type: 'category',
data: rows.map((r) => r.date.slice(5)),
axisLabel: { rotate: rows.length > 14 ? 45 : 0 },
},
yAxis: [
{ type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额', minInterval: 1 },
],
series: [
{ name: '新增合伙人', type: 'line', smooth: true, data: rows.map((r) => r.partners) },
{ name: '新签门店', type: 'line', smooth: true, data: rows.map((r) => r.stores) },
{ name: '核销笔数', type: 'line', smooth: true, data: rows.map((r) => r.redeems) },
{
name: '核销金额',
type: 'line',
smooth: true,
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
},
],
};
}, [analytics]);
const opsByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['合伙人', '门店', '核销笔数'] },
grid: { left: 48, right: 20, top: 40, bottom: 48 },
xAxis: {
type: 'category',
data: rows.map((r) => r.cityName),
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
},
yAxis: { type: 'value', minInterval: 1 },
series: [
{ name: '合伙人', type: 'bar', data: rows.map((r) => r.partners), barMaxWidth: 28 },
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
],
};
}, [analytics]);
const opsByPartnerOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byPartner ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['门店', '核销笔数', '核销金额'] },
grid: { left: 48, right: 48, top: 40, bottom: 64 },
xAxis: {
type: 'category',
data: rows.map((r) => r.companyName),
axisLabel: { interval: 0, rotate: rows.length > 3 ? 30 : 0 },
},
yAxis: [
{ type: 'value', name: '数量', minInterval: 1 },
{ type: 'value', name: '金额' },
],
series: [
{ name: '门店', type: 'bar', data: rows.map((r) => r.stores), barMaxWidth: 28 },
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 28 },
{
name: '核销金额',
type: 'line',
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
},
],
};
}, [analytics]);
const redeemByCityOption = useMemo<EChartsOption>(() => {
const rows = analytics?.byCity ?? [];
return {
tooltip: { trigger: 'axis' },
legend: { data: ['核销笔数', '核销金额'] },
grid: { left: 48, right: 48, top: 40, bottom: 48 },
xAxis: {
type: 'category',
data: rows.map((r) => r.cityName),
axisLabel: { interval: 0, rotate: rows.length > 4 ? 30 : 0 },
},
yAxis: [
{ type: 'value', name: '笔数', minInterval: 1 },
{ type: 'value', name: '金额' },
],
series: [
{ name: '核销笔数', type: 'bar', data: rows.map((r) => r.redeems), barMaxWidth: 36 },
{
name: '核销金额',
type: 'line',
yAxisIndex: 1,
data: rows.map((r) => r.redeemAmount),
},
],
};
}, [analytics]);
return (
<div>
<Typography.Title level={4}></Typography.Title>
{isSuperAdmin ? (
<Card
title="系统版本"
loading={versionLoading}
style={{ marginBottom: 24 }}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
</Button>
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={deploying}
onClick={handleDeploy}
>
</Button>
</Space>
}
>
{version ? (
<Descriptions column={{ xs: 1, sm: 2, lg: 3 }} size="small">
<Descriptions.Item label="分支">{version.branch || '—'}</Descriptions.Item>
<Descriptions.Item label="Tag">{version.gitTag || '—'}</Descriptions.Item>
<Descriptions.Item label="Commit">
<Typography.Text copyable={{ text: version.commitId }}>{shortSha}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="提交说明" span={3}>{version.commitMessage}</Descriptions.Item>
<Descriptions.Item label="发布时间">{fmtTime(version.deployedAt)}</Descriptions.Item>
<Descriptions.Item label="触发来源">
{DEPLOYED_BY_LABELS[version.deployedBy || ''] || version.deployedBy || '—'}
</Descriptions.Item>
</Descriptions>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
</Card>
</Col>
</Row>
<Card
title="数据统计筛选"
style={{ marginBottom: 24 }}
extra={
<Button
icon={<ReloadOutlined />}
size="small"
onClick={() => void loadAnalytics(filters)}
>
</Button>
}
>
<Form
form={filterForm}
layout="inline"
initialValues={{ range: filters.range }}
onFinish={applyFilters}
>
<Form.Item name="range" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker.RangePicker allowClear={false} />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
placeholder="全部开城"
style={{ width: 160 }}
options={[
{ value: 'none', label: '未选城' },
...cities.map((c) => ({ value: c.id, label: c.name })),
]}
/>
</Form.Item>
<Form.Item name="promoCodeId" label="推广码">
<Select
allowClear
placeholder="全部来源"
style={{ width: 200 }}
options={[
{ value: 'none', label: '自然量 / 无推广码' },
...promos.map((p) => ({ value: p.id, label: `${p.name}${p.code}` })),
]}
/>
</Form.Item>
<Form.Item name="partnerAccountId" label="合伙人">
<Select
allowClear
placeholder="全部合伙人"
style={{ width: 200 }}
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.name,
}))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form.Item>
</Form>
</Card>
<Card
title="用户 / 订单统计"
style={{ marginBottom: 24 }}
extra={
<Space>
<Link to={`/orders?${ordersDrillQs}`}></Link>
<Link to="/users"></Link>
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Statistic title="区间新增用户" value={analytics?.summary.users ?? 0} />
</Col>
<Col xs={24} sm={8}>
<Statistic title="区间订单数" value={analytics?.summary.orders ?? 0} />
</Col>
<Col xs={24} sm={8}>
<Statistic title="区间付费用户" value={analytics?.summary.payingUsers ?? 0} />
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card type="inner" title="按日趋势" loading={analyticsLoading} size="small">
<ReactECharts option={byDateOption} style={{ height: 320 }} notMerge />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
type="inner"
title="按城市(点击柱联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={byCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
type="inner"
title="按推广码(点击柱联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={byPromoOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byPromo ?? []).find(
(r) => (r.name || r.code) === params.name,
);
if (row) setPromoFilter(row.promoCodeId ?? 'none');
},
}}
/>
</Card>
</Col>
</Row>
</Card>
<Card
title="合伙人 / 门店 / 核销统计"
style={{ marginBottom: 24 }}
extra={
<Space>
<Link to="/city-partners"></Link>
<Link to="/stores"></Link>
<Link to="/redeem-records"></Link>
</Space>
}
>
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={6}>
<Statistic title="区间新增合伙人" value={analytics?.summary.partners ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间新签门店" value={analytics?.summary.stores ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间核销笔数" value={analytics?.summary.redeems ?? 0} />
</Col>
<Col xs={24} sm={6}>
<Statistic title="区间核销金额" value={analytics?.summary.redeemAmount ?? 0} precision={2} />
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card type="inner" title="按日趋势(合伙人 / 门店 / 核销)" loading={analyticsLoading} size="small">
<ReactECharts option={opsByDateOption} style={{ height: 320 }} notMerge />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
type="inner"
title="按城市(点击柱联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={opsByCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
type="inner"
title="按城市核销金额"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={redeemByCityOption}
style={{ height: 300 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byCity ?? []).find((r) => r.cityName === params.name);
if (row) setCityFilter(row.cityId);
},
}}
/>
</Card>
</Col>
<Col xs={24}>
<Card
type="inner"
title="按合伙人(门店 / 核销,点击联动筛选)"
loading={analyticsLoading}
size="small"
>
<ReactECharts
option={opsByPartnerOption}
style={{ height: 320 }}
notMerge
onEvents={{
click: (params: { name?: string }) => {
const row = (analytics?.byPartner ?? []).find(
(r) => r.companyName === params.name,
);
if (row) setPartnerFilter(row.partnerAccountId);
},
}}
/>
</Card>
</Col>
</Row>
</Card>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待审核合伙人打款"
extra={<Link to="/finance/partner-bills"></Link>}
>
<Statistic
value={stats?.pendingBills ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
<Typography.Text type="secondary">
{(stats?.pendingPartnerDraftBills ?? 0) > 0
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
: ''}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待打款门店结算"
extra={<Link to="/finance/store-bills"></Link>}
>
<Statistic
value={stats?.pendingPayouts ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待审门店提现"
extra={<Link to="/finance/store-bills?kind=WITHDRAW"></Link>}
>
<Statistic
value={stats?.pendingStoreWithdrawals ?? 0}
suffix="笔"
valueStyle={{
color: (stats?.overdueStoreWithdrawals ?? 0) > 0 ? '#cf1322' : (stats?.pendingStoreWithdrawals ?? 0) > 0 ? '#fa8c16' : undefined,
}}
/>
<Typography.Text type="secondary">
{(stats?.overdueStoreWithdrawals ?? 0) > 0
? `超时未审 ${stats?.overdueStoreWithdrawals} 笔(FIN-003`
: '工作日 T+0 审完'}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title="已合并访客账号" loading={loading}>
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="订单状态分布" loading={loading}>
<Table
size="small"
pagination={false}
rowKey="status"
dataSource={stats?.ordersByStatus ?? []}
columns={[
{
title: '状态',
dataIndex: 'status',
render: (s: string) => STATUS_LABELS[s] || s,
},
{ title: '数量', dataIndex: 'count' },
]}
/>
</Card>
</Col>
</Row>
</div>
);
}
@@ -1,95 +0,0 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
};
export default function DeliveriesPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/deliveries',
() => {
const qs = new URLSearchParams();
if (filters.provider) qs.set('provider', filters.provider);
if (filters.trackingNo) qs.set('trackingNo', filters.trackingNo);
if (filters.orderNo) qs.set('orderNo', filters.orderNo);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
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: '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 },
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" 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);
}}></Button>
),
},
];
return (
<div>
<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: 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={
<Space>
<Button onClick={() => setDrawerOpen(false)}></Button>
<Button type="primary" onClick={async () => {
if (!detail) return;
const v = await editForm.validateFields();
await request(`/admin/deliveries/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
</Space>
}>
{detail && (
<>
<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>
<Form form={editForm} layout="vertical">
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form>
</>
)}
</Drawer>
</div>
);
}
@@ -1,127 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
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';
type Row = {
id: string;
eventType: string;
refType: string;
refId: string;
status?: string | null;
param1?: string | null;
param2?: string | null;
param3?: string | null;
remark?: string | null;
createdAt: string;
};
const EVENT_OPTIONS = [
{ value: '', label: '全部领域事件' },
{ value: 'ORDER_STATUS', label: '订单状态' },
{ value: 'BENEFIT_LEDGER', label: '权益流水' },
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'TICKET_COLLAB', label: '工单协作' },
{ value: 'PROMO_TOUCH', label: '推广触达' },
];
export default function DomainEventsPage() {
const [params, setParams] = useSearchParams();
const eventType = params.get('eventType') ?? '';
const refType = params.get('refType') ?? '';
const refId = params.get('refId') ?? '';
const [data, setData] = useState<{ items: Row[]; total: number; page: number; pageSize: number } | null>(null);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Row | null>(null);
const load = useCallback(() => {
setLoading(true);
const q = new URLSearchParams();
if (eventType) q.set('eventType', eventType);
if (refType) q.set('refType', refType);
if (refId) q.set('refId', refId);
q.set('page', params.get('page') ?? '1');
q.set('pageSize', '20');
request<{ items: Row[]; total: number; page: number; pageSize: number }>(
`/admin/logs/domain-events?${q.toString()}`,
)
.then(setData)
.finally(() => setLoading(false));
}, [eventType, refType, refId, params]);
useEffect(() => {
load();
}, [load]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
{ title: '类型', dataIndex: 'eventType', width: 130, render: (v) => <Tag>{v}</Tag> },
{ title: '关联', render: (_, r) => `${r.refType} #${r.refId}` },
{ title: '状态', dataIndex: 'status', width: 100 },
{ title: '摘要', render: (_, r) => r.param1 || r.remark || '—' },
{
title: '操作',
width: 80,
render: (_, r) => (
<a
onClick={async () => {
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
}}
>
</a>
),
},
];
return (
<Card title="领域事件">
<Space wrap style={{ marginBottom: 16 }}>
<Select
style={{ width: 180 }}
value={eventType}
options={EVENT_OPTIONS}
onChange={(v) => {
const next = new URLSearchParams(params);
if (v) next.set('eventType', v);
else next.delete('eventType');
next.set('page', '1');
setParams(next);
}}
/>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: data?.page ?? 1,
pageSize: data?.pageSize ?? 20,
total: data?.total ?? 0,
onChange: (p) => {
const next = new URLSearchParams(params);
next.set('page', String(p));
setParams(next);
},
}}
/>
<Drawer open={!!detail} title="事件详情" width={520} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small">
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.eventType}</Descriptions.Item>
<Descriptions.Item label="关联">{`${detail.refType} #${detail.refId}`}</Descriptions.Item>
<Descriptions.Item label="param1">{detail.param1}</Descriptions.Item>
<Descriptions.Item label="param2">{detail.param2}</Descriptions.Item>
<Descriptions.Item label="param3">{detail.param3}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</Card>
);
}
@@ -1,371 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Divider,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
DEFAULT_XFX_LOGISTICS_PRICING,
FULFILLMENT_PROVIDER_STATUS_LABELS,
FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus,
FulfillmentProviderType,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
LogisticsSettlementMethod,
isXfxProviderCode,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
value,
label,
}));
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}));
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
([value, label]) => ({ value, label }),
);
const SIGN_OPTIONS = [
{ value: 'MD5', label: 'MD5' },
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
];
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
export default function FulfillmentProvidersPage() {
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
const [form] = Form.useForm();
const watchedCode = Form.useWatch('code', form);
const watchedType = Form.useWatch('type', form);
const showXfxFields = useMemo(
() => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API,
[watchedCode, watchedType],
);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
setRows(res);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function openCreate() {
setEditRow(null);
form.resetFields();
form.setFieldsValue({
code: 'XFX',
name: '小飞侠',
type: FulfillmentProviderType.API,
status: FulfillmentProviderStatus.ACTIVE,
apiUrl: DEFAULT_XFX_API_URL,
signType: 'MD5',
settlementMethod: LogisticsSettlementMethod.PREPAID,
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
});
setOpen(true);
}
function openEdit(row: FulfillmentProviderDto) {
setEditRow(row);
const xfx = row.xiaofeixiaConfig;
const pricing = row.pricingRules;
form.setFieldsValue({
code: row.code,
name: row.name,
type: row.type,
status: row.status,
apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL,
mchId: xfx?.mchId || '',
apiKey: '',
signType: xfx?.signType || 'MD5',
appId: xfx?.appId || '',
bankAccountName: row.bankAccountName || '',
bankName: row.bankName || '',
bankBranch: row.bankBranch || '',
bankAccountNo: row.bankAccountNo || '',
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
});
setOpen(true);
}
async function submit() {
const v = await form.validateFields();
const payload: Record<string, unknown> = {
name: v.name,
type: v.type,
status: v.status,
bankAccountName: v.bankAccountName || null,
bankName: v.bankName || null,
bankBranch: v.bankBranch || null,
bankAccountNo: v.bankAccountNo || null,
settlementMethod: v.settlementMethod,
pricingRules: {
baseBottles: Number(v.baseBottles),
baseFee: Number(v.baseFee),
extraBottleFee: Number(v.extraBottleFee),
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
},
};
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
payload.xiaofeixiaConfig = {
apiUrl: v.apiUrl,
mchId: v.mchId,
apiKey: v.apiKey || undefined,
signType: v.signType,
appId: v.appId || undefined,
};
}
if (editRow) {
await request(`/admin/fulfillment-providers/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('已更新');
} else {
await request('/admin/fulfillment-providers', {
method: 'POST',
body: JSON.stringify({
code: v.code,
...payload,
}),
});
message.success('已创建');
}
setOpen(false);
void load();
}
const columns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 },
{ title: '名称', dataIndex: 'name' },
{
title: '类型',
dataIndex: 'type',
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
},
{
title: '结算',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 100,
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
render: (v) => (
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
</Tag>
),
},
{
title: '接口配置',
render: (_, row) => {
if (isXfxProviderCode(row.code)) {
const cfg = row.xiaofeixiaConfig;
if (!cfg?.apiUrl) return <Tag></Tag>;
return (
<span title={cfg.apiUrl}>
{cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'}
</span>
);
}
return row.hasConfig ? '已配置' : '—';
},
},
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
),
},
];
return (
<div>
<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"
showIcon
style={{ marginBottom: 16 }}
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
/>
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
<Modal
title={editRow ? '编辑承运商' : '注册承运商'}
open={open}
onCancel={() => setOpen(false)}
onOk={() => void submit()}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如 小飞侠、京东物流" />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<Divider orientation="left"></Divider>
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
<Select options={SETTLEMENT_OPTIONS} />
</Form.Item>
<Form.Item name="bankAccountName" label="收款户名">
<Input />
</Form.Item>
<Form.Item name="bankName" label="开户银行">
<Input />
</Form.Item>
<Form.Item name="bankBranch" label="开户支行">
<Input />
</Form.Item>
<Form.Item name="bankAccountNo" label="银行账号">
<Input />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item
name="baseBottles"
label="起送瓶数"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="baseFee"
label="起送费用(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="extraBottleFee"
label="加一瓶(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
</Space>
{showXfxFields && (
<>
<Divider orientation="left"></Divider>
<Form.Item
name="apiUrl"
label="API 地址"
rules={[{ required: true, message: '请填写小飞侠 API 地址' }]}
extra="推单请求将发往此地址"
>
<Input placeholder={DEFAULT_XFX_API_URL} />
</Form.Item>
<Form.Item
name="mchId"
label="商户号"
rules={[{ required: true, message: '请填写商户号' }]}
>
<Input />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={editRow ? [] : [{ required: true, message: '请填写 API Key' }]}
extra={
editRow?.xiaofeixiaConfig?.hasApiKey
? '已配置密钥;留空则保持不变'
: undefined
}
>
<Input.Password placeholder={editRow ? '留空则不修改' : '请输入'} />
</Form.Item>
<Form.Item name="signType" label="签名类型" initialValue="MD5">
<Select options={SIGN_OPTIONS} />
</Form.Item>
<Form.Item name="appId" label="AppID(可选)">
<Input />
</Form.Item>
</>
)}
</Form>
</Modal>
</div>
);
}
-199
View File
@@ -1,199 +0,0 @@
import { useEffect, useState } from 'react';
import {
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type HqProfile } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
phone: string;
loginName: string | null;
hasPassword: boolean;
name: string;
adminRole: string;
status: string;
lastLoginAt: string | null;
createdAt: string;
};
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: '超级管理员',
OPS: '运营',
FINANCE: '财务',
CUSTOMER_SERVICE: '客服',
};
export default function HqAccountsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null);
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/hq-accounts',
() => {
const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone);
if (filters.adminRole) qs.set('adminRole', filters.adminRole);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name' },
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 130 },
{
title: '登录方式',
width: 110,
render: (_, r) => (
<Space size={4}>
{r.hasPassword ? <Tag color="blue"></Tag> : null}
<Tag></Tag>
</Space>
),
},
{ 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 },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" disabled={!isSuperAdmin} onClick={() => {
setDetail(row);
editForm.setFieldsValue({
name: row.name,
phone: row.phone,
loginName: row.loginName,
adminRole: row.adminRole,
status: row.status,
});
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<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' });
setCreateOpen(true);
}}
>
</Button>
)}
</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: 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 rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={
<Button type="primary" onClick={async () => {
if (!detail) return;
const v = await editForm.validateFields();
const payload = { ...v };
if (!payload.password) delete payload.password;
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
}>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input maxLength={11} />
</Form.Item>
<Form.Item name="loginName" label="用户名"><Input placeholder="用于密码登录" /></Form.Item>
<Form.Item name="password" label="新密码" extra="留空则不修改">
<Input.Password placeholder="至少 6 位" />
</Form.Item>
<Form.Item name="adminRole" label="角色">
<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 }))} />
</Form.Item>
</Form>
</Drawer>
<Modal
title="新建 HQ 账户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}
>
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}>
<Form.Item name="credentialType" label="创建方式">
<Radio.Group>
<Radio value="phone"></Radio>
<Radio value="password"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="adminRole" label="角色">
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
{credentialType === 'phone' ? (
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
<Input maxLength={11} />
</Form.Item>
) : (
<>
<Form.Item name="loginName" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '至少 6 位' }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="phone" label="手机号(可选)" extra="不填则自动生成占位手机号,用于满足账号唯一约束">
<Input maxLength={11} />
</Form.Item>
</>
)}
</Form>
</Modal>
</div>
);
}
-199
View File
@@ -1,199 +0,0 @@
import { useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import { request } from '../lib/api';
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';
type Row = {
id: string;
hqAccountId: string | null;
hqName: string | null;
hqPhone: string | null;
hqRole: string | null;
action: string | null;
actionLabel: string;
refType: string | null;
refId: string | null;
status: string | null;
remark: string | null;
detail: Record<string, unknown> | null;
createdAt: string;
};
export default function HqLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => ({
hqAccountId: searchParams.get('hqAccountId') ?? '',
action: searchParams.get('action') ?? '',
refType: searchParams.get('refType') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/logs/hq',
() => {
const qs = new URLSearchParams();
if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId);
if (filters.action) qs.set('action', filters.action);
if (filters.refType) qs.set('refType', filters.refType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作人',
width: 200,
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.hqName}
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/>
),
},
{
title: '行为',
dataIndex: 'actionLabel',
width: 160,
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 || '—' },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
const res = await request<Row>(`/admin/logs/hq/${row.id}`);
setDetail(res);
setDrawerOpen(true);
}}
>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}>HQ </Typography.Title>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
const qs = new URLSearchParams();
if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId);
if (values.action) qs.set('action', values.action);
if (values.refType) qs.set('refType', values.refType);
setSearchParams(qs);
}}
>
<Form.Item name="hqAccountId" label="HQ 账户 ID">
<Input allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="action" label="行为">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</Form.Item>
<Form.Item name="refType" label="对象类型">
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => {
form.resetFields();
setFilters({ hqAccountId: '', action: '', refType: '' });
setSearchParams({});
setPage(1);
void reload();
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
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);
},
}}
/>
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="操作人">
<AdminCellLine
primary={detail.hqName}
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/>
</Descriptions.Item>
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
<Descriptions.Item label="行为">
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
</Descriptions.Item>
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>/</Typography.Title>
<pre style={{
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
maxHeight: 400, overflow: 'auto', fontSize: 12,
}}
>
{JSON.stringify(detail.detail, null, 2)}
</pre>
</>
)}
</Drawer>
</div>
);
}
@@ -1,343 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Card,
Checkbox,
Col,
Form,
Row,
Select,
Space,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import {
HQ_ADMIN_ROLES,
HQ_PERMISSION_CATALOG,
type HqPermissionKey,
} from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api';
type RolePermRes = { role: string; permissionKeys: string[] };
type AccountOption = { id: string; name: string; phone: string; loginName: string | null; adminRole: string };
type AccountPermRes = {
account: AccountOption;
permissionKeys: string[];
rolePermissionKeys: string[];
userPermissionKeys: string[];
effectivePermissionKeys: string[];
};
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
function groupColSpan(itemCount: number): number {
if (itemCount <= 2) return 12;
if (itemCount === 3) return 8;
if (itemCount === 4) return 6;
return 8;
}
function PermissionChecklist({
value,
onChange,
disabled,
}: {
value: string[];
onChange: (keys: string[]) => void;
disabled?: boolean;
}) {
return (
<Checkbox.Group
style={{ width: '100%' }}
value={value}
disabled={disabled}
onChange={(checked) => onChange(checked as string[])}
>
{CATALOG_GROUPS.map((group) => {
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
const span = groupColSpan(items.length);
const compact = items.length <= 3;
return (
<div
key={group}
style={{
marginBottom: 16,
padding: '12px 16px',
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
{group}
</Typography.Text>
{compact ? (
<Space size={[24, 8]} wrap>
{items.map((item) => (
<Checkbox key={item.key} value={item.key}>
{item.label}
</Checkbox>
))}
</Space>
) : (
<Row gutter={[12, 10]}>
{items.map((item) => (
<Col key={item.key} xs={24} sm={12} md={span}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
)}
</div>
);
})}
</Checkbox.Group>
);
}
export default function HqPermissionsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null);
const [role, setRole] = useState<string>('OPS');
const [roleKeys, setRoleKeys] = useState<string[]>([]);
const [roleLoading, setRoleLoading] = useState(false);
const [roleSaving, setRoleSaving] = useState(false);
const [accounts, setAccounts] = useState<AccountOption[]>([]);
const [accountId, setAccountId] = useState<string>();
const [accountKeys, setAccountKeys] = useState<string[]>([]);
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = useState(false);
const selectedAccount = useMemo(
() => accounts.find((a) => a.id === accountId),
[accounts, accountId],
);
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys],
);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
if (!isSuperAdmin) return;
request<{ items: AccountOption[] }>('/admin/hq-accounts?page=1&pageSize=100')
.then((res) => setAccounts(res.items))
.catch(() => {});
}, [isSuperAdmin]);
useEffect(() => {
if (!isSuperAdmin || !role) return;
setRoleLoading(true);
request<RolePermRes>(`/admin/hq-permissions/roles/${role}`)
.then((res) => setRoleKeys(res.permissionKeys))
.finally(() => setRoleLoading(false));
}, [isSuperAdmin, role]);
useEffect(() => {
if (!isSuperAdmin || !accountId) return;
setAccountLoading(true);
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
.then((res) => {
setAccountKeys(res.userPermissionKeys);
setRoleInheritedKeys(res.rolePermissionKeys);
})
.finally(() => setAccountLoading(false));
}, [isSuperAdmin, accountId]);
async function saveRolePermissions() {
setRoleSaving(true);
try {
const res = await request<RolePermRes>(`/admin/hq-permissions/roles/${role}`, {
method: 'PUT',
body: JSON.stringify({ permissionKeys: roleKeys }),
});
setRoleKeys(res.permissionKeys);
message.success('角色权限已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setRoleSaving(false);
}
}
async function saveAccountPermissions() {
if (!accountId) return;
setAccountSaving(true);
try {
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
method: 'PUT',
body: JSON.stringify({ permissionKeys: accountKeys }),
});
setAccountKeys(res.userPermissionKeys);
setRoleInheritedKeys(res.rolePermissionKeys);
message.success('用户权限已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setAccountSaving(false);
}
}
if (!isSuperAdmin) {
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Alert type="warning" showIcon message="仅超级管理员可配置权限" />
</div>
);
}
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
=
//
//
</Typography.Paragraph>
<Tabs
items={[
{
key: 'role',
label: '按角色分配',
children: (
<Card loading={roleLoading}>
<Form layout="inline" style={{ marginBottom: 16 }}>
<Form.Item label="角色">
<Select
style={{ width: 180 }}
value={role}
onChange={setRole}
options={HQ_ADMIN_ROLES.map((r) => ({
value: r.value,
label: r.label,
disabled: r.value === 'SUPER_ADMIN',
}))}
/>
</Form.Item>
</Form>
{role === 'SUPER_ADMIN' ? (
<Alert
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
/>
) : (
<>
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
<div style={{ marginTop: 16 }}>
<Button type="primary" loading={roleSaving} onClick={() => void saveRolePermissions()}>
</Button>
</div>
</>
)}
</Card>
),
},
{
key: 'user',
label: '按用户分配',
children: (
<Card loading={accountLoading}>
<Form layout="inline" style={{ marginBottom: 16 }}>
<Form.Item label="HQ 账户">
<Select
showSearch
allowClear
placeholder="选择账户"
style={{ width: 320 }}
value={accountId}
onChange={setAccountId}
optionFilterProp="label"
options={accounts.map((a) => ({
value: a.id,
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
/>
</Form.Item>
</Form>
{!accountId ? (
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : (
<>
<div style={{ marginBottom: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Tag color="blue"></Tag>
) : (
<Space wrap size={[4, 4]}>
{roleInheritedKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return (
<Tag key={key} color="blue">
{item?.label || key}
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary">
//
</Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
<div style={{ marginTop: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Space wrap size={[4, 4]}>
<Tag></Tag>
{accountKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key} color="orange">
{item?.label || key}
</Tag>
);
})}
</Space>
) : (
<Space wrap size={[4, 4]}>
{previewEffectiveKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key}>
{item?.label || key}
</Tag>
);
})}
</Space>
)}
</div>
<div style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
</Button>
</div>
</>
)}
</Card>
),
},
]}
/>
</div>
);
}
-424
View File
@@ -1,424 +0,0 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceStatus,
type InvoiceTitleType,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
type Row = {
id: string;
invoiceNo: string;
orderNo?: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
titleName: string;
status: InvoiceStatus;
overdue?: boolean;
createdAt: string;
fileUrl?: string;
email?: string;
phone?: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
payAmount?: string;
userPhone?: string;
remark?: string;
};
type CreateFormValues = {
orderNo: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
titleName: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
remark?: string;
};
export default function InvoicesPage() {
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);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<CreateFormValues>();
const invoiceKind = Form.useWatch('invoiceKind', createForm);
const titleType = Form.useWatch('titleType', createForm);
async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`));
setDrawerOpen(true);
}
async function issueWithFile(file: File) {
if (!detail) return false;
setUploading(true);
try {
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
await request(`/admin/invoices/${detail.id}/issue`, {
method: 'POST',
body: JSON.stringify({ fileUrl: uploaded.url }),
});
message.success('已开票回传');
reload();
setDrawerOpen(false);
} catch (e) {
message.error(e instanceof Error ? e.message : '开票失败');
} finally {
setUploading(false);
}
return false;
}
async function reject() {
if (!detail) return;
await request(`/admin/invoices/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ remark: '驳回' }),
});
message.success('已驳回');
reload();
setDrawerOpen(false);
}
async function submitCreate() {
const values = await createForm.validateFields();
setCreating(true);
try {
await request('/admin/invoices', {
method: 'POST',
body: JSON.stringify({
orderNo: values.orderNo.trim(),
titleType: values.titleType,
invoiceKind: values.invoiceKind,
titleName: values.titleName.trim(),
taxNo: values.taxNo?.trim() || undefined,
addressPhone: values.addressPhone?.trim() || undefined,
bankAccount: values.bankAccount?.trim() || undefined,
email: values.email.trim(),
phone: values.phone.trim(),
remark: values.remark?.trim() || undefined,
}),
});
message.success('发票申请已创建');
setCreateOpen(false);
createForm.resetFields();
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
const columns: ColumnsType<Row> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{
title: '抬头',
width: 100,
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
},
{
title: '票种',
width: 120,
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
},
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
{
title: '状态',
width: 110,
render: (_, r) => (
<>
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
</Tag>
{r.overdue ? <Tag color="red"></Tag> : null}
</>
),
},
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
),
},
];
return (
<div>
<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',
});
setCreateOpen(true);
}}
>
</Button>
</div>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'PENDING', label: '待开票' },
{ value: 'ISSUED', label: '已开票' },
{ value: 'REJECTED', label: '已驳回' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1000 }}
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
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)}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Upload
accept="image/*,.pdf"
showUploadList={false}
beforeUpload={(file) => {
void issueWithFile(file);
return false;
}}
>
<Button type="primary" loading={uploading}>
</Button>
</Upload>
<Button danger onClick={() => void reject()}>
</Button>
</Space>
) : null
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="抬头类型">
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
</Descriptions.Item>
<Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]}
</Descriptions.Item>
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="状态">
{INVOICE_STATUS_LABELS[detail.status]}
{detail.overdue ? '(超 2 工作日)' : ''}
</Descriptions.Item>
<Descriptions.Item label="发票文件">
{detail.fileUrl ? (
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
/
</a>
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark ?? '—'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
<Modal
title="创建发票申请"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={creating}
destroyOnClose
okText="提交"
width={520}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="orderNo"
label="订单号"
rules={[{ required: true, message: '请填写已完成订单号' }]}
extra="仅已完成订单可开票"
>
<Input placeholder="订单号" allowClear />
</Form.Item>
<Form.Item
name="invoiceKind"
label="发票类型"
rules={[{ required: true, message: '请选择发票类型' }]}
>
<Select
options={(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => ({
value: k,
label: INVOICE_KIND_LABELS[k],
}))}
onChange={(k: InvoiceKind) => {
if (k === 'SPECIAL') createForm.setFieldValue('titleType', 'ENTERPRISE');
}}
/>
</Form.Item>
<Form.Item
name="titleType"
label="抬头类型"
rules={[{ required: true, message: '请选择抬头类型' }]}
>
<Select
options={(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => ({
value: t,
label: INVOICE_TITLE_TYPE_LABELS[t],
disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL',
}))}
/>
</Form.Item>
<Form.Item
name="titleName"
label="抬头名称"
rules={[{ required: true, message: '请填写抬头名称' }]}
>
<Input placeholder="个人姓名或企业全称" />
</Form.Item>
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
<Form.Item
name="taxNo"
label="税号"
rules={[{ required: true, message: '企业抬头须填写税号' }]}
>
<Input placeholder="纳税人识别号" />
</Form.Item>
)}
{invoiceKind === 'SPECIAL' && (
<>
<Form.Item
name="addressPhone"
label="地址电话"
rules={[{ required: true, message: '专用发票须填写地址电话' }]}
>
<Input placeholder="注册地址及电话" />
</Form.Item>
<Form.Item
name="bankAccount"
label="开户行账号"
rules={[{ required: true, message: '专用发票须填写开户行账号' }]}
>
<Input placeholder="开户行及账号" />
</Form.Item>
</>
)}
<Form.Item
name="email"
label="接收邮箱"
rules={[
{ required: true, message: '请填写邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input placeholder="发票发送邮箱" />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[{ required: true, message: '请填写手机号' }]}
>
<Input placeholder="联系手机" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -1,403 +0,0 @@
import { useState } from 'react';
import {
Button,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { UploadOutlined } from '@ant-design/icons';
import type {
KnowledgeBaseDto,
KnowledgeDocumentDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
type FormValues = {
name: string;
description?: string;
enabled: boolean;
};
type DocForm = {
title: string;
contentText?: string;
};
export default function KnowledgeBasesPage() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [docForm] = Form.useForm<DocForm>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeBaseDto | null>(null);
const [saving, setSaving] = useState(false);
const [drawerKb, setDrawerKb] = useState<KnowledgeBaseDto | null>(null);
const [docs, setDocs] = useState<KnowledgeDocumentDto[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
const [docModalOpen, setDocModalOpen] = useState(false);
const [docSaving, setDocSaving] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<KnowledgeBaseDto>(
'/admin/knowledge-bases',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
const loadDocs = async (kb: KnowledgeBaseDto) => {
setDrawerKb(kb);
setDocsLoading(true);
try {
const res = await request<{ items: KnowledgeDocumentDto[] }>(
`/admin/knowledge-bases/${kb.id}/documents`,
);
setDocs(res.items);
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setDocsLoading(false);
}
};
const openCreate = () => {
setEditing(null);
form.setFieldsValue({ name: '', description: '', enabled: true });
setModalOpen(true);
};
const openEdit = (row: KnowledgeBaseDto) => {
setEditing(row);
form.setFieldsValue({
name: row.name,
description: row.description || '',
enabled: row.enabled,
});
setModalOpen(true);
};
const onSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await request(`/admin/knowledge-bases/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(values),
});
message.success('已保存');
} else {
await request('/admin/knowledge-bases', {
method: 'POST',
body: JSON.stringify(values),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const columns: ColumnsType<KnowledgeBaseDto> = [
{ title: '名称', dataIndex: 'name' },
{ title: '说明', dataIndex: 'description', ellipsis: true },
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
{
title: '启用',
dataIndex: 'enabled',
width: 90,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{
title: '创建人',
dataIndex: 'createdByName',
width: 100,
render: (v: string | null, row) => v || row.createdByHqAccountId,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 240,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => void loadDocs(row)}>
</Button>
{row.canEditFull ? (
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
) : null}
{row.canEditFull ? (
<Popconfirm
title="确认删除知识库及全部文档?"
onConfirm={async () => {
try {
await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
{ title: '标题', dataIndex: 'title' },
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) =>
s === 'READY' ? <Tag color="green"></Tag> : s === 'FAILED' ? <Tag color="red"></Tag> : <Tag></Tag>,
},
{
title: '时间',
dataIndex: 'createdAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 80,
render: (_, row) =>
drawerKb?.canEditFull ? (
<Popconfirm
title="删除文档?"
onConfirm={async () => {
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
method: 'DELETE',
});
message.success('已删除');
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null,
},
];
return (
<div>
<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}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) =>
setFilters({
name: v.name || '',
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
})
}
>
<Form.Item name="name" label="名称">
<Input allowClear />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={openCreate}>
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items}
pagination={{
current: page,
pageSize,
total: data?.total,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑知识库' : '新建知识库'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Drawer
title={drawerKb ? `文档 · ${drawerKb.name}` : '文档'}
open={!!drawerKb}
width={720}
onClose={() => setDrawerKb(null)}
extra={
drawerKb?.canEditFull ? (
<Button type="primary" onClick={() => { docForm.resetFields(); setDocModalOpen(true); }}>
/
</Button>
) : null
}
>
<Table
rowKey="id"
size="small"
loading={docsLoading}
columns={docColumns}
dataSource={docs}
pagination={false}
/>
</Drawer>
<Modal
title="添加知识文档"
open={docModalOpen}
onCancel={() => setDocModalOpen(false)}
confirmLoading={docSaving}
onOk={async () => {
if (!drawerKb) return;
const values = await docForm.validateFields();
setDocSaving(true);
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
method: 'POST',
body: JSON.stringify({
title: values.title,
contentText: values.contentText || null,
}),
});
message.success('已添加');
setDocModalOpen(false);
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setDocSaving(false);
}
}}
destroyOnClose
width={640}
>
<Form form={docForm} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contentText" label="正文(可粘贴)">
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
</Form.Item>
<Form.Item label="或上传文本文件">
<Upload
accept=".txt,.md,.markdown,.csv,.json,.log,text/*"
maxCount={1}
customRequest={async ({ file, onSuccess, onError }) => {
if (!drawerKb) return;
try {
const raw = file as File;
const uploaded = await uploadFileToOss(raw, {
bizType: 'KB_DOCUMENT',
mediaType: 'FILE',
});
const title =
docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, '');
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
method: 'POST',
body: JSON.stringify({
title,
fileName: raw.name,
fileUrl: uploaded.url,
mimeType: raw.type || null,
sizeBytes: raw.size,
}),
});
message.success('上传成功');
setDocModalOpen(false);
void loadDocs(drawerKb);
reload();
onSuccess?.(uploaded);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
message.error(err.message);
onError?.(err);
}
}}
showUploadList={false}
>
<Button icon={<UploadOutlined />}> OSS </Button>
</Upload>
</Form.Item>
</Form>
</Modal>
</div>
);
}
-380
View File
@@ -1,380 +0,0 @@
import { useEffect, useState } from 'react';
import {
Button,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
LLM_PROVIDERS,
LLM_PROVIDER_LABELS,
LLM_PROVIDER_PRESETS,
type LlmApiConfigDto,
type LlmProvider,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type FormValues = {
name: string;
provider: LlmProvider;
baseUrl?: string;
apiKey?: string;
modelName?: string;
temperature?: number | null;
maxTokens?: number | null;
systemPrompt?: string;
enabled: boolean;
};
export default function LlmConfigsPage() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<LlmApiConfigDto | null>(null);
const [saving, setSaving] = useState(false);
const providerWatch = Form.useWatch('provider', form);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
'/admin/llm-configs',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
useEffect(() => {
if (!providerWatch || editing) return;
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
if (!preset) return;
form.setFieldsValue({
baseUrl: preset.defaultBaseUrl || undefined,
modelName: preset.defaultModel || undefined,
});
}, [providerWatch, editing, form]);
const openCreate = () => {
setEditing(null);
form.setFieldsValue({
name: '',
provider: 'DEEPSEEK',
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
apiKey: '',
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
temperature: 0.3,
maxTokens: 1024,
systemPrompt: '',
enabled: true,
});
setModalOpen(true);
};
const openEdit = (row: LlmApiConfigDto) => {
setEditing(row);
form.setFieldsValue({
name: row.name,
provider: row.provider,
baseUrl: row.baseUrl,
apiKey: '',
modelName: row.modelName,
temperature: row.temperature,
maxTokens: row.maxTokens,
systemPrompt: row.systemPrompt || '',
enabled: row.enabled,
});
setModalOpen(true);
};
const onSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
if (!editing.canEditFull) {
await request(`/admin/llm-configs/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: values.enabled }),
});
} else {
await request(`/admin/llm-configs/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({
name: values.name,
provider: values.provider,
baseUrl: values.baseUrl,
apiKey: values.apiKey || undefined,
modelName: values.modelName,
temperature: values.temperature ?? null,
maxTokens: values.maxTokens ?? null,
systemPrompt: values.systemPrompt || null,
enabled: values.enabled,
}),
});
}
message.success('已保存');
} else {
await request('/admin/llm-configs', {
method: 'POST',
body: JSON.stringify({
name: values.name,
provider: values.provider,
baseUrl: values.baseUrl,
apiKey: values.apiKey,
modelName: values.modelName,
temperature: values.temperature ?? null,
maxTokens: values.maxTokens ?? null,
systemPrompt: values.systemPrompt || null,
enabled: values.enabled,
}),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const columns: ColumnsType<LlmApiConfigDto> = [
{ title: '名称', dataIndex: 'name' },
{
title: '提供商',
dataIndex: 'provider',
width: 120,
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
},
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
{
title: 'Key',
dataIndex: 'apiKeyConfigured',
width: 80,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{
title: '生效',
dataIndex: 'enabled',
width: 90,
render: (v: boolean, row) => (
<Switch
checked={v}
disabled={!row.isOwner && !row.canEditFull}
onChange={async (checked) => {
try {
await request(`/admin/llm-configs/${row.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: checked }),
});
message.success(checked ? '已启用' : '已停用');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
/>
),
},
{
title: '创建人',
dataIndex: 'createdByName',
width: 100,
render: (v: string | null, row) => v || row.createdByHqAccountId,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space>
<Button
type="link"
size="small"
onClick={() => openEdit(row)}
>
{row.canEditFull ? '编辑' : '开关'}
</Button>
<Button
type="link"
size="small"
onClick={async () => {
try {
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
method: 'POST',
body: '{}',
});
message.success(res.reply || '测试成功');
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
</Button>
{row.canEditFull ? (
<Popconfirm
title="确认删除?"
onConfirm={async () => {
try {
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const fullEdit = !editing || editing.canEditFull;
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) =>
setFilters({
name: v.name || '',
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
})
}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="搜索" />
</Form.Item>
<Form.Item name="enabled" label="生效">
<Select
allowClear
style={{ width: 100 }}
options={[
{ value: true, label: '开' },
{ value: false, label: '关' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={openCreate}>
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items}
pagination={{
current: page,
pageSize,
total: data?.total,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
<Input disabled={!fullEdit} />
</Form.Item>
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
<Select
disabled={!fullEdit}
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
/>
</Form.Item>
<Form.Item
name="baseUrl"
label="Base URL"
rules={[{ required: fullEdit, message: '必填' }]}
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1"
>
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={editing ? [] : [{ required: true, message: '必填' }]}
extra={editing ? '留空表示不修改' : undefined}
>
<Input.Password disabled={!fullEdit} />
</Form.Item>
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
<Input disabled={!fullEdit} />
</Form.Item>
<Space style={{ display: 'flex' }} size="large">
<Form.Item name="temperature" label="Temperature">
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="maxTokens" label="Max Tokens">
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
</Form.Item>
</Space>
<Form.Item name="systemPrompt" label="系统提示词">
<Input.TextArea disabled={!fullEdit} rows={3} />
</Form.Item>
<Form.Item name="enabled" label="生效" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
}
-147
View File
@@ -1,147 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
import { saveAuth, request } from '../lib/api';
type LoginResult = { accessToken: string; refreshToken: string };
export default function LoginPage() {
const navigate = useNavigate();
const [smsForm] = Form.useForm();
const [passwordForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [codeCooldown, setCodeCooldown] = useState(0);
const phone = Form.useWatch('phone', smsForm);
async function sendCode() {
if (!phone) {
message.warning('请先输入手机号');
return;
}
await request('/admin/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }),
});
message.success('验证码已发送');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
}
async function finishLogin(data: LoginResult) {
saveAuth(data);
message.success('登录成功');
navigate('/');
}
async function onSmsFinish(values: { phone: string; code: string }) {
setLoading(true);
try {
const data = await request<LoginResult>('/admin/auth/login/sms', {
method: 'POST',
body: JSON.stringify(values),
});
await finishLogin(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
async function onPasswordFinish(values: { loginName: string; password: string }) {
setLoading(true);
try {
const data = await request<LoginResult>('/admin/auth/login/password', {
method: 'POST',
body: JSON.stringify(values),
});
await finishLogin(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f5f5',
}}
>
<Card style={{ width: 400 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
HQ
</Typography.Title>
<Tabs
items={[
{
key: 'password',
label: '账号密码',
children: (
<Form
form={passwordForm}
layout="vertical"
onFinish={onPasswordFinish}
initialValues={{ loginName: 'admin' }}
>
<Form.Item name="loginName" label="账号" rules={[{ required: true, message: '请输入账号' }]}>
<Input placeholder="admin" autoComplete="username" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form>
),
},
{
key: 'sms',
label: '短信验证码',
children: (
<Form
form={smsForm}
layout="vertical"
onFinish={onSmsFinish}
initialValues={{ phone: '13600000001', code: MOCK_SMS_FIXED_CODE }}
>
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
<Input placeholder="13600000001" maxLength={11} />
</Form.Item>
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
<Input
placeholder={MOCK_SMS_FIXED_CODE}
addonAfter={
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form>
),
},
]}
/>
</Card>
</div>
);
}
@@ -1,625 +0,0 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
LOGISTICS_SETTLEMENT_METHOD_LABELS,
type LogisticsSettlementMethod,
} from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type BillRow = {
id: string;
billNo: string;
fulfillmentProviderId: string;
providerCode?: string;
providerName?: string;
periodStart: string;
periodEnd: string;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
settlementMethod: string;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
quantity: number;
logisticsAmount: number;
shippedAt: string;
};
type ProviderSummary = {
providerId: string;
providerCode: string;
providerName: string;
settlementMethod: string;
prepaidBalance: number;
bankAccountName?: string | null;
bankName?: string | null;
bankAccountNo?: string | null;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
billId?: string | null;
billStatus?: string | null;
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
};
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未结算',
PAID: '已结算',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
};
export default function LogisticsBillsPage() {
const [form] = Form.useForm();
const [summaryForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
'/admin/logistics-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs;
},
[filters],
);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
const [rechargeForm] = Form.useForm();
useEffect(() => {
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
.catch(() => {});
}, []);
async function loadSummary(period: Dayjs) {
setSummaryLoading(true);
try {
const res = await request<{ items: ProviderSummary[] }>(
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
);
setSummaryRows(res.items ?? []);
} finally {
setSummaryLoading(false);
}
}
useEffect(() => {
void loadSummary(summaryPeriod);
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
okText: '确认结算',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已确认结算');
setSelectedKeys([]);
reload();
void loadSummary(summaryPeriod);
},
});
}
async function openDetail(id: string) {
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
const result = await request<{ csv: string; count: number }>(
`/admin/logistics-bills/export?${qs}`,
);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
async function generateBills() {
const v = await genForm.validateFields();
const month = v.month as Dayjs;
await request('/admin/logistics-bills/generate', {
method: 'POST',
body: JSON.stringify({
year: month.year(),
month: month.month() + 1,
providerId: v.providerId || undefined,
}),
});
message.success('已生成物流对账单');
setGenOpen(false);
reload();
void loadSummary(summaryPeriod);
}
async function submitRecharge() {
if (!rechargeTarget) return;
const v = await rechargeForm.validateFields();
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
method: 'POST',
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
});
message.success('充值成功');
setRechargeOpen(false);
void loadSummary(summaryPeriod);
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{
title: '承运商',
width: 140,
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
<Button
type="link"
size="small"
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
>
</Button>
)}
</Space>
),
},
];
const summaryColumns: ColumnsType<ProviderSummary> = [
{
title: '承运商',
render: (_, r) => `${r.providerName} (${r.providerCode})`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 110,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{
title: '计价标准',
width: 220,
render: (_, r) => {
const p = r.pricingRules;
if (!p) return '—';
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
p.boxBottles ? `${p.boxBottles}瓶箱¥${p.boxFee}` : ''
}`;
},
},
{
title: '银行账户',
width: 180,
ellipsis: true,
render: (_, r) =>
r.bankAccountName
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
: '—',
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '应付物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '账单',
width: 100,
render: (_, r) =>
r.billStatus ? (
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
{STATUS_LABELS[r.billStatus] || r.billStatus}
</Tag>
) : (
'未生成'
),
},
{
title: '操作',
width: 100,
render: (_, r) =>
r.settlementMethod === 'PREPAID' ? (
<Button
type="link"
size="small"
onClick={() => {
setRechargeTarget(r);
rechargeForm.resetFields();
setRechargeOpen(true);
}}
>
</Button>
) : null,
},
];
return (
<div>
<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={[
{
key: 'summary',
label: '按承运商汇总',
children: (
<>
<Form
form={summaryForm}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={{ month: summaryPeriod }}
onFinish={(v: { month?: Dayjs }) => {
const m = v.month || dayjs().subtract(1, 'month');
setSummaryPeriod(m);
}}
>
<Form.Item name="month" label="账期">
<DatePicker picker="month" allowClear={false} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="providerId"
loading={summaryLoading}
columns={summaryColumns}
dataSource={summaryRows}
pagination={false}
scroll={{ x: 1200 }}
/>
</>
),
},
{
key: 'bills',
label: '月账单',
children: (
<>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic
title="瓶数合计"
value={summary.bottleCount ?? 0}
/>
<Statistic
title="物流费合计"
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
prefix="¥"
precision={2}
/>
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: {
status?: string;
providerId?: string;
month?: Dayjs;
}) => {
setFilters({
status: v.status || '',
providerId: v.providerId || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'UNPAID', label: '未结算' },
{ value: 'PAID', label: '已结算' },
]}
/>
</Form.Item>
<Form.Item name="providerId" label="承运商">
<Select
allowClear
style={{ width: 180 }}
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期">
<DatePicker picker="month" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
setGenOpen(true);
}}
>
</Button>
<Button loading={exporting} onClick={() => void exportExcel()}>
</Button>
<Button
disabled={!selectedKeys.length}
onClick={() =>
confirmPay(
selectedKeys.map(String),
selectedAmount,
)
}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={billColumns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 1100 }}
/>
</>
),
},
]}
/>
<Drawer
title="物流对账明细"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail && (
<>
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="承运商">
{detail.providerName} ({detail.providerCode})
</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="物流费">
¥{Number(detail.logisticsAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
</Descriptions>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '发货时间',
dataIndex: 'shippedAt',
width: 160,
render: fmtTime,
},
]}
/>
</>
)}
</Drawer>
<Modal
title="生成物流月账单"
open={genOpen}
onCancel={() => setGenOpen(false)}
onOk={() => void generateBills()}
destroyOnClose
>
<Form form={genForm} layout="vertical">
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="providerId" label="承运商(空=全部)">
<Select
allowClear
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
open={rechargeOpen}
onCancel={() => setRechargeOpen(false)}
onOk={() => void submitRecharge()}
destroyOnClose
>
<Form form={rechargeForm} layout="vertical">
<Form.Item
name="amount"
label="金额"
rules={[{ required: true, message: '请输入充值金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="可选,如预充运费" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,461 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button, Checkbox, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
type PartnerOption = { id: string; companyName: string };
type ParentAccount = { id: string; name: string; phone: string };
type AccountTreeRow = {
id: string;
phone: string;
name: string;
status: string;
isPrimary: number;
staffRole?: string | null;
permissions?: string[] | null;
parentAccountId?: string | null;
parent?: ParentAccount | null;
createdAt: string;
lastLoginAt?: string | null;
companyName?: string | null;
children?: AccountTreeRow[];
};
type BillRow = {
id: string; billNo: string; totalAmount: number; orderCommission: number; redeemCommission: number;
status: string; periodStart: string; periodEnd: string; createdAt: string;
};
type OrderRow = {
id: string; orderNo: string; status: string; payAmount: number; createdAt: string;
user?: { userNo: string; phone: string | null };
};
type Detail = AccountTreeRow & { bills?: BillRow[]; orders?: OrderRow[] };
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value, label]) => ({
value,
label,
}));
const PERMISSION_OPTIONS = PARTNER_PERMISSION_KEYS.map((key: PartnerPermissionKey) => ({
value: key,
label: PARTNER_PERMISSION_LABELS[key],
}));
function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
const phoneQ = phone?.trim();
const match = (row: AccountTreeRow) => {
const phoneOk = !phoneQ || row.phone.includes(phoneQ);
const statusOk = !status || row.status === status;
return phoneOk && statusOk;
};
const walk = (list: AccountTreeRow[]): AccountTreeRow[] => {
const result: AccountTreeRow[] = [];
for (const row of list) {
const children = row.children?.length ? walk(row.children) : undefined;
if (match(row) || (children && children.length > 0)) {
result.push({ ...row, children: children?.length ? children : undefined });
}
}
return result;
};
return walk(rows);
}
function staffRoleLabel(role?: string | null) {
if (!role) return '-';
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role;
}
export default function PartnerAccountsPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
const [loading, setLoading] = useState(false);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [detail, setDetail] = useState<Detail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const loadTree = useCallback(async () => {
setLoading(true);
try {
const qs = new URLSearchParams();
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
const path = qs.toString() ? `/admin/partner-accounts/tree?${qs}` : '/admin/partner-accounts/tree';
const res = await request<AccountTreeRow[]>(path);
setTreeData(filterTree(res, filters.phone, filters.status));
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
void loadPartners();
}, []);
useEffect(() => {
void loadTree();
}, [loadTree]);
useEffect(() => {
if (!drawerOpen || !detail) return;
editForm.setFieldsValue({
name: detail.name,
phone: detail.phone,
status: detail.status,
permissions: detail.permissions ?? [],
});
}, [drawerOpen, detail, editForm]);
async function loadPartners() {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setPartners(res.items);
}
async function openAccount(id: string) {
try {
const d = await request<Detail>(`/admin/partner-accounts/${id}`);
setDetail(d);
setDrawerOpen(true);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载账户详情失败');
}
}
async function saveAccount() {
if (!detail) return;
const v = await editForm.validateFields();
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已保存');
setDrawerOpen(false);
void loadTree();
}
async function deleteSubAccount(row: AccountTreeRow) {
await request(`/admin/partner-accounts/${row.id}`, { method: 'DELETE' });
message.success('子账号已删除');
void loadTree();
}
function openAddSub(parent: AccountTreeRow) {
setSubParent(parent);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
function collectExpandableKeys(rows: AccountTreeRow[]): string[] {
const keys: string[] = [];
for (const row of rows) {
if (row.children?.length) {
keys.push(row.id);
keys.push(...collectExpandableKeys(row.children));
}
}
return keys;
}
const columns: ColumnsType<AccountTreeRow> = [
{
title: '姓名 / 类型',
width: 200,
ellipsis: true,
render: (_, row) => (
<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 || '—',
},
{
title: '账号类型',
width: 90,
render: (_, row) => (
<Tag color={row.parentAccountId ? 'default' : 'blue'}>
{row.parentAccountId ? '子账号' : '主账号'}
</Tag>
),
},
{
title: '角色',
dataIndex: 'staffRole',
width: 100,
render: (role) => staffRoleLabel(role),
},
{
title: '所属主账号',
width: 140,
render: (_, row) => (
row.parentAccountId && row.parent
? `${row.parent.name} / ${row.parent.phone}`
: '-'
),
},
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 180,
render: (_, row) => (
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
<Button type="link" size="small" onClick={() => void openAccount(row.id)}></Button>
{!row.parentAccountId ? (
<Button type="link" size="small" onClick={() => openAddSub(row)}></Button>
) : null}
{row.parentAccountId ? (
<Popconfirm title="确定删除该子账号?" onConfirm={() => void deleteSubAccount(row)}>
<Button type="link" size="small" danger></Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 140 },
{ title: '总额', dataIndex: 'totalAmount', width: 90, render: (v) => `¥${v}` },
{ title: '订单佣金', dataIndex: 'orderCommission', width: 90, render: (v) => `¥${v}` },
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 90, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{PARTNER_BILL_STATUS_LABELS[s] || s}</Tag> },
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart)} ~ ${fmtTime(r.periodEnd)}` },
];
const orderColumns: ColumnsType<OrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
{ title: '金额', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag> },
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
];
return (
<div>
<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"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters({
phone: v.phone?.trim() || undefined,
status: v.status || undefined,
partnerId: v.partnerId || undefined,
});
}}
>
<Form.Item name="partnerId" label="开城合伙人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
placeholder="全部"
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
onFocus={() => void loadPartners()}
/>
</Form.Item>
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => setExpandedRowKeys(collectExpandableKeys(treeData))}></Button>
<Button onClick={() => setExpandedRowKeys([])}></Button>
</Space>
</Form.Item>
</Form>
<Table<AccountTreeRow>
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={treeData}
scroll={{ x: 1200 }}
pagination={false}
expandable={{
expandedRowKeys,
onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as string[]),
defaultExpandAllRows: false,
}}
/>
<Drawer
title={detail?.parentAccountId ? '子账号详情' : '主账号详情(只读)'}
width={720}
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
setDetail(null);
}}
destroyOnClose
extra={
detail?.parentAccountId
? <Button type="primary" onClick={() => void saveAccount()}></Button>
: null
}
>
{detail && (
<Tabs items={[
{
key: 'info',
label: '基本信息',
children: (
<Form
form={editForm}
layout="vertical"
key={detail.id}
initialValues={{
name: detail.name,
phone: detail.phone,
status: detail.status,
permissions: detail.permissions ?? [],
}}
>
<Form.Item label="开城合伙人">
<Input value={detail.companyName || detail.parent?.name || '—'} disabled />
</Form.Item>
<Form.Item label="账号类型">
<Input
value={
detail.parentAccountId
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
: '主账号'
}
disabled
/>
</Form.Item>
{detail.parentAccountId && detail.parent ? (
<Form.Item label="所属主账号">
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
</Form.Item>
) : null}
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input disabled={!detail.parentAccountId} /></Form.Item>
<Form.Item
name="phone"
label="登录手机"
rules={[
{ required: true },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
>
<Input maxLength={11} disabled={!detail.parentAccountId} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select disabled={!detail.parentAccountId} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
{detail.parentAccountId ? (
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERMISSION_OPTIONS} />
</Form.Item>
) : null}
{!detail.parentAccountId ? (
<Typography.Text type="secondary">
</Typography.Text>
) : (
<Typography.Text type="secondary">
H5 使
</Typography.Text>
)}
</Form>
),
},
{
key: 'bills',
label: `账单 (${detail.bills?.length ?? 0})`,
children: (
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} />
),
},
{
key: 'orders',
label: `名下订单 (${detail.orders?.length ?? 0})`,
children: (
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} />
),
},
]} />
)}
</Drawer>
<Modal
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
open={subOpen}
onCancel={() => setSubOpen(false)}
onOk={async () => {
if (!subParent) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({
parentAccountId: subParent.id,
name: v.name,
phone: v.phone,
staffRole: v.staffRole,
permissions: v.permissions,
}),
});
message.success('子账号已创建');
setSubOpen(false);
setExpandedRowKeys((keys) => [...new Set([...keys, subParent.id])]);
void loadTree();
}}
>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机"
rules={[
{ required: true },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
>
<Input maxLength={11} />
</Form.Item>
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERMISSION_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -1,470 +0,0 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Form,
Input,
Modal,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
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';
type Row = {
id: string;
billNo: string;
orderCommission: number;
redeemCommission: number;
totalAmount: number;
status: string;
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
};
type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
};
const STATUS_COLORS: Record<string, string> = {
PENDING_REVIEW: 'gold',
AWAITING_CONFIRM: 'blue',
UNPAID: 'red',
PAID: 'green',
REJECTED: 'red',
};
export default function PartnerBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [partners, setPartners] = useState<PartnerOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partner-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs;
},
[filters],
);
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items))
.catch(() => {});
}, []);
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
const year = values.month.year();
const month = values.month.month() + 1;
if (!values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) },
);
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
} else {
await request('/admin/partner-bills/generate', {
method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
});
message.success('账单已生成(待审核)');
}
setGenOpen(false);
reload();
}
function sendBills(ids: string[]) {
Modal.confirm({
title: '发送给合伙人?',
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
okText: '确认发送',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
} else {
await request('/admin/partner-bills/batch-send', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已发送');
setSelectedKeys([]);
reload();
},
});
}
function markPaid(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认打款?',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
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',
body: JSON.stringify({ ids }),
});
}
message.success('已标记打款');
setSelectedKeys([]);
reload();
},
});
}
function openReject(row: Row) {
Modal.confirm({
title: '驳回该账单?',
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
okText: '继续填写理由',
cancelText: '取消',
onOk: () => {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
},
});
}
async function submitReject(values: { reason: string }) {
if (!rejectTarget) return;
setRejecting(true);
try {
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: values.reason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setRejectTarget(null);
reload();
} finally {
setRejecting(false);
}
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
{
title: '合伙人',
width: 160,
ellipsis: true,
render: (_, r) => partnerName(r),
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`,
},
{
title: '酒单佣金',
dataIndex: 'orderCommission',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '核销佣金',
dataIndex: 'redeemCommission',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '合计应付',
dataIndex: 'totalAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 130,
render: (s, row) => (
<Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
{s === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
),
},
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
</Button>
)}
{row.status === 'UNPAID' && (
<>
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
</Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
</>
)}
{row.status === 'AWAITING_CONFIRM' && (
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
)}
</Space>
),
},
];
return (
<div>
<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 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic title="酒单佣金合计" value={summary.orderCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="核销佣金合计" value={summary.redeemCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { partnerId?: string; status?: string; month?: Dayjs }) => {
setFilters({
partnerId: v.partnerId || '',
status: v.status || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="partnerId" label="合伙人">
<Select
allowClear
showSearch
placeholder="全部合伙人"
style={{ width: 220 }}
optionFilterProp="label"
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.phone || p.id,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 150 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => setGenOpen(true)}>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item>
<Form.Item>
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
({canSend.length})
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!canPay.length}
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
>
({canPay.length})
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
}}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form
form={genForm}
layout="vertical"
initialValues={{ month: dayjs().subtract(1, 'month') }}
onFinish={generateBill}
>
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部主合伙人"
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.phone || p.id,
}))}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
<Modal
title="驳回打款申请"
open={rejectOpen}
onCancel={() => {
setRejectOpen(false);
setRejectTarget(null);
}}
footer={null}
destroyOnClose
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
</Typography.Paragraph>
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
<Form.Item
name="reason"
label="驳回理由"
rules={[
{ required: true, message: '请填写驳回理由' },
{ max: 500, message: '不超过 500 字' },
]}
>
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
</Form.Item>
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
</Button>
</Form>
</Modal>
</div>
);
}
@@ -1,234 +0,0 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import {
PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
PARTNER_STAFF_ROLE_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory,
type PartnerStaffRole,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
partnerId: string;
partnerAccountId: string | null;
accountName: string | null;
accountPhone: string | null;
companyName: string | null;
isSubAccount?: boolean;
staffRole?: string | null;
category: PartnerLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
};
function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—';
const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
}
export default function PartnerLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [category, setCategory] = useState(searchParams.get('category') ?? '');
const [filters, setFilters] = useState<Record<string, string>>(() => ({
partnerId: searchParams.get('partnerId') ?? '',
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
phone: searchParams.get('phone') ?? '',
companyName: searchParams.get('companyName') ?? '',
eventName: searchParams.get('eventName') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/logs/partners',
() => {
const qs = new URLSearchParams();
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.eventName) qs.set('eventName', filters.eventName);
if (category) qs.set('category', category);
return qs;
},
[filters, category],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '合伙人',
width: 180,
ellipsis: true,
render: (_, r) => (
r.isSubAccount ? (
<Tag color="blue"></Tag>
) : (
<AdminCellLine primary={r.companyName} secondary={r.partnerId} />
)
),
},
{
title: '账号',
width: 150,
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.accountName}
secondary={
r.isSubAccount && r.staffRole
? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}`
: r.accountPhone || r.partnerAccountId
}
/>
),
},
{
title: '分类',
dataIndex: 'category',
width: 100,
render: (v: PartnerLogCategory | null, r) => (
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 180 },
{
title: '关联',
width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要',
ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
return (
<div>
<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}
onChange={(v) => {
setCategory(String(v));
setPage(1);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (v) next.set('category', String(v));
else next.delete('category');
return next;
});
}}
style={{ marginBottom: 16 }}
/>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="companyName" label="公司">
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="partnerId" label="合伙人ID">
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="eventName" label="事件名">
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
form.resetFields();
setFilters({
partnerId: '',
partnerAccountId: '',
phone: '',
companyName: '',
eventName: '',
});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<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);
},
}}
scroll={{ x: 1100 }}
/>
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && (
<Descriptions column={1} bordered size="small">
{Object.entries(detail).map(([k, v]) => (
<Descriptions.Item key={k} label={k}>
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
</Descriptions.Item>
))}
</Descriptions>
)}
</Drawer>
</div>
);
}
-405
View File
@@ -1,405 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CityPartnerScopeType,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
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';
type Row = {
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
storeCount: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type CityOption = { id: string; name: string; code: string };
type WarehouseOption = { id: string; name: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
export default function PartnersPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
const loadWarehouses = useCallback(async (cityId: string) => {
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
if (d.cityId) await loadWarehouses(d.cityId);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus,
managedWarehouseId: d.managedWarehouseId,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const body = {
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
};
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
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', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 130 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{ title: '门店', dataIndex: 'storeCount', width: 70 },
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 120,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<div>
<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>
<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 ?? []}
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={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<Tabs
items={[
{
key: 'info',
label: '基本信息',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Form.Item name="managedWarehouseId" label="管仓仓库">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
),
},
{
key: 'staff',
label: '子账号',
children: (
<>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
</Button>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={detail.children ?? []}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
{
title: '权限',
dataIndex: 'permissions',
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
},
]}
/>
</>
),
},
]}
/>
)}
</Drawer>
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
try {
const v = await createForm.validateFields();
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}
>
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => {
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
{createCityId && (
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</Form.Item>
)}
</Form>
</Modal>
<Modal
title="添加子账号"
open={subOpen}
onCancel={() => setSubOpen(false)}
onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
void openPartner(detail.id);
}}
>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="permissions" label="权限">
<Select mode="multiple" options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -1,266 +0,0 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
REDEEM_PENDING_STATUS_LABELS,
type RedeemPendingItem,
type RedeemPendingStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange',
COMPLETED: 'green',
REJECTED: 'default',
};
export default function PendingRedeemPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
'/admin/redeem-pending',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
async function openDetail(id: string) {
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
setDetail(res);
setDrawerOpen(true);
}
async function complete() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
message.success('已补核销');
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '补核销失败');
} finally {
setActing(false);
}
}
async function reject() {
if (!detail || !rejectReason.trim()) {
message.error('请填写驳回原因');
return;
}
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: rejectReason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '驳回失败');
} finally {
setActing(false);
}
}
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 || '—' },
{
title: '用户',
width: 120,
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
},
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: RedeemPendingStatus) => (
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="pendingNo" label="待处理单号">
<Input allowClear />
</Form.Item>
<Form.Item name="redeemToken" label="核销码 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
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)}
extra={
detail?.status === 'PENDING' && (
<Space>
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
</Button>
<Button type="primary" loading={acting} onClick={() => void complete()}>
</Button>
</Space>
)
}
>
{detail && (
<>
{detail.photoUrl && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
</div>
)}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
<Descriptions.Item label="核销码 ID">
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status]}>
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
<Descriptions.Item label="门店">
{detail.store?.name || '—'}{detail.store?.id}
</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
</Descriptions.Item>
<Descriptions.Item label="关联核销单">
{detail.redeemRecord?.redeemNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="处理时间">
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
<Modal
title="驳回待处理单"
open={rejectOpen}
okText="确认驳回"
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
onOk={() => void reject()}
onCancel={() => setRejectOpen(false)}
>
<Input.TextArea
rows={3}
placeholder="请填写驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
@@ -1,258 +0,0 @@
import { useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space,
Table, Tag, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import {
AROMA_TYPE_LABELS, DETAIL_TEMPLATE_STATUS_LABELS, fmtTime,
} from '../lib/constants';
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';
type Row = ProductDetailTemplateDto;
type TemplateFormValues = {
code: string;
name: string;
description?: string;
aromaType?: string | null;
storyTitle?: string;
storyText?: string;
features?: Array<{ icon?: string; title?: string; desc?: string }>;
detailImageUrls?: string[];
suggestedDetailImageCount?: number;
sortOrder?: number;
status?: string;
};
function mapToForm(d: Record<string, unknown>) {
const row = d as Row;
return {
...row,
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
features: row.features?.length
? row.features
: [{ icon: 'star', title: '', desc: '' }],
};
}
function buildPayload(v: TemplateFormValues) {
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const features = (v.features ?? [])
.filter((f) => f?.title?.trim() || f?.desc?.trim())
.map((f) => ({
icon: f.icon?.trim() || 'star',
title: f.title?.trim() ?? '',
desc: f.desc?.trim() ?? '',
}));
return {
code: v.code.trim(),
name: v.name.trim(),
description: v.description?.trim() || undefined,
aromaType: v.aromaType || null,
storyTitle: v.storyTitle?.trim() || undefined,
storyText: v.storyText?.trim() || undefined,
features,
detailImageUrls,
suggestedDetailImageCount: detailImageUrls.length || (v.suggestedDetailImageCount ?? 1),
sortOrder: v.sortOrder ?? 0,
status: v.status ?? 'ACTIVE',
};
}
function TemplateContentFields() {
return (
<>
<Typography.Text type="secondary"></Typography.Text>
<DetailImageUrlList
label="详情图"
bizType="DETAIL_TEMPLATE"
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
/>
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
</Form.Item>
<Form.Item name="storyText" label="故事正文">
<Input.TextArea rows={4} placeholder="商品故事描述" />
</Form.Item>
<Typography.Text type="secondary"></Typography.Text>
<Form.List name="features">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
<Space align="start">
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
<Input placeholder="material icon 名" style={{ width: 140 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="标题" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
)}
</Space>
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
<Input placeholder="简短描述" />
</Form.Item>
</Space>
))}
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</>
);
}
export default function ProductDetailTemplatesPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/product-detail-templates',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.aromaType) qs.set('aromaType', filters.aromaType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 120 },
{ 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 },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{DETAIL_TEMPLATE_STATUS_LABELS[s] || s}</Tag> },
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapToForm(d));
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<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 /></Form.Item>
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="aromaType" label="香型">
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
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 && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
const payload = buildPayload(v);
await request(`/admin/product-detail-templates/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
)}>
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="创建">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="aromaType" label="推荐香型">
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Divider />
<TemplateContentFields />
</Form>
</>
)}
</Drawer>
<Modal title="新建详情模板" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
const payload = buildPayload(v);
await request('/admin/product-detail-templates', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}} width={640}>
<Form form={createForm} layout="vertical" initialValues={{
status: 'ACTIVE', sortOrder: 0,
detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
<Input />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="aromaType" label="推荐香型">
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Divider />
<TemplateContentFields />
</Form>
</Modal>
</div>
);
}
-566
View File
@@ -1,566 +0,0 @@
import { useMemo, useRef, useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import type { FormInstance } from 'antd/es/form';
type ProductDetailContentDto = {
storyTitle?: string;
storyText?: string;
features?: Array<{ icon: string; title: string; desc: string }>;
};
type Row = {
id: string;
skuCode: string;
barcode69: string;
name: string;
subtitle?: string;
aromaType: string;
spec: string;
price: number;
benefitAmount: number;
status: string;
sortOrder: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
mainImageUrl?: string | null;
carouselUrls?: string[];
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
createdAt: string;
};
type ProductFormValues = {
skuCode?: string;
barcode69?: string;
name: string;
subtitle?: string;
aromaType?: string;
spec: string;
price: number;
benefitAmount?: number;
status?: string;
sortOrder?: number;
allowOnSitePickup?: boolean;
allowOnlinePurchase?: boolean;
allowCrossCityDelivery?: boolean;
visibilityWhitelistEnabled?: boolean;
visibilityPhones?: string[];
coverUrl?: string;
carouselUrls?: string[];
detailImageUrls?: string[];
storyTitle?: string;
storyText?: string;
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
type UserPickRow = {
id: string;
phone?: string | null;
nickname?: string | null;
userNo?: string;
};
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
const row = d as Row;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: (row.carouselUrls?.length ? row.carouselUrls : ['']) as string[],
detailImageUrls: (row.detailImageUrls?.length ? row.detailImageUrls : ['']) as string[],
allowOnlinePurchase: row.allowOnlinePurchase !== false,
allowCrossCityDelivery: row.allowOnlinePurchase === false ? false : row.allowCrossCityDelivery !== false,
allowOnSitePickup: !!row.allowOnSitePickup,
visibilityWhitelistEnabled: !!row.visibilityWhitelistEnabled,
visibilityPhones: (row.visibilityPhones ?? []) as string[],
storyTitle: detail.storyTitle ?? '',
storyText: detail.storyText ?? '',
features: detail.features?.length
? detail.features
: [{ icon: 'water_drop', title: '', desc: '' }],
};
}
function buildProductPayload(v: ProductFormValues) {
const carouselUrls = (v.carouselUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const features = (v.features ?? [])
.filter((f) => f?.title?.trim() || f?.desc?.trim())
.map((f) => ({
icon: f.icon?.trim() || 'star',
title: f.title?.trim() || '',
desc: f.desc?.trim() || '',
}));
const detailContent: ProductDetailContentDto = {
storyTitle: v.storyTitle?.trim() || undefined,
storyText: v.storyText?.trim() || undefined,
features: features.length ? features : undefined,
};
const visibilityPhones = (v.visibilityPhones ?? [])
.map((p) => String(p || '').replace(/\D/g, '').trim())
.filter(Boolean);
return {
barcode69: v.barcode69,
name: v.name,
subtitle: v.subtitle,
aromaType: v.aromaType,
spec: v.spec,
price: v.price,
benefitAmount: v.benefitAmount,
status: v.status,
sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
allowOnlinePurchase: v.allowOnlinePurchase !== false,
allowCrossCityDelivery:
v.allowOnlinePurchase === false ? false : v.allowCrossCityDelivery !== false,
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
visibilityPhones,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
detailContent,
};
}
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return (
<Form.List name={name}>
{(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={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
</>
)}
</Form.List>
);
}
function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaType?: string }) {
return (
<>
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
<Divider />
<Typography.Text type="secondary">CAROUSEL</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider />
<Typography.Text type="secondary">DETAIL</Typography.Text>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
</Form.Item>
<Form.Item name="storyText" label="故事正文">
<Input.TextArea rows={4} placeholder="商品故事描述" />
</Form.Item>
<Typography.Text type="secondary"></Typography.Text>
<Form.List name="features">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
<Space align="start">
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
<Input placeholder="material icon 名" style={{ width: 140 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="标题" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
)}
</Space>
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
<Input placeholder="简短描述" />
</Form.Item>
</Space>
))}
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</>
);
}
function VisibilityWhitelistFields({ form }: { form: FormInstance }) {
const [userOptions, setUserOptions] = useState<UserPickRow[]>([]);
const [userSearching, setUserSearching] = useState(false);
const searchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
async function searchUsers(keyword: string) {
const q = keyword.trim();
if (searchTimer.current) clearTimeout(searchTimer.current);
if (!q) {
setUserOptions([]);
return;
}
searchTimer.current = setTimeout(() => {
void (async () => {
setUserSearching(true);
try {
const qs = new URLSearchParams({ page: '1', pageSize: '20', phone: q });
const res = await request<{ items: UserPickRow[] }>(`/admin/users?${qs}`);
setUserOptions((res.items ?? []).filter((u) => !!u.phone));
} catch {
setUserOptions([]);
} finally {
setUserSearching(false);
}
})();
}, 300);
}
const enabled = Form.useWatch('visibilityWhitelistEnabled', form);
return (
<>
<Form.Item
name="visibilityWhitelistEnabled"
label="可见白名单"
valuePropName="checked"
extra="开启后仅名单内手机号在 C 端可见/可购,用于在线测试"
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
{enabled ? (
<>
<Form.Item
name="visibilityPhones"
label="白名单手机号"
rules={[{ required: true, message: '请至少添加一个手机号' }]}
extra="可直接输入多个手机号回车添加,或从下方用户库选择"
>
<Select
mode="tags"
tokenSeparators={[',', ' ', '', ';', '']}
placeholder="输入手机号后回车"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item label="从用户库添加">
<Select
showSearch
filterOption={false}
placeholder="按手机号搜索用户"
loading={userSearching}
options={userOptions.map((u) => ({
value: u.phone!,
label: `${u.phone}${u.nickname ? ` · ${u.nickname}` : ''}${u.userNo ? `${u.userNo}` : ''}`,
}))}
onSearch={searchUsers}
onSelect={(phone: string) => {
const cur = (form.getFieldValue('visibilityPhones') as string[] | undefined) ?? [];
if (!cur.includes(phone)) {
form.setFieldsValue({ visibilityPhones: [...cur, phone] });
}
}}
notFoundContent={userSearching ? '搜索中…' : '输入手机号搜索'}
/>
</Form.Item>
</>
) : null}
</>
);
}
function BaseInfoFields({ mode, form }: { mode: 'create' | 'edit'; form: FormInstance }) {
return (
<>
{mode === 'create' && (
<>
<Form.Item label="SKU">
<Input disabled placeholder="保存后自动生成" />
</Form.Item>
<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 }))} />
</Form.Item>
</>
)}
<Form.Item name="name" label="商品名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="subtitle" label="副标题">
<Input />
</Form.Item>
<Form.Item name="spec" label="规格" rules={[{ required: true }]}>
<Input placeholder="500ml | 52度" />
</Form.Item>
<Form.Item name="price" label="售价" rules={[{ required: true }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="benefitAmount" label="权益额">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="allowOnlinePurchase"
label="允许线上购买"
valuePropName="checked"
extra="配送到址(同城)"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
if (!checked) form.setFieldsValue({ allowCrossCityDelivery: false });
}}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.allowOnlinePurchase !== cur.allowOnlinePurchase}>
{() => (
<Form.Item
name="allowCrossCityDelivery"
label="允许跨城配送"
valuePropName="checked"
extra="须先开启线上购买"
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
disabled={!form.getFieldValue('allowOnlinePurchase')}
/>
</Form.Item>
)}
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<VisibilityWhitelistFields form={form} />
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
</>
);
}
export default function ProductsPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/products',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.status) qs.set('status', filters.status);
if (filters.aromaType) qs.set('aromaType', filters.aromaType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
async function handleDelete(row: Row) {
try {
await request(`/admin/products/${row.id}`, { method: 'DELETE' });
message.success('已删除');
if (detail?.id === row.id) {
setDrawerOpen(false);
setDetail(null);
}
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
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: '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>
) },
{
title: '白名单',
dataIndex: 'visibilityWhitelistEnabled',
width: 90,
render: (v: boolean, row) =>
v ? <Tag color="orange">{row.visibilityPhones?.length ?? 0}</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) => (
<Space size={0}>
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
setDrawerOpen(true);
}}></Button>
<Popconfirm
title="确认删除该商品?"
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(row)}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
], [detail, editForm]);
return (
<div>
<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 /></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>
<Form.Item name="aromaType" label="香型">
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1320 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
)}>
{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="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" form={editForm} /> },
{
key: 'detail',
label: '详情页',
children: (
<ProductDetailFields
form={editForm}
aromaType={String(detail.aromaType ?? '')}
/>
),
},
]} />
</Form>
</>
)}
</Drawer>
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
if (!v.allowOnlinePurchase && !v.allowOnSitePickup) {
message.error('请至少勾选「允许线上购买」或「允许现场取货」之一');
return;
}
const payload = buildProductPayload(v);
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
allowOnlinePurchase: true, allowCrossCityDelivery: true, allowOnSitePickup: false,
visibilityWhitelistEnabled: false, visibilityPhones: [],
carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" form={createForm} /> },
{
key: 'detail',
label: '详情页',
children: (
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.aromaType !== cur.aromaType}>
{() => (
<ProductDetailFields
form={createForm}
aromaType={createForm.getFieldValue('aromaType') as string | undefined}
/>
)}
</Form.Item>
),
},
]} />
</Form>
</Modal>
</div>
);
}
-244
View File
@@ -1,244 +0,0 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
type PromoCodeItem,
type PromoCodeScene,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = PromoCodeItem;
type SceneOption = { value: PromoCodeScene; label: string };
async function downloadQrcode(url: string, filename: string) {
try {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
}
export default function PromoCodesPage() {
const navigate = useNavigate();
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
label,
})),
);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
return qs;
},
[filters],
);
async function loadScenes() {
try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
if (list.length) setScenes(list);
} catch {
/* 使用本地默认场景 */
}
}
useEffect(() => {
void loadScenes();
}, []);
const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
dataIndex: 'scene',
width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
</Tag>
),
},
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
{
title: '渠道负责人',
width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button>
<Button
type="link"
size="small"
disabled={!row.qrcodeUrl}
onClick={() => {
if (!row.qrcodeUrl) return;
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
}}
>
</Button>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
</Button>
</Space>
),
},
];
async function handleCreate(values: Record<string, string>) {
setCreating(true);
try {
const created = await request<PromoCodeItem>('/admin/promo-codes', {
method: 'POST',
body: JSON.stringify({
name: values.name,
code: values.code?.trim() || undefined,
scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}),
});
message.success('推广码已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
navigate(`/promo-codes/${created.id}`);
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
return (
<div>
<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}
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>
<Form.Item name="scene" label="场景">
<Select allowClear style={{ width: 130 }} options={scenes} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</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: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Modal
title="创建推广码"
open={createOpen}
onCancel={() => setCreateOpen(false)}
footer={null}
destroyOnClose
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
<Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:郑州品鉴会、门店地推" />
</Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
<Select options={scenes} />
</Form.Item>
<Form.Item name="code" label="自定义码值(选填)">
<Input placeholder="留空自动生成,如 DKDEMO1" />
</Form.Item>
<Form.Item
name="page"
label="小程序落地页(选填)"
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Input placeholder="pages/home/index" />
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
</Button>
</Form>
</Modal>
</div>
);
}
@@ -1,387 +0,0 @@
import { useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
} from 'antd';
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
import { request } from '../lib/api';
type ApiResult = Record<string, unknown>;
function ResultBox({ data }: { data: ApiResult | null }) {
if (!data) return <Typography.Text type="secondary"></Typography.Text>;
return (
<pre style={{
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
maxHeight: 320, overflow: 'auto', fontSize: 12,
}}>
{JSON.stringify(data, null, 2)}
</pre>
);
}
export default function RedeemDebugPage() {
const [createForm] = Form.useForm();
const [previewForm] = Form.useForm();
const [confirmForm] = Form.useForm();
const [phoneLookupForm] = Form.useForm();
const [phoneBalanceForm] = Form.useForm();
const [phonePrepareForm] = Form.useForm();
const [phoneConfirmForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [createResult, setCreateResult] = useState<ApiResult | null>(null);
const [previewResult, setPreviewResult] = useState<ApiResult | null>(null);
const [confirmResult, setConfirmResult] = useState<ApiResult | null>(null);
const [phoneLookupResult, setPhoneLookupResult] = useState<ApiResult | null>(null);
const [phoneBalanceResult, setPhoneBalanceResult] = useState<ApiResult | null>(null);
const [phonePrepareResult, setPhonePrepareResult] = useState<ApiResult | null>(null);
const [phoneConfirmResult, setPhoneConfirmResult] = useState<ApiResult | null>(null);
async function invoke(
path: string,
body: unknown,
setResult: (v: ApiResult | null) => void,
successMsg: string,
onSuccess?: (res: ApiResult) => void,
) {
setLoading(true);
setResult(null);
try {
const res = await request<ApiResult>(path, {
method: 'POST',
body: JSON.stringify(body),
});
setResult(res);
message.success(successMsg);
onSuccess?.(res);
if (path.includes('create-token') && res.token) {
previewForm.setFieldsValue({ token: res.token });
confirmForm.setFieldsValue({ token: res.token });
}
if (path.includes('phone/balance') && res.sessionId) {
phonePrepareForm.setFieldsValue({
storeId: phoneBalanceForm.getFieldValue('storeId'),
sessionId: res.sessionId,
});
phoneConfirmForm.setFieldsValue({
storeId: phoneBalanceForm.getFieldValue('storeId'),
sessionId: res.sessionId,
});
}
} catch (e) {
message.error(e instanceof Error ? e.message : '调用失败');
} finally {
setLoading(false);
}
}
const tokenTabItems = [
{
key: 'create',
label: '1. 生成核销码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={createForm} layout="vertical">
<Form.Item
name="userId"
label="用户"
rules={[{ required: true, message: '请填写用户 ID、编号或手机号' }]}
>
<Input placeholder="数据库 ID / 用户编号 / 手机号" />
</Form.Item>
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="couponId" label="指定券 ID(可选)">
<Input placeholder="不填则自动分摊" />
</Form.Item>
<Form.Item name="storeId" label="绑定门店 ID(可选)">
<Input placeholder="绑定后仅该门店可核销" />
</Form.Item>
<Button
type="primary"
loading={loading}
onClick={() => {
void createForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={createResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'preview',
label: '2. 预览核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={previewForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Button
loading={loading}
onClick={() => {
const values = previewForm.getFieldsValue();
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={previewResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'confirm',
label: '3. 确认核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={confirmForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Button
type="primary"
danger
loading={loading}
onClick={() => {
const values = confirmForm.getFieldsValue();
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
}}
>
</Button>
</Space>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={confirmResult} />
</Card>
</Col>
</Row>
),
},
];
const phoneTabItems = [
{
key: 'phone-lookup',
label: '1. 发送查权益验证码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneLookupForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
<Input placeholder="11 位手机号" />
</Form.Item>
<Button
type="primary"
loading={loading}
onClick={() => {
void phoneLookupForm.validateFields().then((values) => {
phoneBalanceForm.setFieldsValue({
storeId: values.storeId,
phone: values.phone,
});
void invoke(
'/admin/redeem/debug/phone/send-lookup-sms',
values,
setPhoneLookupResult,
'查权益验证码已发送',
);
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneLookupResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-balance',
label: '2. 验证并查权益',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneBalanceForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="验证码" rules={[{ required: true }]}>
<Input placeholder={`Mock 默认 ${MOCK_SMS_FIXED_CODE}`} />
</Form.Item>
<Button
loading={loading}
onClick={() => {
void phoneBalanceForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/phone/balance', values, setPhoneBalanceResult, '权益查询成功');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneBalanceResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-prepare',
label: '3. 发送核销确认码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phonePrepareForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
<Input placeholder="上一步返回的 sessionId" />
</Form.Item>
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
</Form.Item>
<Button
loading={loading}
onClick={() => {
void phonePrepareForm.validateFields().then((values) => {
phoneConfirmForm.setFieldsValue({
storeId: values.storeId,
sessionId: values.sessionId,
});
void invoke('/admin/redeem/debug/phone/prepare', values, setPhonePrepareResult, '核销确认码已发送');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phonePrepareResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-confirm',
label: '4. 确认核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneConfirmForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="核销确认验证码" rules={[{ required: true }]}>
<Input placeholder={`Mock 默认 ${MOCK_SMS_FIXED_CODE}`} />
</Form.Item>
<Button
type="primary"
danger
loading={loading}
onClick={() => {
void phoneConfirmForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/phone/confirm', values, setPhoneConfirmResult, '手机号核销成功');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneConfirmResult} />
</Card>
</Col>
</Row>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Tabs
defaultActiveKey="token"
items={[
{
key: 'token',
label: '扫码核销',
children: <Tabs items={tokenTabItems} />,
},
{
key: 'phone',
label: '手机号核销',
children: <Tabs items={phoneTabItems} />,
},
]}
/>
</div>
);
}
@@ -1,147 +0,0 @@
import { useState } from 'react';
import { Button, Descriptions, 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';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
channel?: RedeemChannel;
createdAt: string;
user?: { userNo: string; phone: string | null };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
};
export default function RedeemRecordsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.channel) qs.set('channel', filters.channel);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{
title: '方式',
dataIndex: 'channel',
width: 110,
render: (v: RedeemChannel | undefined) => {
const channel = v === 'PHONE' ? 'PHONE' : 'SCAN';
return (
<Tag color={channel === 'PHONE' ? 'purple' : 'blue'}>
{REDEEM_CHANNEL_LABELS[channel]}
</Tag>
);
},
},
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
{ title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/redeem-records/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="redeemNo" label="核销号">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店ID">
<Input allowClear />
</Form.Item>
<Form.Item name="channel" label="方式">
<Select
allowClear
style={{ width: 140 }}
options={[
{ value: 'SCAN', label: '扫码核销' },
{ value: 'PHONE', label: '手机号核销' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
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 && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
<Descriptions.Item label="方式">
{
REDEEM_CHANNEL_LABELS[
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel
]
}
</Descriptions.Item>
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
-266
View File
@@ -1,266 +0,0 @@
import { useEffect, useState } from 'react';
import {
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import {
MEDIA_TYPE_LABELS,
RESOURCE_BIZ_TYPE_LABELS,
RESOURCE_OWNER_TYPE_LABELS,
RESOURCE_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import OssUpload from '../components/OssUpload';
type Row = {
id: string;
ownerType: string;
ownerId: string;
bizType: string;
mediaType: string;
ossBucket: string;
ossKey: string;
url: string;
fileName?: string | null;
sortOrder: number;
status: string;
createdAt: string;
};
export default function ResourcesPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [data, setData] = useState<Paginated<Row> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
async function reload(p = page, ps = pageSize, f = filters) {
setLoading(true);
try {
const qs = new URLSearchParams({ page: String(p), pageSize: String(ps) });
if (f.ownerType) qs.set('ownerType', f.ownerType);
if (f.ownerId) qs.set('ownerId', f.ownerId);
if (f.bizType) qs.set('bizType', f.bizType);
if (f.status) qs.set('status', f.status);
const res = await request<Paginated<Row>>(`/common/resources?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, []);
const columns: ColumnsType<Row> = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{
title: '归属',
width: 140,
render: (_, row) => (
<span>
{RESOURCE_OWNER_TYPE_LABELS[row.ownerType] || row.ownerType} / {row.ownerId}
</span>
),
},
{ title: '用途', dataIndex: 'bizType', width: 90, render: (v) => RESOURCE_BIZ_TYPE_LABELS[v] || v },
{ title: '类型', dataIndex: 'mediaType', width: 70, render: (v) => MEDIA_TYPE_LABELS[v] || v },
{
title: '预览',
dataIndex: 'url',
width: 90,
render: (url, row) =>
row.mediaType === 'IMAGE' ? (
<Image src={url} width={56} height={40} style={{ objectFit: 'cover' }} />
) : (
<a href={url} target="_blank" rel="noreferrer">
</a>
),
},
{ title: 'URL', dataIndex: 'url', ellipsis: true },
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{RESOURCE_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Popconfirm
title="确认删除该资源?"
onConfirm={async () => {
await request(`/common/resources/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
];
return (
<div>
<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"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
void reload(1, pageSize, v);
}}
>
<Form.Item name="ownerType" label="归属类型">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="ownerId" label="归属 ID">
<Input allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="bizType" label="用途">
<Select
allowClear
style={{ width: 110 }}
options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(RESOURCE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</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: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
void reload(p, ps);
},
}}
/>
<Modal
title="登记 OSS 资源"
open={createOpen}
confirmLoading={saving}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
setSaving(true);
try {
await request('/common/resources', {
method: 'POST',
body: JSON.stringify({
ownerType: v.ownerType,
ownerId: v.ownerId,
bizType: v.bizType,
mediaType: v.mediaType,
ossKey: v.ossKey,
url: v.url,
fileName: v.fileName,
sortOrder: v.sortOrder ?? 0,
}),
});
message.success('已登记');
setCreateOpen(false);
createForm.resetFields();
void reload();
} finally {
setSaving(false);
}
}}
width={520}
>
<Form form={createForm} layout="vertical" initialValues={{ mediaType: 'IMAGE', bizType: 'COVER', sortOrder: 0 }}>
<Form.Item name="ownerType" label="归属类型" rules={[{ required: true }]}>
<Select options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="ownerId" label="归属 ID" rules={[{ required: true }]}>
<Input placeholder="商品/门店等业务 ID" />
</Form.Item>
<Form.Item name="bizType" label="用途" rules={[{ required: true }]}>
<Select options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="mediaType" label="媒体类型" rules={[{ required: true }]}>
<Select
options={[
...Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label })),
{ value: 'FILE', label: '文件' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType || prev.bizType !== cur.bizType}>
{({ getFieldValue }) => {
const mediaType = getFieldValue('mediaType') || 'IMAGE';
const bizType = getFieldValue('bizType') || 'COVER';
return (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload
bizType={bizType}
mediaType={mediaType}
onUploaded={(result) => {
createForm.setFieldsValue({ ossKey: result.ossKey, url: result.url });
}}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="ossKey" label="OSS Key" rules={[{ required: true }]}>
<Input placeholder="上传后自动填入" />
</Form.Item>
<Form.Item name="fileName" label="文件名">
<Input />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -1,298 +0,0 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
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';
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
type Row = {
id: string;
phone: string;
name: string;
status: string;
createdAt: string;
storeCount?: number;
staffCount?: number;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
store?: StoreBrief | null;
stores?: StoreBrief[];
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
};
type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts',
() => {
const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items);
}
async function refreshDetail(accountId: string) {
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
setDetail(d);
void reload();
}
async function deleteStaff(staffId: string) {
if (!detail) return;
setDeletingStaffId(staffId);
try {
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshDetail(detail.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeletingStaffId(null);
}
}
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '绑定门店',
width: 180,
render: (_, row) =>
row.stores?.length
? row.stores.map((s) => s.name).join('、')
: row.store?.name ?? '—',
},
{
title: '门店数',
dataIndex: 'storeCount',
width: 70,
render: (n, row) => n ?? row.stores?.length ?? 0,
},
{
title: '子账号',
dataIndex: 'staffCount',
width: 70,
render: (n) => n ?? 0,
},
{
title: '收款户名',
dataIndex: 'bankAccountName',
width: 120,
render: (v) => v || '—',
},
{
title: '账号状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
return (
<div>
<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={() => {
void loadStores();
setCreateOpen(true);
}}
>
</Button>
</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="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
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)}
extra={
detail && (
<Select
defaultValue={detail.status}
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
await request(`/admin/store-accounts/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
message.success('已更新');
void reload();
}}
/>
)
}
>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="收款户名">{detail.bankAccountName || '—'}</Descriptions.Item>
<Descriptions.Item label="收款账号">{detail.bankAccountNo || '—'}</Descriptions.Item>
<Descriptions.Item label="开户行">{detail.bankBranch || '—'}</Descriptions.Item>
<Descriptions.Item label="绑定门店">
{(detail.stores ?? []).map((s) => (
<Tag key={s.id}>
{s.name}
{s.status ? `${STORE_STATUS_LABELS[s.status] || s.status}` : ''}
</Tag>
))}
{!detail.stores?.length ? '—' : null}
</Descriptions.Item>
</Descriptions>
{detail.staff?.length ? (
<>
<Typography.Title level={5} style={{ marginTop: 24 }}></Typography.Title>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.staff}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{
title: '状态',
dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
]}
/>
</>
) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
</>
)}
</Drawer>
<Modal
title="新建门店账户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={stores.map((s) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
</Form>
</Modal>
</div>
);
}
-608
View File
@@ -1,608 +0,0 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import {
STORE_SETTLEMENT_KIND_LABELS,
STORE_SETTLEMENT_STATUS_LABELS,
storeSettlementStatusLabel,
type StoreSettlementKind,
type StoreSettlementStatus,
} from '@dukang/shared-types';
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';
type Row = {
kind: StoreSettlementKind;
id: string;
billNo: string;
amount: number;
status: StoreSettlementStatus;
date: string;
overdue?: boolean;
redeemCount?: number | null;
redeemAmount?: number | null;
settlementRate?: number | null;
payoutCount?: number | null;
store?: { id: string; name: string; cityName: string; phone?: string | null };
};
type StoreOption = { id: string; name: string; phone: string };
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
PENDING_REVIEW: 'orange',
REJECTED: 'red',
};
const KIND_COLORS: Record<StoreSettlementKind, string> = {
T1_BILL: 'blue',
WITHDRAW: 'purple',
};
export default function StoreBillsPage() {
const [searchParams] = useSearchParams();
const initialKind = searchParams.get('kind') === 'WITHDRAW' ? 'WITHDRAW' : '';
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({
kind: initialKind,
status: initialKind === 'WITHDRAW' ? 'PENDING_REVIEW' : '',
});
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-settlements',
() => {
const qs = new URLSearchParams();
if (filters.kind) qs.set('kind', filters.kind);
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [detailKind, setDetailKind] = useState<StoreSettlementKind | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
const [overdueSummary, setOverdueSummary] = useState<{
pendingCount: number;
overdueCount: number;
} | null>(null);
useEffect(() => {
form.setFieldsValue({
kind: filters.kind || undefined,
status: filters.status || undefined,
});
}, [filters.kind, filters.status, form]);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setStores(res.items))
.catch(() => {});
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
}, []);
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔 T+1 门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
setBatchLoading(true);
try {
await request('/admin/store-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
} finally {
setBatchLoading(false);
}
}
message.success('已确认打款');
setSelectedKeys([]);
reload();
},
});
}
function approveWithdraw(id: string) {
let paymentRef = '';
Modal.confirm({
title: '审核通过并标记已结算?',
content: (
<Input
placeholder="打款凭证号(可选)"
onChange={(e) => {
paymentRef = e.target.value;
}}
/>
),
okText: '通过并结算',
cancelText: '取消',
onOk: async () => {
await request(`/admin/store-withdrawals/${id}/approve`, {
method: 'POST',
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
});
message.success('已通过并标记已结算');
setDrawerOpen(false);
reload();
void request<{ pendingCount: number; overdueCount: number }>(
'/admin/store-withdrawals/overdue-summary',
)
.then(setOverdueSummary)
.catch(() => {});
},
});
}
function rejectWithdraw(id: string) {
let reason = '';
Modal.confirm({
title: '驳回提现申请?',
content: (
<Input.TextArea
rows={3}
placeholder="请填写驳回理由"
onChange={(e) => {
reason = e.target.value;
}}
/>
),
okText: '确认驳回',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (!reason.trim()) {
message.error('请填写驳回理由');
throw new Error('reason required');
}
await request(`/admin/store-withdrawals/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: reason.trim() }),
});
message.success('已驳回');
setDrawerOpen(false);
reload();
},
});
}
async function openDetail(row: Row) {
const path =
row.kind === 'WITHDRAW' ? `/admin/store-withdrawals/${row.id}` : `/admin/store-bills/${row.id}`;
const d = await request<Record<string, unknown>>(path);
setDetail(d);
setDetailKind(row.kind);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count} 条 T+1 账单`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter(
(r) =>
selectedKeys.includes(`${r.kind}-${r.id}`) &&
r.kind === 'T1_BILL' &&
r.status === 'UNPAID',
);
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.amount), 0);
const columns: ColumnsType<Row> = [
{
title: '类型',
dataIndex: 'kind',
width: 100,
render: (k: StoreSettlementKind) => (
<Tag color={KIND_COLORS[k]}>{STORE_SETTLEMENT_KIND_LABELS[k] ?? k}</Tag>
),
},
{
title: '单号',
dataIndex: 'billNo',
width: 180,
ellipsis: true,
render: (v, row) => (
<Space>
<span>{v}</span>
{row.overdue ? <Tag color="magenta"></Tag> : null}
</Space>
),
},
{
title: '日期',
dataIndex: 'date',
width: 160,
render: (v, row) => (row.kind === 'T1_BILL' ? String(v || '').slice(0, 10) : fmtTime(v)),
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{
title: '笔数',
width: 80,
render: (_, row) =>
row.kind === 'T1_BILL' ? (row.redeemCount ?? '—') : (row.payoutCount ?? '—'),
},
{
title: '应付金额',
dataIndex: 'amount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: string, row) => (
<Tag color={STATUS_COLORS[s] || 'default'}>{storeSettlementStatusLabel(row.kind, s)}</Tag>
),
},
{
title: '操作',
width: 200,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
{row.kind === 'T1_BILL' && row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.amount))}>
</Button>
)}
{row.kind === 'WITHDRAW' && row.status === 'PENDING_REVIEW' && (
<>
<Button type="link" size="small" onClick={() => approveWithdraw(row.id)}>
</Button>
<Button type="link" size="small" danger onClick={() => rejectWithdraw(row.id)}>
</Button>
</>
)}
</Space>
),
},
];
const withdrawDetailItems =
(detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
const storeAccount = detail?.storeAccount as
| {
name?: string;
phone?: string;
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
}
| undefined;
return (
<div>
<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 ? (
<Typography.Text type="danger">
{overdueSummary.overdueCount} {overdueSummary.pendingCount}
</Typography.Text>
) : null}
</Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="记录数" value={summary.count} />
<Statistic title="核销金额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.payoutAmount ?? summary.totalAmount ?? 0} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={{
kind: filters.kind || undefined,
status: filters.status || undefined,
}}
onFinish={(v: {
kind?: string;
storeId?: string;
status?: string;
range?: [Dayjs, Dayjs];
}) => {
setFilters({
kind: v.kind || '',
storeId: v.storeId || '',
status: v.status || '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
});
setPage(1);
setSelectedKeys([]);
}}
>
<Form.Item name="kind" label="类型">
<Select
allowClear
style={{ width: 140 }}
options={Object.entries(STORE_SETTLEMENT_KIND_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item name="storeId" label="门店">
<Select
allowClear
showSearch
placeholder="全部门店"
style={{ width: 200 }}
optionFilterProp="label"
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(STORE_SETTLEMENT_STATUS_LABELS).map(([value, label]) => ({
value,
label:
value === 'PAID'
? '已打款/已结算'
: label,
}))}
/>
</Form.Item>
<Form.Item name="range" label="日期">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
setSelectedKeys([]);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
T+1 Excel
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedRows.length}
loading={batchLoading}
onClick={() => confirmPay(selectedRows.map((r) => r.id), selectedAmount)}
>
({selectedRows.length})
</Button>
</Form.Item>
</Form>
<Table
rowKey={(r) => `${r.kind}-${r.id}`}
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({
disabled: !(r.kind === 'T1_BILL' && r.status === 'UNPAID'),
}),
}}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title={detailKind === 'WITHDRAW' ? '手动提现明细' : 'T+1 对账单明细'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={detailKind === 'WITHDRAW' ? 640 : 520}
>
{detail && detailKind === 'T1_BILL' && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账单日">
{String(detail.billDate || '').slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="应付">
¥{Number(detail.payoutAmount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{storeSettlementStatusLabel('T1_BILL', String(detail.status))}
</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
columns={[
{
title: '核销单号',
render: (_, r) =>
String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
},
{
title: '金额',
dataIndex: 'payoutAmount',
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
},
]}
/>
</>
)}
{detail && detailKind === 'WITHDRAW' && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
<Descriptions.Item label="金额">
¥{Number(detail.amount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{storeSettlementStatusLabel('WITHDRAW', String(detail.status))}
</Descriptions.Item>
<Descriptions.Item label="申请时间">
{detail.appliedAt ? fmtTime(String(detail.appliedAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回理由">
{detail.rejectReason ? String(detail.rejectReason) : '—'}
</Descriptions.Item>
<Descriptions.Item label="打款凭证">
{detail.paymentRef ? String(detail.paymentRef) : '—'}
</Descriptions.Item>
</Descriptions>
{storeAccount ? (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="户名">
{storeAccount.bankAccountName || '—'}
</Descriptions.Item>
<Descriptions.Item label="账号">
{storeAccount.bankAccountNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="开户行">
{storeAccount.bankBranch || '—'}
</Descriptions.Item>
</Descriptions>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={withdrawDetailItems}
columns={[
{
title: '核销单号',
render: (_, r) => {
const payout = r.storePayout as
| { redeemRecord?: { redeemNo?: string }; payoutAmount?: number }
| undefined;
return String(payout?.redeemRecord?.redeemNo || '—');
},
},
{
title: '应付',
render: (_, r) => {
const payout = r.storePayout as { payoutAmount?: number } | undefined;
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
},
},
]}
/>
{String(detail.status) === 'PENDING_REVIEW' ? (
<Space style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => approveWithdraw(String(detail.id))}>
</Button>
<Button danger onClick={() => rejectWithdraw(String(detail.id))}>
</Button>
</Space>
) : null}
</>
)}
</Drawer>
</div>
);
}

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