diff --git a/.cursor/agents/backend-lead.md b/.cursor/agents/backend-lead.md new file mode 100644 index 0000000..5166632 --- /dev/null +++ b/.cursor/agents/backend-lead.md @@ -0,0 +1,64 @@ +--- + 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 diff --git a/.cursor/agents/boundary-reviewer.md b/.cursor/agents/boundary-reviewer.md new file mode 100644 index 0000000..a3d5f75 --- /dev/null +++ b/.cursor/agents/boundary-reviewer.md @@ -0,0 +1,54 @@ +--- + 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 map(2 人团队) + +| 逻辑域 | 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. diff --git a/.cursor/agents/owner-a-user-trade.md b/.cursor/agents/owner-a-user-trade.md new file mode 100644 index 0000000..aa2c48f --- /dev/null +++ b/.cursor/agents/owner-a-user-trade.md @@ -0,0 +1,55 @@ +--- +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: all | pending_pay | pending_ship | pending_receive | completed + +## Session start + +1. Read task card if given → `dukang-task-card` skill +2. Confirm changes stay in Owner A paths +3. UI: `pages/user/` + `pages/ROUTE_MAP.md` +4. API/DB: V2 manual §五/§六 + +## 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 diff --git a/.cursor/agents/owner-b-partner-store.md b/.cursor/agents/owner-b-partner-store.md new file mode 100644 index 0000000..0fb91f0 --- /dev/null +++ b/.cursor/agents/owner-b-partner-store.md @@ -0,0 +1,48 @@ +--- +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 diff --git a/.cursor/agents/owner-c-catalog-ops.md b/.cursor/agents/owner-c-catalog-ops.md new file mode 100644 index 0000000..aebe235 --- /dev/null +++ b/.cursor/agents/owner-c-catalog-ops.md @@ -0,0 +1,49 @@ +--- +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 scope note + +- admin-web replaces HQ **mini program** for dev/demo only +- Many HQ features are **Seed-fixed** in preV1 (cities, 4 SKUs) +- Do not assume admin-web UI matches `pages/hq/` 1:1 — V2 will use Taro mini-hq + +## 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. Check preV1 §6 for what's Seed vs implemented UI +2. Product/city rules: V2 manual §二 §6 +3. Settlement: T+1 store / T+30 partner (M5+) + +## 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 diff --git a/.cursor/agents/owner-d-shop-redeem.md b/.cursor/agents/owner-d-shop-redeem.md new file mode 100644 index 0000000..6a697ee --- /dev/null +++ b/.cursor/agents/owner-d-shop-redeem.md @@ -0,0 +1,53 @@ +--- +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 300s +- Amount: `0 < amount ≤ min(balance, 500)` +- 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 diff --git a/.cursor/rules/backend-modules.mdc b/.cursor/rules/backend-modules.mdc new file mode 100644 index 0000000..84e1691 --- /dev/null +++ b/.cursor/rules/backend-modules.mdc @@ -0,0 +1,42 @@ +--- +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 }` + 对应 Guard(User/Store/Partner/HQ) diff --git a/.cursor/rules/backend-nestjs.mdc b/.cursor/rules/backend-nestjs.mdc new file mode 100644 index 0000000..2e1ade5 --- /dev/null +++ b/.cursor/rules/backend-nestjs.mdc @@ -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 表) diff --git a/.cursor/rules/dukang-core.mdc b/.cursor/rules/dukang-core.mdc new file mode 100644 index 0000000..070ea54 --- /dev/null +++ b/.cursor/rules/dukang-core.mdc @@ -0,0 +1,51 @@ +--- +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 人团队) + +| 负责人 | 路径 | +|--------|------| +| jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, `modules/{iam,trade,benefit,analytics,catalog,settlement,ops}/` | +| 刘京尧 | `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 ≤ min(balance, **500**) +- 订单 Tab:`all | pending_pay | pending_ship | pending_receive | completed` +- API:`/api/v1`,响应 `{ code, message, data }` + +## 提交 + +- Conventional Commits:`feat(trade):` 等 +- 不提交 `.env` diff --git a/.cursor/rules/frontend-h5.mdc b/.cursor/rules/frontend-h5.mdc new file mode 100644 index 0000000..0415a19 --- /dev/null +++ b/.cursor/rules/frontend-h5.mdc @@ -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 小程序规格 | diff --git a/.cursor/rules/git-conventions.mdc b/.cursor/rules/git-conventions.mdc new file mode 100644 index 0000000..0524e85 --- /dev/null +++ b/.cursor/rules/git-conventions.mdc @@ -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 diff --git a/.cursor/rules/packages-shared.mdc b/.cursor/rules/packages-shared.mdc new file mode 100644 index 0000000..ab73ae1 --- /dev/null +++ b/.cursor/rules/packages-shared.mdc @@ -0,0 +1,43 @@ +--- +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`、核销 ¥500 上限在此实现 +- 变更必须有单元测试 + +```typescript +const benefitAmount = product.benefitAmount ?? product.price; +// 同城起购 2 瓶 / 跨城 6 瓶 +// 核销上限 ¥500 +``` + +## shared-ui + +- 跨 App 组件;禁止 import 特定 App 代码 +- admin-web 使用 Ant Design,不引用 shared-ui + +## 破坏性改动 + +需通知 **jacy-dukang** 与 **刘京尧**(若影响 partner/shop API)并在 PR 说明影响面。Prisma 迁移需双方 Review(涉及 store/redeem 表时)。 diff --git a/.cursor/rules/prisma-schema.mdc b/.cursor/rules/prisma-schema.mdc new file mode 100644 index 0000000..9e7ad20 --- /dev/null +++ b/.cursor/rules/prisma-schema.mdc @@ -0,0 +1,29 @@ +--- +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 5 分钟 +- 支付流水 → `log_third_party` + `user_order.pay_*` +- 业务事件 → `common_event`;埋点 → `log_user_analytics` + +## preV1 + +不删表、不改 V2 字段语义。Seed:`prisma/seed-v31.ts` diff --git a/.cursor/skills/dukang-coding/SKILL.md b/.cursor/skills/dukang-coding/SKILL.md new file mode 100644 index 0000000..691858a --- /dev/null +++ b/.cursor/skills/dukang-coding/SKILL.md @@ -0,0 +1,122 @@ +--- +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. 确认阶段:preV1 → 读 `杜康好客-preV1编码手册.md`;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 + +**需求变更** → 只改 V2 手册,编码时不改业务规则。 + +## 编码前 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/ +``` + +Workflow:DTO → 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 端订单 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) + +```typescript +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+) + +- 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` diff --git a/.cursor/skills/dukang-coding/reference-backend.md b/.cursor/skills/dukang-coding/reference-backend.md new file mode 100644 index 0000000..c9e5948 --- /dev/null +++ b/.cursor/skills/dukang-coding/reference-backend.md @@ -0,0 +1,160 @@ +# 后端速查(杜康好客 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-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/真实支付成功** + +``` +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 源码 | + +## integrations(preV1) + +| Flag | Provider | 行为 | +|------|----------|------| +| MOCK_SMS=true | sms.mock | 固定码 123456 | +| MOCK_PAY=true | pay.mock | 同步 PAID + 发券 | +| MOCK_DELIVERY_AUTO=true | delivery.mock | BullMQ 自动推进状态 | diff --git a/.cursor/skills/dukang-coding/reference-frontend.md b/.cursor/skills/dukang-coding/reference-frontend.md new file mode 100644 index 0000000..4195ee8 --- /dev/null +++ b/.cursor/skills/dukang-coding/reference-frontend.md @@ -0,0 +1,127 @@ +# 前端速查(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 + +``` +all | pending_pay | pending_ship | pending_receive | completed +``` + +## 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 差异 + +| 项 | preV1 | V2 | +|----|-------|-----| +| C/合伙人载体 | H5 | 微信小程序 | +| 总部 | admin-web 内部 | mini-hq 小程序 | +| 支付页 | Mock 同步成功 | 微信 JSAPI | diff --git a/.cursor/skills/dukang-prev1/SKILL.md b/.cursor/skills/dukang-prev1/SKILL.md new file mode 100644 index 0000000..3ab1915 --- /dev/null +++ b/.cursor/skills/dukang-prev1/SKILL.md @@ -0,0 +1,94 @@ +--- +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. **无微信登录** → 仅 SMS;wechat 路由返回 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/` 原型混为一谈 diff --git a/.cursor/skills/dukang-project/SKILL.md b/.cursor/skills/dukang-project/SKILL.md new file mode 100644 index 0000000..a6fd205 --- /dev/null +++ b/.cursor/skills/dukang-project/SKILL.md @@ -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 diff --git a/.cursor/skills/dukang-task-card/SKILL.md b/.cursor/skills/dukang-task-card/SKILL.md new file mode 100644 index 0000000..6deacb9 --- /dev/null +++ b/.cursor/skills/dukang-task-card/SKILL.md @@ -0,0 +1,85 @@ +--- +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 命名空间:`P1-*`(preV1)与 `M*-*`(V2)并行,勿混用验收标准 +- 需求变更只改 V2 手册,不在任务实现中私改业务规则 + +## 领取流程 + +1. **解析 ID** → 确定里程碑与 OWNER + - `P1-M2-002` → preV1 §9 + OWNER 见卡片内容 + - `M2-BE-TRD-002` → V2 §七 附录 A +2. **读验收标准** → 手册中该 ID 的「验收」列 +3. **读依赖** → PRD §、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 | 核销 ¥500 上限 | +| 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 无新增错误 +``` + +## 里程碑顺序 + +**preV1**:P1-M0 → P1-M1 → P1-M2 → P1-M3 → P1-M4 → P1-M5 → P1-M6 + +**V2**:M0 → M1 → … → M6 → 上线 + +除非用户指定,默认从当前里程碑**下一个未完成**任务卡开始;不确定时先问用户或跑 smoke 判断进度。 + +## 输出格式(任务完成时) + +```markdown +## 任务卡 {ID} 完成 + +**改动范围**:(路径列表) +**验收**:(逐条 ✓) +**未做/阻塞**:(如有,需其他 OWNER 配合的项) +**建议下一步**:(下一任务卡 ID) +``` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..05b8bf4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,43 @@ +# 杜康好客 CODEOWNERS(2 人团队) +# 仓库:阿里云 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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..deddf75 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,141 @@ +# 杜康好客 · AGENTS.md + +> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。 + +## 项目概览 + +杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。 + +| 阶段 | 手册 | 说明 | +|------|------|------| +| **当前 preV1** | [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md) | 三端 H5 + Mock 联调,同库同 API | +| **目标 V2** | [`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) | 四端小程序 + 真实第三方(唯一事实源) | + +**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。 + +## 启动命令 + +```bash +# 基础设施 +cd deploy && docker compose up -d + +# 依赖与数据库 +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-prev1.mjs +pnpm lint && pnpm test +``` + +Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。 + +> **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。 + +## 文档优先级(冲突时) + +1. `杜康好客-V2编码手册.md` §二 — 业务规则 +2. `杜康好客-preV1编码手册.md` — preV1 裁剪(Mock / Flag) +3. [`conventions.md`](./conventions.md) — 协作与模块边界 +4. V2 手册 §四 §五 §六 — 架构 / DB / API +5. `pages/{user,shop,partner}/` + [`pages/ROUTE_MAP.md`](./pages/ROUTE_MAP.md) — UI 参照 + +## 团队与 OWNER 边界(2 人) + +| 负责人 | Git 账号 | 职责 | +|--------|----------|------| +| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review | +| **刘京尧** | `刘京尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块 | + +逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**人员合并**如下: + +| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module | +|------------|--------|----------|-------------| +| **A + C + Lead** | jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | `iam`, `trade`, `benefit`, `analytics`, `catalog`, `settlement`, `ops` | +| **B + D** | 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` | + +**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 | +| 需求 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 输入 `/` 选择: + +| 子 Agent | 负责人 | 适用场景 | +|----------|--------|----------| +| `owner-a-user-trade` | jacy-dukang | C 端 H5、订单、支付、权益、埋点 | +| `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-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}/`(只读) + +## 核心业务常量(不可偏离) + +``` +权益额 = benefit_amount ?? price +同城起购 2 瓶 / 跨城 6 瓶 +核销:0 < amount ≤ min(balance, 500);Redis 码 5 分钟 +C 端门店仅 status=OPEN +订单 Tab:all | pending_pay | pending_ship | pending_receive | completed +``` + +## 提交与 PR + +- Conventional Commits:`feat(trade):`、`fix(redeem):`;scope = 端或模块 +- 跨模块 PR → jacy-dukang + 刘京尧 共同 Review(若涉及双方模块) +- 改 API/表 → 同步 V2 手册 §五/§六 + `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) diff --git a/README.md b/README.md index ef9ec5e..8eabd17 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ pnpm install cp server/dukang-api/.env.example server/dukang-api/.env pnpm db:generate cd server/dukang-api -npx prisma db push -pnpm prisma:seed +npx prisma db push # v3.1 首次切换加 --accept-data-loss +pnpm prisma:seed # seed-v31.ts ``` ### 4. 启动服务 diff --git a/agent.md b/agent.md index 9d2edfa..6e85a58 100644 --- a/agent.md +++ b/agent.md @@ -1,6 +1,9 @@ # 杜康好客 · Cursor Agent 指引 -> 本文件供 **下一个 Cursor Agent** 在编码前阅读。与 `skills.md`、`杜康好客-V2编码手册.md` 配合使用。 +> 本文件供 **人类与 Agent** 在编码前阅读。 +> **AI 自动加载入口**:[`AGENTS.md`](./AGENTS.md)(跨工具通用) +> **Skills / 子 Agent**:`.cursor/skills/`、`.cursor/agents/`(Cursor 2.4+) +> 与 [`skills.md`](./skills.md)、[`杜康好客-V2编码手册.md`](./杜康好客-V2编码手册.md) 配合使用。 --- @@ -17,12 +20,14 @@ ## 2. 启动流程(每次会话) -1. 阅读 [`杜康好客-preV1编码手册.md`](./杜康好客-preV1编码手册.md);细节查 V2 手册对应 § -2. 阅读 [`skills.md`](./skills.md) 中的编码检查清单 -3. 阅读 [`conventions.md`](./conventions.md) 模块边界 -4. 若用户给出任务卡 ID(如 `P1-M2-002` 或 V2 的 `M2-*`)→ 在 preV1 §9 或 V2 §七 查验收标准 -5. 对照 `pages/{user,shop,partner}/` 原型图(preV1 无 hq) -6. 一次只完成一个任务卡;完成后自验 DoD +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` 审查跨模块违规 --- @@ -71,15 +76,13 @@ --- -## 6. 模块 OWNER(写代码时只改自己的目录) +## 6. 模块 OWNER(2 人团队) -| OWNER | App | Module | -|-------|-----|--------| -| A | mini-user | iam, trade, benefit, analytics | -| B | mini-partner | store | -| C | mini-hq | catalog, settlement, ops | -| D | h5-shop | redeem | -| Lead | packages/*, callbacks/, jobs/ | 横切 | +| 负责人 | Git 账号 | App(preV1 → 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 源码。 @@ -132,4 +135,4 @@ dukang/ --- -*编码时 @ 本文件或阅读 `skills.md`;需求变更只改 `杜康好客-V2编码手册.md`。* +*编码时 @ [`AGENTS.md`](./AGENTS.md) 或 Skill `dukang-coding`;需求变更只改 `杜康好客-V2编码手册.md`。* diff --git a/apps/AGENTS.md b/apps/AGENTS.md new file mode 100644 index 0000000..59250e4 --- /dev/null +++ b/apps/AGENTS.md @@ -0,0 +1,61 @@ +# 杜康好客 · 前端 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/` | +| `h5-shop` | 5174 | `SHOP_H5` | 刘京尧 | `pages/shop/` | +| `h5-partner` | 5175 | `PARTNER_H5` | 刘京尧 | `pages/partner/` | +| `admin-web` | — | (Admin JWT) | jacy-dukang | preV1 内部 HQ 替代 | + +V2 目标:`mini-user` / `mini-partner` / `mini-hq` 替换对应 H5(除门店仍 H5)。 + +## 前端硬规则 + +- 请求基址 `/api/v1`;Header:`Authorization` + `X-Client-App` +- 类型从 `@dukang/shared-types` import,禁止复制枚举字符串 +- 禁止 import `server/` 或另一个 `apps/*` 的源码 +- UI 共享组件优先 `@dukang/shared-ui` +- C 端订单列表 **5 Tab**(含 `pending_ship`) + +## 新页面 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-user(jacy-dukang) + +主链路:登录 → 首页/详情 → 下单 Mock 支付 → 5 Tab 订单 → 权益 → 核销码 → 门店列表 + +**勿改**:门店核销确认 UI(属 h5-shop)、合伙人录店(属 h5-partner) + +### h5-shop(刘京尧) + +主链路:门店登录 → 首页 → 扫码/输入核销 → 确认 → 记录 → 营业状态 + +**勿改**:C 端出码页面(属 h5-user) + +### h5-partner(刘京尧) + +主链路:登录 → 工作台 → 录店 → 门店列表 → 辖区订单 → Mock 推进配送(preV1) + +**勿改**:C 端商品/订单页、总部报表(admin-web / V2 mini-hq) + +### admin-web(jacy-dukang,preV1 限定) + +内部 Web 管理:开城、商品、订单、门店审核、结算查看。**不是** V2 总部小程序规格。 + +变更 admin-web 时勿假设与 `pages/hq/` 原型 1:1;V2 需另建 `mini-hq`。 + +## 启动 + +```bash +pnpm dev:user | dev:shop | dev:partner | dev:admin +``` diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index dd1f8e9..04ab1b0 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -13,6 +13,7 @@ "@dukang/shared-types": "workspace:*", "antd": "^5.22.0", "dayjs": "^1.11.13", + "element-china-area-data": "^6.1.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.0" diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index 3e64608..9b68121 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -16,6 +16,8 @@ import DeliveriesPage from './pages/DeliveriesPage'; import HqAccountsPage from './pages/HqAccountsPage'; import CitiesPage from './pages/CitiesPage'; import StoreMediaPage from './pages/StoreMediaPage'; +import ProductsPage from './pages/ProductsPage'; +import ResourcesPage from './pages/ResourcesPage'; function RequireAuth({ children }: { children: React.ReactNode }) { if (!getToken()) return ; @@ -36,9 +38,11 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/components/ChinaRegionCascader.tsx b/apps/admin-web/src/components/ChinaRegionCascader.tsx new file mode 100644 index 0000000..f0c28da --- /dev/null +++ b/apps/admin-web/src/components/ChinaRegionCascader.tsx @@ -0,0 +1,34 @@ +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 ( + onChange?.((codes ?? []) as string[])} + disabled={disabled} + placeholder={placeholder} + showSearch={{ + filter: (input, path) => + path.some((option) => + String(option.label ?? '').toLowerCase().includes(input.toLowerCase()), + ), + }} + changeOnSelect={false} + /> + ); +} diff --git a/apps/admin-web/src/components/OssUpload.tsx b/apps/admin-web/src/components/OssUpload.tsx new file mode 100644 index 0000000..7196b8e --- /dev/null +++ b/apps/admin-web/src/components/OssUpload.tsx @@ -0,0 +1,91 @@ +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); + if (result.mock) { + message.info('当前为 Mock OSS,已使用占位 URL'); + } else { + message.success('上传成功'); + } + onSuccess?.(result); + } catch (e) { + const err = e instanceof Error ? e : new Error('上传失败'); + message.error(err.message); + onError?.(err); + } finally { + setUploading(false); + } + }; + + return ( + + {value && mediaType === 'IMAGE' && ( + + )} + {value && mediaType === 'VIDEO' && ( + + ); +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index f116527..e9541f8 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -12,6 +12,7 @@ import { CarOutlined, SafetyOutlined, LogoutOutlined, + CloudUploadOutlined, } from '@ant-design/icons'; import { clearAuth, request, type HqProfile } from '../lib/api'; @@ -20,7 +21,9 @@ const { Header, Sider, Content } = Layout; const MENU_ITEMS: MenuProps['items'] = [ { key: '/', icon: , label: '概览' }, { key: '/users', icon: , label: '用户' }, + { key: '/products', icon: , label: '商品' }, { key: '/orders', icon: , label: '订单' }, + { key: '/resources', icon: , label: 'OSS 资源库' }, { key: 'stores-group', icon: , diff --git a/apps/admin-web/src/lib/china-region.ts b/apps/admin-web/src/lib/china-region.ts new file mode 100644 index 0000000..cd9019f --- /dev/null +++ b/apps/admin-web/src/lib/china-region.ts @@ -0,0 +1,76 @@ +import { codeToText, regionData } from 'element-china-area-data'; + +export { regionData as CHINA_REGION_OPTIONS }; + +export type OpenCityRef = { + id: string; + name: string; + code: string; + partnerId?: string | null; + partner?: { id: string }; +}; + +export type ParsedChinaRegion = { + province: string; + city: string; + district: string; + provinceCode: string; + cityCode: string; + districtCode: string; +}; + +/** 区县 adcode → 地级市 adcode(如 410105 → 410100) */ +export function districtCodeToCityCode(districtCode: string): string { + if (districtCode.length < 6) return districtCode; + return `${districtCode.slice(0, 4)}00`; +} + +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, + partnerId?: string, +): string | undefined { + const cityCode = districtCodeToCityCode(districtCode); + const scoped = partnerId + ? cities.filter((c) => { + const pid = c.partnerId ?? c.partner?.id; + return !pid || String(pid) === partnerId; + }) + : 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[], + partnerId?: string, +) { + const region = parseRegionCodes(codes); + if (!region) return null; + const cityId = matchOpenCityId(cities, region.districtCode, partnerId); + const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined; + return { + region, + cityId, + matchedCity, + cityCode: districtCodeToCityCode(region.districtCode), + }; +} diff --git a/apps/admin-web/src/lib/constants.ts b/apps/admin-web/src/lib/constants.ts index 2638f01..bd4cb84 100644 --- a/apps/admin-web/src/lib/constants.ts +++ b/apps/admin-web/src/lib/constants.ts @@ -1,3 +1,6 @@ +/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */ +export const ADMIN_OPTIONS_PAGE_SIZE = 100; + export const ORDER_STATUS_LABELS: Record = { PENDING_PAY: '待付款', PENDING_SHIP: '待发货', @@ -42,6 +45,45 @@ export const PARTNER_BILL_STATUS_LABELS: Record = { export const MEDIA_TYPE_LABELS: Record = { IMAGE: '图片', VIDEO: '视频', + FILE: '文件', +}; + +export const RESOURCE_OWNER_TYPE_LABELS: Record = { + PRODUCT: '商品', + STORE: '门店', + PARTNER: '合伙人', + USER: '用户', + ORDER: '订单', + HQ: '总部', +}; + +export const RESOURCE_BIZ_TYPE_LABELS: Record = { + COVER: '封面', + ENV: '环境图', + CONTRACT: '合同', + CAROUSEL: '轮播', + DETAIL: '详情', + AVATAR: '头像', + QRCODE: '二维码', + SIGN_PHOTO: '签收照', + VIDEO: '视频', +}; + +export const RESOURCE_STATUS_LABELS: Record = { + ACTIVE: '有效', + DELETED: '已删除', +}; + +export const PRODUCT_STATUS_LABELS: Record = { + DRAFT: '草稿', + ON_SALE: '在售', + OFF_SALE: '下架', +}; + +export const AROMA_TYPE_LABELS: Record = { + QINGXIANG: '清香型', + JIANGXIANG: '酱香型', + NONGXIANG: '浓香型', }; export const LEDGER_TYPE_LABELS: Record = { diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts new file mode 100644 index 0000000..bd0df31 --- /dev/null +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -0,0 +1,49 @@ +export type StoreCreateForm = { + partnerId: string; + cityId: string; + regionCodes?: string[]; + province?: string; + city?: string; + district: string; + districtCode?: string; + name: string; + phone: string; + address: string; + intro?: string; + coverUrl?: string; + envPhotoUrls?: string[]; + contractUrl?: string; + bankAccountName: string; + bankAccountNo: string; + bankBranch: string; + accountPhone?: string; + accountName?: string; +}; + +const PHONE_RE = /^1\d{10}$/; +const BANK_RE = /^\d{16,19}$/; + +export function validateStoreCreateStep1( + form: Pick, +): string | null { + if (!form.partnerId) return '请选择开城合伙人'; + if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; + if (!form.cityId) 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 '请填写详细地址'; + if (form.intro?.trim()) { + const len = form.intro.trim().length; + if (len < 10 || len > 500) return '门店简介须为 10~500 字'; + } + return null; +} + +export function validateStoreCreateStep3(form: Pick): 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; +} diff --git a/apps/admin-web/src/lib/upload.ts b/apps/admin-web/src/lib/upload.ts new file mode 100644 index 0000000..5cf1c05 --- /dev/null +++ b/apps/admin-web/src/lib/upload.ts @@ -0,0 +1,49 @@ +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 { + 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 = { + '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, + }; +} diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx index 161d550..fd9324f 100644 --- a/apps/admin-web/src/pages/CitiesPage.tsx +++ b/apps/admin-web/src/pages/CitiesPage.tsx @@ -4,7 +4,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; type Row = { @@ -37,7 +37,7 @@ export default function CitiesPage() { const [partners, setPartners] = useState([]); async function loadPartners() { - const res = await request>('/admin/partners?pageSize=200'); + const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setPartners(res.items); } diff --git a/apps/admin-web/src/pages/OrdersPage.tsx b/apps/admin-web/src/pages/OrdersPage.tsx index 8a4d545..4a5ed7b 100644 --- a/apps/admin-web/src/pages/OrdersPage.tsx +++ b/apps/admin-web/src/pages/OrdersPage.tsx @@ -10,21 +10,11 @@ import { Table, Tag, Typography, + message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type AdminOrderRow, type Paginated } from '../lib/api'; - -const STATUS_LABELS: Record = { - PENDING_PAY: '待付款', - PENDING_SHIP: '待发货', - OUT_WAREHOUSE: '已出库', - SHIPPING: '配送中', - PENDING_RECEIVE: '待收货', - COMPLETED: '已完成', - CANCELLED: '已取消', - REFUNDING: '退款中', - REFUNDED: '已退款', -}; +import { ORDER_STATUS_LABELS } from '../lib/constants'; type OrderDetail = AdminOrderRow & { receiverAddress?: string; @@ -93,7 +83,7 @@ export default function OrdersPage() { title: '状态', dataIndex: 'status', width: 100, - render: (s) => {STATUS_LABELS[s] || s}, + render: (s) => {ORDER_STATUS_LABELS[s] || s}, }, { title: '配送', @@ -140,7 +130,7 @@ export default function OrdersPage() { - ({ value, label }))} /> @@ -171,12 +161,37 @@ export default function OrdersPage() { }} /> - setDrawerOpen(false)}> + setDrawerOpen(false)} + extra={detail && ( + + 调试改状态 + + + ({ value, label }))} /> + + + + { setPage(p); setPageSize(ps); } }} /> + setDrawerOpen(false)} + extra={detail && ( + + )}> + {detail && ( + <> + + {String(detail.skuCode)} + {String(detail.barcode69)} + {AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)} + +
+ + + + + + + + + + + + + + + + ({ value, label }))} + /> + + + + + + ({ value, label }))} + /> + + + + + +
{ + setPage(p); + setPageSize(ps); + void reload(p, ps); + }, + }} + /> + 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} + > +
+ + + + + ({ value, label })), + { value: 'FILE', label: '文件' }, + ]} + /> + + prev.mediaType !== cur.mediaType || prev.bizType !== cur.bizType}> + {({ getFieldValue }) => { + const mediaType = getFieldValue('mediaType') || 'IMAGE'; + const bizType = getFieldValue('bizType') || 'COVER'; + return ( + + { + createForm.setFieldsValue({ ossKey: result.ossKey, url: result.url }); + }} + /> + + ); + }} + + + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/StoreAccountsPage.tsx b/apps/admin-web/src/pages/StoreAccountsPage.tsx index bbbcf7b..8fbd3b1 100644 --- a/apps/admin-web/src/pages/StoreAccountsPage.tsx +++ b/apps/admin-web/src/pages/StoreAccountsPage.tsx @@ -4,7 +4,7 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { ACCOUNT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; type Row = { @@ -34,7 +34,7 @@ export default function StoreAccountsPage() { const [stores, setStores] = useState([]); async function loadStores() { - const res = await request>('/admin/stores?pageSize=200'); + const res = await request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setStores(res.items); } diff --git a/apps/admin-web/src/pages/StoreMediaPage.tsx b/apps/admin-web/src/pages/StoreMediaPage.tsx index 32b7e73..c293c8b 100644 --- a/apps/admin-web/src/pages/StoreMediaPage.tsx +++ b/apps/admin-web/src/pages/StoreMediaPage.tsx @@ -4,8 +4,9 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants'; +import { ADMIN_OPTIONS_PAGE_SIZE, MEDIA_TYPE_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; +import OssUpload from '../components/OssUpload'; type Row = { id: string; mediaType: string; url: string; sortOrder: number; createdAt: string; @@ -35,7 +36,7 @@ export default function StoreMediaPage() { const [stores, setStores] = useState([]); async function loadStores() { - const res = await request>('/admin/stores?pageSize=200'); + const res = await request>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); setStores(res.items); } @@ -102,7 +103,13 @@ export default function StoreMediaPage() { + prev.mediaType !== cur.mediaType}> + {({ getFieldValue }) => ( + + + + )} + @@ -116,7 +123,13 @@ export default function StoreMediaPage() { }}>
+ prev.mediaType !== cur.mediaType}> + {({ getFieldValue }) => ( + + + + )} + diff --git a/apps/admin-web/src/pages/StoresPage.tsx b/apps/admin-web/src/pages/StoresPage.tsx index 31e69df..5800d91 100644 --- a/apps/admin-web/src/pages/StoresPage.tsx +++ b/apps/admin-web/src/pages/StoresPage.tsx @@ -1,11 +1,39 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { - Button, Descriptions, Drawer, Form, Image, Input, Modal, Select, Space, Table, Tag, Typography, message, + Alert, + Button, + Descriptions, + Drawer, + Form, + Image, + Input, + Modal, + Select, + Space, + Steps, + Table, + Tag, + Typography, + message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { request, type Paginated } from '../lib/api'; -import { STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants'; +import { + validateStoreCreateStep1, + validateStoreCreateStep3, + type StoreCreateForm, +} from '../lib/storeCreate'; import { useAdminList } from '../lib/useAdminList'; +import { resolveRegionBinding } from '../lib/china-region'; +import ChinaRegionCascader from '../components/ChinaRegionCascader'; +import OssUpload from '../components/OssUpload'; + +const CREATE_STEPS = [ + { title: '基本信息' }, + { title: '照片上传' }, + { title: '结算资质' }, +]; type StoreRow = { id: string; @@ -24,12 +52,18 @@ type StoreRow = { }; type PartnerOption = { id: string; companyName: string }; -type CityOption = { id: string; name: string; code: string }; +type CityOption = { + id: string; + name: string; + code: string; + partnerId?: string | null; + partner?: { id: string; companyName: string }; +}; export default function StoresPage() { const [form] = Form.useForm(); const [editForm] = Form.useForm(); - const [createForm] = Form.useForm(); + const [createForm] = Form.useForm(); const [filters, setFilters] = useState>({}); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/stores', @@ -45,16 +79,142 @@ export default function StoresPage() { const [detail, setDetail] = useState | null>(null); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [createStep, setCreateStep] = useState(0); + const [createError, setCreateError] = useState(''); const [partners, setPartners] = useState([]); const [cities, setCities] = useState([]); + const [optionsLoading, setOptionsLoading] = useState(false); + + const selectedPartnerId = Form.useWatch('partnerId', createForm); + const selectedRegionCodes = Form.useWatch('regionCodes', createForm); + const selectedCityId = Form.useWatch('cityId', createForm); + + function bindRegionSelection(codes: string[], partnerId?: string) { + const binding = resolveRegionBinding(codes, cities, partnerId ?? selectedPartnerId); + if (!binding) { + createForm.setFieldsValue({ regionCodes: codes, cityId: undefined }); + return; + } + createForm.setFieldsValue({ + regionCodes: codes, + province: binding.region.province, + city: binding.region.city, + district: binding.region.district, + districtCode: binding.region.districtCode, + cityId: binding.cityId, + }); + } + + const regionBindingHint = useMemo(() => { + if (!selectedRegionCodes?.length) return null; + const binding = resolveRegionBinding(selectedRegionCodes, cities, selectedPartnerId); + if (!binding) return null; + if (binding.cityId && binding.matchedCity) { + return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode})`; + } + return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 开城城市」添加`; + }, [selectedRegionCodes, cities, selectedPartnerId]); async function loadOptions() { - const [p, c] = await Promise.all([ - request>('/admin/partners?pageSize=200'), - request>('/admin/cities?pageSize=200'), - ]); - setPartners(p.items); - setCities(c.items); + setOptionsLoading(true); + try { + const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`; + const [p, c] = await Promise.all([ + request>(`/admin/partners?${qs}`), + request>(`/admin/cities?${qs}`), + ]); + setPartners(p.items); + setCities(c.items); + if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 开城合伙人」中创建'); + if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 开城城市」中创建'); + } catch (e) { + message.error(e instanceof Error ? e.message : '加载合伙人/城市失败'); + } finally { + setOptionsLoading(false); + } + } + + function closeCreateModal() { + setCreateOpen(false); + setCreateStep(0); + setCreateError(''); + createForm.resetFields(); + } + + function openCreateModal() { + void loadOptions(); + createForm.setFieldsValue({ + envPhotoUrls: ['', '', ''], + }); + setCreateStep(0); + setCreateError(''); + setCreateOpen(true); + } + + async function handleCreateNext() { + const values = createForm.getFieldsValue(); + if (createStep === 0) { + const msg = validateStoreCreateStep1(values); + if (msg) { + setCreateError(msg); + return; + } + try { + await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']); + } catch { + return; + } + } + setCreateError(''); + setCreateStep((s) => s + 1); + } + + async function handleCreateSubmit() { + try { + const values = await createForm.validateFields(); + const step3Msg = validateStoreCreateStep3(values); + if (step3Msg) { + setCreateError(step3Msg); + return; + } + + const envPhotoUrls = (values.envPhotoUrls ?? []).map((u: string) => u?.trim()).filter(Boolean) as string[]; + await request('/admin/stores', { + method: 'POST', + body: JSON.stringify({ + partnerId: values.partnerId, + cityId: values.cityId, + province: values.province, + city: values.city, + name: values.name.trim(), + phone: values.phone.trim(), + district: values.district.trim(), + address: values.address.trim(), + intro: values.intro?.trim() || undefined, + coverUrl: values.coverUrl?.trim() || undefined, + envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, + contractUrl: values.contractUrl?.trim() || undefined, + bankAccountName: values.bankAccountName.trim(), + bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), + bankBranch: values.bankBranch.trim(), + accountPhone: values.accountPhone?.trim() || undefined, + accountName: values.accountName?.trim() || undefined, + }), + }); + message.success('门店已创建'); + closeCreateModal(); + void reload(); + } catch (e) { + if (e && typeof e === 'object' && 'errorFields' in e) { + const fields = e as { errorFields?: Array<{ name: string[] }> }; + const first = fields.errorFields?.[0]?.name?.[0]; + if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') { + setCreateStep(0); + } + return; + } + message.error(e instanceof Error ? e.message : '创建失败'); + } } const columns: ColumnsType = [ @@ -90,7 +250,7 @@ export default function StoresPage() {
门店 - +
{ setFilters(v); setPage(1); }}> @@ -128,16 +288,18 @@ export default function StoresPage() { {String(detail.id)} {String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)} {String(detail.redeemCount ?? 0)} - {detail.coverUrl && ( + {detail.coverUrl ? ( - )} + ) : null} - + + + @@ -145,29 +307,120 @@ export default function StoresPage() { )} - setCreateOpen(false)} onOk={async () => { - const v = await createForm.validateFields(); - await request('/admin/stores', { method: 'POST', body: JSON.stringify(v) }); - message.success('已创建'); - setCreateOpen(false); - createForm.resetFields(); - void reload(); - }}> - - - ({ value: c.id, label: `${c.name} (${c.code})` }))} /> - - - - - - - - - + + {createStep > 0 && } + {createStep < 2 ? ( + + ) : ( + + )} + + )} + > + + {createError && ( + + )} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + preV1 照片上传为选填,可直接下一步(与合伙人端一致)。 + + + + + 环境照片 + + {(fields) => ( +
+ {fields.map((field, index) => ( + + + + ))} +
+ )} +
+ + + +
+
+ + + + + + + + + + +
diff --git a/apps/h5-partner/src/App.tsx b/apps/h5-partner/src/App.tsx index 37ef4df..a549bad 100644 --- a/apps/h5-partner/src/App.tsx +++ b/apps/h5-partner/src/App.tsx @@ -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() { } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/h5-partner/src/lib/leaderboard.ts b/apps/h5-partner/src/lib/leaderboard.ts new file mode 100644 index 0000000..bf9f7c9 --- /dev/null +++ b/apps/h5-partner/src/lib/leaderboard.ts @@ -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 }, +]; diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts new file mode 100644 index 0000000..90bb5ed --- /dev/null +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -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): 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; + 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), + }; + } + 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; +} diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index 06d43c7..a9b726f 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -103,13 +103,34 @@ export default function CenterPage() {
description
- 待确认账单 + 财务对账
{pendingBills.length > 0 && {pendingBills.length}} chevron_right
+ +
+
monitoring
+ 数据周报 +
+ chevron_right + + +
+
assignment_return
+ 补发处理 +
+ chevron_right + + +
+
military_tech
+ 合伙人贡献榜 +
+ chevron_right + diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index 367680f..00ea2ed 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -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) { + const audits = store.audits as Array<{ status?: string }> | undefined; + return String(audits?.[0]?.status ?? '').toUpperCase(); +} + +function storeStatusLabel(store: Record) { + 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 | null>(null); + const [stores, setStores] = useState>>([]); useEffect(() => { if (!isLoggedIn()) { navigate('/login'); return; } - request('PARTNER_H5', '/partner/dashboard').then(setDash); + request>('PARTNER_H5', '/partner/dashboard').then(setDash); + request>>('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> | undefined) ?? []; + const displayStores = stores.length > 0 ? stores : recentFromDash; + const previewLeaderboard = LEADERBOARD_MOCK.slice(0, 2); return (
@@ -87,7 +118,7 @@ export default function HomePage() {
录入新店 - +
assignment_return {pendingShip > 0 && {pendingShip}} @@ -100,7 +131,7 @@ export default function HomePage() {
财务对账 - +
monitoring
@@ -139,17 +170,69 @@ export default function HomePage() {

本月新增签约

- {Math.ceil(storeCount * 0.08)} - - trending_up 18% - + {monthNew}

待审核门店

-

{Math.ceil(storeCount * 0.03)}

+

{pendingStores.length}

+ + {pendingStores.length > 0 && ( +
+

待审核门店名称

+
    + {pendingStores.map((s) => ( +
  • + {String(s.name || '未命名门店')} +
  • + ))} +
+
+ )} + + {displayStores.length > 0 && ( +
    +
  • + 门店名称 + 状态 +
  • + {displayStores.slice(0, 5).map((s) => ( +
  • + {String(s.name || '未命名门店')} + {storeStatusLabel(s)} +
  • + ))} +
+ )} + +
+
+

+ military_tech + 合伙人贡献榜 +

+ 查看全部 +
+ {previewLeaderboard.map((entry) => ( +
+
{entry.rank}
+
+

+ {entry.name} + ({entry.role}) +

+

累计拓店 {entry.totalStores} 间

+
+
+

{entry.monthStores} 间

+

本月新增

+
+
+ ))} +
+

{String(dash?.companyName || '郑州合伙人')} · 辖区管理

diff --git a/apps/h5-partner/src/pages/LeaderboardPage.tsx b/apps/h5-partner/src/pages/LeaderboardPage.tsx new file mode 100644 index 0000000..e2f2885 --- /dev/null +++ b/apps/h5-partner/src/pages/LeaderboardPage.tsx @@ -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 ( +
+ navigate('/')} /> + +

+ 按拓店数排行,数据每周更新 +

+ +
+ {LEADERBOARD_MOCK.map((entry) => ( +
+
{entry.rank}
+
+

+ {entry.name} + ({entry.role}) +

+

累计拓店 {entry.totalStores} 间

+
+
+

{entry.monthStores} 间

+

本月新增

+
+
+ ))} +
+
+ ); +} diff --git a/apps/h5-partner/src/pages/ReshipPage.tsx b/apps/h5-partner/src/pages/ReshipPage.tsx new file mode 100644 index 0000000..d9c86af --- /dev/null +++ b/apps/h5-partner/src/pages/ReshipPage.tsx @@ -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 = { + 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('ALL'); + + const list = MOCK_ITEMS.filter((item) => filter === 'ALL' || item.status === filter); + + return ( +
+ navigate('/')} /> + +

+ 总部下达的补发工单,确认后可推进配送 +

+ +
+ {FILTERS.map((f) => ( + + ))} +
+ + {list.length === 0 &&
暂无补发工单
} + + {list.map((item) => ( +
+
+ 原单 {item.originalOrderNo} + {STATUS_LABEL[item.status]} +
+

{item.productName}

+

补发单号 {item.orderNo}

+
+ location_on +

{item.address}

+
+ {item.status === 'PENDING' && ( + + )} +
+ ))} +
+ ); +} diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index e6c9ec7..9aeb050 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -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(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) { + 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() { + {error && ( +

{error}

+ )} + {step === 1 && ( <>
@@ -72,30 +117,30 @@ export default function StoreCreatePage() {
store - setForm({ ...form, name: e.target.value })} /> + patchForm({ name: e.target.value })} />
call - setForm({ ...form, phone: e.target.value })} /> + patchForm({ phone: e.target.value })} />
location_on - setForm({ ...form, district: e.target.value })} /> + patchForm({ district: e.target.value })} />
-