Merge commit 'a0ee3a21cd01229a555384ad5e1f4b3ccdcdd620' into dev_jacy
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
---
|
||||
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 5min(无 DB 表)
|
||||
@@ -1,16 +1,27 @@
|
||||
---
|
||||
description: 杜康好客全局约束 — 文档优先级、OWNER 边界、禁止跨模块直写表
|
||||
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` 等),编码以 **当前实际目录** 为准。
|
||||
|
||||
## 文档
|
||||
|
||||
- 业务事实源:`杜康好客-V2编码手册.md`
|
||||
- preV1 裁剪:`杜康好客-preV1编码手册.md`
|
||||
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
|
||||
|
||||
**禁止**:编码时修改业务规则;需求变更只改 V2 手册。
|
||||
|
||||
## 边界(2 人团队)
|
||||
|
||||
| 负责人 | 路径 |
|
||||
@@ -19,13 +30,20 @@ alwaysApply: true
|
||||
| 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/` |
|
||||
|
||||
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
|
||||
- `apps/*` 禁止 import `server/*`
|
||||
- `apps/*` 禁止 import `server/*` 或其他 app
|
||||
- Mock 只在 `integrations/*`,不在业务 Service 散落
|
||||
|
||||
## 共享契约
|
||||
|
||||
- 枚举/DTO → `packages/shared-types`
|
||||
- 纯规则 → `packages/domain`
|
||||
- 纯规则 → `packages/domain`(无 IO)
|
||||
|
||||
## 核心业务常量
|
||||
|
||||
- 权益额 = `benefit_amount ?? price`
|
||||
- 核销:0 < amount ≤ min(balance, **500**)
|
||||
- 订单 Tab:`all | pending_pay | pending_ship | pending_receive | completed`
|
||||
- API:`/api/v1`,响应 `{ code, message, data }`
|
||||
|
||||
## 提交
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
description: 三端 H5 + admin-web 前端边界与 API 请求规范
|
||||
globs: apps/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 前端 App 规则
|
||||
|
||||
## 隔离
|
||||
|
||||
- 禁止 import 其他 `apps/*` 或 `server/*`
|
||||
- 类型从 `@dukang/shared-types`;组件从 `@dukang/shared-ui`
|
||||
|
||||
## 请求
|
||||
|
||||
- Base: `/api/v1`
|
||||
- Headers: `Authorization`, `X-Client-App`(USER_H5 | SHOP_H5 | PARTNER_H5)
|
||||
|
||||
## UI 约束
|
||||
|
||||
- C 端订单 **5 Tab**(含 pending_ship)
|
||||
- 门店列表仅展示 `OPEN` 状态
|
||||
- 原型 `pages/` 只读参照,不在此目录改业务代码
|
||||
|
||||
## App 归属(2 人团队)
|
||||
|
||||
| App | 负责人 | 勿改他端页面 |
|
||||
|-----|--------|--------------|
|
||||
| h5-user | jacy-dukang | shop 核销、partner 录店 |
|
||||
| h5-shop | 刘京尧 | user 出码、admin 报表 |
|
||||
| h5-partner | 刘京尧 | user 下单、admin 开城 |
|
||||
| admin-web | jacy-dukang | 非 V2 小程序规格 |
|
||||
|
||||
路由对照:`pages/ROUTE_MAP.md`
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
description: 杜康好客前端 H5 / Admin 编码规范与 OWNER 边界
|
||||
globs: apps/**/*.{tsx,ts}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 前端规范
|
||||
|
||||
## 目录约定
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # PascalCase(AppToast.tsx)
|
||||
├── pages/ # *Page.tsx
|
||||
├── layouts/ # *Layout.tsx
|
||||
├── contexts/ # *Context.tsx
|
||||
├── lib/ # kebab-case(api.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 | 刘京尧 | 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 小程序规格 |
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
description: Git 提交与 PR 协作规范
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git 规范
|
||||
|
||||
## 提交格式
|
||||
|
||||
Conventional Commits,scope = 端或模块名:
|
||||
|
||||
```
|
||||
feat(trade): add order preview API
|
||||
fix(redeem): enforce 500 yuan cap
|
||||
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
|
||||
@@ -1,16 +1,25 @@
|
||||
---
|
||||
description: packages 共享契约 — shared-types 与 domain 变更策略
|
||||
globs: packages/**/*
|
||||
description: 杜康好客共享包(shared-types / domain / shared-ui)规范
|
||||
globs: packages/**/*.{ts,tsx}
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# packages 规则
|
||||
# 共享包规范
|
||||
|
||||
## 包职责
|
||||
|
||||
| 包 | 用途 | 约束 |
|
||||
|----|------|------|
|
||||
| `@dukang/shared-types` | DTO、枚举、错误码、JWT Payload | 前后端唯一契约 |
|
||||
| `@dukang/domain` | 纯函数业务规则 | **禁止 IO**,含 Vitest 单测 |
|
||||
| `@dukang/shared-ui` | H5 共享组件 + `tokens.css` | 仅 H5 三端使用 |
|
||||
|
||||
## shared-types
|
||||
|
||||
- 前后端唯一 DTO/枚举来源
|
||||
- 枚举用 `SCREAMING_SNAKE_CASE`(如 `OrderStatus.PENDING_PAY`)
|
||||
- API Tab 查询用 snake_case(`pending_pay`)
|
||||
- **加法优先**:新字段 optional;删除/改名先 deprecated
|
||||
- 改 API 必须同步 V2 手册 §六
|
||||
- 改 API 必须同步 V2 手册 §六 与 shared-types
|
||||
|
||||
## domain
|
||||
|
||||
@@ -18,11 +27,17 @@ alwaysApply: false
|
||||
- 起购 2/6 瓶、权益 `benefitAmount ?? price`、核销 ¥500 上限在此实现
|
||||
- 变更必须有单元测试
|
||||
|
||||
```typescript
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
// 同城起购 2 瓶 / 跨城 6 瓶
|
||||
// 核销上限 ¥500
|
||||
```
|
||||
|
||||
## shared-ui
|
||||
|
||||
- 跨 App 组件;禁止 import 特定 App 代码
|
||||
- 设计令牌与 preV1 三 H5 共用
|
||||
- admin-web 使用 Ant Design,不引用 shared-ui
|
||||
|
||||
## 破坏性改动
|
||||
|
||||
需通知 jacy-dukang 与 刘京尧(若影响 partner/shop API)并在 PR 说明影响面。
|
||||
需通知 **jacy-dukang** 与 **刘京尧**(若影响 partner/shop API)并在 PR 说明影响面。Prisma 迁移需双方 Review(涉及 store/redeem 表时)。
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
name: dukang-coding
|
||||
description: >-
|
||||
Implements Dukang Haoke (杜康好客) 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 in this monorepo.
|
||||
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
|
||||
@@ -11,8 +12,11 @@ description: >-
|
||||
## 何时启用
|
||||
|
||||
- 实现/修复 `apps/*`、`server/dukang-api`、`packages/*` 功能
|
||||
- 用户给出任务卡 ID 或 @ 本 skill
|
||||
- **不要**用于纯文档问答(直接读手册即可)
|
||||
- 实现任务卡(`M2-BE-TRD-002`、`P1-M2-002` 等)
|
||||
- 用户给出任务卡 ID、说「开始 coding」或 @dukang-coding
|
||||
- 修复 V1 范围 Bug
|
||||
|
||||
**不用于**:写 PRD、评审需求、纯文档问答 → 用 @dukang-project 或直接读手册
|
||||
|
||||
## 会话启动(按序)
|
||||
|
||||
@@ -22,10 +26,13 @@ description: >-
|
||||
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
|
||||
5. 编码前过一遍下方 Checklist
|
||||
|
||||
**需求变更** → 只改 V2 手册,编码时不改业务规则。
|
||||
|
||||
## 编码前 Checklist
|
||||
|
||||
```
|
||||
- [ ] 任务所属 OWNER 与目标路径已确认
|
||||
- [ ] 已读手册 PRD 对应 § 与 pages/ 原型
|
||||
- [ ] API 路径、Guard、表名已从 V2 §六/§五 核对
|
||||
- [ ] 跨模块需求 → 只 inject 对方 exported Service
|
||||
- [ ] 新枚举/DTO → packages/shared-types
|
||||
@@ -48,9 +55,9 @@ Workflow:DTO → Service(本模块表)→ Controller + Guard → 同步 sh
|
||||
跨模块示例(允许):
|
||||
|
||||
```typescript
|
||||
// trade.service.ts — OWNER A
|
||||
// trade.service.ts — jacy-dukang
|
||||
constructor(
|
||||
private readonly benefitService: BenefitService, // from BenefitModule
|
||||
private readonly benefitService: BenefitService,
|
||||
private readonly catalogService: CatalogService,
|
||||
) {}
|
||||
await this.benefitService.grantOnOrderPaid(orderId);
|
||||
@@ -68,7 +75,14 @@ await this.prisma.order.update({ ... });
|
||||
1. 路由对照 `pages/ROUTE_MAP.md`
|
||||
2. `src/lib/api.ts` 统一 fetch + `X-Client-App`
|
||||
3. 类型从 `@dukang/shared-types` import
|
||||
4. C 端订单 Tab:`all | pending_pay | pending_ship | pending_receive | completed`
|
||||
4. preV1 App:`h5-user` / `h5-shop` / `h5-partner` / `admin-web`
|
||||
5. C 端订单 Tab:`all | pending_pay | pending_ship | pending_receive | completed`
|
||||
|
||||
## 数据库变更
|
||||
|
||||
1. 改 `server/dukang-api/prisma/schema.prisma` 对齐手册 §五
|
||||
2. 迁移需 OWNER Review(store/redeem 表 → @刘京尧)
|
||||
3. 初始化 SQL:`server/dukang-api/prisma/init_v3.sql`
|
||||
|
||||
## 核心业务(packages/domain)
|
||||
|
||||
@@ -76,6 +90,10 @@ await this.prisma.order.update({ ... });
|
||||
const benefitAmount = product.benefitAmount ?? product.price;
|
||||
// 核销:0 < amount ≤ min(balance, 500)
|
||||
// 同城 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+)
|
||||
@@ -92,10 +110,13 @@ const benefitAmount = product.benefitAmount ?? product.price;
|
||||
|
||||
- 任务卡验收项全部满足
|
||||
- 无跨模块 Prisma 直写
|
||||
- `pnpm lint` 无新增错误;domain 相关单测通过
|
||||
- `packages/domain` 相关单测通过
|
||||
- `pnpm lint` 无新增错误
|
||||
- 改了 API/表 → 同步手册与 shared-types
|
||||
|
||||
## 延伸阅读
|
||||
|
||||
- 模块/表/依赖矩阵:[reference-backend.md](reference-backend.md)
|
||||
- 模块/表/路由:[reference-backend.md](reference-backend.md)
|
||||
- 页面↔API 速查:[reference-frontend.md](reference-frontend.md)
|
||||
- preV1 Mock 细节:`dukang-prev1` skill
|
||||
- preV1 Mock:`dukang-prev1` skill
|
||||
- 详细清单:根目录 `skills.md`
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- Prefix: `/api/v1`
|
||||
- Response: `{ code: 0, message: 'ok', data }`
|
||||
- JWT: `actorType` + `actorId` + `clientApp`
|
||||
- 请求头:`Authorization`、`X-Client-App`
|
||||
|
||||
## clientApp → actorType → 表
|
||||
|
||||
@@ -15,36 +16,110 @@
|
||||
| USER_H5 | USER_MINI | USER | user_user |
|
||||
| SHOP_H5 | SHOP_H5 | STORE | store_account |
|
||||
| PARTNER_H5 | PARTNER_MINI | PARTNER | partner_account |
|
||||
| — | HQ_MINI | HQ | hq_account |
|
||||
| HQ_WEB | HQ_MINI | HQ | hq_account |
|
||||
|
||||
## Guard 对照
|
||||
|
||||
| Guard | 用途 |
|
||||
|-------|------|
|
||||
| JwtAuthGuard | 需登录 |
|
||||
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) |
|
||||
| OptionalJwtAuthGuard | 可选登录(bootstrap) |
|
||||
|
||||
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
||||
|
||||
## Module → API 前缀
|
||||
|
||||
| Module | 前缀示例 | OWNER |
|
||||
|--------|----------|-------|
|
||||
| iam | `/auth`, `/user` | A |
|
||||
| catalog | `/catalog`, `/admin/cities`, `/admin/products` | C |
|
||||
| trade | `/trade`, `/partner/orders`, `/admin/orders` | A |
|
||||
| benefit | `/benefit` | A |
|
||||
| store | `/stores`, `/partner/stores`, `/admin/store-audits` | B |
|
||||
| redeem | `/redeem`, `/shop/redeem` | D |
|
||||
| settlement | `/settlement`, `/partner/settlement`, `/admin/settlement` | C |
|
||||
| ops | `/admin/dashboard`, `/admin/reports` | C |
|
||||
| analytics | `/analytics`, `/promo/touch` | A |
|
||||
| callbacks | `/callbacks/wechat/pay`, `/callbacks/xfx/delivery` | Lead |
|
||||
| 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
|
||||
|
||||
| 前缀 | 示例表 | OWNER Module |
|
||||
|------|--------|--------------|
|
||||
| user_ | user_user, user_order, user_benefit_coupon | iam / trade / benefit |
|
||||
| store_ | store_store, store_account, store_payout | store / iam / settlement |
|
||||
| partner_ | partner_partner, partner_bill | store(B) / settlement(C) |
|
||||
| hq_ | hq_account | iam |
|
||||
| common_ | common_product_item, common_event, common_city | catalog / common |
|
||||
| 前缀 | 示例表 | 负责人 |
|
||||
|------|--------|--------|
|
||||
| user_ | user_user, user_order, user_benefit_coupon | jacy-dukang |
|
||||
| store_ | store_store, store_account, store_payout | 刘京尧 / jacy-dukang(settlement) |
|
||||
| partner_ | partner_partner, partner_bill | 刘京尧 / jacy-dukang(settlement) |
|
||||
| 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_tokens(DB)。
|
||||
|
||||
## 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/*` | redeem(C 端) |
|
||||
| `POST /shop/redeem/*` | redeem(门店) |
|
||||
|
||||
### store / settlement
|
||||
|
||||
| 路径 | Module |
|
||||
|------|--------|
|
||||
| `GET /stores` | store(C 端门店列表) |
|
||||
| `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 |
|
||||
|
||||
### ops(admin-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/真实支付成功**
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
|
||||
> 路由事实源:[`pages/ROUTE_MAP.md`](../../../pages/ROUTE_MAP.md)
|
||||
|
||||
## App 配置
|
||||
## 四 App 概览
|
||||
|
||||
| App | Filter | Dev | X-Client-App |
|
||||
|-----|--------|-----|--------------|
|
||||
| h5-user | @dukang/h5-user | pnpm dev:user | USER_H5 |
|
||||
| h5-shop | @dukang/h5-shop | pnpm dev:shop | SHOP_H5 |
|
||||
| h5-partner | @dukang/h5-partner | pnpm dev:partner | PARTNER_H5 |
|
||||
| admin-web | @dukang/admin-web | pnpm dev:admin | AdminAuth(内部) |
|
||||
| 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`。
|
||||
|
||||
## 共享包
|
||||
|
||||
@@ -18,42 +22,100 @@ import { OrderStatus, ClientApp } from '@dukang/shared-types';
|
||||
import { PageHeader } from '@dukang/shared-ui';
|
||||
```
|
||||
|
||||
## h5-user 主路由(OWNER A)
|
||||
H5 三端引用 `@dukang/shared-ui`(`tokens.css`)。admin-web 使用 Ant Design 5,不引用 shared-ui。
|
||||
|
||||
| Route | 页面 | 关键 API |
|
||||
|-------|------|----------|
|
||||
| `/login` | 登录 | POST /auth/sms/send, /auth/login/sms |
|
||||
| `/` | 首页 | GET /catalog/products |
|
||||
| `/product/:id` | 详情 | GET /catalog/products/:id |
|
||||
| `/order/confirm` | 确认订单 | POST /trade/orders/preview, /trade/orders |
|
||||
| `/pay` | Mock 支付 | POST /trade/orders/:id/pay |
|
||||
| `/orders` | 5 Taber | GET /trade/orders?tab= |
|
||||
| `/benefit` | 权益 | GET /benefit/coupons |
|
||||
| `/redeem` | 出码 | POST /redeem/token |
|
||||
| `/stores` | 门店 | GET /stores(仅 OPEN) |
|
||||
## h5-user 路由(jacy-dukang)
|
||||
|
||||
## h5-shop 主路由(OWNER D)
|
||||
| 路由 | 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 |
|
||||
|
||||
| Route | 页面 | 关键 API |
|
||||
|-------|------|----------|
|
||||
| `/login` | 登录 | POST /shop/auth/login/sms |
|
||||
| `/redeem` | 核销确认 | POST /shop/redeem/confirm |
|
||||
| `/records` | 核销记录 | GET /shop/redeem/records |
|
||||
### 订单 5 Tab
|
||||
|
||||
## h5-partner 主路由(OWNER B)
|
||||
```
|
||||
all | pending_pay | pending_ship | pending_receive | completed
|
||||
```
|
||||
|
||||
| Route | 页面 | 关键 API |
|
||||
|-------|------|----------|
|
||||
| `/stores/new` | 录店 | POST /partner/stores |
|
||||
| `/stores` | 门店管理 | GET /partner/stores |
|
||||
| `/orders` | 辖区订单 | GET /partner/orders |
|
||||
## 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 端订单 **5 Tab**(含 pending_ship)
|
||||
- 个人中心无会员等级(V1 不做会员体系)
|
||||
- 清香型 4 SKU 可购;酱香/浓香灰态
|
||||
- 门店列表仅 `OPEN` 状态
|
||||
- 原型只读:`pages/{端}/*/code.html`,勿改原型目录
|
||||
|
||||
## preV1 与 V2 UI 差异
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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 | `杜康好客-V2编码手册.md` §二 | **做什么**(业务规则、状态、边界) |
|
||||
| 2 | `conventions.md` | **怎么协作**(模块边界、提交) |
|
||||
| 3 | V2 手册 §四 | **怎么架构**(NestJS 模块) |
|
||||
| 4 | V2 手册 §五 | **表结构**(Prisma / SQL) |
|
||||
| 5 | V2 手册 §六 | **接口契约**(四端 JWT) |
|
||||
| 6 | `pages/{user,shop,partner,hq}/` | **UI 参照** |
|
||||
| 7 | `杜康好客-preV1编码手册.md` | preV1 联调范围 |
|
||||
|
||||
## 需求变更规则
|
||||
|
||||
- **只改** `杜康好客-V2编码手册.md` 作为业务事实源
|
||||
- 编码 Agent **不得**边写代码边改业务规则
|
||||
- 改 API/表结构 → 同步手册 §五/§六 + `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
|
||||
@@ -39,6 +39,8 @@ pnpm lint && pnpm test
|
||||
|
||||
Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
|
||||
> **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
|
||||
|
||||
## 文档优先级(冲突时)
|
||||
|
||||
1. `杜康好客-V2编码手册.md` §二 — 业务规则
|
||||
@@ -70,6 +72,16 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
- 业务纯规则 → `packages/domain`;枚举/DTO → `packages/shared-types`
|
||||
- 微信/配送回调入口 **仅** `callbacks/`;Mock 实现 **仅** `integrations/*`
|
||||
|
||||
## Cursor 规范
|
||||
|
||||
| 类型 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| Rules | `.cursor/rules/` | 自动加载的编码规范 |
|
||||
| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
|
||||
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
|
||||
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
|
||||
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | P1-* / M*-* |
|
||||
|
||||
## 子 Agent(按职责选用)
|
||||
|
||||
在 Cursor Agent 输入 `/` 选择:
|
||||
@@ -88,9 +100,17 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
| Skill | 何时用 |
|
||||
|-------|--------|
|
||||
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
|
||||
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
|
||||
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
|
||||
| `dukang-task-card` | 领取任务卡 `P1-*` / `M*-*` 并按 DoD 交付 |
|
||||
|
||||
## 快速指令
|
||||
|
||||
- 实现任务卡:`@dukang-coding 实现 P1-M2-002`
|
||||
- 需求评审:`@dukang-project 对照 pages/partner/ 评审 PRD`
|
||||
- PR 前审查:`/boundary-reviewer`
|
||||
- 原型参照:`pages/{user,shop,partner}/`(只读)
|
||||
|
||||
## 核心业务常量(不可偏离)
|
||||
|
||||
```
|
||||
@@ -104,7 +124,7 @@ C 端门店仅 status=OPEN
|
||||
## 提交与 PR
|
||||
|
||||
- Conventional Commits:`feat(trade):`、`fix(redeem):`;scope = 端或模块
|
||||
- 跨模块 PR → 相关双 OWNER Review
|
||||
- 跨模块 PR → jacy-dukang + 刘京尧 共同 Review(若涉及双方模块)
|
||||
- 改 API/表 → 同步 V2 手册 §五/§六 + `shared-types`
|
||||
- 不提交 `.env`、`dist/`、`node_modules/`
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import OrderListPage from './pages/OrderListPage';
|
||||
import OrderDetailPage from './pages/OrderDetailPage';
|
||||
import CenterPage from './pages/CenterPage';
|
||||
import BillsPage from './pages/BillsPage';
|
||||
import ReshipPage from './pages/ReshipPage';
|
||||
import WeeklyReportPage from './pages/WeeklyReportPage';
|
||||
import LeaderboardPage from './pages/LeaderboardPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -22,6 +25,9 @@ export default function App() {
|
||||
</Route>
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/center/bills" element={<BillsPage />} />
|
||||
<Route path="/reshipments" element={<ReshipPage />} />
|
||||
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/orders/:id" element={<OrderDetailPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export type LeaderboardEntry = {
|
||||
rank: number;
|
||||
name: string;
|
||||
role: string;
|
||||
totalStores: number;
|
||||
monthStores: number;
|
||||
};
|
||||
|
||||
/** preV1:贡献榜 Mock,待 `/partner/dashboard/leaderboard` 接入后替换 */
|
||||
export const LEADERBOARD_MOCK: LeaderboardEntry[] = [
|
||||
{ rank: 1, name: '陈经理', role: '内部员工', totalStores: 45, monthStores: 8 },
|
||||
{ rank: 2, name: '李华', role: '城市合伙人', totalStores: 32, monthStores: 5 },
|
||||
{ rank: 3, name: '王拓', role: '推广员', totalStores: 18, monthStores: 3 },
|
||||
{ rank: 4, name: '赵敏', role: '内部员工', totalStores: 12, monthStores: 2 },
|
||||
];
|
||||
@@ -0,0 +1,91 @@
|
||||
export type StoreDraftForm = {
|
||||
name: string;
|
||||
phone: string;
|
||||
district: string;
|
||||
address: string;
|
||||
intro: string;
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
};
|
||||
|
||||
export type StoreDraft = {
|
||||
step: number;
|
||||
form: StoreDraftForm;
|
||||
};
|
||||
|
||||
export const STORE_DRAFT_KEY = 'partner_store_draft_v1';
|
||||
|
||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
name: '',
|
||||
phone: '',
|
||||
district: '金水区',
|
||||
address: '',
|
||||
intro: '',
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
});
|
||||
|
||||
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
const base = defaultStoreForm();
|
||||
return {
|
||||
name: String(raw.name ?? base.name),
|
||||
phone: String(raw.phone ?? base.phone),
|
||||
district: String(raw.district ?? base.district),
|
||||
address: String(raw.address ?? base.address),
|
||||
intro: String(raw.intro ?? base.intro),
|
||||
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
|
||||
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
|
||||
bankBranch: String(raw.bankBranch ?? base.bankBranch),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadStoreDraft(): StoreDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (parsed.form && typeof parsed.form === 'object') {
|
||||
return {
|
||||
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)),
|
||||
form: normalizeForm(parsed.form as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
return { step: 1, form: normalizeForm(parsed) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoreDraft(draft: StoreDraft) {
|
||||
localStorage.setItem(STORE_DRAFT_KEY, JSON.stringify(draft));
|
||||
}
|
||||
|
||||
export function clearStoreDraft() {
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
}
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
export function validateStoreStep1(form: StoreDraftForm): string | null {
|
||||
if (!form.name.trim()) return '请填写门店名称';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.district.trim()) return '请填写区县';
|
||||
if (!form.address.trim()) return '请填写详细地址';
|
||||
if (form.intro.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateStoreStep3(form: StoreDraftForm): 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;
|
||||
}
|
||||
@@ -103,13 +103,34 @@ export default function CenterPage() {
|
||||
<Link to="/center/bills" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>待确认账单</span>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>财务对账</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link to="/reports/weekly" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>数据周报</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/reshipments" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>补发处理</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
<Link to="/leaderboard" className="partner-menu-item">
|
||||
<div className="partner-menu-item-left">
|
||||
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
|
||||
<span className="body-md" style={{ fontSize: 16 }}>合伙人贡献榜</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,29 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function auditStatus(store: Record<string, unknown>) {
|
||||
const audits = store.audits as Array<{ status?: string }> | undefined;
|
||||
return String(audits?.[0]?.status ?? '').toUpperCase();
|
||||
}
|
||||
|
||||
function storeStatusLabel(store: Record<string, unknown>) {
|
||||
const audit = auditStatus(store);
|
||||
if (audit === 'PENDING') return '待审核';
|
||||
const s = String(store.status).toUpperCase();
|
||||
if (s === 'OPEN') return '营业中';
|
||||
if (s === 'PAUSED') return '暂时闭店';
|
||||
if (s === 'CLOSED') return '已关闭';
|
||||
return s;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request('PARTNER_H5', '/partner/dashboard').then(setDash);
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
}, [navigate]);
|
||||
|
||||
const storeCount = Number(dash?.storeCount || 0);
|
||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||
const orderCount = Number(dash?.orderCount || 0);
|
||||
const activeStores = Math.max(0, storeCount - Math.ceil(storeCount * 0.05));
|
||||
const abnormalStores = storeCount - activeStores;
|
||||
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
|
||||
const abnormalStores = Math.max(0, storeCount - activeStores);
|
||||
const pendingStores = useMemo(
|
||||
() => stores.filter((s) => auditStatus(s) === 'PENDING'),
|
||||
[stores],
|
||||
);
|
||||
const revenue = orderCount * 128.45;
|
||||
const profit = revenue * 0.25;
|
||||
const pendingShip = Math.ceil(orderCount * 0.04);
|
||||
const shipping = Math.ceil(orderCount * 0.12);
|
||||
const completed = Math.max(0, orderCount - pendingShip - shipping);
|
||||
const monthNew = stores.filter((s) => {
|
||||
const created = new Date(String(s.createdAt || ''));
|
||||
const now = new Date();
|
||||
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
|
||||
const recentFromDash = (dash?.recentStores as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const displayStores = stores.length > 0 ? stores : recentFromDash;
|
||||
const previewLeaderboard = LEADERBOARD_MOCK.slice(0, 2);
|
||||
|
||||
return (
|
||||
<div className="page partner-home">
|
||||
@@ -87,7 +118,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
<span className="partner-quick-action-label">录入新店</span>
|
||||
</Link>
|
||||
<Link to="/orders" className="partner-quick-action">
|
||||
<Link to="/reshipments" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
|
||||
<span className="material-symbols-outlined">assignment_return</span>
|
||||
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
|
||||
@@ -100,7 +131,7 @@ export default function HomePage() {
|
||||
</div>
|
||||
<span className="partner-quick-action-label">财务对账</span>
|
||||
</Link>
|
||||
<Link to="/orders" className="partner-quick-action">
|
||||
<Link to="/reports/weekly" className="partner-quick-action">
|
||||
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
|
||||
<span className="material-symbols-outlined">monitoring</span>
|
||||
</div>
|
||||
@@ -139,17 +170,69 @@ export default function HomePage() {
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>本月新增签约</p>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{Math.ceil(storeCount * 0.08)}</span>
|
||||
<span className="label-md text-success" style={{ display: 'flex', alignItems: 'center', fontSize: 10 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>trending_up</span> 18%
|
||||
</span>
|
||||
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 4 }}>待审核门店</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24 }}>{Math.ceil(storeCount * 0.03)}</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24 }}>{pendingStores.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingStores.length > 0 && (
|
||||
<div className="partner-pending-stores">
|
||||
<p className="label-md text-muted" style={{ marginBottom: 8 }}>待审核门店名称</p>
|
||||
<ul className="partner-store-name-list partner-store-name-list--compact">
|
||||
{pendingStores.map((s) => (
|
||||
<li key={String(s.id)}>
|
||||
<span className="headline-md">{String(s.name || '未命名门店')}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{displayStores.length > 0 && (
|
||||
<ul className="partner-store-name-list">
|
||||
<li className="partner-store-name-list-header">
|
||||
<span className="label-md text-muted">门店名称</span>
|
||||
<span className="label-md text-muted">状态</span>
|
||||
</li>
|
||||
{displayStores.slice(0, 5).map((s) => (
|
||||
<li key={String(s.id)}>
|
||||
<span className="headline-md">{String(s.name || '未命名门店')}</span>
|
||||
<span className="label-md text-muted">{storeStatusLabel(s)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="partner-leaderboard-section">
|
||||
<div className="partner-leaderboard-header">
|
||||
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
|
||||
合伙人贡献榜
|
||||
</h3>
|
||||
<Link to="/leaderboard" className="label-md text-primary">查看全部</Link>
|
||||
</div>
|
||||
{previewLeaderboard.map((entry) => (
|
||||
<div key={entry.rank} className="partner-leaderboard-row partner-leaderboard-row--compact">
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="body-md" style={{ fontWeight: 600 }}>
|
||||
{entry.name}
|
||||
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.role})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.monthStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
|
||||
{String(dash?.companyName || '郑州合伙人')} · 辖区管理
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { LEADERBOARD_MOCK } from '../lib/leaderboard';
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
|
||||
|
||||
<p className="label-md text-muted" style={{ padding: '0 20px 12px' }}>
|
||||
按拓店数排行,数据每周更新
|
||||
</p>
|
||||
|
||||
<div style={{ padding: '0 20px 24px' }}>
|
||||
{LEADERBOARD_MOCK.map((entry) => (
|
||||
<div key={entry.rank} className="partner-leaderboard-row">
|
||||
<div className="partner-leaderboard-rank">{entry.rank}</div>
|
||||
<div className="partner-leaderboard-info">
|
||||
<p className="headline-md">
|
||||
{entry.name}
|
||||
<span className="label-md text-muted" style={{ marginLeft: 6, fontWeight: 400 }}>({entry.role})</span>
|
||||
</p>
|
||||
<p className="label-md text-muted">累计拓店 {entry.totalStores} 间</p>
|
||||
</div>
|
||||
<div className="partner-leaderboard-stat">
|
||||
<p className="headline-md text-primary">{entry.monthStores} 间</p>
|
||||
<p className="label-md text-muted">本月新增</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
type ReshipStatus = 'ALL' | 'PENDING' | 'SHIPPING' | 'DONE';
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{ id: '1', orderNo: 'DK20260301001', status: 'PENDING' as const, productName: '杜康窖藏', originalOrderNo: 'DK20260228088', address: '郑州市金水区花园路 88 号' },
|
||||
{ id: '2', orderNo: 'DK20260302002', status: 'SHIPPING' as const, productName: '杜康特曲', originalOrderNo: 'DK20260215012', address: '郑州市二七区大学路 12 号' },
|
||||
];
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING: '待配送',
|
||||
SHIPPING: '配送中',
|
||||
DONE: '已完成',
|
||||
};
|
||||
|
||||
const FILTERS: { key: ReshipStatus; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'PENDING', label: '待配送' },
|
||||
{ key: 'SHIPPING', label: '配送中' },
|
||||
{ key: 'DONE', label: '已完成' },
|
||||
];
|
||||
|
||||
/** BUWR-23:补发处理 */
|
||||
export default function ReshipPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filter, setFilter] = useState<ReshipStatus>('ALL');
|
||||
|
||||
const list = MOCK_ITEMS.filter((item) => filter === 'ALL' || item.status === filter);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-orders-page">
|
||||
<PageHeader title="补发处理" onBack={() => navigate('/')} />
|
||||
|
||||
<p className="label-md text-muted" style={{ padding: '0 20px 8px' }}>
|
||||
总部下达的补发工单,确认后可推进配送
|
||||
</p>
|
||||
|
||||
<div className="partner-filter-row" style={{ padding: '0 20px 12px' }}>
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={`partner-chip${filter === f.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(f.key)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{list.length === 0 && <div className="empty">暂无补发工单</div>}
|
||||
|
||||
{list.map((item) => (
|
||||
<div key={item.id} className="partner-order-card" style={{ margin: '0 20px 12px' }}>
|
||||
<div className="partner-order-card-top">
|
||||
<span className="label-md text-muted">原单 {item.originalOrderNo}</span>
|
||||
<span className="label-md text-primary" style={{ fontWeight: 600 }}>{STATUS_LABEL[item.status]}</span>
|
||||
</div>
|
||||
<h3 className="headline-md" style={{ marginTop: 8 }}>{item.productName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>补发单号 {item.orderNo}</p>
|
||||
<div className="partner-order-address" style={{ marginTop: 12 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
|
||||
<p className="line-2-clamp">{item.address}</p>
|
||||
</div>
|
||||
{item.status === 'PENDING' && (
|
||||
<button type="button" className="partner-btn-primary" style={{ marginTop: 16, width: '100%' }}>
|
||||
开始配送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import {
|
||||
clearStoreDraft,
|
||||
defaultStoreForm,
|
||||
loadStoreDraft,
|
||||
saveStoreDraft,
|
||||
type StoreDraftForm,
|
||||
validateStoreStep1,
|
||||
validateStoreStep3,
|
||||
} from '../lib/storeDraft';
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
|
||||
export default function StoreCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const step = Number(params.get('step') || 1);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
district: '金水区',
|
||||
address: '',
|
||||
intro: '',
|
||||
accountPhone: '',
|
||||
accountName: '',
|
||||
bankAccountName: '',
|
||||
bankAccountNo: '',
|
||||
bankBranch: '',
|
||||
});
|
||||
const saved = loadStoreDraft();
|
||||
const [form, setForm] = useState<StoreDraftForm>(saved?.form ?? defaultStoreForm());
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const stepFromUrl = Number(params.get('step') || 0);
|
||||
const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stepFromUrl && saved?.step) {
|
||||
setParams({ step: String(saved.step) }, { replace: true });
|
||||
}
|
||||
}, [stepFromUrl, saved?.step, setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
saveStoreDraft({ step, form });
|
||||
}, [step, form]);
|
||||
|
||||
function patchForm(patch: Partial<StoreDraftForm>) {
|
||||
setForm((prev) => ({ ...prev, ...patch }));
|
||||
setError('');
|
||||
}
|
||||
|
||||
function goStep(n: number) {
|
||||
setParams({ step: String(n) });
|
||||
setError('');
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (step === 1) {
|
||||
const msg = validateStoreStep1(form);
|
||||
if (msg) { setError(msg); return; }
|
||||
}
|
||||
goStep(step + 1);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const msg = validateStoreStep3(form);
|
||||
if (msg) { setError(msg); return; }
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
if (step1Msg) { setError(step1Msg); return; }
|
||||
|
||||
await request('PARTNER_H5', '/partner/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
body: JSON.stringify({
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
district: form.district.trim(),
|
||||
address: form.address.trim(),
|
||||
intro: form.intro.trim() || undefined,
|
||||
bankAccountName: form.bankAccountName.trim(),
|
||||
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: form.bankBranch.trim(),
|
||||
}),
|
||||
});
|
||||
clearStoreDraft();
|
||||
navigate('/stores');
|
||||
}
|
||||
|
||||
@@ -61,6 +102,10 @@ export default function StoreCreatePage() {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{error && (
|
||||
<p className="partner-form-error" role="alert">{error}</p>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<section className="partner-form-card">
|
||||
@@ -72,30 +117,30 @@ export default function StoreCreatePage() {
|
||||
<label>门店名称 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">store</span>
|
||||
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||
<input placeholder="请输入门店名称" value={form.name} onChange={(e) => patchForm({ name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>联系电话 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>区县 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<input placeholder="请选择区县" value={form.district} onChange={(e) => setForm({ ...form, district: e.target.value })} />
|
||||
<input placeholder="请选择区县" value={form.district} onChange={(e) => patchForm({ district: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>详细地址 <span className="text-primary">*</span></label>
|
||||
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店简介</label>
|
||||
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
|
||||
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
|
||||
<div style={{ textAlign: 'right', marginTop: 4 }}>
|
||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||
</div>
|
||||
@@ -108,7 +153,7 @@ export default function StoreCreatePage() {
|
||||
<div>
|
||||
<h3 className="body-md text-primary" style={{ fontWeight: 700 }}>杜康合伙人身份认证</h3>
|
||||
<p className="label-md text-variant" style={{ marginTop: 4, lineHeight: 1.4 }}>
|
||||
传承千年窖香,共筑美酒传奇。录入信息后,系统将在 1-3 个工作日内完成审核。
|
||||
填写内容将自动保存,退出后可继续录入。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,18 +182,12 @@ export default function StoreCreatePage() {
|
||||
<h3 className="headline-md">环境照片 *</h3>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>至少3张,展示店内整洁环境</p>
|
||||
<div className="partner-upload-grid">
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
<button type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<button key={i} type="button" className="partner-upload-dashed">
|
||||
<span className="material-symbols-outlined text-primary">add_a_photo</span>
|
||||
<span>添加照片</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -169,7 +208,7 @@ export default function StoreCreatePage() {
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.2)', borderColor: 'rgba(254,214,91,0.5)' }}>
|
||||
<span className="material-symbols-outlined text-secondary" style={{ fontSize: 18 }}>info</span>
|
||||
<p className="label-md" style={{ lineHeight: 1.5, color: 'var(--color-on-secondary-container)' }}>
|
||||
温馨提示:请确保照片清晰无反光。preV1 占位上传,可直接下一步。
|
||||
温馨提示:preV1 占位上传,可直接下一步。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -184,29 +223,21 @@ export default function StoreCreatePage() {
|
||||
<section className="partner-form-card">
|
||||
<div className="partner-field">
|
||||
<label>户主姓名 *</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => setForm({ ...form, bankAccountName: e.target.value })} />
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>银行卡号 *</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => setForm({ ...form, bankAccountNo: e.target.value })} />
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>开户支行 *</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => setForm({ ...form, bankBranch: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>门店账号手机</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} value={form.accountPhone} onChange={(e) => setForm({ ...form, accountPhone: e.target.value })} />
|
||||
</div>
|
||||
<div className="partner-field">
|
||||
<label>店长姓名</label>
|
||||
<input className="partner-field-input" style={{ width: '100%', padding: '0 16px', minHeight: 48, border: '1px solid rgba(226,190,188,0.3)', borderRadius: 8, background: 'var(--color-background)' }} value={form.accountName} onChange={(e) => setForm({ ...form, accountName: e.target.value })} />
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
||||
</div>
|
||||
</section>
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
|
||||
<span className="material-symbols-outlined text-secondary">info</span>
|
||||
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
|
||||
请确保银行卡信息准确,以免影响每月的餐费结算。结算将按合同约定账期执行。
|
||||
请确保银行卡信息准确,以免影响每月的餐费结算。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -217,7 +248,7 @@ export default function StoreCreatePage() {
|
||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)}>上一步</button>
|
||||
)}
|
||||
{step < 3 ? (
|
||||
<button type="button" className="partner-btn-primary" onClick={() => goStep(step + 1)}>
|
||||
<button type="button" className="partner-btn-primary" onClick={handleNext}>
|
||||
<span>下一步</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,15 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'CLOSED', label: '已关闭' },
|
||||
];
|
||||
|
||||
function auditLabel(store: Record<string, unknown>) {
|
||||
const audits = store.audits as Array<{ status?: string }> | undefined;
|
||||
const s = String(audits?.[0]?.status ?? '').toUpperCase();
|
||||
if (s === 'PENDING') return { cls: 'partner-status-pill--paused', label: '待审核' };
|
||||
if (s === 'REJECTED') return { cls: 'partner-status-pill--closed', label: '已驳回' };
|
||||
if (s === 'APPROVED') return { cls: 'partner-status-pill--open', label: '已通过' };
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function StoreListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
|
||||
@@ -71,16 +80,22 @@ export default function StoreListPage() {
|
||||
|
||||
{filtered.map((s) => {
|
||||
const pill = statusPill(String(s.status));
|
||||
const audit = auditLabel(s);
|
||||
const dim = String(s.status).toUpperCase() === 'CLOSED';
|
||||
const storeName = String(s.name || '未命名门店');
|
||||
return (
|
||||
<div key={String(s.id)} className={`partner-store-card${dim ? ' partner-store-card--dim' : ''}`}>
|
||||
<Link to={`/stores/${s.id}`} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
<div className="partner-store-card-header">
|
||||
<div>
|
||||
<h3 className="headline-md">{String(s.name)}</h3>
|
||||
<p className="label-md text-muted" style={{ marginBottom: 2 }}>门店名称</p>
|
||||
<h3 className="headline-md">{storeName}</h3>
|
||||
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(s.address || s.district || '')}</p>
|
||||
</div>
|
||||
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, alignItems: 'flex-end' }}>
|
||||
<span className={`partner-status-pill ${pill.cls}`}>{pill.label}</span>
|
||||
{audit && <span className={`partner-status-pill ${audit.cls}`}>{audit.label}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="partner-store-card-actions">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** BUWR-25:经营周报(preV1 占位,待 `/partner/reports/weekly` 接入) */
|
||||
export default function WeeklyReportPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||
|
||||
<main style={{ padding: '0 20px 24px' }}>
|
||||
<section className="partner-revenue-card" style={{ marginBottom: 16 }}>
|
||||
<p className="partner-revenue-label">近 7 日 GMV (CNY)</p>
|
||||
<div className="partner-revenue-amount">{fmtMoney(128450)}</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-bento-card" style={{ marginBottom: 12 }}>
|
||||
<div className="partner-expansion-split">
|
||||
<div>
|
||||
<p className="label-md text-muted">购酒订单量</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>86</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="label-md text-muted">活跃门店数</p>
|
||||
<p className="headline-lg" style={{ fontSize: 24, marginTop: 4 }}>12</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="partner-bento-card">
|
||||
<h2 className="headline-md" style={{ marginBottom: 12 }}>本周新签门店</h2>
|
||||
<p className="headline-lg text-primary">3 家</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>详细趋势图待 API 接入后展示</p>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -581,6 +581,106 @@ html, body, #root {
|
||||
border-left: 1px solid rgba(226, 190, 188, 0.2);
|
||||
}
|
||||
|
||||
.partner-form-error {
|
||||
margin: 0 20px 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--color-error-container);
|
||||
color: var(--color-error);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.partner-store-name-list {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.partner-store-name-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.partner-store-name-list-header {
|
||||
padding-bottom: 4px !important;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
|
||||
}
|
||||
|
||||
.partner-store-name-list--compact li {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.partner-pending-stores {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.15);
|
||||
}
|
||||
|
||||
.partner-field-input--block {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
min-height: 48px;
|
||||
border: 1px solid rgba(226, 190, 188, 0.3);
|
||||
border-radius: 8px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.partner-leaderboard-section {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.partner-leaderboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.partner-leaderboard-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
|
||||
}
|
||||
|
||||
.partner-leaderboard-row--compact {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.partner-leaderboard-rank {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-container-high);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partner-leaderboard-row:first-of-type .partner-leaderboard-rank {
|
||||
background: var(--color-aged-amber);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.partner-leaderboard-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.partner-leaderboard-stat {
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Partner bottom tabbar ── */
|
||||
.app-tabbar {
|
||||
display: grid;
|
||||
|
||||
@@ -124,13 +124,24 @@ export class StoreService {
|
||||
|
||||
async partnerDashboard(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const [storeCount, orderCount] = await Promise.all([
|
||||
const [storeCount, orderCount, recentStores] = await Promise.all([
|
||||
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
|
||||
this.prisma.order.count({
|
||||
where: { city: { partnerId: account.partnerId } },
|
||||
}),
|
||||
this.prisma.store.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true, name: true, status: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
return { storeCount, orderCount, companyName: account.partner.companyName };
|
||||
return {
|
||||
storeCount,
|
||||
orderCount,
|
||||
companyName: account.partner.companyName,
|
||||
recentStores: serializeBigInt(recentStores),
|
||||
};
|
||||
}
|
||||
|
||||
private async getPartnerAccount(partnerAccountId: bigint) {
|
||||
|
||||
Reference in New Issue
Block a user