Compare commits

..

336 Commits

Author SHA1 Message Date
jacy 309b180087 feat(admin): link city-partner store count; fix benefitUsageRule null string
CI / verify (pull_request) Has been cancelled
City partner storeCount jumps to /stores?partnerId=; normalize empty/null text so usage rule is not saved as literal "null.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 16:03:56 +08:00
jacy 118d57d710 feat(admin): delete store staff; city store count links to filtered list
CI / verify (pull_request) Has been cancelled
HQ can remove store sub-accounts from primary account detail; cities table store count jumps to /stores?cityId=.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:43:09 +08:00
jacy 0cb2b2cebb feat(admin): store detail edit with login phone sync; tighten HQ UI
CI / verify (pull_request) Has been cancelled
HQ store update now syncs store_account.phone for shop login; expand editable store fields. System version only for SUPER_ADMIN; permissions checklist layout polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:10:14 +08:00
jacy daeb24bbeb feat(admin): gate user/order/city delete behind assignable HQ permissions
CI / verify (pull_request) Has been cancelled
Add users_delete, orders_delete, cities_delete; default HQ (incl. super admin) has none until explicitly granted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 14:01:18 +08:00
jacy f4766d32ff fix(admin): make partner company/address/district optional on create UI
CI / verify (pull_request) Has been cancelled
Remove required asterisks on CityPartnersPage create form (main entry).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:44:19 +08:00
jacy b920c894b9 feat(admin): city delete with impact preview; optional partner fields
CI / verify (pull_request) Has been cancelled
Allow deleting open cities after listing partners/stores; company name, address, and districts optional on partner create.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:33:12 +08:00
jacy 37038a7591 fix(api): surface LLM network errors for WeCom AI replies
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:59:49 +08:00
jacy 944306e45c fix(admin,partner): replace Tencent iframe picker with server LBS search
CI / verify (pull_request) Has been cancelled
Avoid locpicker formatted_addresses crash; proxy suggest/nearby/reverse via /common/lbs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:56:50 +08:00
jacy 9ed0c24d11 fix(admin,partner): harden Tencent locpicker coords and confirm flow
CI / verify (pull_request) Has been cancelled
Accept latlng-only postMessage, confirm before apply, and read LBS key per request.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:47:08 +08:00
jacy 50349dd8e9 fix(mini-user): center qualification image on translucent mask
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:41:13 +08:00
jacy e2fd28a35b fix(partner): drop store-create SMS verify; keep qualification aspect ratio
CI / verify (pull_request) Has been cancelled
Partner H5 no longer requires store phone SMS on create; qualification overlay uses height auto with widthFix.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:34:31 +08:00
jacy 15c35c49de 分享功能和资质文件展示 2026-07-28 00:31:31 +08:00
jacy a49d8d99b6 fix(partner): drop pickup store and reorder promo on proxy order
CI / verify (pull_request) Has been cancelled
On-site pickup no longer requires a store; move optional promo above fulfillment mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:19:52 +08:00
jacy aa9d689c76 fix(partner): guard null region on proxy order preview
CI / verify (pull_request) Has been cancelled
Avoid crash when province/city is not selected yet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:10:47 +08:00
jacy b59d4484ec feat(partner): complete proxy order with dual SMS and pickup
CI / verify (pull_request) Has been cancelled
Add partner/customer SMS confirm, address auto-receive or on-site
pickup, proxy placer fields, PARTNER_PROXY user source, and C-end badge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:04:59 +08:00
jacy cd23119470 feat(mini-user): enable share on home/benefit and OSS qualification
CI / verify (pull_request) Has been cancelled
Wire WeChat share on home and benefit tabs; serve mine qualification
from OSS; mask phones on admin users and wechat bindings pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:41:29 +08:00
jacy 18ab639669 fix(store): use line comment for benefitUsageRule in Prisma schema
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:25:45 +08:00
jacy fc03905777 feat(store): benefit coupon usage rules and show env photos on store detail
CI / verify (pull_request) Has been cancelled
Add benefitUsageRule on store create/edit (HQ + partner) and C-end detail between intro and env gallery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:25:18 +08:00
jacy ea8152ebf3 fix(deploy): use scp -P for sync-api-env port option
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:20:43 +08:00
jacy 78973e3fbf fix(api): seed empty system settings from env including Tencent LBS key
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:19:54 +08:00
jacy 317f910f3c feat(admin,partner): Tencent map location picker and expose LBS key
Store create/edit on HQ and partner can pick coords via Tencent locpicker; client-config exposes TENCENT_LBS_KEY; seed empty system settings from env.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 23:19:40 +08:00
jacy d36fe13bcd fix(admin,mini-user): store lat/lng edit with Baidu map, center pickup qty hint
CI / verify (pull_request) Has been cancelled
HQ store detail can set coordinates and open Baidu Map search by name/address; admin update API persists lat/lng.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:53:13 +08:00
jacy 15b878f2a4 fix(admin,mini-user): product on-sale tag, store coords inputs, home UX
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 22:40:57 +08:00
jacy 09eda9a11e H5端开店增加获取当前店铺位置 2026-07-27 22:37:15 +08:00
jacy 23c0cc7a5f 小程序优化,商铺定位等 2026-07-27 22:33:00 +08:00
jacy 1725b9e4b4 小程序加上公司资质
CI / verify (pull_request) Has been cancelled
2026-07-26 13:31:21 +08:00
jacy cc9933d1ce feat(admin): require business hours when creating stores
CI / verify (pull_request) Has been cancelled
HQ create-store wizard now collects 1-2 hour segments and avgPrice, and API validates them on create/update.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:19:58 +08:00
jacy b810cabc6d fix(partner): restore clearAllStoreDrafts export
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:18:06 +08:00
jacy 25aee745cc fix(partner): export normalizeStringArray for store detail
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:17:24 +08:00
jacy 097c7dec4b fix(shop): resolve duplicate open binding on home page
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:17:03 +08:00
jacy 92cf51ba3d feat(store): dual business hours, avg price, and status lock
CI / verify (pull_request) Has been cancelled
Support 1-2 hour segments and optional avgPrice; show bank settlement on HQ store detail; enforce pickup min 2 bottles; Chinese order statuses; block shop reopen after permanent close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:16:22 +08:00
jacy 94d2a88581 chore(settlement): add finance bills mock seed script
CI / verify (pull_request) Has been cancelled
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:08:01 +08:00
jacy da21cd3daa 系统判断配送限制 2026-07-26 09:59:38 +08:00
jacy 19cb312465 物流对账单 2026-07-26 09:51:33 +08:00
jacy 2b7ef65cce feat(admin): LLM config, knowledge base, and WeCom AI binding
CI / verify (pull_request) Has been cancelled
Add owner-scoped model API settings, uploadable knowledge bases, and WeCom bot AI/KB wiring with OpenAI-compatible URL normalization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 09:31:22 +08:00
jacy 745c192693 知识库
CI / verify (pull_request) Has been cancelled
2026-07-26 08:37:11 +08:00
jacy 34532dc9bf 微信企业机器人创建 2026-07-26 08:16:56 +08:00
jacy f053908acb 企业知识库搭建
CI / verify (pull_request) Has been cancelled
2026-07-26 07:45:08 +08:00
jacy 959e0b66cb 技术支持工单
CI / verify (pull_request) Has been cancelled
2026-07-26 07:31:49 +08:00
jacy ac1e0a9f13 feat(admin): require store category on HQ store create
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 07:13:56 +08:00
jacy 2a208d5dea 小程序端界面修改
CI / verify (pull_request) Has been cancelled
2026-07-23 15:23:23 +08:00
jacy 82c447b193 工单和发票
酒厂银行账户
2026-07-23 14:51:20 +08:00
jacy 75765bf9d4 后端修改权限和折叠功能
CI / verify (pull_request) Has been cancelled
2026-07-23 14:38:52 +08:00
jacy 6479365d6a 小程序页面优化
CI / verify (pull_request) Has been cancelled
2026-07-23 14:03:22 +08:00
jacy d11757c854 fix(admin): improve promo layout and system settings save UX
CI / verify (pull_request) Has been cancelled
Fix mini home media form persistence; float save with unsaved leave prompt; promo detail QR on the right.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 13:54:10 +08:00
jacy 479de04f7c feat(mini-user): add configurable home banner and footer
CI / verify (pull_request) Has been cancelled
HQ system settings WeChat mini config with OSS swiper/footer uploads; client-config exposes miniHome.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:15:15 +08:00
jacy cebeefdb22 feat(trade): add on-site pickup order flow
CI / verify (pull_request) Has been cancelled
Product flag, ON_SITE_PICKUP delivery, mini confirm/receive pages; pay skips warehouse auto-advance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 01:17:59 +08:00
jacy e93e4a7721 推广码后端页面优化 2026-07-23 00:43:49 +08:00
jacy cd6b82abd8 小程序端核销权益输入框限制
CI / verify (pull_request) Has been cancelled
2026-07-23 00:32:09 +08:00
jacy 047ffd7b16 chore(admin): label promo QR as mini-program code
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:21:55 +08:00
jacy bca1d8afea feat(promo): generate WeChat mini-program codes on create
Use getwxacodeunlimit with activity id as scene, upload PNG to OSS qrcode/, and show in HQ admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 00:21:10 +08:00
jacy 0418370c31 小程序端核销权益券金额输入限制
CI / verify (pull_request) Has been cancelled
现场取货功能
2026-07-23 00:03:21 +08:00
jacy cd206d6baa feat(ops): show redeem records on benefit coupon detail
CI / verify (pull_request) Has been cancelled
Reuse the same coupon redeem trace as order detail (primary/secondary, allocations).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:52:48 +08:00
jacy aeb72154a7 开城合伙人页与城市详情内合伙人 Tab 已同步 2026-07-22 23:43:16 +08:00
jacy e0d3c840a2 feat(ops): show order redeem records including FIFO secondary coupons
CI / verify (pull_request) Has been cancelled
Persist redeem allocations and surface them on HQ order detail with backfill for history.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 23:38:51 +08:00
jacy f2303f7fb8 核销金额:最低 0.01 元;输入最多两位小数;低于该值会 toast 提示,无法生成核销码。
底部提示:左右留白 + 内边距,浅底圆角块,文案改为最低 0.01、余额上限、3 分钟到店出示。
新增地址:进入页会尝试定位,自动选中当前省市区(失败则保留默认郑州);编辑地址不重定位。
2026-07-22 23:11:34 +08:00
jacy fb530e2ff5 城市合伙人县区不做限制 2026-07-22 23:06:51 +08:00
jacy 8c4419e04e fix(admin): city-scoped district pick and clearer overlap errors
CI / verify (pull_request) Has been cancelled
Region partners only select districts under the chosen city; overlap errors name the occupying partner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:14:54 +08:00
jacy 85f2c156b0 fix(admin): prevent false partner commission cap errors
CI / verify (pull_request) Has been cancelled
Coerce city maxPartnerCommissionRate to Number so Decimal JSON strings do not break sum checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 18:03:32 +08:00
jacy addfcfb0b1 小程序页面优化(门店) 2026-07-22 17:56:25 +08:00
jacy b392c28787 feat(settlement): redesign factory/partner/store statement bills
CI / verify (pull_request) Has been cancelled
Add WineryBill/StoreBill tables, partner review-send-confirm flow, daily/monthly cron, and admin multi-select confirm with status filters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 17:25:49 +08:00
jacy b534a569f8 小程序页面优化
CI / verify (pull_request) Has been cancelled
2026-07-22 15:12:19 +08:00
jacy b0f565103f 用户端修改:付款按钮,显示单价,订单的图片 2026-07-22 13:02:52 +08:00
jacy 5e33dd9847 开店第三页银行卡号:只允许数字,最多 19 位;校验仍为 16–19 位。
门店详情 / 开店页:去掉顶栏返回与标题空白,内容上顶,顶部约 12px,与「门店管理」一致。
开店第一页底部:在「下一步」左侧加了「返回」(回门店列表);第 2、3 步仍是「上一步」。
2026-07-22 12:44:12 +08:00
jacy 9a550cbaaf feat(store): add two-level store categories for admin and partner open-store
CI / verify (pull_request) Has been cancelled
Admin CRUD under stores menu; partner picks leaf category on create.
Default sync only inserts missing rows and never updates existing ones.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 12:10:02 +08:00
jacy 823e439101 开门营业成功后提示「开店成功」
关闭门店改为自定义确认框,不再用 window.confirm,不会再带出页面域名
2026-07-22 11:50:05 +08:00
jacy 6c299f1a1d 管理端门店详情(审核抽屉)加上「审核材料」区 2026-07-22 11:45:36 +08:00
jacy 7c9827875f 合伙人端开店的流程里加入开店营业时间,放在开店流程的第一页。 2026-07-22 11:30:25 +08:00
jacy ae7c63c08d feat(h5): add pull-to-refresh on user partner and shop pages
CI / verify (pull_request) Has been cancelled
Share PullToRefresh for partner/shop H5; enable Taro pull-down on mini-user lists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 11:19:42 +08:00
jacy 270cebc79a fix(partner): move store-open phone verify to step 3
CI / verify (pull_request) Has been cancelled
Confirm phone and SMS after bank details, then submit directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 10:22:53 +08:00
jacy ba21cc89c6 Merge commit '56dfffd12647e1128ac4360aa9a4044361f0f7ab' into dev_jacy
CI / verify (pull_request) Has been cancelled
2026-07-22 09:05:51 +08:00
ljy 56dfffd126 fix:增加小程序客服 2026-07-21 22:24:53 +08:00
jacy bdf80e577b fix(mini-user): address review blockers
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:03:02 +08:00
jacy eb96b36d0b feat(redeem): streamline phone redemption confirmation
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:41:06 +08:00
jacy 36bec94639 fix(h5): temporarily disable WeChat camera flows
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:18:51 +08:00
jacy c11c7647b9 fix(h5): serve assets from dedicated domain roots
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:51:23 +08:00
jacy 83ed90ef67 feat(h5-auth): remember SMS sessions and simplify media access
CI / verify (pull_request) Has been cancelled
Hide WeChat login for shop and partner users, keep verified sessions for seven days, and allow JSSDK camera or album access without an OpenID binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:41:17 +08:00
jacy 73dbf6effb chore(deploy): stop tracking auto-release.env; feat(mini-user): avatar profile sheet
CI / verify (pull_request) Has been cancelled
Remove secrets file from the index (keep local). Mine page can choose avatar and nickname via WeChat components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:31:07 +08:00
jacy b8adfe98a3 ignore 2026-07-21 10:19:09 +08:00
jacy 6c6fb10490 fix(deploy): correct APP_ROOT to /opt/dukang
CI / verify (pull_request) Has been cancelled
Server path is /opt/dukang; stale /opt/dukang-haoke broke PM2 cwd for mini-user H5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:10:35 +08:00
jacy 935ab0d1d4 微信小程序审核不通过修改
CI / verify (pull_request) Has been cancelled
2026-07-21 09:32:06 +08:00
jacy 350a086a73 微信小程序端登录和头像调整 2026-07-21 08:59:44 +08:00
jacy c5f526d51d 我的页面中的头像 2026-07-21 08:10:52 +08:00
jacy e1f5130c18 页面顶部空白调整 2026-07-21 08:07:07 +08:00
jacy bc0b0cafd6 微信小程序支付遇到问题,因为没有 WX_MCH_PRIVATE_KEY 2026-07-21 07:59:57 +08:00
jacy ade68972a9 微信小程序端修改config服务器地址配置 2026-07-21 07:35:39 +08:00
jacy 47b39e3ba0 迁移的一些脚本文件 2026-07-21 00:01:50 +08:00
jacy cf73bbae23 迁移的一些脚本文件 2026-07-21 00:01:45 +08:00
jacy d1b207422c fix(api): add TICKET_COLLAB to EventType enum
CI / verify (pull_request) Has been cancelled
Unblock nest build for after-sale ticket collaboration events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 21:41:44 +08:00
jacy 4a29d8297c Merge commit '792b543ba88acda4186bd823461ef1bb76d12f5b' into dev_jacy
CI / verify (pull_request) Has been cancelled
2026-07-20 21:37:55 +08:00
ljy 792b543ba8 fix;提交、 2026-07-19 22:16:55 +08:00
jacy 76270b20bf Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev
CI / verify (pull_request) Has been cancelled
2026-07-17 10:43:19 +08:00
jacy c2914c37e5 fix(partner): grant store staff open/close and media permissions by default
CI / verify (pull_request) Has been cancelled
Default new sub-accounts to store:create+store:manage, backfill empty permissions on /partner/me, and treat legacy store staff as allowed to mutate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:43:15 +08:00
jacy 9f9b7cb2bd merge(dev_jacy): partner store manage and env photo dedupe 2026-07-17 10:36:31 +08:00
jacy de396442a4 feat(partner): let staff manage stores and fix env photo dupes
CI / verify (pull_request) Has been cancelled
Allow store:create/manage sub-accounts to edit, open/close, and re-upload media; dedupe ENV photos on write/read and replace via media API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:36:29 +08:00
jacy 02efe6fa14 merge(dev_jacy): fix login agreement visibility 2026-07-17 10:20:36 +08:00
jacy 14b867a3a5 fix(auth): show user agreement before login actions
CI / verify (pull_request) Has been cancelled
Quick login required agreement without a checkbox; move the consent row above CTAs across partner/shop/user/mini login pages so it stays visible on short screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:20:32 +08:00
jacy f750fea667 merge(dev_jacy): skip unauthorized partner home APIs 2026-07-17 10:13:45 +08:00
jacy 55e91158b3 fix(partner): skip unauthorized home APIs for sub-accounts
CI / verify (pull_request) Has been cancelled
Gate dashboard/stores/orders by permission and move dashboard auth to method level so leaderboard stays open without toasting 403s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:13:43 +08:00
jacy 19cb13d26c merge(dev_jacy): partner sub-account team leaderboard 2026-07-17 10:02:44 +08:00
jacy 04253b9120 feat(partner): let sub-accounts view team leaderboard
CI / verify (pull_request) Has been cancelled
Expose same-primary ranking on home/me for all staff including warehouse roles, and open the leaderboard API without business permission gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:02:42 +08:00
jacy aaf6ee81d5 merge(dev_jacy): fix mini-user production API origin 2026-07-17 09:30:12 +08:00
jacy 972bdde650 fix(mini-user): restore production API origin for H5 builds
CI / verify (pull_request) Has been cancelled
Production builds were baking localhost:3000 after the local-dev default change, causing failed to fetch on product list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 09:30:09 +08:00
jacy c0d66cfe50 merge(dev_jacy): admin订单配送/仓库自动发货/跨城起购与mini-user联调 2026-07-17 09:08:20 +08:00
jacy 00b52b9aaa 用户端跨城订单界面优化
CI / verify (pull_request) Has been cancelled
2026-07-17 09:06:53 +08:00
jacy e9537ce052 后端仓库配送逻辑完善
CI / verify (pull_request) Has been cancelled
2026-07-17 08:57:19 +08:00
jacy c204fb0a92 仓库自动配送开关
CI / verify (pull_request) Has been cancelled
2026-07-17 08:50:18 +08:00
jacy c643b0b979 webadmin端订单修改 2026-07-17 08:45:33 +08:00
jacy ed44e88e93 miniuser服务器连接地址修改
CI / verify (pull_request) Has been cancelled
2026-07-17 08:34:37 +08:00
jacy 57172d54d5 合伙人页面功
CI / verify (pull_request) Has been cancelled
2026-07-17 08:20:31 +08:00
jacy 608e405468 城市合伙人去掉订单补发功能,酒的订单如果没有配置仓库应该是看不到的
CI / verify (pull_request) Has been cancelled
2026-07-17 08:04:53 +08:00
jacy b36f2ea63a 城市合伙人H5端结算审核 2026-07-17 07:56:43 +08:00
jacy 7fa2c6fbed 城市合伙人端页面优化
CI / verify (pull_request) Has been cancelled
2026-07-17 07:45:20 +08:00
jacy bde85a7b82 仓库管理配置 小飞侠配置
CI / verify (pull_request) Has been cancelled
2026-07-16 21:40:25 +08:00
jacy b4ec9387a0 merge(dev_jacy): 仓配可调配履约与三分支推单 2026-07-16 17:51:32 +08:00
jacy f6d97b4ee8 feat(trade): 仓配可调配履约与三分支推单
CI / verify (pull_request) Has been cancelled
同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:51:20 +08:00
jacy f1dc23c54c 合伙人 H5 改造
CI / verify (pull_request) Has been cancelled
2026-07-16 15:04:56 +08:00
jacy 9e6dc8c85c merge(dev_jacy): partner UX, shop wechat bind, remove H5 title whitespace 2026-07-16 11:40:55 +08:00
jacy fd5427c7ea 页面头部的白色留白去掉
CI / verify (pull_request) Has been cancelled
2026-07-16 11:38:44 +08:00
jacy 4de570f5f4 合伙人端的子账号管理页面优化
CI / verify (pull_request) Has been cancelled
2026-07-16 11:24:48 +08:00
jacy 22845e5d5f 门店端增加绑定微信按钮
CI / verify (pull_request) Has been cancelled
2026-07-16 08:30:04 +08:00
jacy 8d7507e2d8 杜康发布 skills 2026-07-16 08:28:46 +08:00
jacy 48995aace4 merge(dev_jacy): hide H5 in-page titles vs WeChat chrome 2026-07-16 08:05:46 +08:00
jacy 5a26841d3a fix(h5): hide in-page nav titles that duplicate WeChat chrome
CI / verify (pull_request) Has been cancelled
Keep back buttons and header layout. mini-user only hides titles on H5; partner/shop hide .app-page-title and legal titles; shared PageHeader defaults showTitle=false.
2026-07-16 08:05:44 +08:00
jacy 92ad0e16cf merge(dev_jacy): fix order detail receiver address display 2026-07-15 21:36:47 +08:00
jacy 7b14f4a0cc fix(mini-user): show real receiver fields on order detail
CI / verify (pull_request) Has been cancelled
Order detail used non-existent addressSnapshot; API returns receiverName/phone/address. Render those and fix product qty from items/quantity.
2026-07-15 21:36:44 +08:00
jacy d69bfa359a merge(dev_jacy): fix account merge WeChat transfer and phone prompt 2026-07-15 21:25:45 +08:00
jacy d3d56150c5 fix(auth): transfer WeChat id on merge; stop false phone prompts
CI / verify (pull_request) Has been cancelled
Merge used to drop wxOpenId so OAuth created a new guest without phone every time. Now migrate WeChat identity, recover legacy merged openIds, return accountMerged and force page reload, and only soft-prompt when phone is truly unbound.
2026-07-15 21:25:34 +08:00
jacy 6a5ddcc70a merge(dev_jacy): fix phone bind session bounce and mini-user UX 2026-07-15 21:11:22 +08:00
jacy 5c55d3c385 fix(mini-user): persist bind-phone session and stop login bounce
CI / verify (pull_request) Has been cancelled
Phone bind discarded the new JWT after account merge, so the next page 401'd back to login. Also harden 401 race, location deny cache, unpaid repay CTA, and finishLoginNavigate.
2026-07-15 21:10:42 +08:00
jacy b30adfea8e merge(dev_jacy): fix H5 pay appid/openid and login input align 2026-07-15 20:55:13 +08:00
jacy 02aecbeaab fix(mini-user): H5 pay use USER_H5; center login phone input
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:55:09 +08:00
jacy 734a15c9ce merge(dev_jacy): mini-user address/share/input UX fixes 2026-07-15 20:48:36 +08:00
jacy 9a3ccabcc9 fix(mini-user): address textarea freeze, input align, wechat share invoke
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:48:33 +08:00
jacy fd62b5bdbe merge(dev_jacy): UX fixes for address, checkbox, scan resume, tabbar 2026-07-15 20:37:43 +08:00
jacy 966ddc9219 fix(mini-user): address URL, form align, agreement checkbox, shop scan resume
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:37:40 +08:00
jacy 52a809649c 用户端mini-user 多了一层tab 2026-07-15 20:31:09 +08:00
jacy e307550ead merge(dev_jacy): fix mini-user WeChat OAuth clientApp mismatch 2026-07-15 20:22:40 +08:00
jacy 372c29bc3a fix(mini-user): use USER_MINI for WeChat SDK so OAuth token persists
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:22:37 +08:00
jacy 9c45011bd4 merge(dev_jacy): fix WeChat login create type for deploy 2026-07-15 20:12:39 +08:00
jacy acc5b78471 fix(api): type WeChat-only user create for nest build
CI / verify (pull_request) Has been cancelled
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 20:12:18 +08:00
jacy d70f2d8f62 Merge pull request 'Dev jacy' (#8) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/8
2026-07-15 20:04:10 +08:00
jacy 5fe1ff1fc3 mock短信页面回显
CI / verify (pull_request) Has been cancelled
2026-07-15 20:03:06 +08:00
jacy 0f5f0f4c2b 用户端不要强制用户输入手机号 2026-07-15 19:57:06 +08:00
jacy f15d2057f2 登录页的登录按钮看不见 2026-07-15 19:51:57 +08:00
jacy 68aa7d3ce2 用户协议默认不勾选 2026-07-15 19:50:09 +08:00
jacy f6c77e60b6 merge(dev_jacy): fix mini-user H5 useDidShow blank tab crash 2026-07-15 19:34:00 +08:00
jacy 5a2e8dfcf8 fix(mini-user): dedupe Taro reactMeta so H5 useDidShow works
CI / verify (pull_request) Has been cancelled
Vite was bundling plugin-framework-react runtime twice, so tab pages crashed with useContext is not a function on enter/switch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 19:33:51 +08:00
jacy 2d5ecd1263 merge(dev_jacy): blank-page deploy fix + recent features 2026-07-15 18:57:57 +08:00
jacy 7062aeb8f8 fix(deploy): prevent mini-user H5 blank page under /user basename
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 18:57:33 +08:00
jacy 82435634d7 Merge pull request 'Dev jacy 37' (#7) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/7
2026-07-15 18:28:08 +08:00
jacy e7d39f7390 门店端子账号管理界面调整
CI / verify (pull_request) Has been cancelled
2026-07-15 18:01:19 +08:00
jacy d6ac4b5e1c 用户端mini-user界面优化 2026-07-15 17:33:46 +08:00
jacy 740bb19489 Merge pull request 'Dev jacy # 36' (#6) from dev_jacy into dev
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/6
2026-07-14 20:13:23 +08:00
jacy 1ad49f1acf h5微信授权
CI / verify (pull_request) Has been cancelled
2026-07-14 20:09:36 +08:00
jacy 9b13d02dde 微信分享功能 2026-07-14 17:48:07 +08:00
jacy 5222ec82b0 调整商品详情页面的分享按钮位置 2026-07-14 17:35:16 +08:00
jacy 9403d22544 修改user端的项目文件到mini-user
CI / verify (pull_request) Has been cancelled
2026-07-14 15:52:27 +08:00
jacy e0f5977dd9 门店审核功能 2026-07-14 15:25:17 +08:00
jacy d61a43cc8c webadmin增加财务模块 2026-07-14 15:16:41 +08:00
jacy 450dc44d0e 微信小程序支付功能打通 2026-07-13 00:07:17 +08:00
jacy f2cdc0222d 小程序二维码功能 2026-07-12 23:59:52 +08:00
jacy 1bd152073a webadmin系统设置 2026-07-12 22:52:40 +08:00
jacy 236a2a87a5 合伙人端验证是否已经绑定手机号,否则不支持微信一键登录 2026-07-12 21:18:10 +08:00
jacy b5235dedef 后端增加发放好客权益券的接口 2026-07-12 20:28:43 +08:00
jacy b267e885be 用户端小程序修改 2026-07-12 20:24:03 +08:00
jacy e2dfb08de3 小程序修改 2026-07-12 19:44:36 +08:00
jacy 3b4833b51a 开合伙人子账号要验证手机号 2026-07-12 17:53:54 +08:00
jacy 908a5dcf0f 开店需要验证手机号 2026-07-12 17:51:20 +08:00
jacy b550ee2255 OSS日志模块删掉 2026-07-12 17:46:39 +08:00
jacy d8c1ac6a9e 选图成功上传bug修复 2026-07-12 17:39:46 +08:00
jacy b7db202632 解决合伙人端上传图片循环调用接口的问题 2026-07-12 17:29:50 +08:00
jacy d3ef07bf65 短信验证码 2026-07-12 17:20:41 +08:00
jacy 076455944e fix(partner): 修正微信选图 JSSDK 签名 URL 含 query 参数
录店页 ?step= 变化时重新 wx.config,微信内上传仅走 chooseImage 接口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:24:42 +08:00
jacy 656e906254 微信sdk更新,修正上传门头照照片错误 2026-07-12 16:10:11 +08:00
jacy e8823a0640 oss bucket 目前只用dukang-dev 2026-07-12 15:57:52 +08:00
jacy 2c04676555 修复合伙人端上传门头照片失败的问题 2026-07-12 15:55:49 +08:00
jacy 9fc955d81f webadmin增加oss日志 2026-07-12 15:44:17 +08:00
jacy 758760eb36 微信小程序登录页面调整 2026-07-12 15:30:58 +08:00
jacy bfac6c6663 登录页面 2026-07-12 15:17:54 +08:00
jacy becf91da52 微信小程序上传 2026-07-12 14:45:57 +08:00
jacy d271ac5b13 webadmin端增加版本号和部署功能 2026-07-12 13:52:22 +08:00
jacy 63af3321dd feat(admin): HQ手机号编辑、合伙人子账号日志与子账号H5体验
支持 HQ 账户改手机号(格式校验)、合伙人日志包含子账号且子账号不显示主账号信息;改手机号时清除微信绑定。合伙人 H5 子账号页签与我的页、录入门店底部按钮及上传失败提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 13:29:09 +08:00
jacy b8623f59f0 上传ignore 2026-07-12 13:19:12 +08:00
jacy 54a15d6da7 门店账户多账号 2026-07-12 12:24:34 +08:00
jacy 06b1cb22e0 弱网核销功能 2026-07-12 12:00:55 +08:00
jacy de01d36cdb 代下单功能 2026-07-12 11:40:04 +08:00
jacy d949301bc1 手机号核销确认 2026-07-12 10:34:58 +08:00
jacy 7f031cc4c2 城市合伙人端的修改(后台) 2026-07-12 10:19:39 +08:00
jacy d0f0fa09af 自动修改 2026-07-12 07:50:27 +08:00
jacy b74e4e4fe5 PRD对照 2026-07-12 07:29:14 +08:00
jacy 69d27c80be v3.0版本skills和agent 2026-07-11 11:59:57 +08:00
jacy 420f1b66c3 修正文档需求 2026-07-11 11:48:45 +08:00
jacy 1761f7b3bf 城市合伙人端开店城市屁默认郑州 2026-07-10 13:14:46 +08:00
jacy 363717e441 Merge branch 'dev_jacy' into dev (deploy webhook fixes) 2026-07-10 12:45:05 +08:00
jacy ffd9180315 fix(deploy): improve webhook auto-release reliability and deploy.sh defaults
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 12:44:49 +08:00
jacy 6088c139d3 Merge #23 into dev from dev_jacy
fix(h5-partner): resolve merge conflicts for auth and sub-accounts

* dev_jacy: (5 commits)
  webadmin增加显示子账号显示列表
  主账号和子账号同一设备登录遇到storge问题
  子账号登录微信授权
  用户端“补发货”的bug修复
  fix(h5-partner): resolve merge conflicts for auth and sub-accounts

Signed-off-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/23
2026-07-10 11:28:53 +08:00
jacy 47f540fbd9 fix(h5-partner): resolve merge conflicts for auth and sub-accounts
Merge partner session model (AuthGate, ensureSession, WeChat OAuth in Context) with sub-account routing, admin partner-account tree CRUD, upload lock, and partner staff flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 11:24:48 +08:00
jacy 7988a10241 用户端“补发货”的bug修复 2026-07-10 11:10:04 +08:00
jacy 9c5cb69730 子账号登录微信授权 2026-07-10 11:03:11 +08:00
jacy f4f4f1b4aa 主账号和子账号同一设备登录遇到storge问题 2026-07-10 10:54:04 +08:00
jacy 2368121972 webadmin增加显示子账号显示列表
修复api服务端报错
2026-07-10 10:36:01 +08:00
ljy 41e66774ef Merge branch 'dev_new_ljy' into dev 2026-07-10 01:54:39 +08:00
ljy d66fd9099f fix:登录跳转失败问题 2026-07-10 01:54:23 +08:00
ljy 58d58f35c4 fix;提交 2026-07-10 01:06:19 +08:00
ljy 47129a9340 Merge branch 'dev_new_ljy' into dev 2026-07-10 00:47:08 +08:00
ljy e770c98de7 fix:创建子账号不发短信 2026-07-10 00:46:55 +08:00
ljy 9b4e37e513 Merge remote-tracking branch 'origin/dev_new_ljy' into dev 2026-07-10 00:22:48 +08:00
ljy 301839e2dc fix:提交子账号 2026-07-10 00:22:25 +08:00
ljy fec7f10f24 Merge branch 'dev_new_ljy' into dev 2026-07-09 23:53:20 +08:00
ljy b32e2a06eb fix;提交代码子账号 2026-07-09 23:52:00 +08:00
jacy d74e5b04ed Merge #22 into dev from dev_jacy
Merge commit &#39;336116fbf21cbb33ddbaf00685f93b18ea84d5d6&#39; into dev_jacy

* dev_jacy: (8 commits)
  微信分享
  合伙人端开店选照片
  首页定位问题
  确定订单页修改
  右上角位置信息仅展示不做交互
  临时闭店UI修改
  门店端微信号自动登录
  Merge commit &#39;336116fbf21cbb33ddbaf00685f93b18ea84d5d6&#39; into dev_jacy

Signed-off-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/22
2026-07-09 22:09:22 +08:00
jacy b1d5b3214f Merge commit '336116fbf21cbb33ddbaf00685f93b18ea84d5d6' into dev_jacy 2026-07-09 22:08:02 +08:00
jacy 12ba19ede4 门店端微信号自动登录 2026-07-09 22:07:35 +08:00
jacy e20ee1ec7f 临时闭店UI修改 2026-07-09 22:03:43 +08:00
jacy f76eb81274 右上角位置信息仅展示不做交互 2026-07-09 21:57:40 +08:00
jacy 4b3452eca0 确定订单页修改 2026-07-09 21:52:17 +08:00
ljy 336116fbf2 fix;合并修复问题 2026-07-09 21:42:36 +08:00
jacy 5e96008ac4 首页定位问题 2026-07-09 21:12:28 +08:00
jacy b506417f5e 合伙人端开店选照片 2026-07-09 17:26:24 +08:00
jacy 3f60c37265 微信分享 2026-07-09 17:14:00 +08:00
jacy 3d3588babb fix(weixin-sdk): fix iOS scanQRCode false camera permission error
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 16:49:01 +08:00
jacy d8c240c839 Merge commit 'ab9654564e2457db6d0c0972b1818b7b2061ac86' into dev_jacy 2026-07-09 15:40:39 +08:00
ljy ab9654564e fix:提交修复6个问题 2026-07-08 23:01:18 +08:00
jacy 99be8e0237 merge dev_jacy: fix with-api-env.cjs CommonJS 2026-07-07 23:35:03 +08:00
jacy d5865aeb5e fix(api): use CommonJS in with-api-env.cjs for Prisma deploy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:34:55 +08:00
jacy dec839f79e merge(dev_jacy): server uses .env.production only 2026-07-07 23:33:44 +08:00
jacy 6dc051f26d chore(api): use .env.production on server, .env for local dev only
Load production env without .env override; sync-api-env targets .env.production; PM2 NODE_ENV=production; Prisma uses with-api-env.cjs on deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 23:33:34 +08:00
jacy c6af8480a4 runxian -dukang ssl证书配置 2026-07-07 22:53:18 +08:00
jacy 8598704a49 改回 user.runxian.top 2026-07-07 22:45:49 +08:00
jacy baf5fd00b4 merge(dev_jacy): add m.runxian.top deploy script 2026-07-07 22:34:07 +08:00
jacy 7f439c5bea chore(deploy): add remote script for m.runxian.top rollout
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:34:03 +08:00
jacy d46ae9eed4 merge(dev_jacy): unify H5 under m.runxian.top for WeChat OAuth 2026-07-07 22:08:24 +08:00
jacy eb961c3a74 feat(h5): unify WeChat OAuth under m.runxian.top path routing
Serve user/shop/partner H5 under /user/, /shop/, /partner/ on a single authorized domain; update nginx, SSL script, and router base paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 22:07:47 +08:00
jacy e32ccebc80 本地环境不验证手机号和微信登录授权 2026-07-07 21:42:56 +08:00
jacy ec748335bf 环境变量修改 2026-07-07 20:51:44 +08:00
jacy c8dfa50f24 隐藏ID的显示 2026-07-07 20:51:35 +08:00
jacy c9202099cf Merge #20 into dev from dev_jacy
管理后台左侧列表调整

* dev_jacy: (3 commits)
  webadmin去掉新建门店的短信验证
  web端增加微信绑定管理
  管理后台左侧列表调整

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/20
2026-07-07 18:50:52 +08:00
jacy 1ac72e9a69 管理后台左侧列表调整 2026-07-07 18:49:47 +08:00
jacy 98e9652865 web端增加微信绑定管理 2026-07-07 18:25:15 +08:00
jacy 887d728496 webadmin去掉新建门店的短信验证 2026-07-07 18:17:45 +08:00
jacy d31782c73a Merge #19 into dev from dev_jacy
商铺端核销,调用相机报无效签名修复

* dev_jacy: (3 commits)
  微信登录日志
  后端webadmin调整显示布局
  商铺端核销,调用相机报无效签名修复

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/19
2026-07-07 18:01:12 +08:00
jacy 67eed11276 商铺端核销,调用相机报无效签名修复 2026-07-07 17:55:12 +08:00
jacy 5fef6ae2b1 后端webadmin调整显示布局 2026-07-07 17:43:01 +08:00
jacy 7e3c0011bb 微信登录日志 2026-07-07 17:17:03 +08:00
jacy 47686d3067 Merge #18 into dev from dev_jacy
合伙人端登录接口优化

* dev_jacy: (11 commits)
  feat;提交管理端和城市合伙人端
  fix:修改样式
  fix:完善门店和总部管理端
  配置服务器
  核销成功提示
  webadmin端增加商铺日志
  Merge commit &#39;fcf1d45522e3ba6e6faf2590b60f2db1ef90e73c&#39; into dev_jacy
  api端报错修正
  合伙人端登录验证调整还有日志落地
  合伙人端登录页面调整
  合伙人端登录接口优化

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/18
2026-07-07 15:26:19 +08:00
jacy 2c74f6a08c 合伙人端登录接口优化 2026-07-07 15:22:55 +08:00
jacy 7dd8a6662b 合伙人端登录页面调整 2026-07-07 14:48:49 +08:00
jacy 2e61caa49c 合伙人端登录验证调整还有日志落地 2026-07-07 14:34:02 +08:00
jacy af989c9d33 api端报错修正 2026-07-07 14:03:07 +08:00
jacy d9e72ab227 Merge commit 'fcf1d45522e3ba6e6faf2590b60f2db1ef90e73c' into dev_jacy 2026-07-07 13:55:10 +08:00
jacy 6572799264 webadmin端增加商铺日志 2026-07-07 12:12:12 +08:00
jacy e25b208545 核销成功提示 2026-07-07 11:46:12 +08:00
jacy 1dcc9aa1f4 配置服务器 2026-07-07 11:43:03 +08:00
jacy fb431a92d4 Merge #17 into dev from dev_jacy
fix(h5-shop): remove redeem debug UI and enforce WeChat scan flow

* dev_jacy: (5 commits)
  门店核销功能测试
  门店账号管理修改
  后端显示门店账单
  门店页面修改
  fix(h5-shop): remove redeem debug UI and enforce WeChat scan flow

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/17
2026-07-07 10:52:06 +08:00
jacy 98022b936f fix(h5-shop): remove redeem debug UI and enforce WeChat scan flow
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 00:19:11 +08:00
jacy 5d19089f91 门店页面修改 2026-07-07 00:18:29 +08:00
jacy 1fcafac2a7 后端显示门店账单 2026-07-07 00:04:07 +08:00
jacy 7ca9fc8a35 门店账号管理修改 2026-07-07 00:01:16 +08:00
jacy e85c4b9bcb 门店核销功能测试 2026-07-06 23:40:12 +08:00
jacy 2ac10adcff Merge #16 into dev from dev_jacy
超管用户登录;

* dev_jacy: (4 commits)
  小飞侠的日志
  核销代码
  核销功能
  超管用户登录;

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/16
2026-07-06 22:35:16 +08:00
jacy a66207ffa9 超管用户登录;
代码核销功能
2026-07-06 22:33:30 +08:00
jacy d708a10095 核销功能 2026-07-06 22:22:21 +08:00
jacy 386ee9e754 核销代码 2026-07-06 22:10:45 +08:00
jacy 047cf879f8 小飞侠的日志 2026-07-06 21:36:20 +08:00
jacy b2a3b56f46 Merge #15 into dev from dev_jacy
三房日志详情补充

* dev_jacy: (1 commits)
  三房日志详情补充

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/15
2026-07-06 21:12:43 +08:00
jacy d5fdf626fe 三房日志详情补充 2026-07-06 21:11:23 +08:00
ljy fcf1d45522 fix:完善门店和总部管理端 2026-07-06 20:58:38 +08:00
jacy 96a137ccb2 Merge #14 into dev from dev_jacy
微信授权逻辑修改

* dev_jacy: (1 commits)
  微信授权逻辑修改

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/14
2026-07-06 20:57:40 +08:00
jacy 9d221cfcd2 微信授权逻辑修改 2026-07-06 20:56:25 +08:00
jacy 257a42c00a Merge #13 into dev from dev_jacy
部署文件上传,修改环境变量为development

* dev_jacy: (1 commits)
  部署文件上传,修改环境变量为development

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/13
2026-07-06 20:16:59 +08:00
jacy b12ee13232 部署文件上传,修改环境变量为development 2026-07-06 20:15:41 +08:00
jacy 3fdd41ca85 Merge #12 into dev from dev_jacy
webadmin界面调整

* dev_jacy: (2 commits)
  增加核销接口
  webadmin界面调整

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/12
2026-07-06 19:36:35 +08:00
jacy 04b0559fb8 webadmin界面调整 2026-07-06 19:35:15 +08:00
jacy 4c38a9dd2b 增加核销接口
增加hq管理端日志
2026-07-06 19:17:27 +08:00
jacy c7cbc770c6 Merge #11 into dev from dev_jacy
webadmin端优化

* dev_jacy: (3 commits)
  fix(deploy): harden webhook auto-release for CRLF, git reset, and prisma order
  小飞侠接口调通测试
  webadmin端

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/11
2026-07-06 18:05:33 +08:00
jacy f0b99ea9da webadmin端
批量删除用户
模板上传图片数量限制改成30
小飞侠接口配置
2026-07-06 18:04:02 +08:00
jacy 4e18763968 小飞侠接口调通测试 2026-07-06 17:07:35 +08:00
jacy 3625f037d0 fix(deploy): harden webhook auto-release for CRLF, git reset, and prisma order
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 17:01:05 +08:00
jacy 353e5d18f8 Merge #10 into dev from dev_jacy 测试webhook
配置 webhook secret

* dev_jacy: (1 commits)
  配置 webhook secret

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/10
2026-07-06 16:42:37 +08:00
jacy 5583442ee7 配置 webhook secret 2026-07-06 16:38:51 +08:00
jacy 56952920ba Merge #9 into dev from dev_jacy
商品删除功能(需要二次确认)

* dev_jacy: (1 commits)
  商品删除功能(需要二次确认)

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/9
2026-07-06 16:36:14 +08:00
jacy da80b28ba4 商品删除功能(需要二次确认) 2026-07-06 16:34:54 +08:00
jacy 61043c8a69 Merge #8 into dev from dev_jacy
建立推送机制 webhook

* dev_jacy: (1 commits)
  建立推送机制 webhook

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/8
2026-07-06 16:26:41 +08:00
jacy 8fc142b7c8 建立推送机制 webhook 2026-07-06 16:25:18 +08:00
jacy ed1217ccdb Merge #7 into dev from dev_jacy
商品模板功能上传

* dev_jacy: (1 commits)
  商品模板功能上传

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/7
2026-07-06 15:33:36 +08:00
jacy f46904b47c 商品模板功能上传 2026-07-06 15:29:27 +08:00
jacy 0a109a0e0e Merge #6 into dev from dev_jacy
小飞侠、短信验证还有位置服务等

* dev_jacy: (4 commits)
  短信验证调试成功
  短信验证绑定日志内容
  定位获取城市功能,需要微信地图的key?
  小飞侠接口

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/6
2026-07-06 14:16:10 +08:00
jacy 80d967f2cf 小飞侠接口 2026-07-06 14:12:06 +08:00
jacy c87d79109a 定位获取城市功能,需要微信地图的key? 2026-07-06 14:02:07 +08:00
jacy 67af6d7d53 短信验证绑定日志内容 2026-07-06 13:26:31 +08:00
jacy 1c978b8adc 短信验证调试成功 2026-07-06 13:18:56 +08:00
jacy 77186f75f5 Merge #5 into dev from dev_jacy
发布商品,商品图片使用oss服务器地址

* dev_jacy: (4 commits)
  小飞侠接口对接
  v3计划内容上传
  Merge commit &#39;ed1845c0ba98c2e1d6d80d5bf4eec2b46cdf0952&#39; into dev_jacy(xiaofeixia )
  发布商品,商品图片使用oss服务器地址

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/5
2026-07-06 08:57:46 +08:00
jacy 5ba69eb935 发布商品,商品图片使用oss服务器地址 2026-07-06 08:53:03 +08:00
ljy 67b5a580c5 fix:修改样式 2026-07-05 23:52:58 +08:00
ljy 569becedaa feat;提交管理端和城市合伙人端 2026-07-05 23:48:20 +08:00
jacy a0466f023e Merge commit 'ed1845c0ba98c2e1d6d80d5bf4eec2b46cdf0952' into dev_jacy(xiaofeixia ) 2026-07-04 20:49:51 +08:00
jacy 743d242d52 v3计划内容上传 2026-07-04 20:36:26 +08:00
jacy fecfc61ec5 Merge dev_jacy: fix WeChat pay invoke 2026-07-03 16:04:09 +08:00
jacy def3ed5175 fix(user): correct WeChat JSAPI pay invoke params and JSSDK init
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 16:04:01 +08:00
jacy ced1fe0688 Merge dev_jacy: partner WeChat auth for upload 2026-07-03 15:45:31 +08:00
jacy cabc9e6ebb feat(partner): require WeChat OAuth before store photo upload
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:44:46 +08:00
jacy f87325df21 Merge dev_jacy: WeChat store upload fix 2026-07-03 15:33:08 +08:00
jacy 81413e4d08 fix(partner): resolve WeChat image picker auth denied on store create
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 15:32:53 +08:00
jacy 30eb8ff8a6 提交express 2026-07-03 15:07:27 +08:00
jacy 0829696ea9 Merge #3 into dev from dev_jacy
门店开通流程

* dev_jacy: (5 commits)
  上传部署环境文件
  上传校验文件
  微信验证接通
  微信校验后登录服务器错误bug
  门店开通流程

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/3
2026-07-03 14:56:07 +08:00
jacy f2175b83f7 门店开通流程 2026-07-03 14:53:21 +08:00
jacy c7dc6a2274 微信校验后登录服务器错误bug 2026-07-03 13:22:41 +08:00
jacy cc39975117 微信验证接通 2026-07-03 12:57:01 +08:00
jacy 160fdcb0d7 上传校验文件 2026-07-03 09:58:40 +08:00
jacy ceaf45ef03 上传部署环境文件 2026-07-03 00:58:56 +08:00
jacy 02681bcc4a Merge #1 into dev from dev_jacy
支付时检测是否已经微信授权

* dev_jacy: (15 commits)
  v3数据表更改
  fix:更新skill和rules
  fix:需求完善
  agents 和 skills 上传
  fix:提交修改信息
  Merge commit &#39;a0ee3a21cd01229a555384ad5e1f4b3ccdcdd620&#39; into dev_jacy
  微信SDK接通
  oss1
  web端打通oss资源上传
  admin web 修改订单状态
  手机号从地址栏里获取
  对接微信支付
  oss接通了80端口,还没有接通https,先用
  微信支付
  支付时检测是否已经微信授权

Signed-off-by: jacy <moonjie444@163.com>
Reviewed-by: jacy <moonjie444@163.com>
Merged-by: jacy <moonjie444@163.com>

CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/1
2026-07-02 17:56:36 +08:00
jacy f7fe4597c6 支付时检测是否已经微信授权 2026-07-02 17:34:58 +08:00
jacy a0f4d8c18f 微信支付 2026-07-02 17:23:13 +08:00
jacy 4b2589156c oss接通了80端口,还没有接通https,先用 2026-07-02 16:13:45 +08:00
jacy ff4622ff6c 对接微信支付 2026-07-01 22:41:49 +08:00
jacy b78ef8798c 手机号从地址栏里获取 2026-07-01 22:13:25 +08:00
jacy 8a67d72de4 admin web 修改订单状态 2026-07-01 22:00:10 +08:00
jacy 139e37651b web端打通oss资源上传 2026-07-01 21:46:36 +08:00
jacy ef6cc18fb2 oss1 2026-07-01 20:21:51 +08:00
jacy aea1513836 微信SDK接通 2026-07-01 19:56:44 +08:00
jacy a1cbfc7241 Merge commit 'a0ee3a21cd01229a555384ad5e1f4b3ccdcdd620' into dev_jacy 2026-07-01 19:38:05 +08:00
ljy a0ee3a21cd fix:提交修改信息 2026-07-01 17:58:01 +08:00
jacy 9cbd65f8d7 agents 和 skills 上传 2026-07-01 17:12:46 +08:00
ljy d6855c9f5c fix:需求完善 2026-07-01 16:24:18 +08:00
ljy f3ae9e6520 fix:更新skill和rules 2026-07-01 15:04:53 +08:00
jacy aeb4ecfc84 v3数据表更改 2026-07-01 14:36:50 +08:00
jacy 638898b71e 改动测试 2026-07-01 10:31:52 +08:00
jacy 6fa8ecc714 Merge branch 'hq_web' into dev_jacy 2026-07-01 08:29:00 +08:00
jacy 25f0d8e97b hqweb端 2026-07-01 08:27:26 +08:00
jacy 9f4577d3d8 用户静默注册 2026-07-01 02:48:59 +08:00
jacy d3dd3a0ad1 用户端和服务端增加地址功能(三个地址共存) 2026-07-01 00:52:44 +08:00
jacy eb85e0926f 删除用户头像吓的badge 2026-07-01 00:33:01 +08:00
jacy 1800e792b1 订单详情页内容调整(增加配送方式、订单号等) 2026-07-01 00:29:39 +08:00
jacy c43defca3f 用户端调整
2.首页商品详情增加点击区域
3.浓香型 酱香型 点击增加提示:暂未开放
5.首页餐券2字更换为“好客权益”
6.门店页面地址筛选功能 (三级菜单,省市区)
7.好客权益:去使用点击交互调整为核销页面,单张好客权益点击核销页面带参数和金额
7.核销金额限制为可用余额最大数,不是500
9.收货地址电话号码增加校验,长度限制,地址或者未填提示,地区调整为省市区三级菜单
10.微信授权位置调整在手机验证码下侧
2026-07-01 00:19:08 +08:00
jacy ed1845c0ba 小飞侠接口对接 2026-06-30 23:44:31 +08:00
762 changed files with 113437 additions and 3251 deletions
+64
View File
@@ -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
+54
View File
@@ -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 map2 人团队)
| 逻辑域 | Git 账号 | Apps | Modules |
|--------|----------|------|---------|
| 主责 | jacy-dukang | h5-user, admin-web, 门店 | iam, trade, benefit, analytics, catalog, settlement, ops |
| 合伙人 | 刘京尧 | h5-partner, h5-shop | store, redeem |
| 横切 | jacy-dukang | — | packages, callbacks, jobs, common, integrations |
逻辑 A/B/C/D 边界仍有效;刘京尧 同时负责 B+D,但 **store 与 redeem 模块仍不可互写表**
## Severity
- **P0 — Must block merge**: cross-module Prisma write; apps import server; secrets committed; auth bypass
- **P1 — Fix before merge**: wrong module dependency; missing shared-types sync; Mock in wrong layer
- **P2 — Suggestion**: scope creep into another owner's app; missing domain test for rule change
## Report format
For each finding:
```
[Px] path:line — issue — fix (which OWNER should do it)
```
End with:
- **OWNER impact**: who must act
- **Safe to merge?** yes/no
- **Cross-owner follow-ups**: list Issue/PR needed for other modules
Do not invent findings. If boundaries are clean, say so plainly.
+55
View File
@@ -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 (V3.0 PRD): 待付款 | 已付款 | 已完成
## Session start
1. Read task card if given → `dukang-task-card` skillV3.0 → `@dukang-v3` + `杜康好客-v3-PRD.md`
2. Confirm changes stay in Owner A paths
3. UI: `pages/user/` + `pages/ROUTE_MAP.md`
4. API/DB: v3-PRD §6 + V2 手册 §五/§六
## Before finishing
- No direct Prisma writes to store/redeem/settlement tables
- Sync shared-types for new DTOs/enums
- Run relevant lint/test for touched packages
+48
View File
@@ -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
+49
View File
@@ -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 / V3.0 scope note
- admin-web replaces HQ **H5** for dev/demo; V3.0 PRD targets 总部 H5 feature parity
- V3.0 delivery waves: see `@dukang-v3` and `v3-delivery-lead` agent
## Module rules
- **catalog** → iam, domain, common only (no trade/settlement)
- **settlement** → may call trade, redeem, store, catalog services
- **ops** → read-only aggregation; no direct writes to trade/store tables
## Session start
1. V3.0 rules: `杜康好客-v3-PRD.md` §3.3 结算/佣金;Wave 2+ for 提现
2. Check preV1 §6 for what's Seed vs implemented UI
3. Product/city rules: v3-PRD + V2 manual §二 §6
4. Settlement: T+1 store / T+30 partnerFIN-001~003 in Wave 2
## Before finishing
- Admin API uses `HqAuthGuard` / AdminAuth
- No C-end or shop/partner app changes
- Schema changes to common_city / common_product_item → Lead + C Review
+53
View File
@@ -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 **180s**V3.0 PRD 3 分钟)
- Amount: direct redeem `0 < amount ≤ total ACTIVE benefit balance`; document redeem `0 < amount ≤ document balance`
- Transaction + coupon `version` optimistic lock
- **Never** `prisma.order.update` in redeem module
## preV1 defaults
- `X-Client-App: SHOP_H5`
- Test store phone: `13900000001`
- UI: `pages/shop/` + `pages/ROUTE_MAP.md`
## Before finishing
- No changes to h5-user redeem code pages
- No store onboarding (partner app)
- Sync shared-types for redeem DTOs
+85
View File
@@ -0,0 +1,85 @@
---
name: v3-delivery-lead
description: >-
Dukang Haoke V3.0 delivery lead (负责人 jacy-dukang). Orchestrates Wave 1/2/3
gates, DLV milestones, REQ/ACC traceability, and cross-owner coordination. Use
when planning V3.0 scope, splitting DLV tasks, wave acceptance, or resolving
PRD vs codebase gaps — pair with @dukang-v3 skill.
model: inherit
---
You are **V3.0 Delivery Lead** on the Dukang Haoke monorepo — **human owner: jacy-dukang (`jacy-dukang`)**.
## Mission
Drive **G1G5** delivery through three waves (7.10 → 7.15 → 7.22) per [`杜康好客-v3-PRD.md`](../../杜康好客-v3-PRD.md). You do not override product rules; you sequence work, trace REQ/ACC IDs, and delegate implementation to Owner agents.
## Session start (always)
1. Read `@dukang-v3` skill → confirm **current Wave** (W1 / W2 / W3)
2. Check [reference-waves.md](../skills/dukang-v3/reference-waves.md) for in/out of scope
3. Map task to **DLV-W{n}-M{m}** + **REQ-*** + **ACC-***
4. Assign Owner:
| Domain | Agent | 人 |
|--------|-------|-----|
| C 端 / trade / benefit | `owner-a-user-trade` | jacy-dukang |
| 总部 / catalog / settlement / ops | `owner-c-catalog-ops` | jacy-dukang |
| 合伙人 / store 拓店 | `owner-b-partner-store` | 刘景尧 |
| 门店 / redeem 核销 | `owner-d-shop-redeem` | 刘景尧 |
| packages / integrations / Prisma | `backend-lead` | jacy-dukang |
## Wave gate discipline
**Never** mark a later Wave feature as done while earlier gates fail.
| Wave | Must pass before next |
|------|----------------------|
| W1 | W1-S1~S4 (下单、核销、拓店、合伙人子账号) |
| W2 | W2-S1~S5 (+ 提现、推广码、现场提货、门店子账号、多合伙人) |
| W3 | G1G5 + OPT-001~012 + ACC 全量 |
Wave 1~2 gaps use **operational SOP** (人工补核销、人工代下单) — document in handoff, do not hack into code as permanent shortcuts.
## PRD vs codebase gaps (know these)
| PRD v3.0 | Code today | Lead decision |
|----------|------------|---------------|
| 微信小程序 C 端 | `apps/h5-user` | Track as migration; API contract first |
| 总部 H5 | `apps/admin-web` | Feature parity in admin-web until HQ H5 |
| 核销码 3 min | May be 5 min in old docs | Align to PRD 3 min |
| 订单三 Tab | preV1 五 Tab | Align to 待付款/已付款/已完成 |
## Cross-owner coordination
- Prisma touching `store_*` / redeem → notify **刘景尧** before merge
- Commission / settlement snapshots → `settlement` + `trade` via exported Services only
- New enums/DTOs → `packages/shared-types` (Lead reviews breaking changes)
## Output format (planning / handoff)
```markdown
## V3.0 交付 · {DLV-Wx-Mx}
**Wave / 门禁**W{x}-S{y}
**REQ**REQ-...
**ACC**ACC-...
**Owner 分工**:(谁做什么)
**依赖/阻塞**:(外部对接、前置 Wave、对方 Owner)
**验收步骤**:(可演示清单)
**风险**R1~R8 相关项
```
## Before closing a milestone
- [ ] All ACC items for this DLV demonstrable or scripted
- [ ] No business rules invented beyond v3-PRD
- [ ] `node scripts/smoke-v3.mjs` extended if main path touched
- [ ] v3-PRD / v3编码手册 synced if rules changed
- [ ] Cross-module: `boundary-reviewer` if touching 2+ OWNER modules
## Forbidden
- Implementing store/redeem UI in Owner A paths
- Skipping Wave gates to "finish faster"
- Changing business rules in code without updating `杜康好客-v3-PRD.md`
+60
View File
@@ -0,0 +1,60 @@
---
name: v3-req-reviewer
description: >-
Read-only V3.0 PRD reviewer for 杜康好客. Compares requirements (REQ/ACC/Wave)
against code, prototypes, and coding manuals; flags gaps and conflicts. Use before
implementation or PR when user asks for requirement review — does not write code.
model: inherit
readonly: true
---
You are a **read-only V3.0 Requirements Reviewer** for Dukang Haoke.
## Scope
Review only — **do not edit code or change business rules**. Produce structured gap analysis.
## Sources (read in order)
1. [`杜康好客-v3-PRD.md`](../../杜康好客-v3-PRD.md) — V3.0 fact source
2. `@dukang-v3` skill references (waves, REQ, ACC)
3. [`杜康好客-v3编码手册.md`](../../杜康好客-v3编码手册.md) — implementation/acceptance
4. `pages/{user,shop,partner,hq}/` — UI reference
5. Target code paths per OWNER (read-only grep/read)
## Review dimensions
| 维度 | 检查 |
|------|------|
| Wave 范围 | 功能是否属于当前 Wave in-scope |
| REQ 覆盖 | 每条 REQ 是否有 UI/API/数据支撑 |
| ACC 可验 | 验收条件是否可演示或脚本化 |
| 规则一致 | PRD vs 编码手册 vs 代码常量 |
| 边界 | 是否跨 OWNER 直写 Prisma |
| 外部依赖 | 小飞侠/微信/短信/腾讯位置是否就绪 |
## Conflict priority
`杜康好客-v3-PRD.md` > `杜康好客-v3编码手册.md` > V2 手册 > preV1
## Output format
```markdown
## V3.0 需求审查 · {范围}
**Wave**W{n} · **REQ/ACC**...
### 已满足
- ...
### 缺口(按优先级)
| 项 | REQ/ACC | 现状 | 建议 Owner |
### 冲突
| 规则 | PRD | 代码/手册 | 建议 |
### 阻塞外部依赖
- ...
### 建议下一步 DLV
- DLV-Wx-Mx...
```
## Escalation
Implementation conflicts → delegate to `v3-delivery-lead` or specific Owner agent.
+42
View File
@@ -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 }` + 对应 GuardUser/Store/Partner/HQ
+51
View File
@@ -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 3min`REDEEM_TOKEN_TTL_SECONDS`
+55
View File
@@ -0,0 +1,55 @@
---
description: 杜康好客全局约束 — Monorepo 布局、文档优先级、2 人 OWNER 边界
alwaysApply: true
---
# 杜康好客 · 核心规则
## Monorepo 布局
- `apps/` — preV1`h5-user`、`h5-shop`、`h5-partner`、`admin-web`
- `packages/` — `shared-types`、`domain`、`shared-ui`
- `server/dukang-api/` — NestJS 单体 API
- `pages/` — UI 原型(**只读参照**
> V2 目标为 Taro 四端(`mini-user` 等),编码以 **当前实际目录** 为准。
## 文档
- **V3.0 业务事实源**`杜康好客-v3-PRD.md`
- V3 实现验收:`杜康好客-v3编码手册.md`
- V2 蓝图:`杜康好客-V2编码手册.md`(与 V3 冲突时 V3 PRD 优先)
- preV1 裁剪:`杜康好客-preV1编码手册.md`
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
**禁止**:编码时修改业务规则;需求变更只改 `杜康好客-v3-PRD.md`。
## 边界(2 人团队)
> **临时(2026-07**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
| 负责人 | 路径 |
|--------|------|
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
| ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
- `apps/*` 禁止 import `server/*` 或其他 app
- Mock 只在 `integrations/*`,不在业务 Service 散落
## 共享契约
- 枚举/DTO → `packages/shared-types`
- 纯规则 → `packages/domain`(无 IO
## 核心业务常量
- 权益额 = `benefit_amount ?? price`
- 核销:直接核销 `0 < amount ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额`Redis 码 3 分钟
- 订单 Tab(V3.0):`待付款 | 已付款 | 已完成`
- API`/api/v1`,响应 `{ code, message, data }`
## 提交
- Conventional Commits`feat(trade):` 等
- 不提交 `.env`
+53
View File
@@ -0,0 +1,53 @@
---
description: 杜康好客前端 H5 / Admin 编码规范与 OWNER 边界
globs: apps/**/*.{tsx,ts}
alwaysApply: false
---
# 前端规范
## 目录约定
```
src/
├── components/ # PascalCaseAppToast.tsx
├── pages/ # *Page.tsx
├── layouts/ # *Layout.tsx
├── contexts/ # *Context.tsx
├── lib/ # kebab-caseapi.ts, client-location.ts
├── App.tsx
└── main.tsx
```
## 四 App
| App | 端口 | 负责人 | 样式 | X-Client-App |
|-----|------|--------|------|--------------|
| h5-user | 5173 | jacy-dukang | shared-ui tokens | USER_H5 |
| h5-shop | 5174 | jacy-dukang | shared-ui tokens | SHOP_H5 |
| h5-partner | 5175 | 刘京尧 | shared-ui tokens | PARTNER_H5 |
| admin-web | 5175 | jacy-dukang | Ant Design 5 | HQ_WEB |
> h5-partner 与 admin-web 勿同时 dev。H5 三端用 `@dukang/shared-ui`admin-web 独立 Ant Design。
## 隔离与请求
- 禁止 import 其他 `apps/*` 或 `server/*`
- 类型从 `@dukang/shared-types` import
- Base: `/api/v1`Headers: `Authorization` + `X-Client-App`
- Vite dev 代理 `/api` → `localhost:3000`
## UI 约束
- C 端订单 **5 Tab**(含 pending_ship
- 门店列表仅 `OPEN` 状态
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
## App 归属(勿改他端)
| App | 勿改 |
|-----|------|
| h5-user | shop 核销、partner 录店 |
| h5-shop | user 出码、admin 报表 |
| h5-partner | user 下单、admin 开城 |
| admin-web | 非 V2 小程序规格 |
+32
View File
@@ -0,0 +1,32 @@
---
description: Git 提交与 PR 协作规范
alwaysApply: true
---
# Git 规范
## 提交格式
Conventional Commitsscope = 端或模块名:
```
feat(trade): add order preview API
fix(redeem): validate redeem amount against balance
chore(shared-types): add OrderStatus enum
```
## 禁止提交
- `.env`、`.env.local` 等敏感配置
- `node_modules/`、`dist/`(已在 .gitignore
## PR 与 Review
- feature 分支 + 小步 PR
- 跨模块改动需相关双 Owner Review
- Prisma 迁移必须多 Owner Review
## Agent 行为
- **仅用户明确要求时** 才执行 git commit / push
- 不 amend 已推送的 commit,不 force push main/master
+43
View File
@@ -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`、V3 两路径核销规则在此实现
- 变更必须有单元测试
```typescript
const benefitAmount = product.benefitAmount ?? product.price;
// 同城起购 2 瓶 / 跨城 6 瓶
// 直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
```
## shared-ui
- 跨 App 组件;禁止 import 特定 App 代码
- admin-web 使用 Ant Design,不引用 shared-ui
## 破坏性改动
需通知 **jacy-dukang** 与 **刘京尧**(若影响 partner/shop API)并在 PR 说明影响面。Prisma 迁移需双方 Review(涉及 store/redeem 表时)。
+29
View File
@@ -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 **3 分钟**`REDEEM_TOKEN_TTL_SECONDS=180`
- 支付流水 → `log_third_party` + `user_order.pay_*`
- 业务事件 → `common_event`;埋点 → `log_user_analytics`
## preV1
不删表、不改 V2 字段语义。Seed`prisma/seed-v31.ts`
+122
View File
@@ -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. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`preV1 → preV1 手册;V2 蓝图 → V2 手册
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 表 → **只改本 OWNER 路径**
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
5. 编码前过一遍下方 Checklist
**需求变更** → V3.0 只改 `杜康好客-v3-PRD.md`,编码时不改业务规则。
## 编码前 Checklist
```
- [ ] 任务所属 OWNER 与目标路径已确认
- [ ] 已读手册 PRD 对应 § 与 pages/ 原型
- [ ] API 路径、Guard、表名已从 V2 §六/§五 核对
- [ ] 跨模块需求 → 只 inject 对方 exported Service
- [ ] 新枚举/DTO → packages/shared-types
- [ ] 起购/权益/核销规则 → packages/domain(禁止 Controller 硬编码)
- [ ] preV1 Mock → integrations/*,非业务 Service 内散落 if
```
## 后端(OWNER 模块内)
```
modules/{name}/
├── {name}.module.ts # exports: [XxxService] 唯一出口
├── {name}.controller.ts
├── {name}.service.ts # 仅写本模块 Prisma 表
└── dto/
```
WorkflowDTO → Service(本模块表)→ Controller + Guard → 同步 shared-types。
跨模块示例(允许):
```typescript
// trade.service.ts — jacy-dukang
constructor(
private readonly benefitService: BenefitService,
private readonly catalogService: CatalogService,
) {}
await this.benefitService.grantOnOrderPaid(orderId);
```
跨模块示例(禁止):
```typescript
// redeem.service.ts — ❌ 禁止
await this.prisma.order.update({ ... });
```
## 前端(单 App 内)
1. 路由对照 `pages/ROUTE_MAP.md`
2. `src/lib/api.ts` 统一 fetch + `X-Client-App`
3. 类型从 `@dukang/shared-types` import
4. preV1 App`h5-user` / `h5-shop` / `h5-partner` / `admin-web`
5. C 端订单 TabV3.0):`待付款 | 已付款 | 已完成`
## 数据库变更
1.`server/dukang-api/prisma/schema.prisma` 对齐手册 §五
2. 迁移需 OWNER Reviewstore/redeem 表 → 刘景尧)
3. 初始化 SQL`server/dukang-api/prisma/init_v3.sql`
## 核心业务(packages/domain
```typescript
const benefitAmount = product.benefitAmount ?? product.price;
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
// 同城 min 2 瓶 / 跨城 min 6 瓶
// 支付成功 → log_third_party + user_order.pay_status=PAID
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
// 埋点 → log_user_analytics;业务审计 → common_event
```
## 核销并发(M3+
- Redis `redeem:token:{token}` EX 300
- 事务 + `user_benefit_coupon.version` 乐观锁
-`user_redeem_record` + `store_payout`(PENDING) + `common_event(BENEFIT_LEDGER, REDEEM)`
## 资源上传
`POST /common/resources/upload-token` → OSS 直传 → `POST /common/resources`
## 完成定义
- 任务卡验收项全部满足
- 无跨模块 Prisma 直写
- `packages/domain` 相关单测通过
- `pnpm lint` 无新增错误
- 改了 API/表 → 同步手册与 shared-types
## 延伸阅读
- 模块/表/路由:[reference-backend.md](reference-backend.md)
- 页面↔API 速查:[reference-frontend.md](reference-frontend.md)
- preV1 Mock`dukang-prev1` skill
- 详细清单:根目录 `skills.md`
@@ -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-dukangsettlement |
| partner_ | partner_partner, partner_bill | 刘景尧 / jacy-dukangsettlement |
| hq_ | hq_account | jacy-dukang |
| common_ | common_product_item, common_event, common_city | jacy-dukang |
| log_ | log_third_party, log_user_analytics | 写入方 Module |
**无以下 V1 表**payments、user_order_item、redeem_tokensDB)。
## Controller 路由(当前实现)
### iam
| 路径 | Controller |
|------|-----------|
| `POST /auth/session/bootstrap` | UserAuthController |
| `POST /auth/sms/send` | UserAuthController |
| `POST /auth/sms/login` | UserAuthController |
| `GET /user/me` | UserAuthController |
| `GET/POST/PUT/DELETE /user/addresses` | UserAddressController |
| `POST /shop/auth/*` | ShopAuthController |
| `POST /partner/auth/*` | PartnerAuthController |
| `POST /admin/auth/login` | AdminAuthController |
### catalog / trade / benefit / redeem
| 路径 | Module |
|------|--------|
| `GET /catalog/*` | catalog |
| `POST/GET /trade/orders` | trade |
| `GET/POST /benefit/*` | benefit |
| `POST /redeem/*` | redeemC 端) |
| `POST /shop/redeem/*` | redeem(门店) |
### store / settlement
| 路径 | Module |
|------|--------|
| `GET /stores` | storeC 端门店列表) |
| `GET/POST /partner/stores` | store |
| `GET /partner/dashboard` | store |
| `GET /shop/store` | store |
| `GET /shop/dashboard` | store |
| `GET /partner/settlement/*` | settlement |
| `GET /partner/me` | settlement |
| `GET /partner/orders` | trade |
### opsadmin-web
| 路径 | 说明 |
|------|------|
| `/admin/dashboard` | 看板 |
| `/admin/users` | C 端用户 |
| `/admin/orders` | 订单 |
| `/admin/stores` | 门店 |
| `/admin/store-accounts` | 门店账号 |
| `/admin/store-media` | 门店媒体 |
| `/admin/partners` | 合伙人 |
| `/admin/partner-accounts` | 合伙人账号 |
| `/admin/cities` | 开城 |
| `/admin/hq-accounts` | 总部账号 |
| `/admin/benefit/coupons` | 权益券 |
| `/admin/benefit/ledgers` | 权益流水 |
| `/admin/redeem-records` | 核销记录 |
| `/admin/deliveries` | 配送 |
### analytics / health
| 路径 | Module |
|------|--------|
| `POST /analytics/*` | analytics |
| `GET /health` | health |
## 关键跨模块调用链
**Mock/真实支付成功**
```
PayProvider → TradeService.handlePaySuccess()
→ BenefitService.grantOnOrderPaid(orderId)
→ OrderDelivery 预创建
→ common_event(ORDER_STATUS)
```
**核销确认**
```
RedeemService.confirm()
→ BenefitService.deduct(couponId)
→ SettlementService.createStorePayout(redeemRecordId)
```
## 禁止依赖(PR 拒绝)
| # | 禁止 |
|---|------|
| F1 | redeem 直写 order |
| F2 | store → trade/benefit |
| F3 | benefit → trade/redeem |
| F4 | trade → redeem |
| F5 | benefit → redeem |
| F6 | catalog → trade/settlement |
| F9 | apps import server 源码 |
## integrationspreV1
| Flag | Provider | 行为 |
|------|----------|------|
| MOCK_SMS=true | sms.mock | 固定码 123456 |
| MOCK_PAY=true | pay.mock | 同步 PAID + 发券 |
| MOCK_DELIVERY_AUTO=true | delivery.mock | BullMQ 自动推进状态 |
@@ -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
```
待付款 | 已付款 (`paid`) | 已完成
```
## h5-shop 路由(刘景尧)
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /shop/auth/* |
| / | HomePage | /shop/dashboard |
| /redeem | RedeemConfirmPage | /shop/redeem/* |
| /redeem/success | RedeemSuccessPage | — |
| /records | RecordsPage | /shop/redeem/records |
| /status | StatusPage | /shop/store |
| /mine | MinePage | /shop/store |
## h5-partner 路由(刘景尧)
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /partner/auth/* |
| / | HomePage | /partner/dashboard |
| /stores | StoreListPage | /partner/stores |
| /stores/new | StoreCreatePage | /partner/stores |
| /stores/:id | StoreDetailPage | /partner/stores/:id |
| /orders | OrderListPage | /partner/orders |
| /orders/:id | OrderDetailPage | /partner/orders/:id |
| /center | CenterPage | /partner/me |
| /center/bills | BillsPage | /partner/settlement/* |
| /reshipments | ReshipPage | /partner/after-sales/reshipments |
| /reports/weekly | WeeklyReportPage | /partner/reports/weekly |
| /leaderboard | LeaderboardPage | /partner/dashboard/leaderboard |
| — | Mock 推进配送 | POST /partner/orders/:id/mock-advance-delivery |
## admin-web 路由(jacy-dukang
| 路由 | Page | 主要 API |
|------|------|----------|
| /login | LoginPage | /admin/auth/login |
| / | DashboardPage | /admin/dashboard |
| /users | UsersPage | /admin/users |
| /orders | OrdersPage | /admin/orders |
| /stores | StoresPage | /admin/stores |
| /store-accounts | StoreAccountsPage | /admin/store-accounts |
| /store-media | StoreMediaPage | /admin/store-media |
| /partners | PartnersPage | /admin/partners |
| /partner-accounts | PartnerAccountsPage | /admin/partner-accounts |
| /cities | CitiesPage | /admin/cities |
| /hq-accounts | HqAccountsPage | /admin/hq-accounts |
| /benefit/coupons | BenefitCouponsPage | /admin/benefit/coupons |
| /benefit/ledgers | BenefitLedgersPage | /admin/benefit/ledgers |
| /redeem-records | RedeemRecordsPage | /admin/redeem-records |
| /deliveries | DeliveriesPage | /admin/deliveries |
## 端 → API 前缀
| 端 | API 前缀 | NestJS Module |
|----|----------|---------------|
| C 端 | /auth, /user, /catalog, /trade, /benefit, /redeem, /stores | iam, catalog, trade, benefit, redeem, store |
| 门店 | /shop/auth, /shop/redeem, /shop/store | iam, redeem, store |
| 合伙人 | /partner/* | iam, store, trade, settlement |
| 总部 | /admin/* | iam, catalog, store, trade, settlement, ops |
## UI 硬约束
- C 端订单 **3 Tab**`pending_pay` / `paid` / `completed`
- 个人中心无会员等级(V1 不做会员体系)
- 清香型 4 SKU 可购;酱香/浓香灰态
- 门店列表仅 `OPEN` 状态
- 原型只读:`pages/{端}/*/code.html`,勿改原型目录
## preV1 与 V2 UI 差异
| 项 | preV1 | V2 |
|----|-------|-----|
| C/合伙人载体 | H5 | 微信小程序 |
| 总部 | admin-web 内部 | mini-hq 小程序 |
| 支付页 | Mock 同步成功 | 微信 JSAPI |
+94
View File
@@ -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. **无微信登录** → 仅 SMSwechat 路由返回 501 或 Flag 关闭
4. **验证码 Mock**`123456``MOCK_SMS=true`
5. **配送 Mock** → BullMQ 自动推进或合伙人 `mock-advance-delivery`
6. **支付 Mock**`POST /trade/orders/:id/pay` 同步成功 + **真实发券**
## 环境变量(`.env.example`
```bash
MOCK_SMS=true
MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
```
配置加载:`packages/shared-types/src/config.ts``loadAppConfig()`
## Mock 代码位置(禁止散落)
```
server/dukang-api/src/integrations/
├── sms/sms.interface.ts + sms.mock.provider.ts
├── pay/pay.interface.ts + pay.mock.provider.ts
└── delivery/delivery.interface.ts + delivery.mock.provider.ts
```
业务 Module 只 inject **interface**,由 Nest DI 切换 Mock/Real。
## preV1 必做 vs 跳过
| 必做 | 跳过(V2 补) |
|------|---------------|
| 三端 SMS 登录 | 微信登录/支付/退款 |
| Mock 支付 + 真实发券 | 微信 prepay/回调验签 |
| 5 Tab 订单 | HQ 小程序 UI |
| Redis 核销码 + 门店核销 | 真实小飞侠/物流 |
| 合伙人录店 + AUTO_APPROVE | 总部人工审核 UI |
| 可选埋点 | 推广码 HQ UI、T+30 真实打款 |
## HQ 能力替代(Seed
| V2 HQ | preV1 |
|-------|-------|
| 开城/商品 CRUD | Seed 固定 + admin-web 或改 seed |
| 门店审核 | AUTO_APPROVE_STORE=true |
| 退款/客服 | 跳过 |
| 结算 | store_payout PENDING;可 Seed 演示账单 |
Seed`server/dukang-api/prisma/seed-v31.ts`
## 测试账号
| 角色 | 手机号 |
|------|--------|
| C 端 | 13800000001 |
| 门店 | 13900000001 |
| 合伙人 | 13700000001 |
## 冒烟
```bash
node scripts/smoke-prev1.mjs
```
## preV1 → V2 切换检查
按 preV1 手册 §8.2 逐项关闭 Mock Flag**无需重构表结构**。
## 边界提醒
- preV1 skill **不负责**实现 V2 微信 SDK;遇到真实支付/短信需求 → 停止并切换 V2 手册 §九
- 改 Mock 行为时 **仍须** 遵守 OWNER 模块边界
- admin-web 是 preV1 内部工具,勿与 V2 `pages/hq/` 原型混为一谈
+69
View File
@@ -0,0 +1,69 @@
---
name: dukang-project
description: >-
Manages 杜康好客 PRD, requirements review, prototype mapping, and task card
planning. Use when writing PRD, reviewing requirements, mapping prototypes to
features, or splitting task cards. Not for daily coding (use @dukang-coding).
disable-model-invocation: true
---
# 杜康好客 · 项目/需求 Skill
## 何时启用
- 写或改 PRD、业务规则
- 评审需求、对照原型
- 拆分或规划任务卡
- 用户 @dukang-project
**不用于**:日常编码、修 Bug(用 @dukang-coding
## 文档阅读顺序
| 优先级 | 文档 | 用途 |
|--------|------|------|
| 1 | `杜康好客-v3-PRD.md` | **唯一需求事实源** |
| 2 | `杜康好客-v3-现状对照.md` | 已完成/冲突/缺口 |
| 3 | `杜康好客-v3编码手册.md` | 实现验收、分工 |
| 4 | `conventions.md` | 协作与模块边界 |
| 5 | V2 手册 §四 §五 §六 | 仅架构/DB/API 参考(与 V3 冲突时忽略) |
| 6 | `pages/{user,shop,partner,hq}/` | UI 参照 |
> V2 / preV1 **不再作为需求依据**。
## 需求变更规则
- **只改** `杜康好客-v3-PRD.md`;进度同步 `杜康好客-v3-现状对照.md`
- 编码 Agent **不得**边写代码边改业务规则
- 改 API/表结构 → 同步 v3-PRD + `packages/shared-types`
## 原型 → 模块映射
| 端 | 原型目录 | 主要 API 前缀 | NestJS Module |
|----|----------|---------------|---------------|
| C 端 | pages/user/ | /auth, /user, /catalog, /trade, /benefit, /redeem, /stores | iam, catalog, trade, benefit, redeem, store |
| 门店 | pages/shop/ | /shop/auth, /shop/redeem, /shop/store | iam, redeem, store |
| 合伙人 | pages/partner/ | /partner/* | iam, store, trade, settlement |
| 总部 | pages/hq/ | /admin/* | iam, catalog, store, trade, settlement, ops |
## 任务卡格式
- ID 格式:`Mx-LAYER-NNN`(如 `M2-BE-TRD-002`
- 任务卡全集:V2 手册 §七 附录 A
- preV1 任务卡:preV1 手册 §9
## 团队 OWNER
| OWNER | 前端 | 后端 Module |
|-------|------|-------------|
| A | h5-user | iam, trade, benefit, analytics |
| B | h5-partner | store |
| C | admin-web | catalog, settlement, ops |
| D | h5-shop | redeem |
| Lead | packages/* | callbacks, jobs, common |
## 输出要求
- 需求文档变更需标注影响模块与 Owner
- 新任务卡需含:PRD §、API 路径、DB 表、原型路径、验收标准
- 跨模块 PR 需相关双 Owner Review
+89
View File
@@ -0,0 +1,89 @@
---
name: dukang-release
description: >-
杜康好客生产发布流程:dev_jacy 提交合并到 dev,并用 deploy.sh 发版。
Use when the user says 发布、上传、deploy、上线、发版, or @杜康发布 / @dukang-release.
---
# 杜康发布
将已完成改动发到生产(`aliyun-smarthr`)。**仅用户明确要求发布时执行。**
## 分支约定
| 分支 | 用途 |
|------|------|
| `dev_jacy` | 日常开发 / 提交 |
| `dev` | **发布分支**(服务器拉这个) |
默认:`dev_jacy` → merge → `dev` → 部署。勿 force push `main`/`dev`
## 标准流程(Windows
PowerShell 下 **分支名必须加引号**(否则 `checkout``dev` 会被粘成 `checkoutdev`)。
### 1. 提交(在 `dev_jacy`
```powershell
git status -sb
git add <files>
git commit -m @"
fix(scope): summary
Optional body.
"@
```
- Conventional Commits;不提交 `.env` / `dist` / `node_modules`
- 用户未要求 commit 时不要擅自提交
### 2. 推送并合并到 `dev`
```powershell
git push -u origin HEAD
git checkout "dev"
git pull origin "dev"
git merge "dev_jacy" -m "merge(dev_jacy): <简述>"
git push origin "dev"
git checkout "dev_jacy"
```
### 3. 远程发版
本机无 WSL bash 时用 **Git Bash**
```powershell
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy.sh" --branch "dev" -- --skip-db
```
- 默认加 `--skip-db`(无迁移时)
- 有 Prisma 迁移时去掉 `--skip-db`,按用户确认是否 `--seed` / `--accept-data-loss`
- `deploy/deploy.env` 提供 `DEPLOY_HOST` 等(勿提交密钥)
### 4. 等待完成
后台跑部署时轮询终端输出,成功标志:
- 出现 `发版完成`
- `exit_code: 0`
- `mini-user(h5): 200`(或对应前端 200
说明:API 健康检查短暂 `FAIL`/`000` 多为重启中,以 `发版完成` + `exit_code: 0` 为准。
### 5. 回复用户
一句说明:已合并 commit、发版结果、建议微信强刷验证。
## 常用变体
| 场景 | 做法 |
|------|------|
| 仅部署已推送的 `dev` | 跳过 commit/merge,直接跑 `deploy.sh --branch "dev" -- --skip-db` |
| 检查服务器 | `deploy.sh --branch "dev" --check` |
| 含 DB | `deploy.sh --branch "dev" --`(无 skip-db)或按用户给 seed 参数 |
## 禁止
- 不 amend 已推送 commit;不 force push `main`/`master`/`dev`
- 不把 `.env` / 私钥打进仓库
- 用户只说「修好」未说发布时 → **只改代码,不发版**
+94
View File
@@ -0,0 +1,94 @@
---
name: dukang-task-card
description: >-
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003,
or asks to start/implement a milestone card from the coding manuals.
---
# 杜康好客 · 任务卡 Skill
## 原则
- **一次只做一个任务卡**
- 任务卡 ID 命名空间:`DLV-W*-*`V3.0)· `P1-*` / `M*-*`**历史,不再作验收依据**
- 需求变更只改 `杜康好客-v3-PRD.md`;进度查 `杜康好客-v3-现状对照.md`
## 领取流程
1. **解析 ID** → 确定里程碑与 OWNER
- `DLV-W1-M2``@dukang-v3` + [reference-waves.md](../dukang-v3/reference-waves.md)
- `P1-M2-002` → preV1 §9 + OWNER 见卡片内容
- `M2-BE-TRD-002` → V2 §七 附录 A
2. **读验收标准** → ACC-ID(V3)或手册中该 ID 的「验收」列
3. **读依赖** → REQ-ID、API 路径、DB 表、原型路径
4. **确认边界** → 只改 [`AGENTS.md`](../../AGENTS.md) OWNER 表内路径
5. **实现** → 配合 `dukang-coding` skill
6. **自验 DoD** → 下方清单
## OWNER 快速映射(2 人)
| 领域 | 负责人 | 典型 ID |
|------|--------|---------|
| C 端 FE、h5-user、交易后端 | jacy-dukang | P1-M1-002, M2-* |
| admin、catalog、settlement | jacy-dukang | M1-BE-CAT-* |
| 合伙人、store | 刘景尧 | P1-M4-001 |
| 门店核销、h5-shop | 刘景尧 | P1-M3-002 |
| Monorepo/integrations | jacy-dukang | P1-M0-* |
## preV1 任务卡(精简)
| ID | 验收要点 |
|----|----------|
| P1-M0-001 | 三端 `pnpm dev` 可编译 |
| P1-M0-003 | `pnpm db:validate` 通过 |
| P1-M0-004 | Seed 郑州+4SKU+测试账号 |
| P1-M1-001 | 123456 三端登录 |
| P1-M1-002 | C 端首页 4 款酒 |
| P1-M2-001 | 起购校验 2/6 瓶 |
| P1-M2-002 | Mock 支付发券 |
| P1-M2-003 | 5 Tab 含 pending_ship |
| P1-M3-001 | V3 两路径核销金额限制 |
| P1-M3-002 | 门店扫码核销 |
| P1-M4-001 | 录店 AUTO_APPROVE → C 端可见 |
| P1-M5-001 | 配送自动推进到 COMPLETED |
完整列表:preV1 手册 §9。
## 完成定义(每个任务卡)
```
- [ ] 验收列每一条可演示/可脚本验证
- [ ] 改动在 OWNER 路径内(跨模块需 inject Service + 双 OWNER 知晓)
- [ ] 新枚举/DTO → shared-types
- [ ] 纯规则 → domain + 单测(若涉及起购/权益/核销)
- [ ] API/表变更 → 同步 V2 手册 §五/§六
- [ ] pnpm lint 无新增错误
```
## V3.0 DLV 顺序(当前交付)
**Wave 1**DLV-W1-M1 → M2 → M3 → M4(门禁 W1-S1~S4
**Wave 2**DLV-W2-M1 → M2 → M3 → M4(门禁 W2-S1~S5
**Wave 3**DLV-W3-M1 → M2 → M3 → M4 → M5(全量 ACC
## 历史里程碑
**preV1**P1-M0 → … → P1-M6
**V2**:M0 → M1 → … → M6 → 上线
除非用户指定,默认从当前里程碑**下一个未完成**任务卡开始;不确定时先问用户或跑 smoke 判断进度。
## 输出格式(任务完成时)
```markdown
## 任务卡 {ID} 完成
**改动范围**:(路径列表)
**验收**:(逐条 ✓)
**未做/阻塞**:(如有,需其他 OWNER 配合的项)
**建议下一步**:(下一任务卡 ID
```
+119
View File
@@ -0,0 +1,119 @@
---
name: dukang-v3
description: >-
Manages 杜康好客 V3.0 PRD (v3-PRD), three-wave delivery (W1/W2/W3), REQ/ACC
IDs, and requirements review. Use when implementing V3.0 features, reviewing
PRD v1.3 scope, planning Wave gates, or user @dukang-v3. Not for preV1 Mock
(use @dukang-prev1) or daily coding alone (pair with @dukang-coding).
disable-model-invocation: true
---
# 杜康好客 · V3.0 需求 Skill
## 何时启用
- 读/改 V3.0 业务规则与交付范围
- 评审需求、对照 PRD REQ-ID / ACC-ID
- 规划或验收 Wave 1/2/3 门禁
- 用户 @dukang-v3 或提到「v3.0 PRD」「三波交付」「W1-S1」
**不用于**preV1 Mock 联调(@dukang-prev1)、纯编码实现(@dukang-coding
## 文档阅读顺序
| 优先级 | 文档 | 用途 |
|--------|------|------|
| 1 | [`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) | **唯一需求事实源** |
| 2 | [`杜康好客-v3-现状对照.md`](../../../杜康好客-v3-现状对照.md) | 已完成/冲突/缺口 |
| 3 | [`杜康好客-v3编码手册.md`](../../../杜康好客-v3编码手册.md) | 实现验收、分工 |
| 4 | [reference-waves.md](reference-waves.md) | Wave 门禁与 DLV 映射 |
| 5 | [reference-req-index.md](reference-req-index.md) | REQ-U/S/P/H 全表 |
| 6 | [reference-acc.md](reference-acc.md) | ACC 验收条件 |
| 7 | `pages/{user,shop,partner,hq}/` | UI 参照 |
> V2 / preV1 手册**不再作为需求依据**。
## V3.0 不可偏离常量
```
权益额 = 订单实付 1:1(永久)
同城起购 2 瓶 / 跨城 1 箱(6瓶)
门店结算 = 核销额 × 60%
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
核销码 TTL = 3 分钟;试核销固定 ¥100
订单 Tab:待付款 | 已付款 | 已完成
C 端门店仅 status=OPEN 且审核通过
```
## 三波交付(严格顺序)
```
Wave 1 (7.10) → Wave 2 (7.15) → Wave 3 (7.22)
```
实现前**必须确认当前 Wave**;不得提前做后续 Wave 功能(除非用户明确要求)。
| Wave | 门禁 | 一句话 |
|------|------|--------|
| W1 | W1-S1~S4 | 下单+核销+拓店+合伙人子账号 |
| W2 | W2-S1~S5 | 提现+推广码+现场提货+门店子账号+多合伙人 |
| W3 | 全量 G1~G5 | 代下单+弱网+多仓+跨城+工单+发票+增长 |
详情:[reference-waves.md](reference-waves.md)
## 需求变更规则
- **只改** `杜康好客-v3-PRD.md` 作为 V3.0 业务事实源
- 同步 `杜康好客-v3编码手册.md` + `packages/shared-types` + `packages/domain`
- V2/preV1 与 V3.0 冲突时 **V3.0 PRD 优先**
- 编码 Agent 不得边写代码边改业务规则
## 原型 → 模块映射
| 端 | PRD 形态 | 当前代码 | 后端 Module |
|----|----------|----------|-------------|
| C 端 | 微信小程序 | `apps/h5-user`(过渡) | iam, catalog, trade, benefit |
| 门店 | H5 | `apps/h5-shop` | iam, redeem, store |
| 合伙人 | H5 | `apps/h5-partner` | iam, store, trade, settlement |
| 总部 | H5 | `apps/admin-web`(过渡) | catalog, store, trade, settlement, ops |
## 任务卡命名(V3.0
- **DLV-W{n}-M{m}**:阶段交付(如 `DLV-W1-M2`
- **REQ-{U|S|P|H}-{nnn}**:功能需求 ID
- **ACC-{nnn}** / **ACC-P{nn}** / **ACC-W{nn}**:验收 ID
- preV1 `P1-*` 与 V2 `M*-*` 为历史命名,V3.0 新任务以 DLV/REQ/ACC 为准
领取 DLV 任务时配合 `@dukang-coding` + 对应 Owner agent。
## 团队 OWNER2 人)
| 负责人 | 前端 | 后端 Module |
|--------|------|-------------|
| jacy-dukang | h5-user/mini-user, admin-web | iam, trade, benefit, catalog, settlement, ops, analytics |
| 刘景尧 | h5-shop, h5-partner | store, redeem |
## 需求评审输出格式
```markdown
## V3.0 需求评审 · {主题}
**关联**REQ-xxx / ACC-xxx / Wave N
**影响模块**:(NestJS module + app
**Owner**jacy-dukang | 刘景尧 | 双方
**与现状差异**:(代码/手册冲突点)
**建议任务**DLV-Wx-Mx
**阻塞/依赖**:(外部对接、前置 Wave
```
## 子 Agent 选用
| 场景 | Agent |
|------|-------|
| V3 波次统筹、跨 Wave 排期 | `v3-delivery-lead` |
| C 端下单/权益/支付 | `owner-a-user-trade` |
| 总部开城/结算/运营 | `owner-c-catalog-ops` |
| 合伙人拓店 | `owner-b-partner-store` |
| 门店核销 | `owner-d-shop-redeem` |
| packages/integrations/Prisma | `backend-lead` |
| PR 边界审查 | `boundary-reviewer` |
+72
View File
@@ -0,0 +1,72 @@
# V3.0 ACC 验收标准索引
> 事实源:[`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) §8
## 交易与履约
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-001 | S1 | 访客可浏览;下单须登录;支付成功→权益 1:1 |
| ACC-002 | 同城 | ≥2 瓶免运费;小飞侠;取送拍照;24h 自动完成 |
| ACC-003 | 现场提货 | 隐藏入口;支付即完成;有码归码/无码归总部 |
| ACC-004 | 跨城 | 未开通城市≥1箱;总部物流到付;佣金归总部 |
| ACC-015 | 锁单 | 30 分钟取消;7 天免登 |
## 核销与门店账号
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-005 | S2 | 扫码+手机号两通道;码/验证码 3 分钟;成功短信 |
| ACC-006 | 多店子账号 | W2:一号多店;主账号唯一;店员权限隔离 |
| ACC-006a | 门店登录 | 单店直达;多店含角色;记住所选 |
| ACC-017 | 弱网 | W1~2 重试+人工;W3 5次拍照兜底;不重复扣款 |
| ACC-017a | 弱网 SLA | W3:总部 T+0 处理;超时预警 |
## 拓店与 SOP
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-007 | S3 | 合伙人三步提交;总部审;C 端营业中可见 |
| ACC-019 | SOP | 筛选字段;附件一一致;试核销100;负责人复核;合同同日 |
| ACC-020 | 结算细则 | T+1 顺延;异议3日;店内公示;签约主体 |
## 资金与佣金
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-008 | S4 | 未出账可提;总部打款;FIN 护栏;T+0 审 |
| ACC-009 | 佣金 | 默认 0%+3%;门店结算×60%;§4.3 归属 |
| ACC-P21 | 多合伙人 | 区域区县互斥;全城唯一 |
| ACC-P22 | 佣金配置 | 独立配置;≤5%;快照 |
| ACC-P23 | 订单佣金 | 同城区县解析;余量归总部/全城 |
| ACC-P24 | 核销佣金 | 归门店归属合伙人;变更不溯及 |
| ACC-P25 | 独立账单 | T+30 独立确认打款 |
| ACC-P26 | 数据隔离 | 仅本管辖;推广员仅自己的店 |
| ACC-P27 | 推广码 | 绑定合伙人;现场有码归码 |
| ACC-P28 | 审计 | 管辖/比例/归属变更留痕 |
## 售后与增长
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
| ACC-012 | 代下单 | W3:客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
| ACC-018 | 话术 | OPT-012 3 日内对齐 |
| ACC-021 | 热力图 | 腾讯位置服务按城市 |
## 多仓(Wave 3
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-W01 | 多仓 | 同城多仓;启停 |
| ACC-W02 | 仓-合伙人 | 一仓一合伙人;未关联总部直管 |
| ACC-W03 | 管仓协同 | 管仓合伙人只读仓+工单协同 |
## UI 与隐私
| ACC | 关联 | 验收条件 |
|-----|------|----------|
| ACC-016 | UI/隐私 | §8 规范;按角色脱敏 |
@@ -0,0 +1,124 @@
# V3.0 REQ 需求索引
> 事实源:产品 PRD v1.3 → [`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md)
## 用户端 REQ-U
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
| REQ-U-002 | 登录 | 无感登录;下单提示绑定手机号(可选);微信/短信;7 天免登 |
| REQ-U-003 | 支付 | 仅微信支付 |
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 |
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 |
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
| REQ-U-013 | 门店列表 | 营业中;搜索;省市区筛选;立即核销 |
| REQ-U-014 | 门店详情 | 门头/电话/星级/地址/营业时间/规则/相册 |
| REQ-U-015 | 核销页 | 剩余总额;输入金额;码 3 分钟有效 |
| REQ-U-016 | 规则弹窗 | 核销前展示(OPT-011) |
| REQ-U-017 | 评价 | 核销后引导门店评价 |
| REQ-U-018 | 权益页 | 待使用总额;Tab 待使用/已使用;明细 |
| REQ-U-019 | 个人中心 | 手机号、权益总额、订单入口 |
| REQ-U-020 | 订单 | 待付款/已付款/已完成;物流时间线 |
| REQ-U-021 | 发票 | 四组合;总部 2 工作日回传(OPT-001) |
| REQ-U-022 | 工单 | 四类型;客服引导建单 |
| REQ-U-023 | 问卷 | 支付成功引导;无激励(OPT-004) |
| REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 |
| REQ-U-025 | 分享 | 微信商品分享+渠道参数 |
| REQ-U-026 | 门店规则 | 附件一完整展示 |
## 门店端 REQ-S
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-S-001 | 导航 | 首页/核销记录/门店信息 |
| REQ-S-002 | 登录 | 主账号/店员;§4.6 流程;7 天免登 |
| REQ-S-003 | 多店 | 一号多店选店(Wave 2) |
| REQ-S-004 | 扫码核销 | 大按钮;确认即成功 |
| REQ-S-005 | 手机号核销 | 查余额→用户验证码→确认 |
| REQ-S-006 | 短信 | 核销中/成功文案 |
| REQ-S-007 | 今日汇总 | 首页今日核销总金额 |
| REQ-S-008 | 弱网 | W1~2 重试;W3 §4.8 |
| REQ-S-008a | 弱网弹窗 | W3 拍照兜底文案 |
| REQ-S-009 | 核销记录 | 筛今日/7日/1月/全部;到账×60% |
| REQ-S-010 | 门店信息 | 营业状态;结算入口;切换门店 |
| REQ-S-011 | 结算信息 | T+1;核销额/结算额/状态 |
| REQ-S-012 | 提现 | 未出账可提(Wave 2);FIN 护栏 |
| REQ-S-013 | 提现记录 | 待审/已打款/驳回 |
| REQ-S-014 | 结算异议 | 3 工作日 |
| REQ-S-015 | 店内公示 | 模板下载 |
| REQ-S-016 | 提现护栏 | 白名单/单日上限提示 |
| REQ-S-017 | 子账号 | 创建/停用店员(Wave 2) |
| REQ-S-018 | 权限 | §4.6 矩阵 |
| REQ-S-019 | 多店选店 | 记住上次所选 |
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
## 合伙人端 REQ-P
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-P-001 | 导航 | 首页/门店管理/合伙人中心 |
| REQ-P-002 | 登录 | 7 天免登 |
| REQ-P-003 | 推广员 | 菜单裁剪;仅本管辖范围 |
| REQ-P-004 | 管理员 | 完整能力 |
| REQ-P-005 | 子账号 | 管理员·推广员 |
| REQ-P-006 | 门店管理 | 筛审核/营业/区县/创建人 |
| REQ-P-007 | 列表字段 | 名称/地址/状态/录入 |
| REQ-P-008 | 一号多店 | 创建时二次确认 |
| REQ-P-009 | 创建三步 | 基础/证照+附件一/结算 |
| REQ-P-010 | 编辑规则 | 待审可改;通过后改资料再审 |
| REQ-P-011 | 试核销 | 100 元激活(OPT-009 |
| REQ-P-012 | 入驻短信 | OPT-008 |
| REQ-P-013 | 首页数据 | GMV/核销/订单三卡 |
| REQ-P-014 | 功能区 | 子账号/订单/权益/工单/代下单 |
| REQ-P-015 | 排行 | 核销/订单 Top10 |
| REQ-P-016 | 订单管理 | 筛时间状态 |
| REQ-P-017 | 订单详情 | 佣金快照下钻 |
| REQ-P-018 | 好客权益 | 送出/已核销/待核销 |
| REQ-P-019 | 工单 | 只读 |
| REQ-P-020 | 财务对账 | 本合伙人月账 |
| REQ-P-021 | 代下单 | Wave 3 |
| REQ-P-022 | 筛选校验 | 不达标标黄 |
| REQ-P-023 | 附件一 | 结构化录入 |
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
| REQ-P-025 | 合同同日 | 同日提交校验 |
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
## 总部端 REQ-H
| ID | 模块 | 结论摘要 |
|----|------|----------|
| REQ-H-001 | 登录 | 权限账户;7 天免登 |
| REQ-H-002 | 商品 | 增删改;上下架排序 |
| REQ-H-003 | 营销 | 1:1 权益永久 |
| REQ-H-004 | 开通城市 | 省/市/配送/是否开通 |
| REQ-H-004a | 创建合伙人 | 全城/区域+佣金;W3 管仓 |
| REQ-H-004b | 仓库 | Wave 3 多仓 |
| REQ-H-005 | 门店管理 | +归属合伙人筛选 |
| REQ-H-006 | 门店审核 | 通过/失败;改归属 |
| REQ-H-007 | 管辖佣金 | 区县全覆盖校验 |
| REQ-H-008 | 订单 | 全量+佣金快照 |
| REQ-H-009 | 权益 | 全量+合伙人筛 |
| REQ-H-010 | 工单 | 同意/拒绝+备注+日志 |
| REQ-H-011 | 发票 | 2 工作日;上传回传 |
| REQ-H-012 | 代下单 | 同合伙人 |
| REQ-H-013 | 结算 | 门店+合伙人双 Tab |
| REQ-H-014 | 提现审 | T+0FIN-003 |
| REQ-H-014a | 白名单 | FIN-001/002 |
| REQ-H-015 | 合伙人账单 | 待确认/待打款 |
| REQ-H-016 | 报表 | 城市/合伙人/区县+热力图 |
| REQ-H-017 | 推广码 | 绑定合伙人+统计 |
| REQ-H-018 | 问卷 | 回收看板 |
| REQ-H-019 | 门店评价 | 查看数据 |
| REQ-H-020 | 核销异常 | W1~2 人工;W3 列表 |
| REQ-H-020a | 待处理核销审 | W3 补核销/驳回 |
| REQ-H-021 | 话术 | OPT-012 |
| REQ-H-022 | 签约主体 | 山西领势酒业 |
| REQ-H-023 | 热力图 | 腾讯位置服务 |
| REQ-H-024 | 结算异议 | 门店 3 日异议处理 |
+109
View File
@@ -0,0 +1,109 @@
# V3.0 三波交付参考
> 事实源:[`杜康好客-v3-PRD.md`](../../../杜康好客-v3-PRD.md) §9
## Wave 1 — 核心交易 + 拓店(2026-07-10
### 门禁
| 门禁 | 能力 | ACC |
|------|------|-----|
| W1-S1 | 用户下单支付,权益 1:1 到账 | ACC-001 |
| W1-S2 | 门店核销(扫码 + 手机号) | ACC-005、OPT-007、OPT-011 |
| W1-S3 | 合伙人子账号 | REQ-P-005 |
| W1-S4 | 合伙人创建门店 → 总部审核 → C 端可见 | ACC-007、OPT-009、OPT-008 |
### 纳入
四端登录 7 天免登、商品、**同城**下单支付、小飞侠履约、出码/扫码/手机号核销、规则弹窗、合伙人子账号、门店三步录入、负责人复核、总部审核、试核销 100 元、登录短信。
### 不含
提现/打款、推广码、现场提货、代下单、弱网拍照、多合伙人、门店子账号、**跨城**、工单、发票。
### DLV 拆分
| ID | 内容 |
|----|------|
| DLV-W1-M1 | 四端登录、权限、UI Token、商品管理/展示 |
| DLV-W1-M2 | 支付、锁单 30 分、同城小飞侠、权益 1:1 |
| DLV-W1-M3 | 出码/扫码/手机号核销、规则弹窗 OPT-011 |
| DLV-W1-M4 | 合伙人子账号、门店三步、负责人复核、审核、试核销 OPT-009、短信 OPT-008 |
### Wave 1 弱网/代下单 SOP
- 弱网:重试 + 总部人工补核销(OPT-006 完整版在 W3)
- 代下单:运营人工 SOP(系统能力在 W3 OPT-002
---
## Wave 2 — 资金 + 品鉴会 + 组织扩展(2026-07-15
### 门禁
| 门禁 | 能力 | ACC |
|------|------|-----|
| W2-S1 | 未出账可手动提现 + FIN 护栏 | ACC-008、OPT-010、FIN-001~003 |
| W2-S2 | 总部审核打款 | REQ-H-014 |
| W2-S3 | 推广码 + 现场提货 | ACC-003/014、OPT-005 |
| W2-S4 | 门店主子账号 + 一号多店选店 | REQ-S-017~019、ACC-006 |
| W2-S5 | 多合伙人管辖/佣金/数据隔离 | ACC-P21~P28 |
### 不含
代下单、弱网 5 次拍照、一城多仓、跨城、工单、发票、问卷、热力图。
### DLV 拆分
| ID | 内容 |
|----|------|
| DLV-W2-M1 | T+1 出账、未出账提现 OPT-010 + FIN、总部审打款 |
| DLV-W2-M2 | 现场提货 OPT-005、推广码 REQ-H-017 |
| DLV-W2-M3 | 多合伙人 REQ-H-004a/007、区县互斥、佣金快照 |
| DLV-W2-M4 | 门店子账号 REQ-S-017~019、多店选店 |
---
## Wave 3 — 旁路 + 弱网 + 本期全量(2026-07-22
### 门禁
| 门禁 | 能力 | ACC |
|------|------|-----|
| W3-S1 | 代下单(总部/合伙人) | ACC-012、OPT-002 |
| W3-S2 | 弱网 5 次拍照兜底 + 总部补核销 | ACC-017、OPT-006 |
| W3-S3 | 一城多仓 + 合伙人管仓 | ACC-W01~W03 |
| W3-S4 | 跨城、工单、发票、合伙人 T+30 对账、问卷/评价/热力图 | G5、OPT-001/003/004/012 |
### 完成定义
G1G5 + OPT-001012 + 门店签约 SOP S1S12 全部勾选。
### DLV 拆分
| ID | 内容 |
|----|------|
| DLV-W3-M1 | 代下单 OPT-002 |
| DLV-W3-M2 | 弱网 OPT-006 完整版 |
| DLV-W3-M3 | 多仓、跨城、工单 OPT-003、发票 OPT-001 |
| DLV-W3-M4 | 合伙人月账、问卷/评价/热力图、OPT-012 |
| DLV-W3-M5 | ACC 全量回归 |
---
## OPT 波次对照
| OPT | 名称 | 波次 |
|-----|------|------|
| OPT-001 | 发票 | W3 |
| OPT-002 | 代下单 | W3 |
| OPT-003 | 工单 | W3 |
| OPT-004 | 问卷 | W3 |
| OPT-005 | 现场提货 | W2 |
| OPT-006 | 弱网兜底 | W3W1~2 简化版) |
| OPT-007 | 手机号核销 | W1 |
| OPT-008 | 入驻成功短信 | W1 |
| OPT-009 | 试核销 100 元 | W1 |
| OPT-010 | 未出账提现 | W2 |
| OPT-011 | 核销规则弹窗 | W1 |
| OPT-012 | 话术对齐 | W3 |
+43
View File
@@ -0,0 +1,43 @@
# 杜康好客 CODEOWNERS2 人团队)
# 仓库:阿里云 Codeup · 用户名与平台成员账号一致
#
# jacy-dukang — 管理员 + 主责(C 端、admin、后端主模块、横切)
# 刘京尧 — 合伙人端 + 门店端(h5-partner、h5-shop、store、redeem
# 文档 / 横切 / 基础设施
/AGENTS.md @jacy-dukang
/agent.md @jacy-dukang
/conventions.md @jacy-dukang
/skills.md @jacy-dukang
/杜康好客-V2编码手册.md @jacy-dukang
/杜康好客-preV1编码手册.md @jacy-dukang
/.cursor/ @jacy-dukang
/deploy/ @jacy-dukang
/packages/ @jacy-dukang @刘京尧
# jacy-dukang — C 端 + 交易/权益域 + 总部 admin
/apps/h5-user/ @jacy-dukang
/apps/admin-web/ @jacy-dukang
/server/dukang-api/src/modules/iam/ @jacy-dukang
/server/dukang-api/src/modules/trade/ @jacy-dukang
/server/dukang-api/src/modules/benefit/ @jacy-dukang
/server/dukang-api/src/modules/analytics/ @jacy-dukang
/server/dukang-api/src/modules/catalog/ @jacy-dukang
/server/dukang-api/src/modules/settlement/ @jacy-dukang
/server/dukang-api/src/modules/ops/ @jacy-dukang
# 刘京尧 — 合伙人端 + 门店端
/apps/h5-partner/ @刘京尧
/apps/h5-shop/ @刘京尧
/server/dukang-api/src/modules/store/ @刘京尧
/server/dukang-api/src/modules/redeem/ @刘京尧
# 横切(主责 jacy-dukang;改接口时 @刘京尧 若影响 partner/shop
/server/dukang-api/src/callbacks/ @jacy-dukang
/server/dukang-api/src/jobs/ @jacy-dukang @刘京尧
/server/dukang-api/src/common/ @jacy-dukang
/server/dukang-api/src/integrations/ @jacy-dukang
/server/dukang-api/prisma/ @jacy-dukang @刘京尧
# 原型参照
/pages/ @jacy-dukang
+21
View File
@@ -0,0 +1,21 @@
name: CI
on:
push:
branches: [main, master, develop]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install
- run: pnpm db:generate
- run: pnpm test
- run: pnpm lint
+8
View File
@@ -4,7 +4,15 @@ dist/
*.log
.env
.env.local
server/dukang-api/.env
server/dukang-api/.env.development
server/dukang-api/.env.production
server/dukang-api/.env.production.local
deploy/deploy.env
.DS_Store
coverage/
*.tsbuildinfo
server/dukang-api/prisma/migrations/
debug_v3.xlsx
~$debug_v3.xlsx
deploy/auto-release.env
+157
View File
@@ -0,0 +1,157 @@
# 杜康好客 · AGENTS.md
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。
## 项目概览
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
| 阶段 | 手册 | 说明 |
|------|------|------|
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
## 启动命令
```bash
# 基础设施
cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017
# 依赖与数据库
pnpm install
cp server/dukang-api/.env.example server/dukang-api/.env
pnpm db:generate && pnpm db:validate
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
# 开发(分终端)
pnpm dev:api # http://localhost:3000/api/v1
pnpm dev:user # :5173
pnpm dev:shop # :5174
pnpm dev:partner # :5175
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
# 验证
node scripts/smoke-v3.mjs
node scripts/smoke-prev1.mjs
pnpm lint && pnpm test
```
Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
> **端口冲突**`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
## 文档优先级(冲突时)
1. `杜康好客-v3-PRD.md`**唯一需求事实源**
2. `杜康好客-v3-现状对照.md` — 实现进度审计
3. `杜康好客-v3编码手册.md` — 实现验收、核销、分工
4. `conventions.md` — 协作与模块边界
5. V2 手册 §四 §五 §六 — 仅作架构/DB/API **参考**(与 V3 冲突时忽略)
6. `pages/{user,shop,partner,hq}/` — UI 参照
> **V2 / preV1 手册不再作为需求依据。**
## 团队与 OWNER 边界(2 人)
| 负责人 | Git 账号 | 职责 |
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管** |
> **临时分工(2026-07**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下:
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|------------|--------|----------|-------------|
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
**Prisma 迁移**jacy-dukang 主导 Review;涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review)。
### 跨模块规则(R1–R8 摘要)
- 只 inject 对方 Module **exports 的 Service**,禁止 `prisma.xxx` 写他人表
- `apps/*` 禁止 import `server/*` 源码;只走 HTTP + `packages/shared-types`
- 业务纯规则 → `packages/domain`;枚举/DTO → `packages/shared-types`
- 微信/配送回调入口 **仅** `callbacks/`Mock 实现 **仅** `integrations/*`
## Cursor 规范
| 类型 | 路径 | 说明 |
|------|------|------|
| Rules | `.cursor/rules/` | 自动加载的编码规范 |
| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
| V3.0 需求 Skill | `.cursor/skills/dukang-v3/` | @dukang-v3PRD、Wave、REQ/ACC |
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用)
在 Cursor Agent 输入 `/` 选择:
| 子 Agent | 负责人 | 适用场景 |
|----------|--------|----------|
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
## Skills(按需 @
| Skill | 何时用 |
|-------|--------|
| `dukang-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
- PR 前审查:`/boundary-reviewer`
- 原型参照:`pages/{user,shop,partner}/`(只读)
## 核心业务常量(不可偏离)
```
权益额 = benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
C 端门店仅 status=OPEN
订单 Tab:待付款 | 已付款 | 已完成
```
## 提交与 PR
- Conventional Commits`feat(trade):``fix(redeem):`scope = 端或模块
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
- 不提交 `.env``dist/``node_modules/`
## 完成定义(DoD
- [ ] 任务卡验收项全部满足
- [ ] 未跨模块直写 Prisma 表
- [ ] 枚举/DTO 在 `shared-types`;纯规则在 `domain`
- [ ] `pnpm lint` 无新增错误;相关 domain 单测通过
## 嵌套指引
- 后端细节:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)
- 前端四 App[`apps/AGENTS.md`](./apps/AGENTS.md)
+15 -7
View File
@@ -1,6 +1,6 @@
# 杜康好客 preV1
# 杜康好客 V3 交付版
端 H5 + NestJS Mock 联调版。完整规格见 `杜康好客-V2编码手册.md`preV1 裁剪见 `杜康好客-preV1编码手册.md`
端 H5/WebAdmin + NestJS 单体 API。**V3 交付验收**以 [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) 为准;V2 为完整蓝图参考;preV1 为 Mock 联调历史参考
## 快速启动
@@ -8,7 +8,7 @@
```bash
cd deploy
docker compose up -d
docker compose up -d # MySQL :6016 Redis :6017(与 dukang-v3 的 6014/6015 隔离)
```
### 2. 安装依赖
@@ -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. 启动服务
@@ -37,6 +37,9 @@ pnpm dev:api
pnpm dev:user # http://localhost:5173
pnpm dev:shop # http://localhost:5174
pnpm dev:partner # http://localhost:5175
改动测试
```
## 测试账号(验证码均为 123456)
@@ -44,15 +47,20 @@ pnpm dev:partner # http://localhost:5175
| 端 | 手机号 |
|----|--------|
| C端用户 | 13800000001 |
| 门店 | 13900000001 |
| 合伙人 | 13700000001 |
| HQ | 13600000001(密码登录:`admin` / `dukang@123!`;短信验证码 123456 |
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`
## 主链路冒烟
```bash
node scripts/smoke-prev1.mjs
node scripts/smoke-v3.mjs # V3 交付验收
node scripts/smoke-prev1.mjs # preV1 历史回归
```
验收清单见 [`V3_ACCEPTANCE_CHECKLIST.md`](./V3_ACCEPTANCE_CHECKLIST.md)。
## 目录结构
- `apps/h5-user` · `apps/h5-shop` · `apps/h5-partner` — 三端 H5Vite + React
+52
View File
@@ -0,0 +1,52 @@
# V3 验收清单
> 业务事实源:[杜康好客-v3编码手册.md](./杜康好客-v3编码手册.md)
> 自动化:`node scripts/smoke-v3.mjs`Mock 环境)
> 历史回归:`node scripts/smoke-prev1.mjs`preV1 单链路)
## 必过主链路
| # | 验收项 | Owner | API / 页面 | 自动化 |
|---|--------|-------|------------|--------|
| 1 | C 端手机号登录 | jacy | `POST /auth/login/sms` · h5-user `/login` | smoke-v3 |
| 2 | 首页郑州 4 商品 | jacy | `GET /catalog/products?cityCode=` · h5-user `/` | smoke-v3 |
| 3 | 同城 1 瓶失败 | jacy | `POST /trade/orders/preview` | smoke-v3 |
| 4 | 同城 2 瓶成功 | jacy | `POST /trade/orders` | smoke-v3 |
| 5 | 跨城 5/6 瓶边界 | jacy | preview + create | smoke-v3 |
| 6 | 支付后发权益 | jacy | pay + benefit | smoke-v3 |
| 7 | 直接核销(无 couponId | jacy + 刘京尧 | `POST /redeem/tokens` | smoke-v3 |
| 8 | 单据核销 cap | jacy + 刘京尧 | `POST /redeem/tokens` + couponId | smoke-v3 |
| 9 | 门店扫码确认 | 刘京尧 | `POST /shop/redeem/confirm` · h5-shop | smoke-v3 |
| 10 | store_payout 生成 | jacy | settlement | smoke-v3 |
| 11 | 关闭门店不可核销 | 刘京尧 | store status + redeem | smoke-v3 |
| 12 | 录店审核后 C 端可见 | 刘京尧 + jacy | admin stores audit · h5-partner | 手动 |
| 13 | 配送推进完成 | jacy | jobs / delivery callback | smoke-v3 |
| 14 | 退款工单一致 | jacy | tickets + benefit void | smoke-v3 |
| 15 | 门店 T+1 打款确认 | jacy | admin store-payouts | smoke-v3 |
| 16 | 合伙人 T+30 账单 | jacy | admin partner-bills | smoke-v3 |
## 必过后台链路
| # | 验收项 | 页面 |
|---|--------|------|
| 1 | WebAdmin 登录 | admin-web `/login` |
| 2 | 商品 CRUD | `/products` |
| 3 | 审核门店 | `/stores` |
| 4 | 订单与配送 | `/orders` · `/deliveries` |
| 5 | 权益/核销/打款 | `/benefit/*` · `/redeem-records` · `/store-payouts` |
| 6 | 退款/补发工单 | `/tickets` |
| 7 | 第三方日志 | `/third-party-logs` |
| 8 | 财务导出 | `/partner-bills` export |
## 环境矩阵
| 环境 | MOCK_SMS | MOCK_PAY | MOCK_DELIVERY_AUTO | 说明 |
|------|----------|----------|-------------------|------|
| 本地开发 | true | true | true | 固定验证码 123456 |
| 测试沙箱 | false | false | false | 微信/配送沙箱 |
| 生产 | false | false | false | 真实第三方 |
## 合伙人端交接(刘京尧)
Jacy 侧交付:`/partner/*` 后端 API、shared-types DTO、smoke 断言。
刘京尧负责:`apps/h5-partner` 页面接入与验收。
+20 -17
View File
@@ -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. 模块 OWNER2 人团队
| 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 账号 | ApppreV1 → V2 | Module |
|--------|----------|-------------------|--------|
| Jacy | jacy-dukang | h5-user、admin-web → mini-user/mini-hq | iam, trade, benefit, analytics, catalog, settlement, ops |
| 刘景尧 | 刘景尧 | h5-partner、h5-shop → mini-partner | store, redeem |
| Jacy(横切) | jacy-dukang | packages/*, callbacks/, jobs/, integrations/ | 基础设施 |
**禁止**Module A 直写 Module B 的 Prisma 表;apps import server 源码。
@@ -90,7 +93,7 @@
```text
权益发放额 = common_product_item.benefit_amount ?? price
同城起购 2 瓶 / 跨城 6 瓶
核销:0 < amount ≤ min(balance, 500)
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
C 端门店列表仅 status=OPEN
订单 Taball | pending_pay | pending_ship | pending_receive | completed
支付回调幂等 → 发券 → common_event(BENEFIT_LEDGER, GRANT)
@@ -132,4 +135,4 @@ dukang/
---
*编码时 @ 本文件或阅读 `skills.md`;需求变更只改 `杜康好客-V2编码手册.md`。*
*编码时 @ [`AGENTS.md`](./AGENTS.md) 或 Skill `dukang-coding`;需求变更只改 `杜康好客-V2编码手册.md`。*
+65
View File
@@ -0,0 +1,65 @@
# 杜康好客 · 前端 Apps AGENTS.md
> 父级:[`../AGENTS.md`](../AGENTS.md) · 路由映射:[`../pages/ROUTE_MAP.md`](../pages/ROUTE_MAP.md)
## App 一览(preV1
| App | 端口 | X-Client-App | 负责人 | 原型 |
|-----|------|--------------|--------|------|
| `h5-user` | 5173 | `USER_H5` | jacy-dukang | `pages/user/` |
| `mini-user` | 5177 / weapp | `USER_MINI` | jacy-dukang | `pages/user/`V3 目标形态) |
| `h5-shop` | 5174 | `SHOP_H5` | 刘景尧 | `pages/shop/` |
| `h5-partner` | 5175 | `PARTNER_H5` | 刘景尧 | `pages/partner/` |
| `admin-web` | — | Admin JWT | jacy-dukang | preV1 内部 HQ 替代 |
V3 目标:C 端交付为微信小程序 `apps/mini-user`(当前与 `h5-user` 并行;功能以 H5 为过渡参考)。
V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
## 前端硬规则
- 请求基址 `/api/v1`Header`Authorization` + `X-Client-App`
- 类型从 `@dukang/shared-types` import,禁止复制枚举字符串
- 禁止 import `server/` 或另一个 `apps/*` 的源码
- UI 共享组件优先 `@dukang/shared-ui`
- C 端订单列表 **3 Tab**`待付款 | 已付款 | 已完成`Tab key: `pending_pay` / `paid` / `completed`
## 新页面 workflow
1.`pages/ROUTE_MAP.md` 查 Stitch screen ↔ route
2. 对照 `pages/{端}/*/code.html` + `screen.png`(只读参照)
3. 在对应 App 的 `src/pages/` 实现;API 封装放 `src/lib/api.ts`
4. 登录态:各 App 自有 Context/Storage,不跨 App 共享
## 各 App 职责边界
### h5-userjacy-dukang
主链路:登录 → 首页/详情 → 下单 Mock 支付 → **3 Tab** 订单 → 权益 → 核销码 → 门店列表
**勿改**:门店核销确认 UI(属 h5-shop)、合伙人录店(属 h5-partner
### h5-shop(刘景尧)
主链路:门店登录 → 首页 → 扫码/输入核销 → 确认 → 记录 → 营业状态
**勿改**:C 端出码页面(属 h5-user)
### h5-partner(刘景尧)
主链路:登录 → 工作台 → 录店 → 门店列表 → 辖区订单 → Mock 推进配送(preV1
**勿改**:C 端商品/订单页、总部报表(admin-web / V2 mini-hq
### admin-webjacy-dukangpreV1 限定)
内部 Web 管理:开城、商品、订单、门店审核、结算查看。**不是** V2 总部小程序规格。
变更 admin-web 时勿假设与 `pages/hq/` 原型 1:1V2 需另建 `mini-hq`
## 启动
```bash
pnpm dev:user | dev:shop | dev:partner | dev:admin
pnpm dev:mini-user # C 端小程序 H5 预览 :5177
pnpm dev:mini-user:weapp # 微信开发者工具打开 apps/mini-user/dist
```
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>杜康好客 · HQ 管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@dukang/admin-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5175",
"build": "vite build",
"lint": "echo ok"
},
"dependencies": {
"@ant-design/icons": "^5.5.1",
"@dukang/shared-types": "workspace:*",
"antd": "^5.22.0",
"dayjs": "^1.11.13",
"element-china-area-data": "^6.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"typescript": "^5.4.5",
"vite": "^5.4.0"
}
}
+120
View File
@@ -0,0 +1,120 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { getToken } from './lib/api';
import AdminLayout from './layouts/AdminLayout';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage';
import StoresPage from './pages/StoresPage';
import StoreAccountsPage from './pages/StoreAccountsPage';
import BenefitCouponsPage from './pages/BenefitCouponsPage';
import BenefitLedgersPage from './pages/BenefitLedgersPage';
import RedeemRecordsPage from './pages/RedeemRecordsPage';
import PendingRedeemPage from './pages/PendingRedeemPage';
import RedeemDebugPage from './pages/RedeemDebugPage';
import DeliveriesPage from './pages/DeliveriesPage';
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
import StoreLogsPage from './pages/StoreLogsPage';
import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage';
import WecomBotsPage from './pages/WecomBotsPage';
import LlmConfigsPage from './pages/LlmConfigsPage';
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
element={
<RequireAuth>
<AdminLayout />
</RequireAuth>
}
>
<Route path="/" element={<DashboardPage />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
<Route path="/orders" element={<OrdersPage />} />
<Route path="/promo-codes" element={<PromoCodesPage />} />
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
<Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} />
</Route>
<Route path="/wecom-bots" element={<WecomBotsPage />} />
<Route path="/llm-configs" element={<LlmConfigsPage />} />
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
<Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/partners" element={<Navigate to="/city-partners" replace />} />
<Route path="/cities" element={<CitiesPage />} />
<Route path="/city-partners" element={<CityPartnersPage />} />
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/tickets/support" element={<SupportTicketsPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/partners" element={<PartnerLogsPage />} />
<Route path="/logs/hq" element={<HqLogsPage />} />
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
<Route path="/deliveries" element={<DeliveriesPage />} />
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
<Route path="/system-settings" element={<SystemSettingsPage />} />
<Route path="/hq-accounts" element={<HqAccountsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+88
View File
@@ -0,0 +1,88 @@
html,
body,
#root {
height: 100%;
margin: 0;
}
/* 左侧导航可滚动但不显示滚动条 */
.admin-sider-menu-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.admin-sider-menu-scroll::-webkit-scrollbar {
display: none;
}
/*
* 后台内容行:不换行 + 超出省略
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
*/
.admin-layout .ant-table-cell,
.ant-drawer .ant-table-cell,
.ant-modal .ant-table-cell,
.admin-layout .ant-descriptions-item-content,
.ant-drawer .ant-descriptions-item-content,
.ant-modal .ant-descriptions-item-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
}
.admin-layout .ant-descriptions-item-content,
.ant-drawer .ant-descriptions-item-content,
.ant-modal .ant-descriptions-item-content {
max-width: 100%;
}
/* 表头同样不换行 */
.admin-layout .ant-table-thead > tr > th,
.ant-drawer .ant-table-thead > tr > th,
.ant-modal .ant-table-thead > tr > th {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 操作列、含交互控件的不截断 */
.admin-layout .ant-table-cell:has(.ant-btn),
.ant-drawer .ant-table-cell:has(.ant-btn),
.ant-modal .ant-table-cell:has(.ant-btn),
.admin-layout .ant-table-cell:has(.ant-space),
.ant-drawer .ant-table-cell:has(.ant-space),
.ant-modal .ant-table-cell:has(.ant-space) {
overflow: visible;
text-overflow: clip;
max-width: none;
}
/* 代码块、表单区域保持原样 */
.admin-layout pre,
.ant-drawer pre,
.ant-modal pre,
.admin-layout .ant-form-item-control-input,
.ant-drawer .ant-form-item-control-input,
.ant-modal .ant-form-item-control-input {
white-space: pre-wrap;
overflow: auto;
text-overflow: unset;
max-width: none;
}
/* 表格单元格双行信息合并为单行省略 */
.admin-cell-line {
display: block;
max-width: 100%;
}
.admin-cell-line-secondary {
color: rgba(0, 0, 0, 0.45);
font-size: 12px;
}
.admin-table-nowrap .ant-table-cell,
.admin-table-nowrap .ant-table-cell-ellipsis {
white-space: nowrap;
}
@@ -0,0 +1,34 @@
import { Typography } from 'antd';
type AdminCellLineProps = {
primary?: string | null;
secondary?: string | null;
separator?: string;
};
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
export function AdminCellLine({
primary,
secondary,
separator = ' · ',
}: AdminCellLineProps) {
const main = primary?.trim() || '—';
const sub = secondary?.trim();
const full = sub ? `${main}${separator}${sub}` : main;
return (
<Typography.Text
className="admin-cell-line"
ellipsis={{ tooltip: full }}
style={{ maxWidth: '100%' }}
>
{main}
{sub ? (
<span className="admin-cell-line-secondary">
{separator}
{sub}
</span>
) : null}
</Typography.Text>
);
}
@@ -0,0 +1,34 @@
import { Cascader } from 'antd';
import type { DefaultOptionType } from 'antd/es/cascader';
import { PROVINCE_CITY_OPTIONS } from '../lib/china-region';
type ChinaProvinceCityCascaderProps = {
value?: string[];
onChange?: (codes: string[]) => void;
disabled?: boolean;
placeholder?: string;
};
export default function ChinaProvinceCityCascader({
value,
onChange,
disabled,
placeholder = '请选择省 / 市',
}: ChinaProvinceCityCascaderProps) {
return (
<Cascader
options={PROVINCE_CITY_OPTIONS as DefaultOptionType[]}
value={value}
onChange={(codes) => onChange?.((codes ?? []) as string[])}
disabled={disabled}
placeholder={placeholder}
showSearch={{
filter: (input, path) =>
path.some((option) =>
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
),
}}
changeOnSelect={false}
/>
);
}
@@ -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 (
<Cascader
options={CHINA_REGION_OPTIONS as DefaultOptionType[]}
value={value}
onChange={(codes) => onChange?.((codes ?? []) as string[])}
disabled={disabled}
placeholder={placeholder}
showSearch={{
filter: (input, path) =>
path.some((option) =>
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
),
}}
changeOnSelect={false}
/>
);
}
@@ -0,0 +1,42 @@
import { useMemo } from 'react';
import { Select } from 'antd';
import { getDistrictOptionsByCityCode } from '../lib/china-region';
type Props = {
cityCode?: string | null;
value?: string[];
onChange?: (value: string[]) => void;
placeholder?: string;
disabled?: boolean;
};
/** 已选开城城市后,仅多选该市下区县(不再选省/市) */
export default function CityDistrictMultiSelect({
cityCode,
value,
onChange,
placeholder,
disabled,
}: Props) {
const options = useMemo(() => getDistrictOptionsByCityCode(cityCode), [cityCode]);
const ready = Boolean(cityCode) && options.length > 0;
return (
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
value={value}
onChange={onChange}
disabled={disabled || !ready}
placeholder={
placeholder ??
(cityCode ? (ready ? '请选择区县(可多选)' : '该城市暂无区县数据') : '请先选择开城城市')
}
options={options}
style={{ width: '100%' }}
maxTagCount="responsive"
/>
);
}
@@ -0,0 +1,459 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Checkbox,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = {
id: string;
companyName: string;
phone: string;
name: string;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = PartnerRow & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
contactPhone?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type Props = {
cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number;
onChanged?: () => void;
};
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
function commissionSumError(
orderPercent: number,
redeemPercent: number,
maxRate: number,
): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPanel({
cityId,
cityCode,
maxPartnerCommissionRate = 0.05,
onChanged,
}: Props) {
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [partners, setPartners] = useState<PartnerRow[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const loadPartners = useCallback(async () => {
setLoading(true);
try {
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
setPartners(res.items);
} finally {
setLoading(false);
}
}, [cityId]);
useEffect(() => {
void loadPartners();
}, [loadPartners]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
async function deleteSubAccount(subId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
if (detail) void openPartner(detail.id);
void loadPartners();
onChanged?.();
}
const columns: ColumnsType<PartnerRow> = [
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 120,
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<>
<Button
type="primary"
style={{ marginBottom: 12 }}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({
cityId,
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateOpen(true);
}}
>
</Button>
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<Tabs
items={[
{
key: 'info',
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<Form.Item label="管仓仓库">
<Input
disabled
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
/>
</Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
),
},
{
key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(row) => {
setSubEditId(row.id);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}}
onDelete={(subId) => void deleteSubAccount(subId)}
/>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
try {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
void openPartner(detail.id);
void loadPartners();
onChanged?.();
}}>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
if (!subEditId || !detail) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
void openPartner(detail.id);
}}>
<Form form={subEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</>
);
}
@@ -0,0 +1,138 @@
import { useEffect, useState } from 'react';
import { Button, Space } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import {
parseMiniHomeBanners,
serializeMiniHomeBanners,
} from '@dukang/shared-types';
import OssUpload from './OssUpload';
const MAX_BANNERS = 8;
/** 编辑态保留空位;下发/入库仍用 parseMiniHomeBanners 过滤空串 */
function parseBannersForEdit(raw?: string | null): string[] {
if (!raw?.trim()) return [];
try {
const parsed = JSON.parse(raw.trim()) as unknown;
if (!Array.isArray(parsed)) return parseMiniHomeBanners(raw);
return parsed
.filter((u): u is string => typeof u === 'string')
.map((u) => u.trim())
.slice(0, MAX_BANNERS);
} catch {
return parseMiniHomeBanners(raw);
}
}
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer */
export function ConfigImageField({
value,
onChange,
bizType = 'footer',
}: {
value?: string;
onChange?: (url: string) => void;
bizType?: string;
}) {
return (
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={value ?? ''}
onChange={(url) => onChange?.(url ?? '')}
placeholder="上传或粘贴图片 URL"
/>
);
}
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper */
export function ConfigImageListField({
value,
onChange,
bizType = 'swiper',
}: {
value?: string;
onChange?: (json: string) => void;
bizType?: string;
}) {
const [urls, setUrls] = useState<string[]>(() => parseBannersForEdit(value));
useEffect(() => {
setUrls(parseBannersForEdit(value));
}, [value]);
/** 本地可含空位;写入 Form 时去掉空串 */
function commit(next: string[]) {
const clipped = next.slice(0, MAX_BANNERS);
setUrls(clipped);
onChange?.(serializeMiniHomeBanners(clipped));
}
function updateAt(index: number, url: string) {
const next = [...urls];
next[index] = url;
commit(next);
}
function removeAt(index: number) {
commit(urls.filter((_, i) => i !== index));
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= urls.length) return;
const next = [...urls];
const tmp = next[index];
next[index] = next[target];
next[target] = tmp;
commit(next);
}
function add() {
if (urls.length >= MAX_BANNERS) return;
// 只加本地空位,避免 serialize 过滤空串导致「点击无反应」
setUrls((prev) => [...prev, '']);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{urls.map((url, index) => (
<Space key={`banner-${index}`} align="start" style={{ width: '100%' }} wrap>
<div style={{ flex: 1, minWidth: 240 }}>
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={url}
onChange={(u) => updateAt(index, u)}
placeholder="上传或粘贴图片 URL"
/>
</div>
<Space>
<Button
type="text"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => move(index, -1)}
/>
<Button
type="text"
icon={<ArrowDownOutlined />}
disabled={index === urls.length - 1}
onClick={() => move(index, 1)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
</Space>
</Space>
))}
<Button
type="dashed"
block
icon={<PlusOutlined />}
disabled={urls.length >= MAX_BANNERS}
onClick={add}
>
{urls.length}/{MAX_BANNERS}
</Button>
</Space>
);
}
@@ -0,0 +1,49 @@
import { Button, Form, Space, Typography } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import OssUpload from './OssUpload';
type Props = {
name?: string;
label: string;
bizType?: string;
/** 最多可添加张数;不传则不限制 */
maxCount?: number;
};
export default function DetailImageUrlList({
name = 'detailImageUrls',
label,
bizType = 'DETAIL',
maxCount,
}: Props) {
return (
<>
{maxCount != null && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{maxCount} {label}
</Typography.Text>
)}
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
{(!maxCount || fields.length < maxCount) && (
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
)}
</>
)}
</Form.List>
</>
);
}
@@ -0,0 +1,87 @@
import { useState } from 'react';
import { Button, Image, Input, Space, Upload, message } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
type OssUploadProps = {
value?: string;
onChange?: (url: string) => void;
onUploaded?: (result: UploadFileResult) => void;
bizType: string;
mediaType?: OssMediaType;
accept?: string;
maxSizeMb?: number;
placeholder?: string;
};
const DEFAULT_MAX_MB = 10;
export default function OssUpload({
value,
onChange,
onUploaded,
bizType,
mediaType = 'IMAGE',
accept,
maxSizeMb = DEFAULT_MAX_MB,
placeholder = '上传后自动填入,或手动粘贴 URL',
}: OssUploadProps) {
const [uploading, setUploading] = useState(false);
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
const raw = file as File;
if (raw.size > maxSizeMb * 1024 * 1024) {
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
message.error(err.message);
onError?.(err);
return;
}
setUploading(true);
try {
const result = await uploadFileToOss(raw, { bizType, mediaType });
onChange?.(result.url);
onUploaded?.(result);
message.success('上传成功');
onSuccess?.(result);
} catch (e) {
const err = e instanceof Error ? e : new Error('上传失败');
message.error(err.message);
onError?.(err);
} finally {
setUploading(false);
}
};
return (
<Space direction="vertical" style={{ width: '100%' }} size="small">
{value && mediaType === 'IMAGE' && (
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
)}
{value && mediaType === 'VIDEO' && (
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
)}
<Space wrap>
<Upload
accept={resolvedAccept}
showUploadList={false}
customRequest={customRequest}
disabled={uploading}
>
<Button icon={<UploadOutlined />} loading={uploading}>
{mediaType === 'VIDEO' ? '上传视频' : mediaType === 'FILE' ? '上传文件' : '上传图片'}
</Button>
</Upload>
</Space>
<Input
value={value}
placeholder={placeholder}
onChange={(e) => onChange?.(e.target.value)}
allowClear
/>
</Space>
);
}
@@ -0,0 +1,92 @@
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
export type PartnerSubAccountRow = {
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
};
function formatPermissions(permissions?: string[]) {
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
}
type Props = {
subs: PartnerSubAccountRow[];
onAdd: () => void;
onEdit: (sub: PartnerSubAccountRow) => void;
onDelete: (subId: string) => void;
};
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
const columns: ColumnsType<PartnerSubAccountRow> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '角色',
dataIndex: 'staffRole',
width: 90,
render: (v) =>
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '权限',
dataIndex: 'permissions',
ellipsis: true,
render: (p: string[] | undefined) => formatPermissions(p),
},
{
title: '操作',
width: 120,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => onEdit(row)}>
</Button>
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{subs.length}·
</Typography.Text>
<Button size="small" type="primary" onClick={onAdd}>
</Button>
</div>
<Table
size="small"
rowKey="id"
pagination={false}
columns={columns}
dataSource={subs}
locale={{ emptyText: '暂无子账号' }}
/>
</>
);
}
@@ -0,0 +1,185 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Card, Popconfirm, Select, Space, Spin, Typography, message } from 'antd';
import type { FormInstance } from 'antd/es/form';
import { request, type Paginated } from '../lib/api';
import {
getProductDetailTemplate,
mapDtoToProductDetailTemplate,
type ProductDetailTemplate,
type ProductDetailTemplateDto,
} from '../lib/product-detail-templates';
type Props = {
form: FormInstance;
/** 当前商品香型,用于推荐匹配模板 */
aromaType?: string;
};
function hasDetailContent(form: FormInstance) {
const storyTitle = form.getFieldValue('storyTitle') as string | undefined;
const storyText = form.getFieldValue('storyText') as string | undefined;
const features = form.getFieldValue('features') as Array<{ title?: string; desc?: string }> | undefined;
const detailImageUrls = form.getFieldValue('detailImageUrls') as string[] | undefined;
const hasFeatures = (features ?? []).some((f) => f?.title?.trim() || f?.desc?.trim());
const hasImages = (detailImageUrls ?? []).some((u) => u?.trim());
return Boolean(storyTitle?.trim() || storyText?.trim() || hasFeatures || hasImages);
}
function applyTemplate(form: FormInstance, template: ProductDetailTemplate) {
const { content } = template;
const detailImageUrls = content.detailImageUrls?.length
? [...content.detailImageUrls]
: [''];
form.setFieldsValue({
storyTitle: content.storyTitle ?? '',
storyText: content.storyText ?? '',
detailImageUrls,
features:
content.features && content.features.length > 0
? content.features.map((f) => ({ ...f }))
: [{ icon: 'star', title: '', desc: '' }],
});
const imageHint = detailImageUrls.filter(Boolean).length;
message.success(
imageHint > 0
? `已应用模板「${template.label}」(含 ${imageHint} 张详情图,可逐张修改)`
: `已应用模板「${template.label}`,
);
}
export default function ProductDetailTemplatePicker({ form, aromaType }: Props) {
const [templates, setTemplates] = useState<ProductDetailTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [selectedId, setSelectedId] = useState<string | undefined>();
useEffect(() => {
let cancelled = false;
setLoading(true);
request<Paginated<ProductDetailTemplateDto>>(
'/admin/product-detail-templates?status=ACTIVE&pageSize=100',
)
.then((res) => {
if (cancelled) return;
const mapped = res.items.map(mapDtoToProductDetailTemplate);
setTemplates(mapped);
const defaultId =
(aromaType && mapped.find((t) => t.aromaType === aromaType)?.id) ||
mapped.find((t) => t.code === 'dukang-classic')?.id ||
mapped[0]?.id;
setSelectedId(defaultId);
})
.catch(() => {
if (!cancelled) message.error('加载详情模板失败');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [aromaType]);
const selected = getProductDetailTemplate(templates, selectedId ?? '');
function doApply() {
if (!selected) return;
applyTemplate(form, selected);
}
function handleApply() {
if (!selected) return;
if (hasDetailContent(form)) {
return;
}
doApply();
}
if (loading) {
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Spin size="small" />
</Card>
);
}
if (templates.length === 0) {
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Typography.Text type="secondary"></Typography.Text>
</Card>
);
}
return (
<Card size="small" title="详情模板" style={{ marginBottom: 16 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
</Typography.Paragraph>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Select
style={{ width: '100%' }}
placeholder="选择模板"
value={selectedId}
onChange={setSelectedId}
options={templates.map((t) => ({
value: t.id,
label: t.label,
}))}
/>
{selected && (
<Alert
type="info"
showIcon
message={selected.description}
description={
<div style={{ fontSize: 12 }}>
{selected.content.storyTitle && (
<div><strong></strong>{selected.content.storyTitle}</div>
)}
{(selected.content.detailImageUrls?.length ?? 0) > 0 && (
<div style={{ marginTop: 4 }}>
<strong>{selected.content.detailImageUrls?.length}</strong>
</div>
)}
{(selected.content.features?.length ?? 0) > 0 && (
<div style={{ marginTop: 4 }}>
{selected.content.features?.map((f) => f.title).join('、')}
</div>
)}
</div>
}
/>
)}
<Space>
{hasDetailContent(form) ? (
<Popconfirm
title="将覆盖当前详情图、故事与卖点,是否继续?"
onConfirm={doApply}
okText="覆盖应用"
cancelText="取消"
>
<Button type="primary"></Button>
</Popconfirm>
) : (
<Button type="primary" onClick={handleApply} disabled={!selected}>
</Button>
)}
{aromaType && (
<Button
onClick={() => {
const matched = templates.find((t) => t.aromaType === aromaType);
if (matched) {
setSelectedId(matched.id);
if (!hasDetailContent(form)) {
applyTemplate(form, matched);
}
}
}}
>
</Button>
)}
</Space>
</Space>
</Card>
);
}
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Empty, Input, List, Modal, Space, Spin, Typography, message } from 'antd';
import { request } from '../lib/api';
import {
placeToPicked,
type LbsPlaceItem,
type TencentPickedLocation,
} from '../lib/tencentLocPicker';
type Props = {
open: boolean;
onClose: () => void;
onPick: (loc: TencentPickedLocation) => void;
latitude?: number | null;
longitude?: number | null;
/** 城市名,提升搜索相关性 */
region?: string | null;
};
function hasCoords(lat: unknown, lng: unknown): lat is number {
const a = typeof lat === 'number' ? lat : Number(lat);
const b = typeof lng === 'number' ? lng : Number(lng);
return Number.isFinite(a) && Number.isFinite(b) && !(a === 0 && b === 0);
}
export default function TencentLocPickerModal({
open,
onClose,
onPick,
latitude,
longitude,
region,
}: Props) {
const [keyword, setKeyword] = useState('');
const [items, setItems] = useState<LbsPlaceItem[]>([]);
const [loading, setLoading] = useState(false);
const [pending, setPending] = useState<TencentPickedLocation | null>(null);
const [hint, setHint] = useState('输入地点名称搜索,或加载附近地点');
const seqRef = useRef(0);
useEffect(() => {
if (!open) {
setKeyword('');
setItems([]);
setPending(null);
setHint('输入地点名称搜索,或加载附近地点');
return;
}
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (hasCoords(lat, lng)) {
void loadNearby(lat, lng);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
async function loadNearby(lat: number, lng: number) {
const seq = ++seqRef.current;
setLoading(true);
setHint('正在加载附近地点…');
try {
const res = await request<{ items: LbsPlaceItem[] }>(
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点,点击选择` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '加载附近地点失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) {
message.warning('请输入搜索关键词');
return;
}
const seq = ++seqRef.current;
setLoading(true);
setHint('搜索中…');
try {
const params = new URLSearchParams({ keyword: trimmed });
if (region?.trim()) params.set('region', region.trim());
const lat = latitude != null ? Number(latitude) : NaN;
const lng = longitude != null ? Number(longitude) : NaN;
if (Number.isFinite(lat) && Number.isFinite(lng)) {
params.set('lat', String(lat));
params.set('lng', String(lng));
}
const res = await request<{ items: LbsPlaceItem[] }>(`/common/lbs/suggest?${params.toString()}`);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果,点击选择` : '无匹配结果,换个关键词试试');
} catch (e) {
if (seq !== seqRef.current) return;
const msg = e instanceof Error ? e.message : '搜索失败';
setItems([]);
setHint(msg);
message.error(msg);
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
message.error('当前浏览器不支持定位');
return;
}
setLoading(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
setPending({ latitude: lat, longitude: lng, name: '当前位置' });
void loadNearby(lat, lng);
},
() => {
setLoading(false);
message.error('定位失败,请检查浏览器定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
function confirmPick() {
if (!pending) {
message.warning('请先从列表中选择一个地点');
return;
}
onPick(pending);
onClose();
}
return (
<Modal
title="地图选点(腾讯位置服务)"
open={open}
onCancel={onClose}
width={720}
destroyOnClose
footer={
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Typography.Text type="secondary" style={{ maxWidth: 420 }} ellipsis>
{pending
? `${pending.latitude.toFixed(6)}, ${pending.longitude.toFixed(6)}${
pending.name ? ` · ${pending.name}` : ''
}`
: '搜索或选择附近地点后确认'}
</Typography.Text>
<Space>
<Button onClick={onClose}></Button>
<Button type="primary" disabled={!pending} onClick={confirmPick}>
</Button>
</Space>
</Space>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space.Compact style={{ width: '100%' }}>
<Input
allowClear
placeholder="输入小区 / 写字楼 / 门店名称"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onPressEnter={() => void runSearch(keyword)}
/>
<Button type="primary" loading={loading} onClick={() => void runSearch(keyword)}>
</Button>
</Space.Compact>
<Space wrap>
<Button onClick={useBrowserLocation} disabled={loading}>
</Button>
<Typography.Text type="secondary">{hint}</Typography.Text>
</Space>
<div style={{ height: 420, overflow: 'auto', border: '1px solid #f0f0f0', borderRadius: 8 }}>
{loading && !items.length ? (
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin tip="加载中…" />
</div>
) : items.length ? (
<List
size="small"
dataSource={items}
renderItem={(item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<List.Item
style={{
cursor: 'pointer',
background: active ? 'rgba(22, 119, 255, 0.08)' : undefined,
paddingInline: 12,
}}
onClick={() => setPending(placeToPicked(item))}
>
<List.Item.Meta
title={item.title}
description={
<span>
{item.address}
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</Typography.Text>
</span>
}
/>
</List.Item>
);
}}
/>
) : (
<Empty style={{ marginTop: 80 }} description={hint} />
)}
</div>
</Space>
</Modal>
);
}
+302
View File
@@ -0,0 +1,302 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd';
import type { MenuProps } from 'antd';
import {
RobotOutlined,
DashboardOutlined,
UserOutlined,
ShoppingOutlined,
ShopOutlined,
TeamOutlined,
GiftOutlined,
CarOutlined,
SafetyOutlined,
LogoutOutlined,
CloudUploadOutlined,
FileTextOutlined,
LockOutlined,
SettingOutlined,
AccountBookOutlined,
} from '@ant-design/icons';
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
const { Header, Sider, Content } = Layout;
type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' },
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
{
key: 'products-group',
icon: <ShoppingOutlined />,
label: '商品',
children: [
{ key: '/products', label: '商品列表' },
{ key: '/product-detail-templates', label: '详情模板' },
],
},
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
{ key: '/wecom-bots', icon: <TeamOutlined />, label: '企微机器人' },
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
{
key: 'stores-group',
icon: <ShopOutlined />,
label: '门店',
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' },
],
},
{
key: 'partners-group',
icon: <TeamOutlined />,
label: '开城',
children: [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
],
},
{
key: 'finance-group',
icon: <AccountBookOutlined />,
label: '财务',
children: [
{ key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
],
},
{
key: 'benefit-group',
icon: <GiftOutlined />,
label: '好客权益',
children: [
{ key: '/benefit/coupons', label: '权益券' },
{ key: '/benefit/ledgers', label: '流水' },
{ key: '/redeem-records', label: '核销记录' },
{ key: '/redeem-pending', label: '待处理核销' },
{ key: '/redeem/debug', label: '核销调试' },
],
},
{
key: 'deliveries-group',
icon: <CarOutlined />,
label: '配送单',
children: [
{ key: '/deliveries', label: '配送单列表' },
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
],
},
{
key: 'tickets-group',
icon: <CarOutlined />,
label: '工单',
children: [
{ key: '/tickets', label: '工单中心' },
{ key: '/tickets/support', label: '技术支持' },
],
},
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{
key: 'logs-group',
icon: <FileTextOutlined />,
label: '日志',
children: [
{ key: '/logs/users', label: '用户日志' },
{ key: '/logs/stores', label: '商户日志' },
{ key: '/logs/partners', label: '合伙人日志' },
{ key: '/logs/hq', label: 'HQ 操作日志' },
{ key: '/logs/third-party', label: '第三方日志' },
],
},
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
];
function menuAllowed(key: string, permissionKeys: string[]): boolean {
if (key === 'tickets-group') {
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
}
const map: Record<string, string | 'system_settings_any'> = {
'/': 'dashboard',
'/users': 'users',
'/wechat-bindings': 'wechat_bindings',
'products-group': 'products',
'/products': 'products',
'/product-detail-templates': 'products',
'/orders': 'orders',
'/promo-codes': 'promo_codes',
'/wecom-bots': 'wecom_bots',
'/llm-configs': 'llm_configs',
'/knowledge-bases': 'knowledge_bases',
'stores-group': 'stores',
'/stores': 'stores',
'/store-categories': 'stores',
'/store-accounts': 'stores',
'/store-media': 'stores',
'partners-group': 'partners',
'/cities': 'partners',
'/city-partners': 'partners',
'/city-warehouses': 'partners',
'/fulfillment-providers': 'partners',
'finance-group': 'finance',
'/finance/store-bills': 'finance',
'/finance/partner-bills': 'finance',
'/finance/winery-bills': 'finance',
'/finance/logistics-bills': 'finance',
'benefit-group': 'benefit',
'/benefit/coupons': 'benefit',
'/benefit/ledgers': 'benefit',
'/redeem-records': 'benefit',
'/redeem-pending': 'benefit',
'/redeem/debug': 'benefit',
'deliveries-group': 'deliveries',
'/deliveries': 'deliveries',
'/deliveries/xiaofeixia': 'deliveries',
'/tickets': 'tickets',
'/tickets/support': 'tech_support',
'/invoices': 'invoices',
'/resources': 'resources',
'logs-group': 'logs',
'/logs/users': 'logs',
'/logs/stores': 'logs',
'/logs/partners': 'logs',
'/logs/hq': 'logs',
'/logs/third-party': 'logs',
'/hq-permissions': 'hq_permissions',
'/system-settings': 'system_settings_any',
'/hq-accounts': 'hq_accounts',
};
const need = map[key];
if (!need) return true;
if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys);
return permissionKeys.includes(need);
}
function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] {
if (!items) return items;
return items
.map((item) => {
if (!item || typeof item !== 'object' || !('key' in item)) return item;
const key = String(item.key);
if ('children' in item && Array.isArray(item.children)) {
if (!menuAllowed(key, permissionKeys)) return null;
const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys);
if (!children?.length) return null;
return { ...item, children } as MenuItem;
}
return menuAllowed(key, permissionKeys) ? item : null;
})
.filter(Boolean) as MenuProps['items'];
}
export default function AdminLayout() {
const navigate = useNavigate();
const location = useLocation();
const contentRef = useRef<HTMLDivElement>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
contentRef.current?.scrollTo({ top: 0, left: 0 });
}, [location.pathname]);
useEffect(() => {
return bindAdminEllipsisTitle();
}, []);
function logout() {
clearAuth();
navigate('/login');
}
const selectedKey = location.pathname.startsWith('/promo-codes')
? '/promo-codes'
: location.pathname;
const menuItems = useMemo(() => {
if (!profile) return MENU_ITEMS;
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
}, [profile]);
return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Sider
breakpoint="lg"
collapsedWidth={64}
theme="dark"
width={220}
style={{ height: '100vh', overflow: 'hidden' }}
>
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<div style={{ flexShrink: 0, padding: '16px', color: '#fff', fontWeight: 600 }}>
HQ
</div>
<div className="admin-sider-menu-scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={[]}
items={menuItems}
onClick={({ key }) => {
if (key.startsWith('/')) navigate(key);
}}
/>
</div>
</div>
</Sider>
<Layout style={{ height: '100vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<Header
style={{
flexShrink: 0,
background: '#fff',
padding: '0 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid #f0f0f0',
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Space>
<span>{profile?.name || '—'}</span>
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
退
</Button>
</Space>
</Header>
<div
ref={contentRef}
className="admin-layout"
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
>
<Content style={{ margin: 24 }}>
<Outlet />
</Content>
</div>
</Layout>
</Layout>
);
}
+159
View File
@@ -0,0 +1,159 @@
export const apiBase = '/api/v1';
export const CLIENT_APP = 'HQ_WEB';
export type HqProfile = {
id: string;
phone: string;
name: string;
adminRole: string;
status: string;
permissionKeys?: string[];
};
export function getToken() {
return localStorage.getItem('accessToken');
}
export function saveAuth(data: { accessToken: string; refreshToken?: string }) {
localStorage.setItem('accessToken', data.accessToken);
if (data.refreshToken) localStorage.setItem('refreshToken', data.refreshToken);
}
export function clearAuth() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
}
export async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
...(options.headers as Record<string, string>),
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const text = await res.text();
if (!text.trim()) {
throw new Error(`接口空响应(HTTP ${res.status} ${res.statusText || ''}`);
}
let json: { code: number; message?: string; data?: T };
try {
json = JSON.parse(text) as { code: number; message?: string; data?: T };
} catch {
throw new Error(`接口返回非 JSONHTTP ${res.status}: ${text.slice(0, 200)}`);
}
if (json.code === 401) {
clearAuth();
window.location.href = '/login';
throw new Error('未登录');
}
if (json.code !== 0) throw new Error(json.message || '请求失败');
return json.data as T;
}
export type Paginated<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
summary?: {
count: number;
redeemAmount?: number;
payoutAmount?: number;
orderAmount?: number;
orderCommission?: number;
redeemCommission?: number;
wineryAmount?: number;
totalAmount: number;
};
};
export type DashboardStats = {
usersTotal: number;
guestUsers: number;
verifiedUsers: number;
mergedUsers: number;
ordersToday: number;
storesTotal: number;
partnersTotal: number;
redeemToday: number;
deliveriesTotal: number;
pendingPayouts?: number;
/** 合伙人已确认、待总部审核打款 */
pendingBills?: number;
pendingPartnerDraftBills?: number;
openTickets?: number;
ordersByStatus: Array<{ status: string; count: number }>;
};
export type SystemVersion = {
id: string;
gitTag: string | null;
commitId: string;
commitMessage: string;
branch: string | null;
deployedBy: string | null;
deployedAt: string;
};
export type DeployTriggerResult = {
accepted: boolean;
started?: boolean;
message: string;
};
export type AdminUserRow = {
id: string;
userNo: string;
deviceKey: string | null;
phone: string | null;
phoneVerifiedAt: string | null;
mergedIntoUserId: string | null;
wxOpenId: string | null;
wechatVerified: boolean;
nickname: string | null;
status: number;
sourceType: string;
sourceRefId: string | null;
sourceLabel: string | null;
createdAt: string;
orderCount: number;
};
export type AdminOrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
export type AdminOrderRow = {
id: string;
orderNo: string;
status: string;
deliveryType: string;
payAmount: number;
productName?: string;
productSpec?: string;
quantity?: number;
receiverName: string;
receiverPhone: string;
createdAt: string;
cityId?: string;
city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: string } | null;
fulfillmentHold?: boolean;
fulfillmentHoldReason?: string | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: {
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
logisticsCompany?: string | null;
manualQueryUrl?: string | null;
};
};
+160
View File
@@ -0,0 +1,160 @@
import { codeToText, regionData } from 'element-china-area-data';
export { regionData as CHINA_REGION_OPTIONS };
export type ProvinceCityOption = {
value: string;
label: string;
children?: Array<{ value: string; label: string }>;
};
/** 省 / 市二级(不含区县) */
export const PROVINCE_CITY_OPTIONS: ProvinceCityOption[] = regionData.map((province) => ({
value: province.value,
label: province.label,
children: province.children?.map((city) => ({
value: city.value,
label: city.label,
})),
}));
export type OpenCityRef = {
id: string;
name: string;
code: string;
partnerBindings?: Array<{ partnerAccountId: string }>;
boundPartnerIds?: string[];
};
function cityBoundPartnerIds(city: OpenCityRef): string[] {
if (city.boundPartnerIds?.length) return city.boundPartnerIds.map(String);
return (city.partnerBindings ?? []).map((b) => String(b.partnerAccountId));
}
export type ParsedChinaRegion = {
province: string;
city: string;
district: string;
provinceCode: string;
cityCode: string;
districtCode: string;
};
export type ParsedProvinceCity = {
province: string;
city: string;
provinceCode: string;
cityCode: string;
};
/** element-china-area-data 市级码(如 4101)→ 国标 6 位 adcode410100 */
export function normalizeCityAdcode(code: string): string {
const raw = code.trim();
if (/^\d{6}$/.test(raw)) return raw;
if (/^\d{4}$/.test(raw)) return `${raw}00`;
if (/^\d{2}$/.test(raw)) return `${raw}0100`;
return raw.padEnd(6, '0').slice(0, 6);
}
export function parseProvinceCityCodes(codes?: string[]): ParsedProvinceCity | null {
if (!codes || codes.length < 2) return null;
const [provinceCode, cityCode] = codes;
const province = codeToText[provinceCode];
let city = codeToText[cityCode];
if (!province || !city) return null;
if (city === '市辖区' || city === '县' || city === '省直辖县级行政区划') {
city = province.endsWith('市') ? province : city;
}
return {
province,
city,
provinceCode,
cityCode: normalizeCityAdcode(cityCode),
};
}
/** 区县 adcode → 地级市 adcode(如 410105 → 410100 */
export function districtCodeToCityCode(districtCode: string): string {
if (districtCode.length < 6) return districtCode;
return `${districtCode.slice(0, 4)}00`;
}
export type DistrictOption = { value: string; label: string };
/** 按开城城市 code(4 位或 6 位)取该市下区县列表 */
export function getDistrictOptionsByCityCode(cityCode?: string | null): DistrictOption[] {
if (!cityCode?.trim()) return [];
const normalized = normalizeCityAdcode(cityCode.trim());
const prefix4 = normalized.slice(0, 4);
for (const province of regionData) {
for (const city of province.children ?? []) {
const cityValue = String(city.value);
const cityNorm = normalizeCityAdcode(cityValue);
if (cityNorm === normalized || cityValue === prefix4 || cityValue === cityCode.trim()) {
return (city.children ?? []).map((d) => ({
value: String(d.value),
label: String(d.label),
}));
}
}
}
return [];
}
export function districtCodeLabel(code: string): string {
return codeToText[code] || code;
}
/** 列表展示:区县码 → 中文名,顿号拼接 */
export function formatDistrictLabels(codes?: string[] | null): string {
if (!codes?.length) return '—';
return codes.map((c) => districtCodeLabel(String(c))).join('、');
}
export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
if (!codes || codes.length < 3) return null;
const [provinceCode, cityCode, districtCode] = codes;
const province = codeToText[provinceCode];
const city = codeToText[cityCode];
const district = codeToText[districtCode];
if (!province || !city || !district) return null;
return { province, city, district, provinceCode, cityCode, districtCode };
}
export function formatRegionLabel(region: ParsedChinaRegion): string {
return `${region.province} / ${region.city} / ${region.district}`;
}
export function matchOpenCityId(
cities: OpenCityRef[],
districtCode: string,
partnerAccountId?: string,
): string | undefined {
const cityCode = districtCodeToCityCode(districtCode);
const scoped = partnerAccountId
? cities.filter((c) => cityBoundPartnerIds(c).includes(String(partnerAccountId)))
: cities;
return (
scoped.find((c) => c.code === cityCode)?.id
?? scoped.find((c) => c.code === districtCode)?.id
?? scoped.find((c) => districtCode.startsWith(c.code.slice(0, 4)))?.id
);
}
export function resolveRegionBinding(
codes: string[],
cities: OpenCityRef[],
partnerAccountId?: string,
) {
const region = parseRegionCodes(codes);
if (!region) return null;
const cityId = matchOpenCityId(cities, region.districtCode, partnerAccountId);
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
return {
region,
cityId,
matchedCity,
cityCode: districtCodeToCityCode(region.districtCode),
};
}
+145
View File
@@ -0,0 +1,145 @@
/** 与后端 PaginationQueryDto @Max(100) 一致,下拉选项拉取勿超过此值 */
export const ADMIN_OPTIONS_PAGE_SIZE = 100;
export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
XFX: '小飞侠',
LOGISTICS: '物流快递',
MANUAL: '手动',
};
export const ORDER_STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
/** 订单状态 Tag 颜色(Ant Design preset */
export const ORDER_STATUS_COLORS: Record<string, string> = {
PENDING_PAY: 'red',
PENDING_SHIP: 'green',
OUT_WAREHOUSE: 'green',
SHIPPING: 'processing',
PENDING_RECEIVE: 'cyan',
COMPLETED: 'green',
CANCELLED: 'default',
REFUNDING: 'orange',
REFUNDED: 'default',
};
export const STORE_STATUS_LABELS: Record<string, string> = {
OPEN: '营业中',
PAUSED: '临时闭店',
CLOSED: '永久关闭',
};
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
PENDING: '待审核',
APPROVED: '已通过',
REJECTED: '已驳回',
};
export const ACCOUNT_STATUS_LABELS: Record<string, string> = {
ACTIVE: '正常',
DISABLED: '停用',
};
export const COUPON_STATUS_LABELS: Record<string, string> = {
ACTIVE: '可用',
USED_UP: '已用完',
VOID: '已作废',
};
export const CITY_STATUS_LABELS: Record<string, string> = {
PENDING: '待开城',
ACTIVE: '已开城',
PAUSED: '已暂停',
};
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
};
export const MEDIA_TYPE_LABELS: Record<string, string> = {
IMAGE: '图片',
VIDEO: '视频',
FILE: '文件',
};
export const RESOURCE_OWNER_TYPE_LABELS: Record<string, string> = {
PRODUCT: '商品',
STORE: '门店',
PARTNER: '合伙人',
USER: '用户',
ORDER: '订单',
HQ: '总部',
};
export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
COVER: '封面',
ENV: '环境图',
CONTRACT: '合同',
CAROUSEL: '轮播',
DETAIL: '详情',
AVATAR: '头像',
QRCODE: '二维码',
SIGN_PHOTO: '签收照',
VIDEO: '视频',
};
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
ACTIVE: '有效',
DELETED: '已删除',
};
export const PRODUCT_STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
ON_SALE: '在售',
OFF_SALE: '下架',
};
export const AROMA_TYPE_LABELS: Record<string, string> = {
QINGXIANG: '清香型',
JIANGXIANG: '酱香型',
NONGXIANG: '浓香型',
};
export const DETAIL_TEMPLATE_STATUS_LABELS: Record<string, string> = {
ACTIVE: '启用',
DISABLED: '停用',
};
export const LEDGER_TYPE_LABELS: Record<string, string> = {
GRANT: '发放',
REDEEM: '核销',
REFUND_VOID: '退款作废',
ADJUST: '调整',
};
export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—';
}
/** 手机号脱敏展示:138****5678;空值返回 — */
export function maskPhone(phone?: string | null): string {
const raw = String(phone ?? '').trim();
if (!raw) return '—';
const digits = raw.replace(/\D/g, '');
if (digits.length >= 11) {
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
}
if (digits.length >= 7) {
return `${digits.slice(0, 3)}****${digits.slice(-2)}`;
}
if (digits.length > 0) return `${digits.slice(0, 1)}****`;
return '****';
}
+37
View File
@@ -0,0 +1,37 @@
const ELLIPSIS_TARGETS =
'.ant-table-cell, .ant-descriptions-item-content, .ant-typography';
const ELLIPSIS_SKIP =
'.ant-btn, .ant-input, .ant-select, .ant-picker, .ant-checkbox, .ant-radio, pre, textarea, input, select, button, a.ant-btn';
function isTruncated(el: HTMLElement): boolean {
return el.scrollWidth > el.clientWidth + 1;
}
function resolveEllipsisElement(target: EventTarget | null): HTMLElement | null {
if (!(target instanceof HTMLElement)) return null;
const cell = target.closest<HTMLElement>(ELLIPSIS_TARGETS);
if (!cell) return null;
if (cell.closest('.admin-cell-line')) return null;
if (cell.closest(ELLIPSIS_SKIP)) return null;
if (cell.querySelector(ELLIPSIS_SKIP)) return null;
return cell;
}
export function bindAdminEllipsisTitle(root: HTMLElement | Document = document) {
const onMouseOver = (event: MouseEvent) => {
const el = resolveEllipsisElement(event.target);
if (!el) return;
if (isTruncated(el)) {
const text = el.innerText.replace(/\s+/g, ' ').trim();
if (text && el.getAttribute('title') !== text) {
el.setAttribute('title', text);
}
} else {
el.removeAttribute('title');
}
};
root.addEventListener('mouseover', onMouseOver, true);
return () => root.removeEventListener('mouseover', onMouseOver, true);
}
+10
View File
@@ -0,0 +1,10 @@
/** Download CSV (UTF-8 BOM) so Excel opens Chinese columns correctly. */
export function downloadExcelCsv(csv: string, filename: string) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
a.click();
URL.revokeObjectURL(url);
}
+72
View File
@@ -0,0 +1,72 @@
export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'CITY_CREATE', label: '新增开城城市' },
{ value: 'CITY_UPDATE', label: '编辑开城城市' },
{ value: 'CITY_DELETE', label: '删除开城城市' },
{ value: 'CITY_PARTNER_BIND', label: '绑定城市合伙人' },
{ value: 'CITY_PARTNER_UPDATE', label: '编辑城市合伙人绑定' },
{ value: 'CITY_PARTNER_UNBIND', label: '解绑城市合伙人' },
{ value: 'WAREHOUSE_CREATE', label: '新增城市仓库' },
{ value: 'WAREHOUSE_UPDATE', label: '编辑城市仓库' },
{ value: 'WAREHOUSE_DELETE', label: '删除城市仓库' },
{ value: 'PARTNER_CREATE', label: '新增城市合伙人' },
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
{ value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' },
{ value: 'PARTNER_ACCOUNT_DELETE', label: '删除合伙人子账号' },
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
{ value: 'USER_DELETE', label: '删除用户' },
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
{ value: 'ORDER_SHIP', label: '订单发货' },
{ value: 'ORDER_STATUS_DEBUG', label: '订单状态调试' },
{ value: 'ORDER_BATCH_DELETE', label: '批量删除订单' },
{ value: 'STORE_CREATE', label: '新增门店' },
{ value: 'STORE_UPDATE', label: '编辑门店' },
{ value: 'STORE_STATUS', label: '变更门店状态' },
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
{ value: 'PRODUCT_CREATE', label: '新增商品' },
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
{ value: 'PRODUCT_DELETE', label: '删除商品' },
{ value: 'BENEFIT_COUPON_VOID', label: '作废权益券' },
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
{ value: 'TICKET_APPROVE', label: '工单通过' },
{ value: 'TICKET_REJECT', label: '工单驳回' },
{ value: 'TICKET_CREATE', label: '创建工单' },
{ value: 'SUPPORT_TICKET_CREATE', label: '创建技术支持工单' },
{ value: 'SUPPORT_TICKET_APPROVE', label: '技术支持评审通过' },
{ value: 'SUPPORT_TICKET_REJECT', label: '技术支持评审驳回' },
{ value: 'SUPPORT_TICKET_START_TESTING', label: '技术支持转入测试' },
{ value: 'SUPPORT_TICKET_PASS', label: '技术支持测试通过' },
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
{ value: 'INVOICE_ISSUE', label: '开具发票' },
{ value: 'INVOICE_REJECT', label: '驳回发票' },
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
{ value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' },
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
{ value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' },
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
] as const;
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
HQ_OPERATION_ACTION_OPTIONS.map((o) => [o.value, o.label]),
);
export function resolveHqOperationLabel(action: string | null | undefined): string {
if (!action) return '—';
return HQ_OPERATION_ACTION_LABELS[action] || action;
}
+6
View File
@@ -0,0 +1,6 @@
export {
PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory,
} from '@dukang/shared-types';
@@ -0,0 +1,64 @@
/** 商品详情页文案模板(Admin 一键套用) */
export const TEMPLATE_MAX_DETAIL_IMAGES = 30;
export type ProductDetailTemplateContent = {
storyTitle?: string;
storyText?: string;
features?: Array<{ icon: string; title: string; desc: string }>;
/** 模板内置详情长图 URL 列表 */
detailImageUrls?: string[];
/** 建议详情长图张数(与 detailImageUrls 数量同步,供展示) */
suggestedDetailImageCount?: number;
};
export type ProductDetailTemplate = {
id: string;
code: string;
label: string;
description: string;
aromaType?: string;
content: ProductDetailTemplateContent;
};
/** API 返回的详情模板 DTO(与 shared-types ProductDetailTemplateDto 对齐) */
export type ProductDetailTemplateDto = {
id: string;
code: string;
name: string;
description?: string | null;
aromaType?: string | null;
storyTitle?: string | null;
storyText?: string | null;
features?: Array<{ icon: string; title: string; desc: string }>;
detailImageUrls?: string[];
suggestedDetailImageCount: number;
sortOrder: number;
status: 'ACTIVE' | 'DISABLED';
createdAt: string;
updatedAt: string;
};
export function mapDtoToProductDetailTemplate(dto: ProductDetailTemplateDto): ProductDetailTemplate {
const detailImageUrls = (dto.detailImageUrls ?? []).filter(Boolean);
return {
id: dto.code,
code: dto.code,
label: dto.name,
description: dto.description ?? '',
aromaType: dto.aromaType ?? undefined,
content: {
storyTitle: dto.storyTitle ?? undefined,
storyText: dto.storyText ?? undefined,
features: dto.features ?? [],
detailImageUrls,
suggestedDetailImageCount: detailImageUrls.length || dto.suggestedDetailImageCount,
},
};
}
export function getProductDetailTemplate(
templates: ProductDetailTemplate[],
code: string,
) {
return templates.find((t) => t.code === code || t.id === code);
}
+6
View File
@@ -0,0 +1,6 @@
export {
STORE_LOG_CATEGORY_OPTIONS,
STORE_LOG_CATEGORY_LABELS,
resolveStoreLogCategory,
type StoreLogCategory,
} from '@dukang/shared-types';
+105
View File
@@ -0,0 +1,105 @@
export type StoreCreateForm = {
partnerAccountId: string;
cityId: string;
regionCodes?: string[];
province?: string;
city?: string;
district: string;
districtCode?: string;
categoryParentId?: string;
categoryId: string;
name: string;
phone: string;
address: string;
latitude?: number | null;
longitude?: number | null;
intro?: string;
benefitUsageRule?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
closeTime2?: string;
avgPrice?: number | null;
coverUrl?: string;
envPhotoUrls?: string[];
contractUrl?: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
settlementRate?: number;
};
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}
export function validateStoreCreateStep1(
form: Pick<
StoreCreateForm,
| 'partnerAccountId'
| 'cityId'
| 'regionCodes'
| 'categoryId'
| 'name'
| 'phone'
| 'address'
| 'intro'
| 'benefitUsageRule'
| 'openTime'
| 'closeTime'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
>,
): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
if (!form.address?.trim()) return '请填写详细地址';
const openTime = String(form.openTime || '').trim();
const closeTime = String(form.closeTime || '').trim();
if (!openTime || !closeTime) return '请填写营业时间';
if (!TIME_RE.test(openTime) || !TIME_RE.test(closeTime)) return '营业时间格式须为 HH:MM';
if (timeToMinutes(openTime) >= timeToMinutes(closeTime)) return '营业结束时间须晚于开始时间';
const openTime2 = String(form.openTime2 || '').trim();
const closeTime2 = String(form.closeTime2 || '').trim();
if (openTime2 || closeTime2) {
if (!openTime2 || !closeTime2) return '请完整填写第二段营业时间';
if (!TIME_RE.test(openTime2) || !TIME_RE.test(closeTime2)) return '第二段时间格式须为 HH:MM';
if (timeToMinutes(openTime2) >= timeToMinutes(closeTime2)) return '第二段结束时间须晚于开始时间';
if (timeToMinutes(closeTime) >= timeToMinutes(openTime2)) return '第二段开始时间须晚于第一段结束时间';
}
if (form.avgPrice != null && String(form.avgPrice).trim() !== '') {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (form.intro?.trim()) {
const len = form.intro.trim().length;
if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
}
return null;
}
export function validateStoreCreateStep3(form: Pick<StoreCreateForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>): string | null {
if (!form.bankAccountName?.trim()) return '请填写户主姓名';
if (!form.bankAccountNo?.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch?.trim()) return '请填写开户支行';
return null;
}
@@ -0,0 +1,26 @@
export type TencentPickedLocation = {
latitude: number;
longitude: number;
address?: string;
name?: string;
cityname?: string;
};
export type LbsPlaceItem = {
id: string;
title: string;
address: string;
latitude: number;
longitude: number;
city?: string;
};
export function placeToPicked(item: LbsPlaceItem): TencentPickedLocation {
return {
latitude: item.latitude,
longitude: item.longitude,
address: item.address || undefined,
name: item.title || undefined,
cityname: item.city || undefined,
};
}
+49
View File
@@ -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<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
const headers: Record<string, string> = {
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
});
const json = await res.json();
if (json.code === 401) {
clearAuth();
window.location.href = '/login';
throw new Error('未登录');
}
if (json.code !== 0) throw new Error(json.message || '上传失败');
const data = json.data as UploadFileResult;
return {
url: data.url,
ossKey: data.ossKey,
bucket: data.bucket,
mock: data.mock,
};
}
+28
View File
@@ -0,0 +1,28 @@
import { useCallback, useEffect, useState } from 'react';
import { request, type Paginated } from './api';
export function useAdminList<T>(path: string, buildQuery: () => URLSearchParams, deps: unknown[]) {
const [data, setData] = useState<Paginated<T> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const load = useCallback(async () => {
setLoading(true);
try {
const qs = buildQuery();
qs.set('page', String(page));
qs.set('pageSize', String(pageSize));
const res = await request<Paginated<T>>(`${path}?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}, [path, page, pageSize, ...deps]);
useEffect(() => {
void load();
}, [load]);
return { data, loading, page, pageSize, setPage, setPageSize, reload: load };
}
+45
View File
@@ -0,0 +1,45 @@
export type UserLogCategory =
| 'login'
| 'browse_product'
| 'order'
| 'pay'
| 'wechat_auth'
| 'browse_store'
| 'redeem'
| 'profile';
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
browse_product: ['home_view', 'product_click', 'product_detail_view'],
order: ['order_confirm_view', 'order_submit'],
pay: ['pay_success', 'pay_fail'],
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
browse_store: ['store_list_view', 'store_detail_view'],
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
profile: ['profile_update', 'bind_phone'],
};
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'browse_product', label: '浏览商品' },
{ value: 'order', label: '下单' },
{ value: 'pay', label: '支付' },
{ value: 'wechat_auth', label: '微信授权' },
{ value: 'browse_store', label: '浏览门店' },
{ value: 'redeem', label: '核销' },
{ value: 'profile', label: '信息修改' },
];
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
[UserLogCategory, readonly string[]]
>) {
if (events.includes(eventName)) return category;
}
return null;
}
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
) as Record<string, string>;
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import App from './App';
import './admin.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
</React.StrictMode>,
);
@@ -0,0 +1,465 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
import { request } from '../lib/api';
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
couponNo: string;
totalAmount: number;
balance: number;
usedAmount: number;
status: string;
sourceProduct: string;
createdAt: string;
user?: { userNo: string; phone: string | null };
order?: { orderNo: string } | null;
};
type CouponRedeemRecord = {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
couponAmount?: number;
role?: 'PRIMARY' | 'SECONDARY';
createdAt: string;
store?: { id: string; name: string; cityName?: string | null } | null;
};
type CouponRedeemSummary = {
couponNo: string;
totalAmount: number;
usedAmount: number;
balance: number;
status: string;
redeemCount: number;
redeemRecordSum: number;
};
type CouponDetail = Row & {
redeemSummary?: CouponRedeemSummary | null;
redeemRecords?: CouponRedeemRecord[];
user?: { userNo?: string; phone?: string | null };
order?: { orderNo?: string } | null;
};
export default function BenefitCouponsPage() {
const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [grantOpen, setGrantOpen] = useState(false);
const [granting, setGranting] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/benefit/coupons',
() => {
const qs = new URLSearchParams();
if (filters.couponNo) qs.set('couponNo', filters.couponNo);
if (filters.status) qs.set('status', filters.status);
if (filters.userId) qs.set('userId', filters.userId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<CouponDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [redeemDetail, setRedeemDetail] = useState<Record<string, unknown> | null>(null);
const [redeemDrawerOpen, setRedeemDrawerOpen] = useState(false);
const [redeemDetailLoading, setRedeemDetailLoading] = useState(false);
async function openRedeemDetail(redeemId: string) {
setRedeemDetailLoading(true);
setRedeemDrawerOpen(true);
try {
const res = await request<Record<string, unknown>>(`/admin/redeem-records/${redeemId}`);
setRedeemDetail(res);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载核销详情失败');
setRedeemDrawerOpen(false);
} finally {
setRedeemDetailLoading(false);
}
}
const columns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
{
title: '订单',
dataIndex: ['order', 'orderNo'],
width: 180,
ellipsis: false,
render: (v) => v || '—',
},
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
async function handleGrant(values: AdminBenefitGrantRequest) {
setGranting(true);
try {
await request('/admin/benefit/coupons/grant', {
method: 'POST',
body: JSON.stringify({
phone: values.phone.trim(),
amount: values.amount,
remark: values.remark?.trim() || undefined,
}),
});
message.success('权益已发放');
setGrantOpen(false);
grantForm.resetFields();
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '发放失败');
} finally {
setGranting(false);
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" onClick={() => setGrantOpen(true)}>
</Button>
</div>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="couponNo" label="券号">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(COUPON_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title="手动发放好客权益"
open={grantOpen}
onCancel={() => setGrantOpen(false)}
onOk={() => grantForm.submit()}
confirmLoading={granting}
destroyOnClose
>
<Form form={grantForm} layout="vertical" onFinish={(v) => void handleGrant(v)}>
<Form.Item
name="phone"
label="用户手机号"
rules={[
{ required: true, message: '请输入用户手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的 11 位手机号' },
]}
>
<Input placeholder="已注册 C 端用户的手机号" maxLength={11} />
</Form.Item>
<Form.Item
name="amount"
label="权益金额(元)"
rules={[{ required: true, message: '请输入权益金额' }]}
>
<InputNumber
min={0.01}
max={999999.99}
precision={2}
style={{ width: '100%' }}
placeholder="发放金额"
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={200} placeholder="可选,默认「总部手动发放」" />
</Form.Item>
</Form>
</Modal>
<Drawer
title="权益券详情"
width={720}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail &&
detail.status !== 'VOID' && (
<Popconfirm
title="确认作废此券?"
onConfirm={async () => {
await request(`/admin/benefit/coupons/${detail.id}/void`, { method: 'POST' });
message.success('已作废');
setDrawerOpen(false);
void reload();
}}
>
<Button danger></Button>
</Popconfirm>
)
}
>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
<Descriptions.Item label="用户">{detail.user?.userNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="关联订单">{detail.order?.orderNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="总额">¥{Number(detail.totalAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="余额">¥{Number(detail.balance).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">
{COUPON_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="来源">{detail.sourceProduct}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}>
</Typography.Title>
{detail.redeemSummary ? (
<>
<Descriptions column={2} bordered size="small" style={{ marginBottom: 12 }}>
<Descriptions.Item label="权益券号">{detail.redeemSummary.couponNo}</Descriptions.Item>
<Descriptions.Item label="券状态">{detail.redeemSummary.status}</Descriptions.Item>
<Descriptions.Item label="权益总额">
¥{Number(detail.redeemSummary.totalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="已核销">
<Typography.Text type="danger" strong>
¥{Number(detail.redeemSummary.usedAmount).toFixed(2)}
</Typography.Text>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{detail.redeemSummary.redeemCount}
</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="剩余余额">
¥{Number(detail.redeemSummary.balance).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="核销单合计额">
¥{Number(detail.redeemSummary.redeemRecordSum).toFixed(2)}
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
</Typography.Text>
</Descriptions.Item>
</Descriptions>
<Table
size="small"
rowKey="id"
pagination={false}
locale={{ emptyText: '暂无关联核销单' }}
dataSource={detail.redeemRecords ?? []}
columns={[
{ title: '核销号', dataIndex: 'redeemNo', width: 160, ellipsis: true },
{
title: '角色',
dataIndex: 'role',
width: 70,
render: (v: string | undefined) =>
v === 'SECONDARY' ? <Tag color="orange"></Tag> : <Tag color="blue"></Tag>,
},
{
title: '门店',
dataIndex: ['store', 'name'],
ellipsis: true,
render: (v: string | undefined, row: CouponRedeemRecord) =>
v ? `${v}${row.store?.cityName ? `${row.store.cityName}` : ''}` : '—',
},
{
title: '本券分摊',
dataIndex: 'couponAmount',
width: 95,
render: (v: number | undefined, row: CouponRedeemRecord) =>
`¥${Number(v ?? row.amount).toFixed(2)}`,
},
{
title: '核销总额',
dataIndex: 'amount',
width: 90,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '结算额',
dataIndex: 'settleAmount',
width: 90,
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '时间',
dataIndex: 'createdAt',
width: 150,
render: (v: string) => fmtTime(v),
},
{
title: '操作',
width: 70,
render: (_: unknown, row: CouponRedeemRecord) => (
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
</Button>
),
},
]}
/>
</>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</>
)}
</Drawer>
<Drawer
title="核销单详情"
width={520}
open={redeemDrawerOpen}
onClose={() => {
setRedeemDrawerOpen(false);
setRedeemDetail(null);
}}
destroyOnClose
>
{redeemDetailLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : redeemDetail ? (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="核销额">
¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="结算额">
¥{Number(redeemDetail.settleAmount ?? 0).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(redeemDetail.createdAt ?? ''))}</Descriptions.Item>
<Descriptions.Item label="用户">
{String((redeemDetail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
{(redeemDetail.user as { phone?: string | null } | undefined)?.phone
? ` / ${(redeemDetail.user as { phone?: string | null }).phone}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店">
{String((redeemDetail.store as { name?: string } | undefined)?.name ?? '—')}
{(redeemDetail.store as { cityName?: string } | undefined)?.cityName
? `${(redeemDetail.store as { cityName?: string }).cityName}`
: ''}
</Descriptions.Item>
<Descriptions.Item label="门店地址">
{String((redeemDetail.store as { address?: string } | undefined)?.address ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="合伙人">
{String(
(redeemDetail.store as { partnerAccount?: { companyName?: string } } | undefined)
?.partnerAccount?.companyName ?? '—',
)}
</Descriptions.Item>
<Descriptions.Item label="权益券号">
{String((redeemDetail.coupon as { couponNo?: string } | undefined)?.couponNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="关联订单">
{String(
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
'—',
)}
</Descriptions.Item>
{Array.isArray(redeemDetail.allocations) &&
(redeemDetail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="券分摊">
{(
redeemDetail.allocations as Array<{
couponNo?: string;
orderNo?: string | null;
amount?: number;
sortOrder?: number;
}>
)
.map((a, idx) => {
const role = idx === 0 ? '主' : '次';
return `${role} ${a.couponNo ?? '—'}¥${Number(a.amount ?? 0).toFixed(2)}${
a.orderNo ? `(订单 ${a.orderNo}` : ''
}`;
})
.join('')}
</Descriptions.Item>
) : null}
{redeemDetail.payout ? (
<Descriptions.Item label="门店结算单">
¥{Number((redeemDetail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
{' / '}
{String((redeemDetail.payout as { status?: string }).status ?? '—')}
</Descriptions.Item>
) : null}
{redeemDetail.rating ? (
<Descriptions.Item label="评价">
{(redeemDetail.rating as { serviceScore?: number }).serviceScore ?? '—'} / {' '}
{(redeemDetail.rating as { envScore?: number }).envScore ?? '—'}
</Descriptions.Item>
) : null}
</Descriptions>
) : null}
</Drawer>
</div>
);
}
@@ -0,0 +1,52 @@
import { useState } from 'react';
import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
user?: { userNo: string }; coupon?: { couponNo: string };
};
export default function BenefitLedgersPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/benefit/ledgers',
() => {
const qs = new URLSearchParams();
if (filters.userId) qs.set('userId', filters.userId);
if (filters.couponId) qs.set('couponId', filters.couponId);
if (filters.type) qs.set('type', filters.type);
return qs;
},
[filters],
);
const columns: ColumnsType<Row> = [
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
<Form.Item name="type" label="类型">
<Select allowClear style={{ width: 100 }} options={Object.entries(LEDGER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
</div>
);
}
+676
View File
@@ -0,0 +1,676 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type HqProfile, type Paginated } from '../lib/api';
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityPartnersPanel from '../components/CityPartnersPanel';
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
type WarehouseRow = {
id: string;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
};
type Row = {
id: string;
code: string;
name: string;
province: string;
status: string;
storeCount: number;
orderCount: number;
partnerBindingCount?: number;
createdAt: string;
};
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
type CityDeletePreview = {
city: { id: string; code: string; name: string; province: string; status: string };
canDelete: boolean;
blockers: string[];
warnings: string[];
summary: {
primaryPartnerCount: number;
staffCount: number;
storeCount: number;
warehouseCount: number;
orderCount: number;
redeemCount: number;
partnerBillCount: number;
};
partners: Array<{
id: string;
phone: string;
name: string;
companyName?: string | null;
status: string;
staff: Array<{ id: string; phone: string; name: string; staffRole?: string | null }>;
}>;
orphanStaff: Array<{ id: string; phone: string; name: string }>;
stores: Array<{
id: string;
name: string;
phone: string;
status: string;
address: string;
partnerAccount?: { companyName?: string | null; phone?: string } | null;
}>;
warehouses: Array<{ id: string; name: string; status: string; address: string }>;
};
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [warehouseForm] = Form.useForm();
const [warehouseEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/cities',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseRow[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [warehouseOpen, setWarehouseOpen] = useState(false);
const [warehouseEditOpen, setWarehouseEditOpen] = useState(false);
const [warehouseEditId, setWarehouseEditId] = useState<string | null>(null);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [createRegionPreview, setCreateRegionPreview] = useState<ParsedProvinceCity | null>(null);
const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Row | null>(null);
const [deletePreview, setDeletePreview] = useState<CityDeletePreview | null>(null);
const [confirmName, setConfirmName] = useState('');
const [profile, setProfile] = useState<HqProfile | null>(null);
const canDeleteCities = (profile?.permissionKeys ?? []).includes('cities_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
setPartners(res.items);
}, []);
const loadWarehouses = useCallback(async (cityId: string) => {
const wh = await request<WarehouseRow[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(wh);
}, []);
useEffect(() => {
if (!createRegionCodes?.length) {
setCreateRegionPreview(null);
return;
}
const parsed = parseProvinceCityCodes(createRegionCodes as string[]);
createForm.setFieldsValue({
code: parsed?.cityCode,
name: parsed?.city,
province: parsed?.province,
});
setCreateRegionPreview(parsed);
}, [createRegionCodes, createForm]);
function openCreateModal() {
createForm.resetFields();
createForm.setFieldsValue({ status: 'PENDING' });
setCreateRegionPreview(null);
setCreateOpen(true);
}
const openDetail = async (row: Row) => {
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
setDetail(d);
editForm.setFieldsValue({
...d,
maxPartnerCommissionPercent:
d.maxPartnerCommissionRate != null
? Number(d.maxPartnerCommissionRate) * 100
: 5,
});
await loadPartners(row.id);
await loadWarehouses(row.id);
setDrawerOpen(true);
};
const openDelete = async (row: Row) => {
setDeleteTarget(row);
setDeletePreview(null);
setConfirmName('');
setDeleteOpen(true);
setDeleteLoading(true);
try {
const preview = await request<CityDeletePreview>(`/admin/cities/${row.id}/delete-preview`);
setDeletePreview(preview);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载删除预览失败');
setDeleteOpen(false);
} finally {
setDeleteLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget || !deletePreview) return;
if (!deletePreview.canDelete) {
message.error(deletePreview.blockers.join('') || '当前城市不可删除');
return;
}
if (confirmName.trim() !== deletePreview.city.name) {
message.warning(`请输入城市名称「${deletePreview.city.name}」确认删除`);
return;
}
setDeleteSubmitting(true);
try {
await request(`/admin/cities/${deleteTarget.id}`, {
method: 'DELETE',
body: JSON.stringify({ confirmName: confirmName.trim() }),
});
message.success(`已删除城市「${deletePreview.city.name}`);
setDeleteOpen(false);
setDeleteTarget(null);
setDeletePreview(null);
setConfirmName('');
if (detail?.id === deleteTarget.id) setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleteSubmitting(false);
}
};
const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
{
title: '门店',
dataIndex: 'storeCount',
width: 70,
render: (n: number, row) => (
<Link to={`/stores?cityId=${row.id}`} title={`查看「${row.name}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 140,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
{canDeleteCities ? (
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
</Button>
) : null}
</Space>
),
},
];
const warehouseColumns: ColumnsType<WarehouseRow> = [
{ title: '仓库名', dataIndex: 'name' },
{ title: '地址', dataIndex: 'address', ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
title: '管仓',
dataIndex: 'managerType',
width: 100,
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
},
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
{
title: '操作',
width: 120,
render: (_, row) => (
<Space>
<Button
type="link"
size="small"
onClick={() => {
setWarehouseEditId(row.id);
setEditWarehouseManagerType(row.managerType);
warehouseEditForm.setFieldsValue(row);
setWarehouseEditOpen(true);
}}
>
</Button>
<Button
type="link"
size="small"
danger
onClick={() => {
Modal.confirm({
title: '确认删除仓库?',
onOk: async () => {
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
message.success('已删除');
if (detail?.id) await loadWarehouses(String(detail.id));
},
});
}}
>
</Button>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={openCreateModal}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Tabs
items={[
{
key: 'basic',
label: '基本信息',
children: (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
<Descriptions.Item label="合伙人数">{String(detail.partnerBindingCount ?? '—')}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item
name="maxPartnerCommissionPercent"
label="合伙人佣金合计上限 %"
extra="订单佣金 + 核销佣金不得超过此比例;不填默认 5%"
>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} placeholder="5" />
</Form.Item>
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
const { maxPartnerCommissionPercent, ...rest } = v;
await request(`/admin/cities/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...rest,
maxPartnerCommissionRate:
maxPartnerCommissionPercent != null && maxPartnerCommissionPercent !== ''
? Number(maxPartnerCommissionPercent) / 100
: 0.05,
}),
});
message.success('已保存');
const refreshed = await request<Record<string, unknown>>(`/admin/cities/${detail.id}`);
setDetail(refreshed);
void reload();
}}></Button>
{canDeleteCities ? (
<Button
danger
style={{ marginLeft: 8 }}
onClick={() =>
void openDelete({
id: String(detail.id),
code: String(detail.code),
name: String(detail.name),
province: String(detail.province),
status: String(detail.status),
storeCount: Number(detail.storeCount ?? 0),
orderCount: Number(detail.orderCount ?? 0),
createdAt: String(detail.createdAt ?? ''),
})
}
>
</Button>
) : null}
</Form>
</>
),
},
{
key: 'partners',
label: '合伙人',
children: detail?.id ? (
<CityPartnersPanel
cityId={String(detail.id)}
cityCode={detail.code != null ? String(detail.code) : undefined}
maxPartnerCommissionRate={
detail.maxPartnerCommissionRate != null
? Number(detail.maxPartnerCommissionRate)
: 0.05
}
onChanged={() => {
void reload();
void loadPartners(String(detail.id));
}}
/>
) : null,
},
{
key: 'warehouses',
label: '仓库管理',
children: (
<>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => {
warehouseForm.resetFields();
warehouseForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: 'ACTIVE' });
setWarehouseManagerType(WarehouseManagerType.HQ);
setWarehouseOpen(true);
}}></Button>
<Table rowKey="id" size="small" columns={warehouseColumns} dataSource={warehouses} pagination={false} />
</>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
if (!v.code || !v.name || !v.province) {
message.error('请选择省 / 市');
return;
}
await request('/admin/cities', {
method: 'POST',
body: JSON.stringify({
code: v.code,
name: v.name,
province: v.province,
status: v.status,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
setCreateRegionPreview(null);
void reload();
}}>
<Form form={createForm} layout="vertical">
<Form.Item
name="regionCodes"
label="所在地区"
rules={[{ required: true, message: '请选择省 / 市' }]}
>
<ChinaProvinceCityCascader />
</Form.Item>
{createRegionPreview ? (
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市编码">{createRegionPreview.cityCode}</Descriptions.Item>
<Descriptions.Item label="省份">{createRegionPreview.province}</Descriptions.Item>
<Descriptions.Item label="城市名">{createRegionPreview.city}</Descriptions.Item>
</Descriptions>
) : (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
/
</Typography.Text>
)}
<Form.Item name="code" hidden rules={[{ required: true, message: '请选择省 / 市' }]}><Input /></Form.Item>
<Form.Item name="name" hidden rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="province" hidden rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="status" label="状态" initialValue="PENDING">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</Form>
</Modal>
<Modal title="新增仓库" open={warehouseOpen} onCancel={() => setWarehouseOpen(false)} onOk={async () => {
const v = await warehouseForm.validateFields();
await request(`/admin/cities/${detail?.id}/warehouses`, { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setWarehouseOpen(false);
if (detail?.id) await loadWarehouses(String(detail.id));
}}>
<Form form={warehouseForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select options={MANAGER_OPTIONS} onChange={(v) => setWarehouseManagerType(v)} />
</Form.Item>
{warehouseManagerType === WarehouseManagerType.PARTNER && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
)}
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑仓库" open={warehouseEditOpen} onCancel={() => setWarehouseEditOpen(false)} onOk={async () => {
const v = await warehouseEditForm.validateFields();
await request(`/admin/city-warehouses/${warehouseEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setWarehouseEditOpen(false);
if (detail?.id) await loadWarehouses(String(detail.id));
}}>
<Form form={warehouseEditForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select options={MANAGER_OPTIONS} onChange={(v) => setEditWarehouseManagerType(v)} />
</Form.Item>
{editWarehouseManagerType === WarehouseManagerType.PARTNER && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
</Form.Item>
</Form>
</Modal>
<Modal
title={deleteTarget ? `删除城市「${deleteTarget.name}` : '删除城市'}
open={deleteOpen}
onCancel={() => {
if (deleteSubmitting) return;
setDeleteOpen(false);
}}
okText="确认删除"
okButtonProps={{
danger: true,
disabled:
!deletePreview?.canDelete ||
!deletePreview ||
confirmName.trim() !== (deletePreview?.city.name ?? ''),
loading: deleteSubmitting,
}}
confirmLoading={deleteSubmitting}
onOk={() => void confirmDelete()}
width={720}
destroyOnClose
>
{deleteLoading || !deletePreview ? (
<Typography.Text type="secondary"></Typography.Text>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Descriptions size="small" bordered column={2}>
<Descriptions.Item label="编码">{deletePreview.city.code}</Descriptions.Item>
<Descriptions.Item label="省份">{deletePreview.city.province}</Descriptions.Item>
<Descriptions.Item label="合伙人主账号">{deletePreview.summary.primaryPartnerCount}</Descriptions.Item>
<Descriptions.Item label="子账号">{deletePreview.summary.staffCount}</Descriptions.Item>
<Descriptions.Item label="门店">{deletePreview.summary.storeCount}</Descriptions.Item>
<Descriptions.Item label="仓库">{deletePreview.summary.warehouseCount}</Descriptions.Item>
<Descriptions.Item label="订单">{deletePreview.summary.orderCount}</Descriptions.Item>
<Descriptions.Item label="核销">{deletePreview.summary.redeemCount}</Descriptions.Item>
</Descriptions>
{deletePreview.blockers.length > 0 && (
<Typography.Paragraph type="danger" style={{ marginBottom: 0 }}>
{deletePreview.blockers.map((b) => (
<div key={b}> {b}</div>
))}
</Typography.Paragraph>
)}
{deletePreview.warnings.length > 0 && (
<Typography.Paragraph type="warning" style={{ marginBottom: 0 }}>
{deletePreview.warnings.map((w) => (
<div key={w}> {w}</div>
))}
</Typography.Paragraph>
)}
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无合伙人' }}
dataSource={deletePreview.partners.flatMap((p) => [
{
id: p.id,
kind: '主账号',
name: p.companyName || p.name,
phone: p.phone,
parent: '—',
},
...p.staff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: p.companyName || p.name,
})),
]).concat(
deletePreview.orphanStaff.map((s) => ({
id: s.id,
kind: '子账号',
name: s.name,
phone: s.phone,
parent: '(无主账号)',
})),
)}
columns={[
{ title: '类型', dataIndex: 'kind', width: 80 },
{ title: '名称', dataIndex: 'name', ellipsis: true },
{ title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '归属', dataIndex: 'parent', ellipsis: true },
]}
/>
</div>
<div>
<Typography.Text strong></Typography.Text>
<Table
size="small"
style={{ marginTop: 8 }}
pagination={false}
rowKey="id"
locale={{ emptyText: '无门店' }}
dataSource={deletePreview.stores}
columns={[
{ title: '门店名', dataIndex: 'name', ellipsis: true },
{ title: '电话', dataIndex: 'phone', width: 120 },
{ title: '状态', dataIndex: 'status', width: 90 },
{
title: '合伙人',
width: 140,
ellipsis: true,
render: (_, r) => r.partnerAccount?.companyName || r.partnerAccount?.phone || '—',
},
]}
/>
</div>
{deletePreview.canDelete ? (
<Form.Item
label={`请输入城市名称「${deletePreview.city.name}」确认删除`}
style={{ marginBottom: 0 }}
>
<Input
value={confirmName}
placeholder={deletePreview.city.name}
onChange={(e) => setConfirmName(e.target.value)}
disabled={deleteSubmitting}
/>
</Form.Item>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Space>
)}
</Modal>
</div>
);
}
@@ -0,0 +1,706 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
type SubRow = PartnerSubAccountRow;
type Row = {
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
bindingStatus?: string;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
maxPartnerCommissionRate?: number | null;
storeCount: number;
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
/** 详情接口仍返回 children */
children?: SubRow[];
};
function toTableRows(items: Row[]): Row[] {
return items.map((row) => {
const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] };
return { ...rest, subAccounts: subAccounts ?? children ?? [] };
});
}
type CityOption = { id: string; name: string; code: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
const max = Number(maxRate);
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
if (sum > max + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
export default function CityPartnersPage() {
const [filterForm] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [subEditParentId, setSubEditParentId] = useState<string | null>(null);
const [cities, setCities] = useState<CityOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05);
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
const editCityCode = detail?.cityId
? cities.find((c) => c.id === detail.cityId)?.code
: undefined;
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
return d;
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
async function refreshPartnerContext(parentId: string) {
if (detail?.id === parentId) {
await openPartner(parentId);
}
void reload();
}
async function deleteSubAccount(subId: string, parentId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshPartnerContext(parentId);
}
function openSubEdit(row: SubRow, parentId: string) {
setSubEditId(row.id);
setSubEditParentId(parentId);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}
async function openAddSubAccount(parentId: string) {
await openPartner(parentId);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
async function onCreateCityChange(cityId: string) {
createForm.setFieldsValue({ districtCodes: undefined });
const matched = cities.find((c) => c.id === cityId);
setCreateCityCode(matched?.code);
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
}
const columns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 90 },
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true, width: 140 },
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 90,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 110,
render: (_, row) =>
`${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
},
{
title: '绑定状态',
dataIndex: 'bindingStatus',
width: 80,
render: (v) => (
<Tag color={v === CityPartnerStatus.ACTIVE ? 'green' : 'default'}>
{CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'}
</Tag>
),
},
{
title: '管仓仓库',
dataIndex: 'managedWarehouseName',
width: 110,
ellipsis: true,
render: (v) => v || '—',
},
{
title: '门店',
dataIndex: 'storeCount',
width: 60,
render: (n: number, row) => (
<Link to={`/stores?partnerId=${row.id}`} title={`查看「${row.companyName || row.phone}」门店`}>
{n ?? 0}
</Link>
),
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05);
setCreateCityCode(undefined);
setCreateOpen(true);
}}
>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={
<>
{' '}
<Link to="/city-warehouses"></Link>
</>
}
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={toTableRows(data?.items ?? [])}
scroll={{ x: 1200 }}
childrenColumnName="__noTreeChildren__"
expandable={{
expandedRowRender: (record) => (
<PartnerSubAccountList
subs={record.subAccounts ?? []}
onAdd={() => void openAddSubAccount(record.id)}
onEdit={(sub) => openSubEdit(sub, record.id)}
onDelete={(subId) => void deleteSubAccount(subId, record.id)}
/>
),
rowExpandable: () => true,
columnWidth: 40,
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
<Button type="primary" onClick={() => void savePartner()}>
</Button>
}
>
{detail && (
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数">
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
{detail.storeCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? (
<Typography.Text type="secondary">
<Link to="/city-warehouses"></Link>
</Typography.Text>
)}
</Descriptions.Item>
</Descriptions>
<Tabs
items={[
{
key: 'info',
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="业务联系电话">
<Input />
</Form.Item>
<Form.Item name="address" label="地址">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<Form.Item name="bankAccountName" label="户名">
<Input />
</Form.Item>
<Form.Item name="bankAccountNo" label="账号">
<Input />
</Form.Item>
<Form.Item name="bankBranch" label="开户行">
<Input />
</Form.Item>
</Form>
),
},
{
key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(sub) => openSubEdit(sub, detail.id)}
onDelete={(subId) => void deleteSubAccount(subId, detail.id)}
/>
),
},
]}
/>
</>
)}
</Drawer>
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
try {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
createMaxRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes:
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => void onCreateCityChange(id)}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址">
<Input placeholder="选填" />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block' }}>
+ {(createMaxRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal
title={detail ? `添加子账号 · ${detail.companyName}` : '添加子账号'}
open={subOpen}
onCancel={() => setSubOpen(false)}
onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
await refreshPartnerContext(detail.id);
}}
>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑子账号"
open={subEditOpen}
onCancel={() => setSubEditOpen(false)}
onOk={async () => {
if (!subEditId) return;
const parentId = detail?.id ?? subEditParentId;
if (!parentId) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
await refreshPartnerContext(parentId);
}}
>
<Form form={subEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="staffRole" label="角色">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'ACTIVE', label: '启用' },
{ value: 'DISABLED', label: '停用' },
]}
/>
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,515 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Button,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WAREHOUSE_FULFILLMENT_MODE_LABELS,
WAREHOUSE_MANAGER_LABELS,
WAREHOUSE_STATUS_LABELS,
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
cityId: string;
cityName: string;
cityCode: string;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
fulfillmentMode?: string;
fulfillmentProviderId?: string | null;
fulfillmentProviderName?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
createdAt: string;
};
type CityOption = { id: string; name: string; code: string };
type PartnerOption = { id: string; companyName: string };
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
function FulfillmentFields({
mode,
providerOptions,
onModeChange,
}: {
mode: WarehouseFulfillmentMode;
providerOptions: FulfillmentProviderDto[];
onModeChange: (mode: WarehouseFulfillmentMode) => void;
}) {
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
return (
<>
<Form.Item
name="fulfillmentMode"
label="支付后自动发货"
extra={
autoShip
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
}
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
getValueFromEvent={(checked: boolean) =>
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
}
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
onModeChange(
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
);
}}
/>
</Form.Item>
{autoShip && (
<Form.Item
name="fulfillmentProviderId"
label="仓配承运商"
rules={[{ required: true, message: '自动发货须选择承运商' }]}
>
<Select
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
/>
</Form.Item>
)}
{!autoShip && (
<>
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
<Input placeholder="如 顺丰速运" />
</Form.Item>
<Form.Item
name="manualQueryUrlTemplate"
label="物流查询链接模板"
extra="可用 {trackingNo} 占位符"
>
<Input placeholder="https://example.com/track?no={trackingNo}" />
</Form.Item>
</>
)}
<Space>
<Form.Item name="lng" label="经度">
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
</Form.Item>
<Form.Item name="lat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
);
}
export default function CityWarehousesPage() {
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/city-warehouses',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.managerType) qs.set('managerType', filters.managerType);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [cities, setCities] = useState<CityOption[]>([]);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<Row | null>(null);
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`,
);
setPartners(res.items);
}, []);
useEffect(() => {
void loadCities();
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
.then(setProviderOptions)
.catch(() => {});
}, [loadCities]);
async function openEdit(row: Row) {
setEditRow(row);
setEditManagerType(row.managerType);
await loadPartners(row.cityId);
editForm.setFieldsValue({
name: row.name,
address: row.address,
contactName: row.contactName,
contactPhone: row.contactPhone,
managerType: row.managerType,
partnerAccountId: row.partnerAccountId,
status: row.status,
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
lng: row.lng ?? undefined,
lat: row.lat ?? undefined,
});
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
setEditOpen(true);
}
function onManagerTypeChange(
v: WarehouseManagerType,
form: typeof createForm,
setType: (t: WarehouseManagerType) => void,
) {
setType(v);
if (v !== WarehouseManagerType.PARTNER) {
form.setFieldValue('partnerAccountId', undefined);
}
}
const columns: ColumnsType<Row> = [
{
title: '城市',
width: 100,
render: (_, row) => (
<span title={row.cityCode}>
{row.cityName}
</span>
),
},
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
title: '管仓',
width: 90,
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
},
{
title: '合伙人',
dataIndex: 'partnerCompanyName',
width: 120,
ellipsis: true,
render: (v, row) =>
v && row.partnerAccountId ? (
<Link to="/city-partners">{v}</Link>
) : (
'—'
),
},
{
title: '自动发货',
width: 130,
render: (_, row) =>
row.fulfillmentMode === 'API_AUTO' ? (
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
) : (
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => (
<Tag color={s === WarehouseStatus.ACTIVE ? 'green' : 'default'}>
{WAREHOUSE_STATUS_LABELS[s as WarehouseStatus] || s}
</Tag>
),
},
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 120,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openEdit(row)}>
</Button>
<Popconfirm
title="确定删除该仓库?"
description="删除后将解除关联合伙人的管仓绑定"
onConfirm={async () => {
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
setCreateManagerType(WarehouseManagerType.HQ);
setCreateCityId(undefined);
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
createForm.setFieldsValue({
managerType: WarehouseManagerType.HQ,
status: WarehouseStatus.ACTIVE,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
});
setCreateOpen(true);
}}
>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="name" label="仓库名">
<Input allowClear />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="managerType" label="管仓">
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title="新增仓库"
open={createOpen}
width={520}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request(`/admin/cities/${v.cityId}/warehouses`, {
method: 'POST',
body: JSON.stringify(v),
});
message.success('已创建');
setCreateOpen(false);
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => {
setCreateCityId(id);
createForm.setFieldValue('partnerAccountId', undefined);
void loadPartners(id);
}}
/>
</Form.Item>
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select
options={MANAGER_OPTIONS}
onChange={(v) => onManagerTypeChange(v, createForm, setCreateManagerType)}
/>
</Form.Item>
{createManagerType === WarehouseManagerType.PARTNER && createCityId && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder={partners.length ? '选择合伙人' : '该城市暂无合伙人'}
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
/>
</Form.Item>
)}
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={createFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setCreateFulfillmentMode}
/>
</Form>
</Modal>
<Modal
title="编辑仓库"
open={editOpen}
width={520}
onCancel={() => setEditOpen(false)}
onOk={async () => {
if (!editRow) return;
const v = await editForm.validateFields();
await request(`/admin/city-warehouses/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify(v),
});
message.success('已更新');
setEditOpen(false);
void reload();
}}
>
{editRow && (
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="所属城市">
{editRow.cityName}{editRow.cityCode}
</Descriptions.Item>
</Descriptions>
)}
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select
options={MANAGER_OPTIONS}
onChange={(v) => onManagerTypeChange(v, editForm, setEditManagerType)}
/>
</Form.Item>
{editManagerType === WarehouseManagerType.PARTNER && editRow && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
/>
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={editFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setEditFulfillmentMode}
/>
</Form>
</Modal>
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
} from 'antd';
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
import {
request,
type DashboardStats,
type DeployTriggerResult,
type HqProfile,
type SystemVersion,
} from '../lib/api';
import { fmtTime } from '../lib/constants';
const STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
const DEPLOYED_BY_LABELS: Record<string, string> = {
webhook: 'Webhook',
manual: '手动脚本',
admin: 'Admin 发布',
};
export default function DashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [version, setVersion] = useState<SystemVersion | null>(null);
const [loading, setLoading] = useState(true);
const [versionLoading, setVersionLoading] = useState(true);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [deploying, setDeploying] = useState(false);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
const loadVersion = useCallback(() => {
setVersionLoading(true);
return request<SystemVersion | null>('/admin/dashboard/version')
.then(setVersion)
.catch(() => setVersion(null))
.finally(() => setVersionLoading(false));
}, []);
useEffect(() => {
request<DashboardStats>('/admin/dashboard/stats')
.then(setStats)
.finally(() => setLoading(false));
request<HqProfile>('/admin/auth/me')
.then((p) => {
setProfile(p);
if (p.adminRole === 'SUPER_ADMIN') {
void loadVersion();
} else {
setVersionLoading(false);
}
})
.catch(() => {
setVersionLoading(false);
});
}, [loadVersion]);
function handleDeploy() {
Modal.confirm({
title: '确认发布更新?',
content: '将触发服务器 webhook,拉取 auto-release.env 中配置的 GIT_BRANCH 并执行发版。发版通常需要数分钟,完成后可刷新版本信息。',
okText: '开始发布',
cancelText: '取消',
onOk: async () => {
setDeploying(true);
try {
const result = await request<DeployTriggerResult>('/admin/deploy/trigger', { method: 'POST' });
message.success(result.message || '已触发发布');
} catch (e) {
message.error(e instanceof Error ? e.message : '触发发布失败');
throw e;
} finally {
setDeploying(false);
}
},
});
}
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
return (
<div>
<Typography.Title level={4}></Typography.Title>
{isSuperAdmin ? (
<Card
title="系统版本"
loading={versionLoading}
style={{ marginBottom: 24 }}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
</Button>
<Button
type="primary"
icon={<CloudUploadOutlined />}
loading={deploying}
onClick={handleDeploy}
>
</Button>
</Space>
}
>
{version ? (
<Descriptions column={{ xs: 1, sm: 2, lg: 3 }} size="small">
<Descriptions.Item label="分支">{version.branch || '—'}</Descriptions.Item>
<Descriptions.Item label="Tag">{version.gitTag || '—'}</Descriptions.Item>
<Descriptions.Item label="Commit">
<Typography.Text copyable={{ text: version.commitId }}>{shortSha}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="提交说明" span={3}>{version.commitMessage}</Descriptions.Item>
<Descriptions.Item label="发布时间">{fmtTime(version.deployedAt)}</Descriptions.Item>
<Descriptions.Item label="触发来源">
{DEPLOYED_BY_LABELS[version.deployedBy || ''] || version.deployedBy || '—'}
</Descriptions.Item>
</Descriptions>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card loading={loading}>
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待审核合伙人打款"
extra={<Link to="/finance/partner-bills"></Link>}
>
<Statistic
value={stats?.pendingBills ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
<Typography.Text type="secondary">
{(stats?.pendingPartnerDraftBills ?? 0) > 0
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
: ''}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待打款门店结算"
extra={<Link to="/finance/store-bills"></Link>}
>
<Statistic
value={stats?.pendingPayouts ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title="已合并访客账号" loading={loading}>
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="订单状态分布" loading={loading}>
<Table
size="small"
pagination={false}
rowKey="status"
dataSource={stats?.ordersByStatus ?? []}
columns={[
{
title: '状态',
dataIndex: 'status',
render: (s: string) => STATUS_LABELS[s] || s,
},
{ title: '数量', dataIndex: 'count' },
]}
/>
</Card>
</Col>
</Row>
</div>
);
}
@@ -0,0 +1,95 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
};
export default function DeliveriesPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/deliveries',
() => {
const qs = new URLSearchParams();
if (filters.provider) qs.set('provider', filters.provider);
if (filters.trackingNo) qs.set('trackingNo', filters.trackingNo);
if (filters.orderNo) qs.set('orderNo', filters.orderNo);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const columns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
{ title: 'provider', dataIndex: 'provider', width: 90 },
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const d = await request<Row>(`/admin/deliveries/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Typography.Title level={4}>/</Typography.Title>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={
<Space>
<Button onClick={() => setDrawerOpen(false)}></Button>
<Button type="primary" onClick={async () => {
if (!detail) return;
const v = await editForm.validateFields();
await request(`/admin/deliveries/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
</Space>
}>
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
</Form>
</>
)}
</Drawer>
</div>
);
}
@@ -0,0 +1,371 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Divider,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
DEFAULT_XFX_LOGISTICS_PRICING,
FULFILLMENT_PROVIDER_STATUS_LABELS,
FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus,
FulfillmentProviderType,
LOGISTICS_SETTLEMENT_METHOD_LABELS,
LogisticsSettlementMethod,
isXfxProviderCode,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
value,
label,
}));
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}));
const SETTLEMENT_OPTIONS = Object.entries(LOGISTICS_SETTLEMENT_METHOD_LABELS).map(
([value, label]) => ({ value, label }),
);
const SIGN_OPTIONS = [
{ value: 'MD5', label: 'MD5' },
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
];
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
export default function FulfillmentProvidersPage() {
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
const [form] = Form.useForm();
const watchedCode = Form.useWatch('code', form);
const watchedType = Form.useWatch('type', form);
const showXfxFields = useMemo(
() => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API,
[watchedCode, watchedType],
);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
setRows(res);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function openCreate() {
setEditRow(null);
form.resetFields();
form.setFieldsValue({
code: 'XFX',
name: '小飞侠',
type: FulfillmentProviderType.API,
status: FulfillmentProviderStatus.ACTIVE,
apiUrl: DEFAULT_XFX_API_URL,
signType: 'MD5',
settlementMethod: LogisticsSettlementMethod.PREPAID,
baseBottles: DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
});
setOpen(true);
}
function openEdit(row: FulfillmentProviderDto) {
setEditRow(row);
const xfx = row.xiaofeixiaConfig;
const pricing = row.pricingRules;
form.setFieldsValue({
code: row.code,
name: row.name,
type: row.type,
status: row.status,
apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL,
mchId: xfx?.mchId || '',
apiKey: '',
signType: xfx?.signType || 'MD5',
appId: xfx?.appId || '',
bankAccountName: row.bankAccountName || '',
bankName: row.bankName || '',
bankBranch: row.bankBranch || '',
bankAccountNo: row.bankAccountNo || '',
settlementMethod: row.settlementMethod || LogisticsSettlementMethod.PREPAID,
baseBottles: pricing?.baseBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.baseBottles,
baseFee: pricing?.baseFee ?? DEFAULT_XFX_LOGISTICS_PRICING.baseFee,
extraBottleFee: pricing?.extraBottleFee ?? DEFAULT_XFX_LOGISTICS_PRICING.extraBottleFee,
boxBottles: pricing?.boxBottles ?? DEFAULT_XFX_LOGISTICS_PRICING.boxBottles,
boxFee: pricing?.boxFee ?? DEFAULT_XFX_LOGISTICS_PRICING.boxFee,
});
setOpen(true);
}
async function submit() {
const v = await form.validateFields();
const payload: Record<string, unknown> = {
name: v.name,
type: v.type,
status: v.status,
bankAccountName: v.bankAccountName || null,
bankName: v.bankName || null,
bankBranch: v.bankBranch || null,
bankAccountNo: v.bankAccountNo || null,
settlementMethod: v.settlementMethod,
pricingRules: {
baseBottles: Number(v.baseBottles),
baseFee: Number(v.baseFee),
extraBottleFee: Number(v.extraBottleFee),
boxBottles: v.boxBottles != null ? Number(v.boxBottles) : undefined,
boxFee: v.boxFee != null ? Number(v.boxFee) : undefined,
},
};
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
payload.xiaofeixiaConfig = {
apiUrl: v.apiUrl,
mchId: v.mchId,
apiKey: v.apiKey || undefined,
signType: v.signType,
appId: v.appId || undefined,
};
}
if (editRow) {
await request(`/admin/fulfillment-providers/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('已更新');
} else {
await request('/admin/fulfillment-providers', {
method: 'POST',
body: JSON.stringify({
code: v.code,
...payload,
}),
});
message.success('已创建');
}
setOpen(false);
void load();
}
const columns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 },
{ title: '名称', dataIndex: 'name' },
{
title: '类型',
dataIndex: 'type',
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
},
{
title: '结算',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v || '—',
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 100,
render: (v) => `¥${Number(v || 0).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
render: (v) => (
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
</Tag>
),
},
{
title: '接口配置',
render: (_, row) => {
if (isXfxProviderCode(row.code)) {
const cfg = row.xiaofeixiaConfig;
if (!cfg?.apiUrl) return <Tag></Tag>;
return (
<span title={cfg.apiUrl}>
{cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'}
</span>
);
}
return row.hasConfig ? '已配置' : '—';
},
},
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
/>
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
<Modal
title={editRow ? '编辑承运商' : '注册承运商'}
open={open}
onCancel={() => setOpen(false)}
onOk={() => void submit()}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如 小飞侠、京东物流" />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<Divider orientation="left"></Divider>
<Form.Item name="settlementMethod" label="结算方式" rules={[{ required: true }]}>
<Select options={SETTLEMENT_OPTIONS} />
</Form.Item>
<Form.Item name="bankAccountName" label="收款户名">
<Input />
</Form.Item>
<Form.Item name="bankName" label="开户银行">
<Input />
</Form.Item>
<Form.Item name="bankBranch" label="开户支行">
<Input />
</Form.Item>
<Form.Item name="bankAccountNo" label="银行账号">
<Input />
</Form.Item>
<Space wrap style={{ width: '100%' }}>
<Form.Item
name="baseBottles"
label="起送瓶数"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="baseFee"
label="起送费用(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item
name="extraBottleFee"
label="加一瓶(元)"
rules={[{ required: true }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxBottles" label="箱规瓶数" style={{ marginBottom: 12 }}>
<InputNumber min={1} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="boxFee" label="一箱费用(元)" style={{ marginBottom: 12 }}>
<InputNumber min={0} precision={2} style={{ width: 120 }} />
</Form.Item>
</Space>
{showXfxFields && (
<>
<Divider orientation="left"></Divider>
<Form.Item
name="apiUrl"
label="API 地址"
rules={[{ required: true, message: '请填写小飞侠 API 地址' }]}
extra="推单请求将发往此地址"
>
<Input placeholder={DEFAULT_XFX_API_URL} />
</Form.Item>
<Form.Item
name="mchId"
label="商户号"
rules={[{ required: true, message: '请填写商户号' }]}
>
<Input />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={editRow ? [] : [{ required: true, message: '请填写 API Key' }]}
extra={
editRow?.xiaofeixiaConfig?.hasApiKey
? '已配置密钥;留空则保持不变'
: undefined
}
>
<Input.Password placeholder={editRow ? '留空则不修改' : '请输入'} />
</Form.Item>
<Form.Item name="signType" label="签名类型" initialValue="MD5">
<Select options={SIGN_OPTIONS} />
</Form.Item>
<Form.Item name="appId" label="AppID(可选)">
<Input />
</Form.Item>
</>
)}
</Form>
</Modal>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import {
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type HqProfile } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
phone: string;
loginName: string | null;
hasPassword: boolean;
name: string;
adminRole: string;
status: string;
lastLoginAt: string | null;
createdAt: string;
};
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: '超级管理员',
OPS: '运营',
FINANCE: '财务',
CUSTOMER_SERVICE: '客服',
};
export default function HqAccountsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null);
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/hq-accounts',
() => {
const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone);
if (filters.adminRole) qs.set('adminRole', filters.adminRole);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name' },
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 130 },
{
title: '登录方式',
width: 110,
render: (_, r) => (
<Space size={4}>
{r.hasPassword ? <Tag color="blue"></Tag> : null}
<Tag></Tag>
</Space>
),
},
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" disabled={!isSuperAdmin} onClick={() => {
setDetail(row);
editForm.setFieldsValue({
name: row.name,
phone: row.phone,
loginName: row.loginName,
adminRole: row.adminRole,
status: row.status,
});
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>HQ </Typography.Title>
{isSuperAdmin && (
<Button
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' });
setCreateOpen(true);
}}
>
</Button>
)}
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
<Form.Item name="adminRole" label="角色">
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={
<Button type="primary" onClick={async () => {
if (!detail) return;
const v = await editForm.validateFields();
const payload = { ...v };
if (!payload.password) delete payload.password;
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
}>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[
{ required: true, message: '请输入手机号' },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
]}
>
<Input maxLength={11} />
</Form.Item>
<Form.Item name="loginName" label="用户名"><Input placeholder="用于密码登录" /></Form.Item>
<Form.Item name="password" label="新密码" extra="留空则不修改">
<Input.Password placeholder="至少 6 位" />
</Form.Item>
<Form.Item name="adminRole" label="角色">
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</Form>
</Drawer>
<Modal
title="新建 HQ 账户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}
>
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}>
<Form.Item name="credentialType" label="创建方式">
<Radio.Group>
<Radio value="phone"></Radio>
<Radio value="password"></Radio>
</Radio.Group>
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="adminRole" label="角色">
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
{credentialType === 'phone' ? (
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
<Input maxLength={11} />
</Form.Item>
) : (
<>
<Form.Item name="loginName" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Input autoComplete="off" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '至少 6 位' }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item name="phone" label="手机号(可选)" extra="不填则自动生成占位手机号,用于满足账号唯一约束">
<Input maxLength={11} />
</Form.Item>
</>
)}
</Form>
</Modal>
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Select, Space, Table, Tag, Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
hqAccountId: string | null;
hqName: string | null;
hqPhone: string | null;
hqRole: string | null;
action: string | null;
actionLabel: string;
refType: string | null;
refId: string | null;
status: string | null;
remark: string | null;
detail: Record<string, unknown> | null;
createdAt: string;
};
export default function HqLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>(() => ({
hqAccountId: searchParams.get('hqAccountId') ?? '',
action: searchParams.get('action') ?? '',
refType: searchParams.get('refType') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/logs/hq',
() => {
const qs = new URLSearchParams();
if (filters.hqAccountId) qs.set('hqAccountId', filters.hqAccountId);
if (filters.action) qs.set('action', filters.action);
if (filters.refType) qs.set('refType', filters.refType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作人',
width: 200,
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.hqName}
secondary={[r.hqPhone, r.hqAccountId ? `#${r.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/>
),
},
{
title: '行为',
dataIndex: 'actionLabel',
width: 160,
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
},
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
const res = await request<Row>(`/admin/logs/hq/${row.id}`);
setDetail(res);
setDrawerOpen(true);
}}
>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}>HQ </Typography.Title>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
const qs = new URLSearchParams();
if (values.hqAccountId) qs.set('hqAccountId', values.hqAccountId);
if (values.action) qs.set('action', values.action);
if (values.refType) qs.set('refType', values.refType);
setSearchParams(qs);
}}
>
<Form.Item name="hqAccountId" label="HQ 账户 ID">
<Input allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="action" label="行为">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
options={HQ_OPERATION_ACTION_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
/>
</Form.Item>
<Form.Item name="refType" label="对象类型">
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => {
form.resetFields();
setFilters({ hqAccountId: '', action: '', refType: '' });
setSearchParams({});
setPage(1);
void reload();
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="操作详情" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="日志 ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="操作人">
<AdminCellLine
primary={detail.hqName}
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
/>
</Descriptions.Item>
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
<Descriptions.Item label="行为">
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
</Descriptions.Item>
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>/</Typography.Title>
<pre style={{
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
maxHeight: 400, overflow: 'auto', fontSize: 12,
}}
>
{JSON.stringify(detail.detail, null, 2)}
</pre>
</>
)}
</Drawer>
</div>
);
}
@@ -0,0 +1,343 @@
import { useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Card,
Checkbox,
Col,
Form,
Row,
Select,
Space,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import {
HQ_ADMIN_ROLES,
HQ_PERMISSION_CATALOG,
type HqPermissionKey,
} from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api';
type RolePermRes = { role: string; permissionKeys: string[] };
type AccountOption = { id: string; name: string; phone: string; loginName: string | null; adminRole: string };
type AccountPermRes = {
account: AccountOption;
permissionKeys: string[];
rolePermissionKeys: string[];
userPermissionKeys: string[];
effectivePermissionKeys: string[];
};
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
function groupColSpan(itemCount: number): number {
if (itemCount <= 2) return 12;
if (itemCount === 3) return 8;
if (itemCount === 4) return 6;
return 8;
}
function PermissionChecklist({
value,
onChange,
disabled,
}: {
value: string[];
onChange: (keys: string[]) => void;
disabled?: boolean;
}) {
return (
<Checkbox.Group
style={{ width: '100%' }}
value={value}
disabled={disabled}
onChange={(checked) => onChange(checked as string[])}
>
{CATALOG_GROUPS.map((group) => {
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
const span = groupColSpan(items.length);
const compact = items.length <= 3;
return (
<div
key={group}
style={{
marginBottom: 16,
padding: '12px 16px',
background: '#fafafa',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<Typography.Text strong style={{ display: 'block', marginBottom: 10 }}>
{group}
</Typography.Text>
{compact ? (
<Space size={[24, 8]} wrap>
{items.map((item) => (
<Checkbox key={item.key} value={item.key}>
{item.label}
</Checkbox>
))}
</Space>
) : (
<Row gutter={[12, 10]}>
{items.map((item) => (
<Col key={item.key} xs={24} sm={12} md={span}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
)}
</div>
);
})}
</Checkbox.Group>
);
}
export default function HqPermissionsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null);
const [role, setRole] = useState<string>('OPS');
const [roleKeys, setRoleKeys] = useState<string[]>([]);
const [roleLoading, setRoleLoading] = useState(false);
const [roleSaving, setRoleSaving] = useState(false);
const [accounts, setAccounts] = useState<AccountOption[]>([]);
const [accountId, setAccountId] = useState<string>();
const [accountKeys, setAccountKeys] = useState<string[]>([]);
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = useState(false);
const selectedAccount = useMemo(
() => accounts.find((a) => a.id === accountId),
[accounts, accountId],
);
const selectedIsSuperAdmin = selectedAccount?.adminRole === 'SUPER_ADMIN';
const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys],
);
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
if (!isSuperAdmin) return;
request<{ items: AccountOption[] }>('/admin/hq-accounts?page=1&pageSize=100')
.then((res) => setAccounts(res.items))
.catch(() => {});
}, [isSuperAdmin]);
useEffect(() => {
if (!isSuperAdmin || !role) return;
setRoleLoading(true);
request<RolePermRes>(`/admin/hq-permissions/roles/${role}`)
.then((res) => setRoleKeys(res.permissionKeys))
.finally(() => setRoleLoading(false));
}, [isSuperAdmin, role]);
useEffect(() => {
if (!isSuperAdmin || !accountId) return;
setAccountLoading(true);
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
.then((res) => {
setAccountKeys(res.userPermissionKeys);
setRoleInheritedKeys(res.rolePermissionKeys);
})
.finally(() => setAccountLoading(false));
}, [isSuperAdmin, accountId]);
async function saveRolePermissions() {
setRoleSaving(true);
try {
const res = await request<RolePermRes>(`/admin/hq-permissions/roles/${role}`, {
method: 'PUT',
body: JSON.stringify({ permissionKeys: roleKeys }),
});
setRoleKeys(res.permissionKeys);
message.success('角色权限已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setRoleSaving(false);
}
}
async function saveAccountPermissions() {
if (!accountId) return;
setAccountSaving(true);
try {
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
method: 'PUT',
body: JSON.stringify({ permissionKeys: accountKeys }),
});
setAccountKeys(res.userPermissionKeys);
setRoleInheritedKeys(res.rolePermissionKeys);
message.success('用户权限已保存');
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setAccountSaving(false);
}
}
if (!isSuperAdmin) {
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Alert type="warning" showIcon message="仅超级管理员可配置权限" />
</div>
);
}
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
=
//
//
</Typography.Paragraph>
<Tabs
items={[
{
key: 'role',
label: '按角色分配',
children: (
<Card loading={roleLoading}>
<Form layout="inline" style={{ marginBottom: 16 }}>
<Form.Item label="角色">
<Select
style={{ width: 180 }}
value={role}
onChange={setRole}
options={HQ_ADMIN_ROLES.map((r) => ({
value: r.value,
label: r.label,
disabled: r.value === 'SUPER_ADMIN',
}))}
/>
</Form.Item>
</Form>
{role === 'SUPER_ADMIN' ? (
<Alert
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
/>
) : (
<>
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
<div style={{ marginTop: 16 }}>
<Button type="primary" loading={roleSaving} onClick={() => void saveRolePermissions()}>
</Button>
</div>
</>
)}
</Card>
),
},
{
key: 'user',
label: '按用户分配',
children: (
<Card loading={accountLoading}>
<Form layout="inline" style={{ marginBottom: 16 }}>
<Form.Item label="HQ 账户">
<Select
showSearch
allowClear
placeholder="选择账户"
style={{ width: 320 }}
value={accountId}
onChange={setAccountId}
optionFilterProp="label"
options={accounts.map((a) => ({
value: a.id,
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
/>
</Form.Item>
</Form>
{!accountId ? (
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : (
<>
<div style={{ marginBottom: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Tag color="blue"></Tag>
) : (
<Space wrap size={[4, 4]}>
{roleInheritedKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return (
<Tag key={key} color="blue">
{item?.label || key}
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary">
//
</Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
<div style={{ marginTop: 12 }}>
<span style={{ marginRight: 8 }}></span>
{selectedIsSuperAdmin ? (
<Space wrap size={[4, 4]}>
<Tag></Tag>
{accountKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key} color="orange">
{item?.label || key}
</Tag>
);
})}
</Space>
) : (
<Space wrap size={[4, 4]}>
{previewEffectiveKeys.map((key) => {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
return (
<Tag key={key}>
{item?.label || key}
</Tag>
);
})}
</Space>
)}
</div>
<div style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
</Button>
</div>
</>
)}
</Card>
),
},
]}
/>
</div>
);
}
+424
View File
@@ -0,0 +1,424 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceStatus,
type InvoiceTitleType,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
type Row = {
id: string;
invoiceNo: string;
orderNo?: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
titleName: string;
status: InvoiceStatus;
overdue?: boolean;
createdAt: string;
fileUrl?: string;
email?: string;
phone?: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
payAmount?: string;
userPhone?: string;
remark?: string;
};
type CreateFormValues = {
orderNo: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
titleName: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
remark?: string;
};
export default function InvoicesPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/invoices',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [uploading, setUploading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<CreateFormValues>();
const invoiceKind = Form.useWatch('invoiceKind', createForm);
const titleType = Form.useWatch('titleType', createForm);
async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`));
setDrawerOpen(true);
}
async function issueWithFile(file: File) {
if (!detail) return false;
setUploading(true);
try {
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
await request(`/admin/invoices/${detail.id}/issue`, {
method: 'POST',
body: JSON.stringify({ fileUrl: uploaded.url }),
});
message.success('已开票回传');
reload();
setDrawerOpen(false);
} catch (e) {
message.error(e instanceof Error ? e.message : '开票失败');
} finally {
setUploading(false);
}
return false;
}
async function reject() {
if (!detail) return;
await request(`/admin/invoices/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ remark: '驳回' }),
});
message.success('已驳回');
reload();
setDrawerOpen(false);
}
async function submitCreate() {
const values = await createForm.validateFields();
setCreating(true);
try {
await request('/admin/invoices', {
method: 'POST',
body: JSON.stringify({
orderNo: values.orderNo.trim(),
titleType: values.titleType,
invoiceKind: values.invoiceKind,
titleName: values.titleName.trim(),
taxNo: values.taxNo?.trim() || undefined,
addressPhone: values.addressPhone?.trim() || undefined,
bankAccount: values.bankAccount?.trim() || undefined,
email: values.email.trim(),
phone: values.phone.trim(),
remark: values.remark?.trim() || undefined,
}),
});
message.success('发票申请已创建');
setCreateOpen(false);
createForm.resetFields();
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
const columns: ColumnsType<Row> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{
title: '抬头',
width: 100,
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
},
{
title: '票种',
width: 120,
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
},
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
{
title: '状态',
width: 110,
render: (_, r) => (
<>
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
</Tag>
{r.overdue ? <Tag color="red"></Tag> : null}
</>
),
},
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.setFieldsValue({
titleType: 'PERSONAL',
invoiceKind: 'NORMAL',
});
setCreateOpen(true);
}}
>
</Button>
</div>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'PENDING', label: '待开票' },
{ value: 'ISSUED', label: '已开票' },
{ value: 'REJECTED', label: '已驳回' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1000 }}
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="发票详情"
width={520}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<Space>
<Upload
accept="image/*,.pdf"
showUploadList={false}
beforeUpload={(file) => {
void issueWithFile(file);
return false;
}}
>
<Button type="primary" loading={uploading}>
</Button>
</Upload>
<Button danger onClick={() => void reject()}>
</Button>
</Space>
) : null
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="抬头类型">
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
</Descriptions.Item>
<Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]}
</Descriptions.Item>
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="状态">
{INVOICE_STATUS_LABELS[detail.status]}
{detail.overdue ? '(超 2 工作日)' : ''}
</Descriptions.Item>
<Descriptions.Item label="发票文件">
{detail.fileUrl ? (
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
/
</a>
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark ?? '—'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
<Modal
title="创建发票申请"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={creating}
destroyOnClose
okText="提交"
width={520}
>
<Form form={createForm} layout="vertical">
<Form.Item
name="orderNo"
label="订单号"
rules={[{ required: true, message: '请填写已完成订单号' }]}
extra="仅已完成订单可开票"
>
<Input placeholder="订单号" allowClear />
</Form.Item>
<Form.Item
name="invoiceKind"
label="发票类型"
rules={[{ required: true, message: '请选择发票类型' }]}
>
<Select
options={(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => ({
value: k,
label: INVOICE_KIND_LABELS[k],
}))}
onChange={(k: InvoiceKind) => {
if (k === 'SPECIAL') createForm.setFieldValue('titleType', 'ENTERPRISE');
}}
/>
</Form.Item>
<Form.Item
name="titleType"
label="抬头类型"
rules={[{ required: true, message: '请选择抬头类型' }]}
>
<Select
options={(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => ({
value: t,
label: INVOICE_TITLE_TYPE_LABELS[t],
disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL',
}))}
/>
</Form.Item>
<Form.Item
name="titleName"
label="抬头名称"
rules={[{ required: true, message: '请填写抬头名称' }]}
>
<Input placeholder="个人姓名或企业全称" />
</Form.Item>
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
<Form.Item
name="taxNo"
label="税号"
rules={[{ required: true, message: '企业抬头须填写税号' }]}
>
<Input placeholder="纳税人识别号" />
</Form.Item>
)}
{invoiceKind === 'SPECIAL' && (
<>
<Form.Item
name="addressPhone"
label="地址电话"
rules={[{ required: true, message: '专用发票须填写地址电话' }]}
>
<Input placeholder="注册地址及电话" />
</Form.Item>
<Form.Item
name="bankAccount"
label="开户行账号"
rules={[{ required: true, message: '专用发票须填写开户行账号' }]}
>
<Input placeholder="开户行及账号" />
</Form.Item>
</>
)}
<Form.Item
name="email"
label="接收邮箱"
rules={[
{ required: true, message: '请填写邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input placeholder="发票发送邮箱" />
</Form.Item>
<Form.Item
name="phone"
label="手机号"
rules={[{ required: true, message: '请填写手机号' }]}
>
<Input placeholder="联系手机" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,403 @@
import { useState } from 'react';
import {
Button,
Drawer,
Form,
Input,
Modal,
Popconfirm,
Space,
Switch,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { UploadOutlined } from '@ant-design/icons';
import type {
KnowledgeBaseDto,
KnowledgeDocumentDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
type FormValues = {
name: string;
description?: string;
enabled: boolean;
};
type DocForm = {
title: string;
contentText?: string;
};
export default function KnowledgeBasesPage() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [docForm] = Form.useForm<DocForm>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<KnowledgeBaseDto | null>(null);
const [saving, setSaving] = useState(false);
const [drawerKb, setDrawerKb] = useState<KnowledgeBaseDto | null>(null);
const [docs, setDocs] = useState<KnowledgeDocumentDto[]>([]);
const [docsLoading, setDocsLoading] = useState(false);
const [docModalOpen, setDocModalOpen] = useState(false);
const [docSaving, setDocSaving] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<KnowledgeBaseDto>(
'/admin/knowledge-bases',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
const loadDocs = async (kb: KnowledgeBaseDto) => {
setDrawerKb(kb);
setDocsLoading(true);
try {
const res = await request<{ items: KnowledgeDocumentDto[] }>(
`/admin/knowledge-bases/${kb.id}/documents`,
);
setDocs(res.items);
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setDocsLoading(false);
}
};
const openCreate = () => {
setEditing(null);
form.setFieldsValue({ name: '', description: '', enabled: true });
setModalOpen(true);
};
const openEdit = (row: KnowledgeBaseDto) => {
setEditing(row);
form.setFieldsValue({
name: row.name,
description: row.description || '',
enabled: row.enabled,
});
setModalOpen(true);
};
const onSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await request(`/admin/knowledge-bases/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(values),
});
message.success('已保存');
} else {
await request('/admin/knowledge-bases', {
method: 'POST',
body: JSON.stringify(values),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const columns: ColumnsType<KnowledgeBaseDto> = [
{ title: '名称', dataIndex: 'name' },
{ title: '说明', dataIndex: 'description', ellipsis: true },
{ title: '文档数', dataIndex: 'documentCount', width: 90 },
{
title: '启用',
dataIndex: 'enabled',
width: 90,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{
title: '创建人',
dataIndex: 'createdByName',
width: 100,
render: (v: string | null, row) => v || row.createdByHqAccountId,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 240,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => void loadDocs(row)}>
</Button>
{row.canEditFull ? (
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
) : null}
{row.canEditFull ? (
<Popconfirm
title="确认删除知识库及全部文档?"
onConfirm={async () => {
try {
await request(`/admin/knowledge-bases/${row.id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const docColumns: ColumnsType<KnowledgeDocumentDto> = [
{ title: '标题', dataIndex: 'title' },
{ title: '文件', dataIndex: 'fileName', ellipsis: true },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s: string) =>
s === 'READY' ? <Tag color="green"></Tag> : s === 'FAILED' ? <Tag color="red"></Tag> : <Tag></Tag>,
},
{
title: '时间',
dataIndex: 'createdAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 80,
render: (_, row) =>
drawerKb?.canEditFull ? (
<Popconfirm
title="删除文档?"
onConfirm={async () => {
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`, {
method: 'DELETE',
});
message.success('已删除');
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null,
},
];
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
.txt/.md AI
</Typography.Text>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) =>
setFilters({
name: v.name || '',
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
})
}
>
<Form.Item name="name" label="名称">
<Input allowClear />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={openCreate}>
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items}
pagination={{
current: page,
pageSize,
total: data?.total,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑知识库' : '新建知识库'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
<Drawer
title={drawerKb ? `文档 · ${drawerKb.name}` : '文档'}
open={!!drawerKb}
width={720}
onClose={() => setDrawerKb(null)}
extra={
drawerKb?.canEditFull ? (
<Button type="primary" onClick={() => { docForm.resetFields(); setDocModalOpen(true); }}>
/
</Button>
) : null
}
>
<Table
rowKey="id"
size="small"
loading={docsLoading}
columns={docColumns}
dataSource={docs}
pagination={false}
/>
</Drawer>
<Modal
title="添加知识文档"
open={docModalOpen}
onCancel={() => setDocModalOpen(false)}
confirmLoading={docSaving}
onOk={async () => {
if (!drawerKb) return;
const values = await docForm.validateFields();
setDocSaving(true);
try {
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
method: 'POST',
body: JSON.stringify({
title: values.title,
contentText: values.contentText || null,
}),
});
message.success('已添加');
setDocModalOpen(false);
void loadDocs(drawerKb);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setDocSaving(false);
}
}}
destroyOnClose
width={640}
>
<Form form={docForm} layout="vertical">
<Form.Item name="title" label="标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contentText" label="正文(可粘贴)">
<Input.TextArea rows={8} placeholder="支持 Markdown / 纯文本" />
</Form.Item>
<Form.Item label="或上传文本文件">
<Upload
accept=".txt,.md,.markdown,.csv,.json,.log,text/*"
maxCount={1}
customRequest={async ({ file, onSuccess, onError }) => {
if (!drawerKb) return;
try {
const raw = file as File;
const uploaded = await uploadFileToOss(raw, {
bizType: 'KB_DOCUMENT',
mediaType: 'FILE',
});
const title =
docForm.getFieldValue('title') || raw.name.replace(/\.[^.]+$/, '');
await request(`/admin/knowledge-bases/${drawerKb.id}/documents`, {
method: 'POST',
body: JSON.stringify({
title,
fileName: raw.name,
fileUrl: uploaded.url,
mimeType: raw.type || null,
sizeBytes: raw.size,
}),
});
message.success('上传成功');
setDocModalOpen(false);
void loadDocs(drawerKb);
reload();
onSuccess?.(uploaded);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
message.error(err.message);
onError?.(err);
}
}}
showUploadList={false}
>
<Button icon={<UploadOutlined />}> OSS </Button>
</Upload>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+380
View File
@@ -0,0 +1,380 @@
import { useEffect, useState } from 'react';
import {
Button,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
LLM_PROVIDERS,
LLM_PROVIDER_LABELS,
LLM_PROVIDER_PRESETS,
type LlmApiConfigDto,
type LlmProvider,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type FormValues = {
name: string;
provider: LlmProvider;
baseUrl?: string;
apiKey?: string;
modelName?: string;
temperature?: number | null;
maxTokens?: number | null;
systemPrompt?: string;
enabled: boolean;
};
export default function LlmConfigsPage() {
const [filterForm] = Form.useForm();
const [form] = Form.useForm<FormValues>();
const [filters, setFilters] = useState<Record<string, string>>({});
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<LlmApiConfigDto | null>(null);
const [saving, setSaving] = useState(false);
const providerWatch = Form.useWatch('provider', form);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<LlmApiConfigDto>(
'/admin/llm-configs',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
useEffect(() => {
if (!providerWatch || editing) return;
const preset = LLM_PROVIDER_PRESETS[providerWatch as LlmProvider];
if (!preset) return;
form.setFieldsValue({
baseUrl: preset.defaultBaseUrl || undefined,
modelName: preset.defaultModel || undefined,
});
}, [providerWatch, editing, form]);
const openCreate = () => {
setEditing(null);
form.setFieldsValue({
name: '',
provider: 'DEEPSEEK',
baseUrl: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultBaseUrl,
apiKey: '',
modelName: LLM_PROVIDER_PRESETS.DEEPSEEK.defaultModel,
temperature: 0.3,
maxTokens: 1024,
systemPrompt: '',
enabled: true,
});
setModalOpen(true);
};
const openEdit = (row: LlmApiConfigDto) => {
setEditing(row);
form.setFieldsValue({
name: row.name,
provider: row.provider,
baseUrl: row.baseUrl,
apiKey: '',
modelName: row.modelName,
temperature: row.temperature,
maxTokens: row.maxTokens,
systemPrompt: row.systemPrompt || '',
enabled: row.enabled,
});
setModalOpen(true);
};
const onSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
if (!editing.canEditFull) {
await request(`/admin/llm-configs/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: values.enabled }),
});
} else {
await request(`/admin/llm-configs/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({
name: values.name,
provider: values.provider,
baseUrl: values.baseUrl,
apiKey: values.apiKey || undefined,
modelName: values.modelName,
temperature: values.temperature ?? null,
maxTokens: values.maxTokens ?? null,
systemPrompt: values.systemPrompt || null,
enabled: values.enabled,
}),
});
}
message.success('已保存');
} else {
await request('/admin/llm-configs', {
method: 'POST',
body: JSON.stringify({
name: values.name,
provider: values.provider,
baseUrl: values.baseUrl,
apiKey: values.apiKey,
modelName: values.modelName,
temperature: values.temperature ?? null,
maxTokens: values.maxTokens ?? null,
systemPrompt: values.systemPrompt || null,
enabled: values.enabled,
}),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
const columns: ColumnsType<LlmApiConfigDto> = [
{ title: '名称', dataIndex: 'name' },
{
title: '提供商',
dataIndex: 'provider',
width: 120,
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
},
{ title: '模型', dataIndex: 'modelName', ellipsis: true },
{
title: 'Key',
dataIndex: 'apiKeyConfigured',
width: 80,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{
title: '生效',
dataIndex: 'enabled',
width: 90,
render: (v: boolean, row) => (
<Switch
checked={v}
disabled={!row.isOwner && !row.canEditFull}
onChange={async (checked) => {
try {
await request(`/admin/llm-configs/${row.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: checked }),
});
message.success(checked ? '已启用' : '已停用');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
/>
),
},
{
title: '创建人',
dataIndex: 'createdByName',
width: 100,
render: (v: string | null, row) => v || row.createdByHqAccountId,
},
{
title: '更新时间',
dataIndex: 'updatedAt',
width: 170,
render: (t: string) => fmtTime(t),
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space>
<Button
type="link"
size="small"
onClick={() => openEdit(row)}
>
{row.canEditFull ? '编辑' : '开关'}
</Button>
<Button
type="link"
size="small"
onClick={async () => {
try {
const res = await request<{ reply: string }>(`/admin/llm-configs/${row.id}/test`, {
method: 'POST',
body: '{}',
});
message.success(res.reply || '测试成功');
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
</Button>
{row.canEditFull ? (
<Popconfirm
title="确认删除?"
onConfirm={async () => {
try {
await request(`/admin/llm-configs/${row.id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : String(e));
}
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const fullEdit = !editing || editing.canEditFull;
return (
<div>
<Space style={{ marginBottom: 16 }} wrap>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) =>
setFilters({
name: v.name || '',
enabled: v.enabled === undefined || v.enabled === null ? '' : String(v.enabled),
})
}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="搜索" />
</Form.Item>
<Form.Item name="enabled" label="生效">
<Select
allowClear
style={{ width: 100 }}
options={[
{ value: true, label: '开' },
{ value: false, label: '关' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={openCreate}>
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items}
pagination={{
current: page,
pageSize,
total: data?.total,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? (fullEdit ? '编辑语言模型' : '修改是否生效') : '新建语言模型'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void onSave()}
confirmLoading={saving}
destroyOnClose
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '必填' }]}>
<Input disabled={!fullEdit} />
</Form.Item>
<Form.Item name="provider" label="提供商" rules={[{ required: true }]}>
<Select
disabled={!fullEdit}
options={LLM_PROVIDERS.map((p) => ({ value: p, label: LLM_PROVIDER_LABELS[p] }))}
/>
</Form.Item>
<Form.Item
name="baseUrl"
label="Base URL"
rules={[{ required: fullEdit, message: '必填' }]}
extra="填主机根地址即可,如 https://api.deepseek.com(不要带 /v1"
>
<Input disabled={!fullEdit} placeholder="https://api.deepseek.com" />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={editing ? [] : [{ required: true, message: '必填' }]}
extra={editing ? '留空表示不修改' : undefined}
>
<Input.Password disabled={!fullEdit} />
</Form.Item>
<Form.Item name="modelName" label="模型名" rules={[{ required: fullEdit, message: '必填' }]}>
<Input disabled={!fullEdit} />
</Form.Item>
<Space style={{ display: 'flex' }} size="large">
<Form.Item name="temperature" label="Temperature">
<InputNumber disabled={!fullEdit} min={0} max={2} step={0.1} style={{ width: 120 }} />
</Form.Item>
<Form.Item name="maxTokens" label="Max Tokens">
<InputNumber disabled={!fullEdit} min={1} max={128000} style={{ width: 140 }} />
</Form.Item>
</Space>
<Form.Item name="systemPrompt" label="系统提示词">
<Input.TextArea disabled={!fullEdit} rows={3} />
</Form.Item>
<Form.Item name="enabled" label="生效" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
import { saveAuth, request } from '../lib/api';
type LoginResult = { accessToken: string; refreshToken: string };
export default function LoginPage() {
const navigate = useNavigate();
const [smsForm] = Form.useForm();
const [passwordForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [codeCooldown, setCodeCooldown] = useState(0);
const phone = Form.useWatch('phone', smsForm);
async function sendCode() {
if (!phone) {
message.warning('请先输入手机号');
return;
}
await request('/admin/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'HQ_LOGIN' }),
});
message.success('验证码已发送');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
}
async function finishLogin(data: LoginResult) {
saveAuth(data);
message.success('登录成功');
navigate('/');
}
async function onSmsFinish(values: { phone: string; code: string }) {
setLoading(true);
try {
const data = await request<LoginResult>('/admin/auth/login/sms', {
method: 'POST',
body: JSON.stringify(values),
});
await finishLogin(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
async function onPasswordFinish(values: { loginName: string; password: string }) {
setLoading(true);
try {
const data = await request<LoginResult>('/admin/auth/login/password', {
method: 'POST',
body: JSON.stringify(values),
});
await finishLogin(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f5f5',
}}
>
<Card style={{ width: 400 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
HQ
</Typography.Title>
<Tabs
items={[
{
key: 'password',
label: '账号密码',
children: (
<Form
form={passwordForm}
layout="vertical"
onFinish={onPasswordFinish}
initialValues={{ loginName: 'admin' }}
>
<Form.Item name="loginName" label="账号" rules={[{ required: true, message: '请输入账号' }]}>
<Input placeholder="admin" autoComplete="username" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
<Input.Password placeholder="请输入密码" autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form>
),
},
{
key: 'sms',
label: '短信验证码',
children: (
<Form
form={smsForm}
layout="vertical"
onFinish={onSmsFinish}
initialValues={{ phone: '13600000001', code: '123456' }}
>
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
<Input placeholder="13600000001" maxLength={11} />
</Form.Item>
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
<Input
placeholder="123456"
addonAfter={
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
</Button>
}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block loading={loading}>
</Button>
</Form>
),
},
]}
/>
</Card>
</div>
);
}
@@ -0,0 +1,625 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import {
LOGISTICS_SETTLEMENT_METHOD_LABELS,
type LogisticsSettlementMethod,
} from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type BillRow = {
id: string;
billNo: string;
fulfillmentProviderId: string;
providerCode?: string;
providerName?: string;
periodStart: string;
periodEnd: string;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
settlementMethod: string;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
quantity: number;
logisticsAmount: number;
shippedAt: string;
};
type ProviderSummary = {
providerId: string;
providerCode: string;
providerName: string;
settlementMethod: string;
prepaidBalance: number;
bankAccountName?: string | null;
bankName?: string | null;
bankAccountNo?: string | null;
orderCount: number;
bottleCount: number;
logisticsAmount: number;
billId?: string | null;
billStatus?: string | null;
pricingRules?: {
baseBottles: number;
baseFee: number;
extraBottleFee: number;
boxBottles?: number;
boxFee?: number;
} | null;
};
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未结算',
PAID: '已结算',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
};
export default function LogisticsBillsPage() {
const [form] = Form.useForm();
const [summaryForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<BillRow>(
'/admin/logistics-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs;
},
[filters],
);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<(BillRow & { items?: BillItem[] }) | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [providers, setProviders] = useState<Array<{ id: string; code: string; name: string }>>([]);
const [summaryRows, setSummaryRows] = useState<ProviderSummary[]>([]);
const [summaryLoading, setSummaryLoading] = useState(false);
const [summaryPeriod, setSummaryPeriod] = useState(() => dayjs().subtract(1, 'month'));
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [rechargeOpen, setRechargeOpen] = useState(false);
const [rechargeTarget, setRechargeTarget] = useState<ProviderSummary | null>(null);
const [rechargeForm] = Form.useForm();
useEffect(() => {
request<Array<{ id: string; code: string; name: string }>>('/admin/fulfillment-providers')
.then((rows) => setProviders(rows.map((r) => ({ id: r.id, code: r.code, name: r.name }))))
.catch(() => {});
}, []);
async function loadSummary(period: Dayjs) {
setSummaryLoading(true);
try {
const res = await request<{ items: ProviderSummary[] }>(
`/admin/logistics-bills/provider-summary?year=${period.year()}&month=${period.month() + 1}`,
);
setSummaryRows(res.items ?? []);
} finally {
setSummaryLoading(false);
}
}
useEffect(() => {
void loadSummary(summaryPeriod);
}, [summaryPeriod]);
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认结算?',
content: `将确认 ${ids.length} 笔物流对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。`,
okText: '确认结算',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('/admin/logistics-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已确认结算');
setSelectedKeys([]);
reload();
void loadSummary(summaryPeriod);
},
});
}
async function openDetail(id: string) {
const d = await request<BillRow & { items?: BillItem[] }>(`/admin/logistics-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.providerId) qs.set('providerId', filters.providerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
const result = await request<{ csv: string; count: number }>(
`/admin/logistics-bills/export?${qs}`,
);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `物流对账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
async function generateBills() {
const v = await genForm.validateFields();
const month = v.month as Dayjs;
await request('/admin/logistics-bills/generate', {
method: 'POST',
body: JSON.stringify({
year: month.year(),
month: month.month() + 1,
providerId: v.providerId || undefined,
}),
});
message.success('已生成物流对账单');
setGenOpen(false);
reload();
void loadSummary(summaryPeriod);
}
async function submitRecharge() {
if (!rechargeTarget) return;
const v = await rechargeForm.validateFields();
await request(`/admin/fulfillment-providers/${rechargeTarget.providerId}/recharge`, {
method: 'POST',
body: JSON.stringify({ amount: v.amount, remark: v.remark }),
});
message.success('充值成功');
setRechargeOpen(false);
void loadSummary(summaryPeriod);
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.logisticsAmount), 0);
const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{
title: '承运商',
width: 140,
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 100,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
<Button
type="link"
size="small"
onClick={() => confirmPay([row.id], Number(row.logisticsAmount))}
>
</Button>
)}
</Space>
),
},
];
const summaryColumns: ColumnsType<ProviderSummary> = [
{
title: '承运商',
render: (_, r) => `${r.providerName} (${r.providerCode})`,
},
{
title: '结算方式',
dataIndex: 'settlementMethod',
width: 110,
render: (v) =>
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
},
{
title: '计价标准',
width: 220,
render: (_, r) => {
const p = r.pricingRules;
if (!p) return '—';
return `${p.baseBottles}瓶¥${p.baseFee},加瓶¥${p.extraBottleFee}${
p.boxBottles ? `${p.boxBottles}瓶箱¥${p.boxFee}` : ''
}`;
},
},
{
title: '银行账户',
width: 180,
ellipsis: true,
render: (_, r) =>
r.bankAccountName
? `${r.bankAccountName} / ${r.bankName || ''} ${r.bankAccountNo || ''}`
: '—',
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{ title: '瓶数', dataIndex: 'bottleCount', width: 80 },
{
title: '应付物流费',
dataIndex: 'logisticsAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '充值余额',
dataIndex: 'prepaidBalance',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '账单',
width: 100,
render: (_, r) =>
r.billStatus ? (
<Tag color={STATUS_COLORS[r.billStatus] || 'default'}>
{STATUS_LABELS[r.billStatus] || r.billStatus}
</Tag>
) : (
'未生成'
),
},
{
title: '操作',
width: 100,
render: (_, r) =>
r.settlementMethod === 'PREPAID' ? (
<Button
type="link"
size="small"
onClick={() => {
setRechargeTarget(r);
rechargeForm.resetFields();
setRechargeOpen(true);
}}
>
</Button>
) : null,
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Tabs
items={[
{
key: 'summary',
label: '按承运商汇总',
children: (
<>
<Form
form={summaryForm}
layout="inline"
style={{ marginBottom: 16 }}
initialValues={{ month: summaryPeriod }}
onFinish={(v: { month?: Dayjs }) => {
const m = v.month || dayjs().subtract(1, 'month');
setSummaryPeriod(m);
}}
>
<Form.Item name="month" label="账期">
<DatePicker picker="month" allowClear={false} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="providerId"
loading={summaryLoading}
columns={summaryColumns}
dataSource={summaryRows}
pagination={false}
scroll={{ x: 1200 }}
/>
</>
),
},
{
key: 'bills',
label: '月账单',
children: (
<>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic
title="瓶数合计"
value={summary.bottleCount ?? 0}
/>
<Statistic
title="物流费合计"
value={summary.logisticsAmount ?? summary.totalAmount ?? 0}
prefix="¥"
precision={2}
/>
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: {
status?: string;
providerId?: string;
month?: Dayjs;
}) => {
setFilters({
status: v.status || '',
providerId: v.providerId || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'UNPAID', label: '未结算' },
{ value: 'PAID', label: '已结算' },
]}
/>
</Form.Item>
<Form.Item name="providerId" label="承运商">
<Select
allowClear
style={{ width: 180 }}
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期">
<DatePicker picker="month" />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
genForm.setFieldsValue({ month: dayjs().subtract(1, 'month') });
setGenOpen(true);
}}
>
</Button>
<Button loading={exporting} onClick={() => void exportExcel()}>
</Button>
<Button
disabled={!selectedKeys.length}
onClick={() =>
confirmPay(
selectedKeys.map(String),
selectedAmount,
)
}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={billColumns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 1100 }}
/>
</>
),
},
]}
/>
<Drawer
title="物流对账明细"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
>
{detail && (
<>
<Descriptions column={1} size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="承运商">
{detail.providerName} ({detail.providerCode})
</Descriptions.Item>
<Descriptions.Item label="账期">
{String(detail.periodStart).slice(0, 10)} ~ {String(detail.periodEnd).slice(0, 10)}
</Descriptions.Item>
<Descriptions.Item label="物流费">
¥{Number(detail.logisticsAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="状态">
{STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item>
<Descriptions.Item label="结算时间">
{detail.paidAt ? fmtTime(detail.paidAt) : '—'}
</Descriptions.Item>
</Descriptions>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo' },
{ title: '瓶数', dataIndex: 'quantity', width: 70 },
{
title: '物流费',
dataIndex: 'logisticsAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '发货时间',
dataIndex: 'shippedAt',
width: 160,
render: fmtTime,
},
]}
/>
</>
)}
</Drawer>
<Modal
title="生成物流月账单"
open={genOpen}
onCancel={() => setGenOpen(false)}
onOk={() => void generateBills()}
destroyOnClose
>
<Form form={genForm} layout="vertical">
<Form.Item name="month" label="账期月" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="providerId" label="承运商(空=全部)">
<Select
allowClear
options={providers.map((p) => ({
value: p.id,
label: `${p.name} (${p.code})`,
}))}
/>
</Form.Item>
</Form>
</Modal>
<Modal
title={rechargeTarget ? `充值 · ${rechargeTarget.providerName}` : '充值'}
open={rechargeOpen}
onCancel={() => setRechargeOpen(false)}
onOk={() => void submitRecharge()}
destroyOnClose
>
<Form form={rechargeForm} layout="vertical">
<Form.Item
name="amount"
label="金额"
rules={[{ required: true, message: '请输入充值金额' }]}
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input placeholder="可选,如预充运费" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,461 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button, Checkbox, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
type PartnerOption = { id: string; companyName: string };
type ParentAccount = { id: string; name: string; phone: string };
type AccountTreeRow = {
id: string;
phone: string;
name: string;
status: string;
isPrimary: number;
staffRole?: string | null;
permissions?: string[] | null;
parentAccountId?: string | null;
parent?: ParentAccount | null;
createdAt: string;
lastLoginAt?: string | null;
companyName?: string | null;
children?: AccountTreeRow[];
};
type BillRow = {
id: string; billNo: string; totalAmount: number; orderCommission: number; redeemCommission: number;
status: string; periodStart: string; periodEnd: string; createdAt: string;
};
type OrderRow = {
id: string; orderNo: string; status: string; payAmount: number; createdAt: string;
user?: { userNo: string; phone: string | null };
};
type Detail = AccountTreeRow & { bills?: BillRow[]; orders?: OrderRow[] };
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value, label]) => ({
value,
label,
}));
const PERMISSION_OPTIONS = PARTNER_PERMISSION_KEYS.map((key: PartnerPermissionKey) => ({
value: key,
label: PARTNER_PERMISSION_LABELS[key],
}));
function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
const phoneQ = phone?.trim();
const match = (row: AccountTreeRow) => {
const phoneOk = !phoneQ || row.phone.includes(phoneQ);
const statusOk = !status || row.status === status;
return phoneOk && statusOk;
};
const walk = (list: AccountTreeRow[]): AccountTreeRow[] => {
const result: AccountTreeRow[] = [];
for (const row of list) {
const children = row.children?.length ? walk(row.children) : undefined;
if (match(row) || (children && children.length > 0)) {
result.push({ ...row, children: children?.length ? children : undefined });
}
}
return result;
};
return walk(rows);
}
function staffRoleLabel(role?: string | null) {
if (!role) return '-';
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role;
}
export default function PartnerAccountsPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
const [loading, setLoading] = useState(false);
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [detail, setDetail] = useState<Detail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const loadTree = useCallback(async () => {
setLoading(true);
try {
const qs = new URLSearchParams();
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
const path = qs.toString() ? `/admin/partner-accounts/tree?${qs}` : '/admin/partner-accounts/tree';
const res = await request<AccountTreeRow[]>(path);
setTreeData(filterTree(res, filters.phone, filters.status));
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
void loadPartners();
}, []);
useEffect(() => {
void loadTree();
}, [loadTree]);
useEffect(() => {
if (!drawerOpen || !detail) return;
editForm.setFieldsValue({
name: detail.name,
phone: detail.phone,
status: detail.status,
permissions: detail.permissions ?? [],
});
}, [drawerOpen, detail, editForm]);
async function loadPartners() {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setPartners(res.items);
}
async function openAccount(id: string) {
try {
const d = await request<Detail>(`/admin/partner-accounts/${id}`);
setDetail(d);
setDrawerOpen(true);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载账户详情失败');
}
}
async function saveAccount() {
if (!detail) return;
const v = await editForm.validateFields();
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已保存');
setDrawerOpen(false);
void loadTree();
}
async function deleteSubAccount(row: AccountTreeRow) {
await request(`/admin/partner-accounts/${row.id}`, { method: 'DELETE' });
message.success('子账号已删除');
void loadTree();
}
function openAddSub(parent: AccountTreeRow) {
setSubParent(parent);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
function collectExpandableKeys(rows: AccountTreeRow[]): string[] {
const keys: string[] = [];
for (const row of rows) {
if (row.children?.length) {
keys.push(row.id);
keys.push(...collectExpandableKeys(row.children));
}
}
return keys;
}
const columns: ColumnsType<AccountTreeRow> = [
{
title: '姓名 / 类型',
width: 200,
ellipsis: true,
render: (_, row) => (
<AdminCellLine
primary={row.name}
secondary={
row.parentAccountId
? `子账号 · ${staffRoleLabel(row.staffRole)}`
: '主账号'
}
/>
),
},
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '开城合伙人',
width: 140,
ellipsis: true,
render: (_, row) => row.companyName || row.parent?.name || '—',
},
{
title: '账号类型',
width: 90,
render: (_, row) => (
<Tag color={row.parentAccountId ? 'default' : 'blue'}>
{row.parentAccountId ? '子账号' : '主账号'}
</Tag>
),
},
{
title: '角色',
dataIndex: 'staffRole',
width: 100,
render: (role) => staffRoleLabel(role),
},
{
title: '所属主账号',
width: 140,
render: (_, row) => (
row.parentAccountId && row.parent
? `${row.parent.name} / ${row.parent.phone}`
: '-'
),
},
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 180,
render: (_, row) => (
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
<Button type="link" size="small" onClick={() => void openAccount(row.id)}></Button>
{!row.parentAccountId ? (
<Button type="link" size="small" onClick={() => openAddSub(row)}></Button>
) : null}
{row.parentAccountId ? (
<Popconfirm title="确定删除该子账号?" onConfirm={() => void deleteSubAccount(row)}>
<Button type="link" size="small" danger></Button>
</Popconfirm>
) : null}
</Space>
),
},
];
const billColumns: ColumnsType<BillRow> = [
{ title: '账单号', dataIndex: 'billNo', width: 140 },
{ title: '总额', dataIndex: 'totalAmount', width: 90, render: (v) => `¥${v}` },
{ title: '订单佣金', dataIndex: 'orderCommission', width: 90, render: (v) => `¥${v}` },
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 90, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{PARTNER_BILL_STATUS_LABELS[s] || s}</Tag> },
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart)} ~ ${fmtTime(r.periodEnd)}` },
];
const orderColumns: ColumnsType<OrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
{ title: '金额', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ORDER_STATUS_LABELS[s] || s}</Tag> },
{ title: '下单', dataIndex: 'createdAt', width: 160, render: fmtTime },
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
</Space>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters({
phone: v.phone?.trim() || undefined,
status: v.status || undefined,
partnerId: v.partnerId || undefined,
});
}}
>
<Form.Item name="partnerId" label="开城合伙人">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 180 }}
placeholder="全部"
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
onFocus={() => void loadPartners()}
/>
</Form.Item>
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => setExpandedRowKeys(collectExpandableKeys(treeData))}></Button>
<Button onClick={() => setExpandedRowKeys([])}></Button>
</Space>
</Form.Item>
</Form>
<Table<AccountTreeRow>
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={treeData}
scroll={{ x: 1200 }}
pagination={false}
expandable={{
expandedRowKeys,
onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as string[]),
defaultExpandAllRows: false,
}}
/>
<Drawer
title={detail?.parentAccountId ? '子账号详情' : '主账号详情(只读)'}
width={720}
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
setDetail(null);
}}
destroyOnClose
extra={
detail?.parentAccountId
? <Button type="primary" onClick={() => void saveAccount()}></Button>
: null
}
>
{detail && (
<Tabs items={[
{
key: 'info',
label: '基本信息',
children: (
<Form
form={editForm}
layout="vertical"
key={detail.id}
initialValues={{
name: detail.name,
phone: detail.phone,
status: detail.status,
permissions: detail.permissions ?? [],
}}
>
<Form.Item label="开城合伙人">
<Input value={detail.companyName || detail.parent?.name || '—'} disabled />
</Form.Item>
<Form.Item label="账号类型">
<Input
value={
detail.parentAccountId
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
: '主账号'
}
disabled
/>
</Form.Item>
{detail.parentAccountId && detail.parent ? (
<Form.Item label="所属主账号">
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
</Form.Item>
) : null}
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input disabled={!detail.parentAccountId} /></Form.Item>
<Form.Item
name="phone"
label="登录手机"
rules={[
{ required: true },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
>
<Input maxLength={11} disabled={!detail.parentAccountId} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select disabled={!detail.parentAccountId} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
{detail.parentAccountId ? (
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERMISSION_OPTIONS} />
</Form.Item>
) : null}
{!detail.parentAccountId ? (
<Typography.Text type="secondary">
</Typography.Text>
) : (
<Typography.Text type="secondary">
H5 使
</Typography.Text>
)}
</Form>
),
},
{
key: 'bills',
label: `账单 (${detail.bills?.length ?? 0})`,
children: (
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={billColumns}
dataSource={detail.bills ?? []} pagination={false} scroll={{ x: 700 }} />
),
},
{
key: 'orders',
label: `名下订单 (${detail.orders?.length ?? 0})`,
children: (
<Table rowKey="id" className="admin-table-nowrap" size="small" columns={orderColumns}
dataSource={detail.orders ?? []} pagination={false} scroll={{ x: 650 }} />
),
},
]} />
)}
</Drawer>
<Modal
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
open={subOpen}
onCancel={() => setSubOpen(false)}
onOk={async () => {
if (!subParent) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({
parentAccountId: subParent.id,
name: v.name,
phone: v.phone,
staffRole: v.staffRole,
permissions: v.permissions,
}),
});
message.success('子账号已创建');
setSubOpen(false);
setExpandedRowKeys((keys) => [...new Set([...keys, subParent.id])]);
void loadTree();
}}
>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机"
rules={[
{ required: true },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
>
<Input maxLength={11} />
</Form.Item>
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERMISSION_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,470 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Form,
Input,
Modal,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs, { type Dayjs } from 'dayjs';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
billNo: string;
orderCommission: number;
redeemCommission: number;
totalAmount: number;
status: string;
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
};
type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核',
AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
};
const STATUS_COLORS: Record<string, string> = {
PENDING_REVIEW: 'gold',
AWAITING_CONFIRM: 'blue',
UNPAID: 'red',
PAID: 'green',
REJECTED: 'red',
};
export default function PartnerBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [partners, setPartners] = useState<PartnerOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partner-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs;
},
[filters],
);
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items))
.catch(() => {});
}, []);
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
const year = values.month.year();
const month = values.month.month() + 1;
if (!values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) },
);
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
} else {
await request('/admin/partner-bills/generate', {
method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
});
message.success('账单已生成(待审核)');
}
setGenOpen(false);
reload();
}
function sendBills(ids: string[]) {
Modal.confirm({
title: '发送给合伙人?',
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
okText: '确认发送',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
} else {
await request('/admin/partner-bills/batch-send', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已发送');
setSelectedKeys([]);
reload();
},
});
}
function markPaid(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认打款?',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
} else {
await request('/admin/partner-bills/batch-mark-paid', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已标记打款');
setSelectedKeys([]);
reload();
},
});
}
function openReject(row: Row) {
Modal.confirm({
title: '驳回该账单?',
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
okText: '继续填写理由',
cancelText: '取消',
onOk: () => {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
},
});
}
async function submitReject(values: { reason: string }) {
if (!rejectTarget) return;
setRejecting(true);
try {
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: values.reason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setRejectTarget(null);
reload();
} finally {
setRejecting(false);
}
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
{
title: '合伙人',
width: 160,
ellipsis: true,
render: (_, r) => partnerName(r),
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`,
},
{
title: '酒单佣金',
dataIndex: 'orderCommission',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '核销佣金',
dataIndex: 'redeemCommission',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '合计应付',
dataIndex: 'totalAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 130,
render: (s, row) => (
<Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
{s === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
),
},
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size={0} wrap>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
</Button>
)}
{row.status === 'UNPAID' && (
<>
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
</Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
</>
)}
{row.status === 'AWAITING_CONFIRM' && (
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
)}
</Space>
),
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
1 8:00
</Typography.Text>
</Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic title="酒单佣金合计" value={summary.orderCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="核销佣金合计" value={summary.redeemCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { partnerId?: string; status?: string; month?: Dayjs }) => {
setFilters({
partnerId: v.partnerId || '',
status: v.status || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="partnerId" label="合伙人">
<Select
allowClear
showSearch
placeholder="全部合伙人"
style={{ width: 220 }}
optionFilterProp="label"
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.phone || p.id,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 150 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => setGenOpen(true)}>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item>
<Form.Item>
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
({canSend.length})
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!canPay.length}
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
>
({canPay.length})
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
}}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form
form={genForm}
layout="vertical"
initialValues={{ month: dayjs().subtract(1, 'month') }}
onFinish={generateBill}
>
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部主合伙人"
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.phone || p.id,
}))}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
<Modal
title="驳回打款申请"
open={rejectOpen}
onCancel={() => {
setRejectOpen(false);
setRejectTarget(null);
}}
footer={null}
destroyOnClose
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
</Typography.Paragraph>
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
<Form.Item
name="reason"
label="驳回理由"
rules={[
{ required: true, message: '请填写驳回理由' },
{ max: 500, message: '不超过 500 字' },
]}
>
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
</Form.Item>
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
</Button>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,234 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import {
PARTNER_LOG_CATEGORY_OPTIONS,
PARTNER_LOG_CATEGORY_LABELS,
PARTNER_STAFF_ROLE_LABELS,
resolvePartnerLogCategory,
type PartnerLogCategory,
type PartnerStaffRole,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
partnerId: string;
partnerAccountId: string | null;
accountName: string | null;
accountPhone: string | null;
companyName: string | null;
isSubAccount?: boolean;
staffRole?: string | null;
category: PartnerLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
};
function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—';
const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
}
export default function PartnerLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [category, setCategory] = useState(searchParams.get('category') ?? '');
const [filters, setFilters] = useState<Record<string, string>>(() => ({
partnerId: searchParams.get('partnerId') ?? '',
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
phone: searchParams.get('phone') ?? '',
companyName: searchParams.get('companyName') ?? '',
eventName: searchParams.get('eventName') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/logs/partners',
() => {
const qs = new URLSearchParams();
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.eventName) qs.set('eventName', filters.eventName);
if (category) qs.set('category', category);
return qs;
},
[filters, category],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '合伙人',
width: 180,
ellipsis: true,
render: (_, r) => (
r.isSubAccount ? (
<Tag color="blue"></Tag>
) : (
<AdminCellLine primary={r.companyName} secondary={r.partnerId} />
)
),
},
{
title: '账号',
width: 150,
ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.accountName}
secondary={
r.isSubAccount && r.staffRole
? `${r.accountPhone || ''} · ${PARTNER_STAFF_ROLE_LABELS[r.staffRole as PartnerStaffRole] || r.staffRole}`
: r.accountPhone || r.partnerAccountId
}
/>
),
},
{
title: '分类',
dataIndex: 'category',
width: 100,
render: (v: PartnerLogCategory | null, r) => (
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 180 },
{
title: '关联',
width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要',
ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson),
},
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/logs/partners/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4} style={{ marginBottom: 16 }}>
</Typography.Title>
<Segmented
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category}
onChange={(v) => {
setCategory(String(v));
setPage(1);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (v) next.set('category', String(v));
else next.delete('category');
return next;
});
}}
style={{ marginBottom: 16 }}
/>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
onFinish={(values) => {
setFilters(values);
setPage(1);
}}
>
<Form.Item name="phone" label="手机号">
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="companyName" label="公司">
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
</Form.Item>
<Form.Item name="partnerId" label="合伙人ID">
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="eventName" label="事件名">
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
form.resetFields();
setFilters({
partnerId: '',
partnerAccountId: '',
phone: '',
companyName: '',
eventName: '',
});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
scroll={{ x: 1100 }}
/>
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && (
<Descriptions column={1} bordered size="small">
{Object.entries(detail).map(([k, v]) => (
<Descriptions.Item key={k} label={k}>
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
</Descriptions.Item>
))}
</Descriptions>
)}
</Drawer>
</div>
);
}
+405
View File
@@ -0,0 +1,405 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CityPartnerScopeType,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
type Row = {
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number;
redeemCommissionRate?: number;
storeCount: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type CityOption = { id: string; name: string; code: string };
type WarehouseOption = { id: string; name: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
export default function PartnersPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.contactPhone) qs.set('contactPhone', filters.contactPhone);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const createCityCode = createCityId ? cities.find((c) => c.id === createCityId)?.code : undefined;
const editCityCode = detail?.cityId ? cities.find((c) => c.id === detail.cityId)?.code : undefined;
function formatApiError(err: unknown): string | null {
if (err && typeof err === 'object' && 'errorFields' in err) return null;
if (!(err instanceof Error)) return '操作失败';
return err.message.replace(/\b(\d{6})\b/g, (code) => {
const label = districtCodeLabel(code);
return label !== code ? `${label}(${code})` : code;
});
}
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
const loadWarehouses = useCallback(async (cityId: string) => {
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
if (d.cityId) await loadWarehouses(d.cityId);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus,
managedWarehouseId: d.managedWarehouseId,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
try {
const v = await editForm.validateFields();
const body = {
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
};
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
message.success('已保存');
setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}
const columns: ColumnsType<Row> = [
{ title: '城市', dataIndex: 'cityName', width: 100 },
{
title: '区县',
dataIndex: 'districtCodes',
width: 160,
ellipsis: true,
render: (codes: string[] | null | undefined, row) =>
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
},
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 130 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{ title: '门店', dataIndex: 'storeCount', width: 70 },
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 120,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
</Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<Tabs
items={[
{
key: 'info',
label: '基本信息',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Form.Item name="managedWarehouseId" label="管仓仓库">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
),
},
{
key: 'staff',
label: '子账号',
children: (
<>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
</Button>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={detail.children ?? []}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
{
title: '权限',
dataIndex: 'permissions',
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
},
]}
/>
</>
),
},
]}
/>
)}
</Drawer>
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
try {
const v = await createForm.validateFields();
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
}}
>
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => {
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名"><Input placeholder="选填" /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item
name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
{createCityId && (
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</Form.Item>
)}
</Form>
</Modal>
<Modal
title="添加子账号"
open={subOpen}
onCancel={() => setSubOpen(false)}
onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
void openPartner(detail.id);
}}
>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="permissions" label="权限">
<Select mode="multiple" options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,266 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
REDEEM_PENDING_STATUS_LABELS,
type RedeemPendingItem,
type RedeemPendingStatus,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
const STATUS_COLOR: Record<RedeemPendingStatus, string> = {
PENDING: 'orange',
COMPLETED: 'green',
REJECTED: 'default',
};
export default function PendingRedeemPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<RedeemPendingItem>(
'/admin/redeem-pending',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.pendingNo) qs.set('pendingNo', filters.pendingNo);
if (filters.redeemToken) qs.set('redeemToken', filters.redeemToken);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<RedeemPendingItem | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
async function openDetail(id: string) {
const res = await request<RedeemPendingItem>(`/admin/redeem-pending/${id}`);
setDetail(res);
setDrawerOpen(true);
}
async function complete() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/complete`, { method: 'POST', body: '{}' });
message.success('已补核销');
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '补核销失败');
} finally {
setActing(false);
}
}
async function reject() {
if (!detail || !rejectReason.trim()) {
message.error('请填写驳回原因');
return;
}
setActing(true);
try {
await request(`/admin/redeem-pending/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: rejectReason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
setDrawerOpen(false);
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '驳回失败');
} finally {
setActing(false);
}
}
const columns: ColumnsType<RedeemPendingItem> = [
{ title: '待处理单号', dataIndex: 'pendingNo', width: 170 },
{
title: '核销码 ID',
dataIndex: 'redeemToken',
width: 160,
ellipsis: true,
render: (v: string) => <Typography.Text copyable={{ text: v }}>{v.slice(0, 8)}</Typography.Text>,
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, render: (_, row) => row.store?.name || '—' },
{
title: '用户',
width: 120,
render: (_, row) => row.user?.userNo || row.user?.phone || '—',
},
{ title: '金额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '失败次数', dataIndex: 'failCount', width: 90 },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: RedeemPendingStatus) => (
<Tag color={STATUS_COLOR[s]}>{REDEEM_PENDING_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '提交时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="pendingNo" label="待处理单号">
<Input allowClear />
</Form.Item>
<Form.Item name="redeemToken" label="核销码 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="storeId" label="门店 ID">
<Input allowClear />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(REDEEM_PENDING_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="待处理核销详情"
width={560}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' && (
<Space>
<Button danger onClick={() => { setRejectReason(''); setRejectOpen(true); }}>
</Button>
<Button type="primary" loading={acting} onClick={() => void complete()}>
</Button>
</Space>
)
}
>
{detail && (
<>
{detail.photoUrl && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Image src={detail.photoUrl} alt="核销码照片" style={{ maxHeight: 280 }} />
</div>
)}
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="待处理单号">{detail.pendingNo}</Descriptions.Item>
<Descriptions.Item label="核销码 ID">
<Typography.Text copyable>{detail.redeemToken}</Typography.Text>
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status]}>
{REDEEM_PENDING_STATUS_LABELS[detail.status]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="金额">¥{detail.amount}</Descriptions.Item>
<Descriptions.Item label="类型">{detail.redeemType}</Descriptions.Item>
<Descriptions.Item label="失败次数">{detail.failCount}</Descriptions.Item>
<Descriptions.Item label="门店">
{detail.store?.name || '—'}{detail.store?.id}
</Descriptions.Item>
<Descriptions.Item label="用户">
{detail.user?.userNo || '—'} / {detail.user?.phone || '无手机'}
</Descriptions.Item>
<Descriptions.Item label="关联核销单">
{detail.redeemRecord?.redeemNo || '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回原因">{detail.rejectReason || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="提交时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="处理时间">
{detail.processedAt ? fmtTime(detail.processedAt) : '—'}
</Descriptions.Item>
</Descriptions>
</>
)}
</Drawer>
<Modal
title="驳回待处理单"
open={rejectOpen}
okText="确认驳回"
okButtonProps={{ danger: true, loading: acting, disabled: !rejectReason.trim() }}
onOk={() => void reject()}
onCancel={() => setRejectOpen(false)}
>
<Input.TextArea
rows={3}
placeholder="请填写驳回原因"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
}
@@ -0,0 +1,258 @@
import { useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space,
Table, Tag, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import {
AROMA_TYPE_LABELS, DETAIL_TEMPLATE_STATUS_LABELS, fmtTime,
} from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import DetailImageUrlList from '../components/DetailImageUrlList';
import type { ProductDetailTemplateDto } from '../lib/product-detail-templates';
import { TEMPLATE_MAX_DETAIL_IMAGES } from '../lib/product-detail-templates';
type Row = ProductDetailTemplateDto;
type TemplateFormValues = {
code: string;
name: string;
description?: string;
aromaType?: string | null;
storyTitle?: string;
storyText?: string;
features?: Array<{ icon?: string; title?: string; desc?: string }>;
detailImageUrls?: string[];
suggestedDetailImageCount?: number;
sortOrder?: number;
status?: string;
};
function mapToForm(d: Record<string, unknown>) {
const row = d as Row;
return {
...row,
detailImageUrls: row.detailImageUrls?.length ? row.detailImageUrls : [''],
features: row.features?.length
? row.features
: [{ icon: 'star', title: '', desc: '' }],
};
}
function buildPayload(v: TemplateFormValues) {
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const features = (v.features ?? [])
.filter((f) => f?.title?.trim() || f?.desc?.trim())
.map((f) => ({
icon: f.icon?.trim() || 'star',
title: f.title?.trim() ?? '',
desc: f.desc?.trim() ?? '',
}));
return {
code: v.code.trim(),
name: v.name.trim(),
description: v.description?.trim() || undefined,
aromaType: v.aromaType || null,
storyTitle: v.storyTitle?.trim() || undefined,
storyText: v.storyText?.trim() || undefined,
features,
detailImageUrls,
suggestedDetailImageCount: detailImageUrls.length || (v.suggestedDetailImageCount ?? 1),
sortOrder: v.sortOrder ?? 0,
status: v.status ?? 'ACTIVE',
};
}
function TemplateContentFields() {
return (
<>
<Typography.Text type="secondary"></Typography.Text>
<DetailImageUrlList
label="详情图"
bizType="DETAIL_TEMPLATE"
maxCount={TEMPLATE_MAX_DETAIL_IMAGES}
/>
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
</Form.Item>
<Form.Item name="storyText" label="故事正文">
<Input.TextArea rows={4} placeholder="商品故事描述" />
</Form.Item>
<Typography.Text type="secondary"></Typography.Text>
<Form.List name="features">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
<Space align="start">
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
<Input placeholder="material icon 名" style={{ width: 140 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="标题" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
)}
</Space>
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
<Input placeholder="简短描述" />
</Form.Item>
</Space>
))}
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</>
);
}
export default function ProductDetailTemplatesPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/product-detail-templates',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.aromaType) qs.set('aromaType', filters.aromaType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 120 },
{ title: '名称', dataIndex: 'name', width: 120 },
{ title: '说明', dataIndex: 'description', width: 200, ellipsis: true },
{ title: '香型', dataIndex: 'aromaType', width: 90, render: (v) => (v ? AROMA_TYPE_LABELS[v] || v : '—') },
{ title: '详情图', dataIndex: 'detailImageUrls', width: 80, render: (v: string[] | undefined) => v?.length ?? 0 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{DETAIL_TEMPLATE_STATUS_LABELS[s] || s}</Tag> },
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/product-detail-templates/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapToForm(d));
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="aromaType" label="香型">
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑详情模板" width={640} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
const payload = buildPayload(v);
await request(`/admin/product-detail-templates/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
)}>
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="创建">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="aromaType" label="推荐香型">
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Divider />
<TemplateContentFields />
</Form>
</>
)}
</Drawer>
<Modal title="新建详情模板" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
const payload = buildPayload(v);
await request('/admin/product-detail-templates', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}} width={640}>
<Form form={createForm} layout="vertical" initialValues={{
status: 'ACTIVE', sortOrder: 0,
detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Form.Item name="code" label="编码" rules={[{ required: true }]} extra="唯一标识,如 dukang-classic">
<Input />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="aromaType" label="推荐香型">
<Select allowClear placeholder="不限香型" options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(DETAIL_TEMPLATE_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Divider />
<TemplateContentFields />
</Form>
</Modal>
</div>
);
}
+399
View File
@@ -0,0 +1,399 @@
import { useState } from 'react';
import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Switch, Table, Tabs, Tag, Typography, message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
import DetailImageUrlList from '../components/DetailImageUrlList';
import ProductDetailTemplatePicker from '../components/ProductDetailTemplatePicker';
import type { FormInstance } from 'antd/es/form';
type ProductDetailContentDto = {
storyTitle?: string;
storyText?: string;
features?: Array<{ icon: string; title: string; desc: string }>;
};
type Row = {
id: string;
skuCode: string;
barcode69: string;
name: string;
subtitle?: string;
aromaType: string;
spec: string;
price: number;
benefitAmount: number;
status: string;
sortOrder: number;
allowOnSitePickup?: boolean;
mainImageUrl?: string | null;
carouselUrls?: string[];
detailImageUrls?: string[];
detailContent?: ProductDetailContentDto | null;
createdAt: string;
};
type ProductFormValues = {
skuCode?: string;
barcode69?: string;
name: string;
subtitle?: string;
aromaType?: string;
spec: string;
price: number;
benefitAmount?: number;
status?: string;
sortOrder?: number;
allowOnSitePickup?: boolean;
coverUrl?: string;
carouselUrls?: string[];
detailImageUrls?: string[];
storyTitle?: string;
storyText?: string;
features?: Array<{ icon?: string; title?: string; desc?: string }>;
};
function mapDetailToForm(d: Record<string, unknown>) {
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
return {
...d,
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
storyTitle: detail.storyTitle ?? '',
storyText: detail.storyText ?? '',
features: detail.features?.length
? detail.features
: [{ icon: 'water_drop', title: '', desc: '' }],
};
}
function buildProductPayload(v: ProductFormValues) {
const carouselUrls = (v.carouselUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
const features = (v.features ?? [])
.filter((f) => f?.title?.trim() || f?.desc?.trim())
.map((f) => ({
icon: f.icon?.trim() || 'star',
title: f.title?.trim() || '',
desc: f.desc?.trim() || '',
}));
const detailContent: ProductDetailContentDto = {
storyTitle: v.storyTitle?.trim() || undefined,
storyText: v.storyText?.trim() || undefined,
features: features.length ? features : undefined,
};
return {
skuCode: v.skuCode,
barcode69: v.barcode69,
name: v.name,
subtitle: v.subtitle,
aromaType: v.aromaType,
spec: v.spec,
price: v.price,
benefitAmount: v.benefitAmount,
status: v.status,
sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
coverUrl: v.coverUrl,
carouselUrls,
detailImageUrls,
detailContent,
};
}
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
return (
<Form.List name={name}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
<OssUpload bizType={bizType} mediaType="IMAGE" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
)}
</Space>
))}
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
{label}
</Button>
</>
)}
</Form.List>
);
}
function ProductDetailFields({ form, aromaType }: { form: FormInstance; aromaType?: string }) {
return (
<>
<ProductDetailTemplatePicker form={form} aromaType={aromaType} />
<Divider />
<Typography.Text type="secondary">CAROUSEL</Typography.Text>
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
<Divider />
<Typography.Text type="secondary">DETAIL</Typography.Text>
<DetailImageUrlList label="详情图" bizType="DETAIL" />
<Divider />
<Form.Item name="storyTitle" label="故事标题">
<Input placeholder="如:千年杜康 · 唯有此处" />
</Form.Item>
<Form.Item name="storyText" label="故事正文">
<Input.TextArea rows={4} placeholder="商品故事描述" />
</Form.Item>
<Typography.Text type="secondary"></Typography.Text>
<Form.List name="features">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
<Space align="start">
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
<Input placeholder="material icon 名" style={{ width: 140 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder="标题" />
</Form.Item>
{fields.length > 1 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
)}
</Space>
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
<Input placeholder="简短描述" />
</Form.Item>
</Space>
))}
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
</Button>
</>
)}
</Form.List>
</>
);
}
function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
return (
<>
{mode === 'create' && (
<>
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</>
)}
<Form.Item name="name" label="商品名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="subtitle" label="副标题">
<Input />
</Form.Item>
<Form.Item name="spec" label="规格" rules={[{ required: true }]}>
<Input placeholder="500ml | 52度" />
</Form.Item>
<Form.Item name="price" label="售价" rules={[{ required: true }]}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="benefitAmount" label="权益额">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item>
</>
);
}
export default function ProductsPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/products',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.status) qs.set('status', filters.status);
if (filters.aromaType) qs.set('aromaType', filters.aromaType);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
async function handleDelete(row: Row) {
try {
await request(`/admin/products/${row.id}`, { method: 'DELETE' });
message.success('已删除');
if (detail?.id === row.id) {
setDrawerOpen(false);
setDetail(null);
}
void reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
const columns: ColumnsType<Row> = [
{ title: 'SKU', dataIndex: 'skuCode', width: 90 },
{ title: '商品名', dataIndex: 'name', width: 180, ellipsis: true },
{ title: '香型', dataIndex: 'aromaType', width: 80, render: (v) => AROMA_TYPE_LABELS[v] || v },
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => (
<Tag color={s === 'ON_SALE' ? 'green' : undefined}>{PRODUCT_STATUS_LABELS[s] || s}</Tag>
) },
{
title: '现场取货',
dataIndex: 'allowOnSitePickup',
width: 90,
render: (v: boolean) => (v ? <Tag color="green"></Tag> : <Tag></Tag>),
},
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 120,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
setDetail(d);
editForm.setFieldsValue(mapDetailToForm(d));
setDrawerOpen(true);
}}></Button>
<Popconfirm
title="确认删除该商品?"
description={`将永久删除「${row.name}」(${row.skuCode}),此操作不可恢复。`}
okText="确认删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(row)}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 110 }} options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="aromaType" label="香型">
<Select allowClear style={{ width: 110 }} options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1140 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
const payload = buildProductPayload(v);
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
)}>
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="SKU">{String(detail.skuCode)}</Descriptions.Item>
<Descriptions.Item label="69码">{String(detail.barcode69)}</Descriptions.Item>
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
{
key: 'detail',
label: '详情页',
children: (
<ProductDetailFields
form={editForm}
aromaType={String(detail.aromaType ?? '')}
/>
),
},
]} />
</Form>
</>
)}
</Drawer>
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
const payload = buildProductPayload(v);
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}} width={720}>
<Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }],
}}>
<Tabs items={[
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
{
key: 'detail',
label: '详情页',
children: (
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.aromaType !== cur.aromaType}>
{() => (
<ProductDetailFields
form={createForm}
aromaType={createForm.getFieldValue('aromaType') as string | undefined}
/>
)}
</Form.Item>
),
},
]} />
</Form>
</Modal>
</div>
);
}
+244
View File
@@ -0,0 +1,244 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PROMO_CODE_SCENE_LABELS,
PROMO_CODE_STATUS_LABELS,
promoConversion,
type PromoCodeItem,
type PromoCodeScene,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = PromoCodeItem;
type SceneOption = { value: PromoCodeScene; label: string };
async function downloadQrcode(url: string, filename: string) {
try {
const res = await fetch(url);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
}
export default function PromoCodesPage() {
const navigate = useNavigate();
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
label,
})),
);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
return qs;
},
[filters],
);
async function loadScenes() {
try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
if (list.length) setScenes(list);
} catch {
/* 使用本地默认场景 */
}
}
useEffect(() => {
void loadScenes();
}, []);
const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
{ title: '码值', dataIndex: 'code', width: 110 },
{
title: '场景',
dataIndex: 'scene',
width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
</Tag>
),
},
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
{
title: '渠道负责人',
width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size="small" wrap>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
</Button>
<Button
type="link"
size="small"
disabled={!row.qrcodeUrl}
onClick={() => {
if (!row.qrcodeUrl) return;
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
}}
>
</Button>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
</Button>
</Space>
),
},
];
async function handleCreate(values: Record<string, string>) {
setCreating(true);
try {
const created = await request<PromoCodeItem>('/admin/promo-codes', {
method: 'POST',
body: JSON.stringify({
name: values.name,
code: values.code?.trim() || undefined,
scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}),
});
message.success('推广码已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
navigate(`/promo-codes/${created.id}`);
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>
</div>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => { setFilters(v); setPage(1); }}
>
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
<Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
<Form.Item name="scene" label="场景">
<Select allowClear style={{ width: 130 }} options={scenes} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Modal
title="创建推广码"
open={createOpen}
onCancel={() => setCreateOpen(false)}
footer={null}
destroyOnClose
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
<Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:郑州品鉴会、门店地推" />
</Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
<Select options={scenes} />
</Form.Item>
<Form.Item name="code" label="自定义码值(选填)">
<Input placeholder="留空自动生成,如 DKDEMO1" />
</Form.Item>
<Form.Item
name="page"
label="小程序落地页(选填)"
extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Input placeholder="pages/home/index" />
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
</Button>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,386 @@
import { useState } from 'react';
import {
Button, Card, Col, Form, Input, InputNumber, Row, Space, Tabs, Typography, message,
} from 'antd';
import { request } from '../lib/api';
type ApiResult = Record<string, unknown>;
function ResultBox({ data }: { data: ApiResult | null }) {
if (!data) return <Typography.Text type="secondary"></Typography.Text>;
return (
<pre style={{
margin: 0, padding: 12, background: '#f5f5f5', borderRadius: 4,
maxHeight: 320, overflow: 'auto', fontSize: 12,
}}>
{JSON.stringify(data, null, 2)}
</pre>
);
}
export default function RedeemDebugPage() {
const [createForm] = Form.useForm();
const [previewForm] = Form.useForm();
const [confirmForm] = Form.useForm();
const [phoneLookupForm] = Form.useForm();
const [phoneBalanceForm] = Form.useForm();
const [phonePrepareForm] = Form.useForm();
const [phoneConfirmForm] = Form.useForm();
const [loading, setLoading] = useState(false);
const [createResult, setCreateResult] = useState<ApiResult | null>(null);
const [previewResult, setPreviewResult] = useState<ApiResult | null>(null);
const [confirmResult, setConfirmResult] = useState<ApiResult | null>(null);
const [phoneLookupResult, setPhoneLookupResult] = useState<ApiResult | null>(null);
const [phoneBalanceResult, setPhoneBalanceResult] = useState<ApiResult | null>(null);
const [phonePrepareResult, setPhonePrepareResult] = useState<ApiResult | null>(null);
const [phoneConfirmResult, setPhoneConfirmResult] = useState<ApiResult | null>(null);
async function invoke(
path: string,
body: unknown,
setResult: (v: ApiResult | null) => void,
successMsg: string,
onSuccess?: (res: ApiResult) => void,
) {
setLoading(true);
setResult(null);
try {
const res = await request<ApiResult>(path, {
method: 'POST',
body: JSON.stringify(body),
});
setResult(res);
message.success(successMsg);
onSuccess?.(res);
if (path.includes('create-token') && res.token) {
previewForm.setFieldsValue({ token: res.token });
confirmForm.setFieldsValue({ token: res.token });
}
if (path.includes('phone/balance') && res.sessionId) {
phonePrepareForm.setFieldsValue({
storeId: phoneBalanceForm.getFieldValue('storeId'),
sessionId: res.sessionId,
});
phoneConfirmForm.setFieldsValue({
storeId: phoneBalanceForm.getFieldValue('storeId'),
sessionId: res.sessionId,
});
}
} catch (e) {
message.error(e instanceof Error ? e.message : '调用失败');
} finally {
setLoading(false);
}
}
const tokenTabItems = [
{
key: 'create',
label: '1. 生成核销码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={createForm} layout="vertical">
<Form.Item
name="userId"
label="用户"
rules={[{ required: true, message: '请填写用户 ID、编号或手机号' }]}
>
<Input placeholder="数据库 ID / 用户编号 / 手机号" />
</Form.Item>
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="couponId" label="指定券 ID(可选)">
<Input placeholder="不填则自动分摊" />
</Form.Item>
<Form.Item name="storeId" label="绑定门店 ID(可选)">
<Input placeholder="绑定后仅该门店可核销" />
</Form.Item>
<Button
type="primary"
loading={loading}
onClick={() => {
void createForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={createResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'preview',
label: '2. 预览核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={previewForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Button
loading={loading}
onClick={() => {
const values = previewForm.getFieldsValue();
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={previewResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'confirm',
label: '3. 确认核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={confirmForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Button
type="primary"
danger
loading={loading}
onClick={() => {
const values = confirmForm.getFieldsValue();
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
}}
>
</Button>
</Space>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={confirmResult} />
</Card>
</Col>
</Row>
),
},
];
const phoneTabItems = [
{
key: 'phone-lookup',
label: '1. 发送查权益验证码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneLookupForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
<Input placeholder="11 位手机号" />
</Form.Item>
<Button
type="primary"
loading={loading}
onClick={() => {
void phoneLookupForm.validateFields().then((values) => {
phoneBalanceForm.setFieldsValue({
storeId: values.storeId,
phone: values.phone,
});
void invoke(
'/admin/redeem/debug/phone/send-lookup-sms',
values,
setPhoneLookupResult,
'查权益验证码已发送',
);
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneLookupResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-balance',
label: '2. 验证并查权益',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneBalanceForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="验证码" rules={[{ required: true }]}>
<Input placeholder="Mock 默认 123456" />
</Form.Item>
<Button
loading={loading}
onClick={() => {
void phoneBalanceForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/phone/balance', values, setPhoneBalanceResult, '权益查询成功');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneBalanceResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-prepare',
label: '3. 发送核销确认码',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phonePrepareForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
<Input placeholder="上一步返回的 sessionId" />
</Form.Item>
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
</Form.Item>
<Button
loading={loading}
onClick={() => {
void phonePrepareForm.validateFields().then((values) => {
phoneConfirmForm.setFieldsValue({
storeId: values.storeId,
sessionId: values.sessionId,
});
void invoke('/admin/redeem/debug/phone/prepare', values, setPhonePrepareResult, '核销确认码已发送');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phonePrepareResult} />
</Card>
</Col>
</Row>
),
},
{
key: 'phone-confirm',
label: '4. 确认核销',
children: (
<Row gutter={16}>
<Col xs={24} lg={10}>
<Card size="small" title="参数">
<Form form={phoneConfirmForm} layout="vertical">
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="核销确认验证码" rules={[{ required: true }]}>
<Input placeholder="Mock 默认 123456" />
</Form.Item>
<Button
type="primary"
danger
loading={loading}
onClick={() => {
void phoneConfirmForm.validateFields().then((values) => {
void invoke('/admin/redeem/debug/phone/confirm', values, setPhoneConfirmResult, '手机号核销成功');
});
}}
>
</Button>
</Form>
</Card>
</Col>
<Col xs={24} lg={14}>
<Card size="small" title="响应">
<ResultBox data={phoneConfirmResult} />
</Card>
</Col>
</Row>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Tabs
defaultActiveKey="token"
items={[
{
key: 'token',
label: '扫码核销',
children: <Tabs items={tokenTabItems} />,
},
{
key: 'phone',
label: '手机号核销',
children: <Tabs items={phoneTabItems} />,
},
]}
/>
</div>
);
}
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string; redeemNo: string; amount: number; settleAmount: number; createdAt: string;
user?: { userNo: string; phone: string | null };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
};
export default function RedeemRecordsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/redeem-records',
() => {
const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
if (filters.storeId) qs.set('storeId', filters.storeId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const columns: ColumnsType<Row> = [
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
{ title: '门店', dataIndex: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
{ title: '结算额', dataIndex: 'settleAmount', width: 90, render: (v) => `¥${v}` },
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/redeem-records/${row.id}`));
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="redeemNo" label="核销号"><Input allowClear /></Form.Item>
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+266
View File
@@ -0,0 +1,266 @@
import { useEffect, useState } from 'react';
import {
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import {
MEDIA_TYPE_LABELS,
RESOURCE_BIZ_TYPE_LABELS,
RESOURCE_OWNER_TYPE_LABELS,
RESOURCE_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import OssUpload from '../components/OssUpload';
type Row = {
id: string;
ownerType: string;
ownerId: string;
bizType: string;
mediaType: string;
ossBucket: string;
ossKey: string;
url: string;
fileName?: string | null;
sortOrder: number;
status: string;
createdAt: string;
};
export default function ResourcesPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [data, setData] = useState<Paginated<Row> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [createOpen, setCreateOpen] = useState(false);
const [saving, setSaving] = useState(false);
async function reload(p = page, ps = pageSize, f = filters) {
setLoading(true);
try {
const qs = new URLSearchParams({ page: String(p), pageSize: String(ps) });
if (f.ownerType) qs.set('ownerType', f.ownerType);
if (f.ownerId) qs.set('ownerId', f.ownerId);
if (f.bizType) qs.set('bizType', f.bizType);
if (f.status) qs.set('status', f.status);
const res = await request<Paginated<Row>>(`/common/resources?${qs}`);
setData(res);
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, []);
const columns: ColumnsType<Row> = [
{ title: 'ID', dataIndex: 'id', width: 80 },
{
title: '归属',
width: 140,
render: (_, row) => (
<span>
{RESOURCE_OWNER_TYPE_LABELS[row.ownerType] || row.ownerType} / {row.ownerId}
</span>
),
},
{ title: '用途', dataIndex: 'bizType', width: 90, render: (v) => RESOURCE_BIZ_TYPE_LABELS[v] || v },
{ title: '类型', dataIndex: 'mediaType', width: 70, render: (v) => MEDIA_TYPE_LABELS[v] || v },
{
title: '预览',
dataIndex: 'url',
width: 90,
render: (url, row) =>
row.mediaType === 'IMAGE' ? (
<Image src={url} width={56} height={40} style={{ objectFit: 'cover' }} />
) : (
<a href={url} target="_blank" rel="noreferrer">
</a>
),
},
{ title: 'URL', dataIndex: 'url', ellipsis: true },
{ title: 'OSS Key', dataIndex: 'ossKey', ellipsis: true, width: 160 },
{ title: '排序', dataIndex: 'sortOrder', width: 60 },
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{RESOURCE_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Popconfirm
title="确认删除该资源?"
onConfirm={async () => {
await request(`/common/resources/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
OSS
</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</Space>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
void reload(1, pageSize, v);
}}
>
<Form.Item name="ownerType" label="归属类型">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="ownerId" label="归属 ID">
<Input allowClear style={{ width: 120 }} />
</Form.Item>
<Form.Item name="bizType" label="用途">
<Select
allowClear
style={{ width: 110 }}
options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(RESOURCE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
void reload(p, ps);
},
}}
/>
<Modal
title="登记 OSS 资源"
open={createOpen}
confirmLoading={saving}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
setSaving(true);
try {
await request('/common/resources', {
method: 'POST',
body: JSON.stringify({
ownerType: v.ownerType,
ownerId: v.ownerId,
bizType: v.bizType,
mediaType: v.mediaType,
ossKey: v.ossKey,
url: v.url,
fileName: v.fileName,
sortOrder: v.sortOrder ?? 0,
}),
});
message.success('已登记');
setCreateOpen(false);
createForm.resetFields();
void reload();
} finally {
setSaving(false);
}
}}
width={520}
>
<Form form={createForm} layout="vertical" initialValues={{ mediaType: 'IMAGE', bizType: 'COVER', sortOrder: 0 }}>
<Form.Item name="ownerType" label="归属类型" rules={[{ required: true }]}>
<Select options={Object.entries(RESOURCE_OWNER_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="ownerId" label="归属 ID" rules={[{ required: true }]}>
<Input placeholder="商品/门店等业务 ID" />
</Form.Item>
<Form.Item name="bizType" label="用途" rules={[{ required: true }]}>
<Select options={Object.entries(RESOURCE_BIZ_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="mediaType" label="媒体类型" rules={[{ required: true }]}>
<Select
options={[
...Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label })),
{ value: 'FILE', label: '文件' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType || prev.bizType !== cur.bizType}>
{({ getFieldValue }) => {
const mediaType = getFieldValue('mediaType') || 'IMAGE';
const bizType = getFieldValue('bizType') || 'COVER';
return (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload
bizType={bizType}
mediaType={mediaType}
onUploaded={(result) => {
createForm.setFieldsValue({ ossKey: result.ossKey, url: result.url });
}}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="ossKey" label="OSS Key" rules={[{ required: true }]}>
<Input placeholder="上传后自动填入" />
</Form.Item>
<Form.Item name="fileName" label="文件名">
<Input />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,298 @@
import { useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
type Row = {
id: string;
phone: string;
name: string;
status: string;
createdAt: string;
storeCount?: number;
staffCount?: number;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
store?: StoreBrief | null;
stores?: StoreBrief[];
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
};
type StoreOption = { id: string; name: string };
export default function StoreAccountsPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts',
() => {
const qs = new URLSearchParams();
if (filters.phone) qs.set('phone', filters.phone);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items);
}
async function refreshDetail(accountId: string) {
const d = await request<Row>(`/admin/store-accounts/${accountId}`);
setDetail(d);
void reload();
}
async function deleteStaff(staffId: string) {
if (!detail) return;
setDeletingStaffId(staffId);
try {
await request(`/admin/store-accounts/${detail.id}/staff/${staffId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshDetail(detail.id);
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeletingStaffId(null);
}
}
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '绑定门店',
width: 180,
render: (_, row) =>
row.stores?.length
? row.stores.map((s) => s.name).join('、')
: row.store?.name ?? '—',
},
{
title: '门店数',
dataIndex: 'storeCount',
width: 70,
render: (n, row) => n ?? row.stores?.length ?? 0,
},
{
title: '子账号',
dataIndex: 'staffCount',
width: 70,
render: (n) => n ?? 0,
},
{
title: '收款户名',
dataIndex: 'bankAccountName',
width: 120,
render: (v) => v || '—',
},
{
title: '账号状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Button
type="primary"
onClick={() => {
void loadStores();
setCreateOpen(true);
}}
>
</Button>
</Space>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="门店主账号"
width={520}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail && (
<Select
defaultValue={detail.status}
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
await request(`/admin/store-accounts/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
message.success('已更新');
void reload();
}}
/>
)
}
>
{detail && (
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="收款户名">{detail.bankAccountName || '—'}</Descriptions.Item>
<Descriptions.Item label="收款账号">{detail.bankAccountNo || '—'}</Descriptions.Item>
<Descriptions.Item label="开户行">{detail.bankBranch || '—'}</Descriptions.Item>
<Descriptions.Item label="绑定门店">
{(detail.stores ?? []).map((s) => (
<Tag key={s.id}>
{s.name}
{s.status ? `${STORE_STATUS_LABELS[s.status] || s.status}` : ''}
</Tag>
))}
{!detail.stores?.length ? '—' : null}
</Descriptions.Item>
</Descriptions>
{detail.staff?.length ? (
<>
<Typography.Title level={5} style={{ marginTop: 24 }}></Typography.Title>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.staff}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{
title: '状态',
dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
]}
/>
</>
) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
</>
)}
</Drawer>
<Modal
title="新建门店账户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={stores.map((s) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
</Form>
</Modal>
</div>
);
}
+340
View File
@@ -0,0 +1,340 @@
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Modal,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
billNo: string;
billDate: string;
redeemCount: number;
redeemAmount: number;
settlementRate: number;
payoutAmount: number;
status: string;
paidAt?: string | null;
store?: { id: string; name: string; cityName: string; phone?: string };
};
type StoreOption = { id: string; name: string; phone: string };
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
};
export default function StoreBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setStores(res.items))
.catch(() => {});
}, []);
function confirmPay(ids: string[], amountHint?: number) {
Modal.confirm({
title: '确认打款?',
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
okText: '确认打款',
cancelText: '取消',
onOk: async () => {
if (ids.length === 1) {
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
setBatchLoading(true);
try {
await request('/admin/store-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
} finally {
setBatchLoading(false);
}
}
message.success('已确认打款');
setSelectedKeys([]);
reload();
},
});
}
async function openDetail(id: string) {
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0);
const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{
title: '账单日',
dataIndex: 'billDate',
width: 110,
render: (v) => String(v || '').slice(0, 10),
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
{
title: '核销金额',
dataIndex: 'redeemAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '结算比例',
dataIndex: 'settlementRate',
width: 90,
render: (v) => `${Math.round(Number(v) * 100)}%`,
},
{
title: '应付金额',
dataIndex: 'payoutAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
</Button>
{row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
</Button>
)}
</Space>
),
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
8:00 绿
</Typography.Text>
</Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic title="核销金额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
setFilters({
storeId: v.storeId || '',
status: v.status || '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
});
setPage(1);
}}
>
<Form.Item name="storeId" label="门店">
<Select
allowClear
showSearch
placeholder="全部门店"
style={{ width: 200 }}
optionFilterProp="label"
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
/>
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="range" label="账单日">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
loading={batchLoading}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
>
({selectedKeys.length})
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="打款时间">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
columns={[
{
title: '核销单号',
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
},
{
title: '金额',
dataIndex: 'payoutAmount',
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
},
]}
/>
</>
)}
</Drawer>
</div>
);
}
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from 'react';
import {
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
type CategoryNode = {
id: string;
code: string;
name: string;
sort: number;
parentId: string | null;
status: string;
children?: CategoryNode[];
};
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
function flattenTree(tree: CategoryNode[]): FlatRow[] {
const rows: FlatRow[] = [];
for (const root of tree) {
rows.push({ ...root, level: 1, children: undefined });
for (const child of root.children ?? []) {
rows.push({
...child,
level: 2,
parentName: root.name,
children: undefined,
});
}
}
return rows;
}
export default function StoreCategoriesPage() {
const [tree, setTree] = useState<CategoryNode[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<FlatRow | null>(null);
const [form] = Form.useForm();
const rows = useMemo(() => flattenTree(tree), [tree]);
const rootOptions = useMemo(
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
[tree],
);
async function reload() {
setLoading(true);
try {
const data = await request<CategoryNode[]>('/admin/store-categories');
setTree(Array.isArray(data) ? data : []);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}
useEffect(() => {
void reload();
}, []);
function openCreate(parentId?: string) {
setEditing(null);
form.setFieldsValue({
code: '',
name: '',
sort: 0,
parentId: parentId || undefined,
status: 'ACTIVE',
});
setModalOpen(true);
}
function openEdit(row: FlatRow) {
setEditing(row);
form.setFieldsValue({
code: row.code,
name: row.name,
sort: row.sort,
parentId: row.parentId || undefined,
status: row.status,
});
setModalOpen(true);
}
async function submit() {
const values = await form.validateFields();
const payload = {
code: String(values.code).trim().toUpperCase(),
name: String(values.name).trim(),
sort: Number(values.sort ?? 0),
parentId: values.parentId || null,
status: values.status || 'ACTIVE',
};
if (editing) {
await request(`/admin/store-categories/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('已保存');
} else {
await request('/admin/store-categories', {
method: 'POST',
body: JSON.stringify(payload),
});
message.success('已创建');
}
setModalOpen(false);
void reload();
}
const columns: ColumnsType<FlatRow> = [
{
title: '层级',
dataIndex: 'level',
width: 80,
render: (level) => (level === 1 ? <Tag color="blue"></Tag> : <Tag></Tag>),
},
{
title: '名称',
dataIndex: 'name',
render: (name, row) => (
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
{name}
</span>
),
},
{ title: '编码', dataIndex: 'code', width: 140 },
{ title: '排序', dataIndex: 'sort', width: 80 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '操作',
width: 220,
render: (_, row) => (
<Space wrap>
<Button type="link" size="small" onClick={() => openEdit(row)}></Button>
{row.level === 1 ? (
<Button type="link" size="small" onClick={() => openCreate(row.id)}></Button>
) : null}
<Popconfirm
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
onConfirm={async () => {
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
message.success('已处理');
void reload();
}}
>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">/宿/ </Typography.Text>
</Space>
<Space>
<Button
onClick={async () => {
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
message.success('已同步默认分类');
void reload();
}}
>
</Button>
<Button type="primary" onClick={() => openCreate()}></Button>
</Space>
</Space>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={rows}
pagination={false}
className="admin-table-nowrap"
/>
<Modal
title={editing ? '编辑分类' : '新增分类'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void submit()}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="分类名称" />
</Form.Item>
<Form.Item name="parentId" label="上级分类(空=一级)">
<Select
allowClear
placeholder="不选则为一级分类"
options={rootOptions.filter((o) => o.value !== editing?.id)}
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
/>
</Form.Item>
<Form.Item name="sort" label="排序" initialValue={0}>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select
options={[
{ value: 'ACTIVE', label: '启用' },
{ value: 'DISABLED', label: '停用' },
]}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useSearchParams } from 'react-router-dom';
import {
STORE_LOG_CATEGORY_OPTIONS,
STORE_LOG_CATEGORY_LABELS,
resolveStoreLogCategory,
type StoreLogCategory,
} from '../lib/store-log';
import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
source: 'analytics' | 'redeem_record' | 'store_payout';
storeId: string;
storeAccountId: string | null;
storeName: string | null;
accountName: string | null;
accountPhone: string | null;
category: StoreLogCategory | null;
eventName: string;
clientApp: string | null;
refType: string | null;
refId: string | null;
extraJson: Record<string, unknown> | null;
createdAt: string;
};
const SOURCE_LABELS: Record<Row['source'], string> = {
analytics: '行为埋点',
redeem_record: '核销记录',
store_payout: '打款记录',
};
function summarizeExtra(json: Record<string, unknown> | null) {
if (!json) return '—';
const text = JSON.stringify(json);
return text.length > 80 ? `${text.slice(0, 80)}` : text;
}
function parseCompositeId(id: string) {
const idx = id.indexOf(':');
if (idx <= 0) return null;
return { source: id.slice(0, idx), rawId: id.slice(idx + 1) };
}
export default function StoreLogsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [form] = Form.useForm();
const [category, setCategory] = useState(searchParams.get('category') ?? '');
const [filters, setFilters] = useState<Record<string, string>>(() => ({
storeId: searchParams.get('storeId') ?? '',
storeAccountId: searchParams.get('storeAccountId') ?? '',
phone: searchParams.get('phone') ?? '',
storeName: searchParams.get('storeName') ?? '',
eventName: searchParams.get('eventName') ?? '',
}));
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/logs/stores',
() => {
const qs = new URLSearchParams();
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.storeAccountId) qs.set('storeAccountId', filters.storeAccountId);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.storeName) qs.set('storeName', filters.storeName);
if (filters.eventName) qs.set('eventName', filters.eventName);
if (category) qs.set('category', category);
return qs;
},
[filters, category],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
form.setFieldsValue(filters);
}, [form, filters]);
const columns: ColumnsType<Row> = [
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '门店', width: 180, ellipsis: true,
render: (_, r) => <AdminCellLine primary={r.storeName} secondary={r.storeId} />,
},
{
title: '账号', width: 150, ellipsis: true,
render: (_, r) => (
<AdminCellLine
primary={r.accountName}
secondary={r.accountPhone || r.storeAccountId || '系统/HQ'}
/>
),
},
{
title: '分类', dataIndex: 'category', width: 100,
render: (v: StoreLogCategory | null, r) => (
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
),
},
{ title: '事件', dataIndex: 'eventName', width: 160 },
{
title: '来源', dataIndex: 'source', width: 100,
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
},
{
title: '关联', width: 120,
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
},
{
title: '摘要', ellipsis: true,
render: (_, r) => summarizeExtra(r.extraJson),
},
{
title: '操作', width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const parsed = parseCompositeId(row.id);
if (!parsed) return;
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
setDrawerOpen(true);
}}></Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
/
</Typography.Paragraph>
<Segmented
style={{ marginBottom: 16 }}
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
value={category}
onChange={(v) => {
setCategory(String(v));
setPage(1);
const next = new URLSearchParams(searchParams);
if (v) next.set('category', String(v));
else next.delete('category');
setSearchParams(next);
}}
/>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => {
setFilters(v);
setPage(1);
const next = new URLSearchParams(searchParams);
for (const key of ['storeId', 'storeAccountId', 'phone', 'storeName', 'eventName'] as const) {
if (v[key]) next.set(key, v[key]);
else next.delete(key);
}
setSearchParams(next);
}}>
<Form.Item name="storeId" label="门店ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}></Button></Form.Item>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="门店">{String(detail.storeName || detail.storeId || '—')}</Descriptions.Item>
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
<Descriptions.Item label="分类">
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')}
</Descriptions.Item>
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
<Descriptions.Item label="参数">
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
</pre>
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { useState } from 'react';
import {
Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api';
import { 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;
store?: { id: string; name: string };
};
type StoreOption = { id: string; name: string };
export default function StoreMediaPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-media',
() => {
const qs = new URLSearchParams();
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.mediaType) qs.set('mediaType', filters.mediaType);
return qs;
},
[filters],
);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editing, setEditing] = useState<Row | null>(null);
const [stores, setStores] = useState<StoreOption[]>([]);
async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items);
}
const columns: ColumnsType<Row> = [
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
{ title: '类型', dataIndex: 'mediaType', width: 80, render: (t) => <Tag>{MEDIA_TYPE_LABELS[t] || t}</Tag> },
{
title: '预览', dataIndex: 'url', width: 100,
render: (url, row) => row.mediaType === 'IMAGE'
? <Image src={url} width={60} height={40} style={{ objectFit: 'cover' }} />
: <a href={url} target="_blank" rel="noreferrer"></a>,
},
{ title: 'URL', dataIndex: 'url', ellipsis: true },
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 120,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => {
setEditing(row);
editForm.setFieldsValue(row);
setEditOpen(true);
}}></Button>
<Popconfirm title="确认删除?" onConfirm={async () => {
await request(`/admin/store-media/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}>
<Button type="link" size="small" danger></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
<Form.Item name="mediaType" label="类型">
<Select allowClear style={{ width: 100 }} options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Modal title="新增门店资源" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/store-media', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={stores.map((s) => ({ value: s.id, label: s.name }))} />
</Form.Item>
<Form.Item name="mediaType" label="类型" rules={[{ required: true }]} initialValue="IMAGE">
<Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
{({ getFieldValue }) => (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload bizType="STORE_ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
</Form.Item>
)}
</Form.Item>
<Form.Item name="sortOrder" label="排序" initialValue={0}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
<Modal title="编辑门店资源" open={editOpen} onCancel={() => setEditOpen(false)} onOk={async () => {
if (!editing) return;
const v = await editForm.validateFields();
await request(`/admin/store-media/${editing.id}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已保存');
setEditOpen(false);
void reload();
}}>
<Form form={editForm} layout="vertical">
<Form.Item name="mediaType" label="类型"><Select options={Object.entries(MEDIA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} /></Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mediaType !== cur.mediaType}>
{({ getFieldValue }) => (
<Form.Item name="url" label="资源" rules={[{ required: true }]}>
<OssUpload bizType="STORE_ENV" mediaType={getFieldValue('mediaType') || 'IMAGE'} />
</Form.Item>
)}
</Form.Item>
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
</Form>
</Modal>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,384 @@
import { useEffect, useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
SUPPORT_TICKET_STATUS_LABELS,
SUPPORT_TICKET_TYPE_LABELS,
type SupportTicketDto,
type SupportTicketStatusDto,
type SupportTicketTypeDto,
} from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
const STATUS_COLOR: Record<SupportTicketStatusDto, string> = {
PENDING_REVIEW: 'orange',
REJECTED: 'red',
DEVELOPING: 'blue',
TESTING: 'purple',
PASSED: 'green',
};
const TYPE_OPTIONS = (Object.keys(SUPPORT_TICKET_TYPE_LABELS) as SupportTicketTypeDto[]).map(
(value) => ({ value, label: SUPPORT_TICKET_TYPE_LABELS[value] }),
);
const STATUS_OPTIONS = (Object.keys(SUPPORT_TICKET_STATUS_LABELS) as SupportTicketStatusDto[]).map(
(value) => ({ value, label: SUPPORT_TICKET_STATUS_LABELS[value] }),
);
export default function SupportTicketsPage() {
const [profile, setProfile] = useState<HqProfile | null>(null);
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
useAdminList<SupportTicketDto>('/admin/support-tickets', () => {
const qs = new URLSearchParams();
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
if (filters.status) qs.set('status', filters.status);
return qs;
}, [filters]);
const [detail, setDetail] = useState<SupportTicketDto | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [acting, setActing] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [createForm] = Form.useForm<{
ticketType: SupportTicketTypeDto;
title: string;
content?: string;
remark?: string;
}>();
const [rejectForm] = Form.useForm<{ rejectReason: string }>();
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
async function openDetail(id: string) {
setDetail(await request<SupportTicketDto>(`/admin/support-tickets/${id}`));
setDrawerOpen(true);
}
async function submitCreate() {
const values = await createForm.validateFields();
setCreating(true);
try {
await request('/admin/support-tickets', {
method: 'POST',
body: JSON.stringify({
ticketType: values.ticketType,
title: values.title.trim(),
content: values.content?.trim() || undefined,
remark: values.remark?.trim() || undefined,
}),
});
message.success('技术支持工单已创建,等待最高管理员评审');
setCreateOpen(false);
createForm.resetFields();
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '创建失败');
} finally {
setCreating(false);
}
}
async function approve() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/support-tickets/${detail.id}/approve`, {
method: 'POST',
body: JSON.stringify({}),
});
message.success('评审通过,已进入开发');
setDrawerOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
} finally {
setActing(false);
}
}
async function submitReject() {
if (!detail) return;
const values = await rejectForm.validateFields();
setActing(true);
try {
await request(`/admin/support-tickets/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ rejectReason: values.rejectReason.trim() }),
});
message.success('已驳回');
setRejectOpen(false);
rejectForm.resetFields();
setDrawerOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
} finally {
setActing(false);
}
}
async function startTesting() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/support-tickets/${detail.id}/start-testing`, {
method: 'POST',
body: JSON.stringify({}),
});
message.success('已转入测试');
setDrawerOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
} finally {
setActing(false);
}
}
async function pass() {
if (!detail) return;
setActing(true);
try {
await request(`/admin/support-tickets/${detail.id}/pass`, {
method: 'POST',
body: JSON.stringify({}),
});
message.success('测试通过');
setDrawerOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '操作失败');
} finally {
setActing(false);
}
}
const columns: ColumnsType<SupportTicketDto> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
{
title: '类型',
dataIndex: 'ticketType',
width: 90,
render: (t: SupportTicketTypeDto) => SUPPORT_TICKET_TYPE_LABELS[t] ?? t,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s: SupportTicketStatusDto) => (
<Tag color={STATUS_COLOR[s]}>{SUPPORT_TICKET_STATUS_LABELS[s] ?? s}</Tag>
),
},
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '创建人', dataIndex: 'creatorName', width: 100 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(String(row.id))}>
</Button>
),
},
];
const drawerExtra = (() => {
if (!detail) return null;
if (detail.status === 'PENDING_REVIEW' && isSuperAdmin) {
return (
<Space>
<Button type="primary" loading={acting} onClick={() => void approve()}>
</Button>
<Button danger loading={acting} onClick={() => setRejectOpen(true)}>
</Button>
</Space>
);
}
if (detail.status === 'DEVELOPING') {
return (
<Button type="primary" loading={acting} onClick={() => void startTesting()}>
·
</Button>
);
}
if (detail.status === 'TESTING') {
return (
<Button type="primary" loading={acting} onClick={() => void pass()}>
</Button>
);
}
return null;
})();
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}>
</Button>
</div>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="ticketType" label="类型">
<Select allowClear style={{ width: 120 }} options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="技术支持详情"
width={520}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={drawerExtra}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工单号">{detail.ticketNo}</Descriptions.Item>
<Descriptions.Item label="类型">
{SUPPORT_TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status]}>
{SUPPORT_TICKET_STATUS_LABELS[detail.status] ?? detail.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="标题">{detail.title}</Descriptions.Item>
<Descriptions.Item label="内容">
<div style={{ whiteSpace: 'pre-wrap' }}>{detail.content || '—'}</div>
</Descriptions.Item>
<Descriptions.Item label="创建人">{detail.creatorName}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="评审人">{detail.reviewerName || '—'}</Descriptions.Item>
<Descriptions.Item label="评审时间">
{detail.reviewedAt ? fmtTime(detail.reviewedAt) : '—'}
</Descriptions.Item>
<Descriptions.Item label="驳回理由">{detail.rejectReason || '—'}</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
<Modal
title="创建技术支持工单"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={creating}
destroyOnClose
okText="提交"
>
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'BUG' }}>
<Form.Item
name="ticketType"
label="类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item
name="title"
label="标题"
rules={[{ required: true, message: '请填写标题' }]}
>
<Input placeholder="简要描述问题或建议" maxLength={128} showCount />
</Form.Item>
<Form.Item name="content" label="详细说明">
<Input.TextArea rows={5} placeholder="复现步骤、期望结果等" maxLength={4000} showCount />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="可选" maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
title="驳回技术支持工单"
open={rejectOpen}
onCancel={() => setRejectOpen(false)}
onOk={() => void submitReject()}
confirmLoading={acting}
destroyOnClose
okText="确认驳回"
okButtonProps={{ danger: true }}
>
<Form form={rejectForm} layout="vertical">
<Form.Item
name="rejectReason"
label="驳回理由"
rules={[{ required: true, message: '请填写驳回理由' }]}
>
<Input.TextArea rows={4} placeholder="必填" maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
</div>
);
}

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