Compare commits

..

146 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
381 changed files with 33585 additions and 6029 deletions
@@ -23,7 +23,7 @@
| Guard | 用途 | | Guard | 用途 |
|-------|------| |-------|------|
| JwtAuthGuard | 需登录 | | JwtAuthGuard | 需登录 |
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) | | PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
| OptionalJwtAuthGuard | 可选登录(bootstrap | | OptionalJwtAuthGuard | 可选登录(bootstrap |
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*` Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`
+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` / 私钥打进仓库
- 用户只说「修好」未说发布时 → **只改代码,不发版**
+1 -1
View File
@@ -51,7 +51,7 @@
|-----|------|----------| |-----|------|----------|
| ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 | | ACC-010 | 工单 | 四类型;总部决策;补发/退款流转 |
| ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 | | ACC-011 | 发票 | 四组合申请;2 工作日回传;可下载 |
| ACC-012 | 代下单 | W3手机号建用户;权益自动发 | | ACC-012 | 代下单 | W3客户+合伙人双短信;手机号建用户(来源 PARTNER_PROXY);线下确认后已付款已完成并发权益;C 端展示代下单人 |
| ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 | | ACC-013 | 问卷评价 | 成交问卷无激励;核销评价;总部可看 |
| ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 | | ACC-014 | 推广分享 | 推广码归因;商品分享带渠道 |
| ACC-018 | 话术 | OPT-012 3 日内对齐 | | ACC-018 | 话术 | OPT-012 3 日内对齐 |
@@ -7,7 +7,7 @@
| ID | 模块 | 结论摘要 | | ID | 模块 | 结论摘要 |
|----|------|----------| |----|------|----------|
| REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 | | REQ-U-001 | 导航 | 首页/门店/好客权益/个人中心 |
| REQ-U-002 | 登录 | 无感登录;下单强制手机号;微信/短信;7 天免登 | | REQ-U-002 | 登录 | 无感登录;下单提示绑定手机号(可选);微信/短信;7 天免登 |
| REQ-U-003 | 支付 | 仅微信支付 | | REQ-U-003 | 支付 | 仅微信支付 |
| REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 | | REQ-U-004 | 定位 | 右上角市区;默认 IP;可授权精确定位 |
| REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 | | REQ-U-005 | 首页商品 | 4 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
+1
View File
@@ -15,3 +15,4 @@ coverage/
server/dukang-api/prisma/migrations/ server/dukang-api/prisma/migrations/
debug_v3.xlsx debug_v3.xlsx
~$debug_v3.xlsx ~$debug_v3.xlsx
deploy/auto-release.env
+2
View File
@@ -90,6 +90,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project | | 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag | | preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* | | 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用) ## 子 Agent(按职责选用)
@@ -115,6 +116,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) | | `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag | | `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 | | `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令 ## 快速指令
+16
View File
@@ -18,7 +18,9 @@ import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage'; import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage'; import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage'; import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage'; import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage'; import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout'; import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage'; import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
@@ -29,7 +31,10 @@ import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage'; import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage'; import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
import TicketsPage from './pages/TicketsPage'; import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage'; import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage'; import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
@@ -38,6 +43,9 @@ import PartnerLogsPage from './pages/PartnerLogsPage';
import WechatBindingsPage from './pages/WechatBindingsPage'; import WechatBindingsPage from './pages/WechatBindingsPage';
import HqPermissionsPage from './pages/HqPermissionsPage'; import HqPermissionsPage from './pages/HqPermissionsPage';
import SystemSettingsPage from './pages/SystemSettingsPage'; 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 }) { function RequireAuth({ children }: { children: React.ReactNode }) {
if (!getToken()) return <Navigate to="/login" replace />; if (!getToken()) return <Navigate to="/login" replace />;
@@ -64,9 +72,13 @@ export default function App() {
<Route index element={<PromoCodeDetailPage />} /> <Route index element={<PromoCodeDetailPage />} />
<Route path="users" element={<PromoCodeUsersPage />} /> <Route path="users" element={<PromoCodeUsersPage />} />
</Route> </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="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} /> <Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} /> <Route path="/stores" element={<StoresPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} /> <Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} /> <Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} /> <Route path="/resources" element={<ResourcesPage />} />
@@ -74,6 +86,7 @@ export default function App() {
<Route path="/cities" element={<CitiesPage />} /> <Route path="/cities" element={<CitiesPage />} />
<Route path="/city-partners" element={<CityPartnersPage />} /> <Route path="/city-partners" element={<CityPartnersPage />} />
<Route path="/city-warehouses" element={<CityWarehousesPage />} /> <Route path="/city-warehouses" element={<CityWarehousesPage />} />
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} /> <Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} /> <Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} /> <Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
@@ -83,10 +96,13 @@ export default function App() {
<Route path="/finance/store-bills" element={<StoreBillsPage />} /> <Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} /> <Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} /> <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-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" 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="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} /> <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/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} /> <Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/partners" element={<PartnerLogsPage />} /> <Route path="/logs/partners" element={<PartnerLogsPage />} />
@@ -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"
/>
);
}
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Cascader,
Checkbox, Checkbox,
Drawer, Drawer,
Form, Form,
@@ -27,8 +26,9 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import CityDistrictMultiSelect from './CityDistrictMultiSelect';
import PartnerSubAccountList from './PartnerSubAccountList'; import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = { type PartnerRow = {
@@ -37,6 +37,7 @@ type PartnerRow = {
phone: string; phone: string;
name: string; name: string;
scopeType?: string; scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number; orderCommissionRate?: number;
redeemCommissionRate?: number; redeemCommissionRate?: number;
accountCount: number; accountCount: number;
@@ -65,6 +66,7 @@ type PartnerDetail = PartnerRow & {
type Props = { type Props = {
cityId: string; cityId: string;
cityCode?: string;
maxPartnerCommissionRate?: number; maxPartnerCommissionRate?: number;
onChanged?: () => void; onChanged?: () => void;
}; };
@@ -92,14 +94,31 @@ function commissionSumError(
redeemPercent: number, redeemPercent: number,
maxRate: number, maxRate: number,
): string | null { ): string | null {
const sum = orderPercent / 100 + redeemPercent / 100; // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
if (sum > maxRate + 1e-9) { const max = Number(maxRate);
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`; 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; return null;
} }
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) { 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 [editForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
@@ -153,29 +172,34 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const err = commissionSumError( const v = await editForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
maxPartnerCommissionRate, Number(v.redeemCommissionRate ?? 0),
); maxPartnerCommissionRate,
if (err) { );
message.error(err); if (err) {
return; 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);
} }
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?.();
} }
async function deleteSubAccount(subId: string) { async function deleteSubAccount(subId: string) {
@@ -187,6 +211,14 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
} }
const columns: ColumnsType<PartnerRow> = [ 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: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 120 }, { title: '主账号', dataIndex: 'phone', width: 120 },
{ {
@@ -249,11 +281,11 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" 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="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
</Form.Item> </Form.Item>
@@ -261,8 +293,12 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -292,7 +328,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
subs={detail.children ?? []} subs={detail.children ?? []}
onAdd={() => { onAdd={() => {
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
}} }}
onEdit={(row) => { onEdit={(row) => {
@@ -316,42 +352,51 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
</Drawer> </Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields(); try {
const err = commissionSumError( const v = await createForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 3), Number(v.orderCommissionRate ?? 0),
maxPartnerCommissionRate, Number(v.redeemCommissionRate ?? 3),
); maxPartnerCommissionRate,
if (err) { );
message.error(err); if (err) {
return; 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);
} }
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?.();
}}> }}>
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={cityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -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,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>
);
}
+106 -4
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Layout, Menu, Typography, Button, Space } from 'antd'; import { Layout, Menu, Typography, Button, Space } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import { import {
RobotOutlined,
DashboardOutlined, DashboardOutlined,
UserOutlined, UserOutlined,
ShoppingOutlined, ShoppingOutlined,
@@ -18,11 +19,14 @@ import {
SettingOutlined, SettingOutlined,
AccountBookOutlined, AccountBookOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api'; import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title'; import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' }, { key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' }, { key: '/users', icon: <UserOutlined />, label: '用户' },
@@ -38,12 +42,16 @@ const MENU_ITEMS: MenuProps['items'] = [
}, },
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' }, { key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
{ key: '/promo-codes', icon: <GiftOutlined />, 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', key: 'stores-group',
icon: <ShopOutlined />, icon: <ShopOutlined />,
label: '门店', label: '门店',
children: [ children: [
{ key: '/stores', label: '门店列表' }, { key: '/stores', label: '门店列表' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' }, { key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' }, { key: '/store-media', label: '门店资源' },
], ],
@@ -56,6 +64,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' }, { key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' }, { key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' }, { key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
], ],
}, },
{ {
@@ -66,6 +75,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/finance/store-bills', label: '门店账单' }, { key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' }, { key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' }, { key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
], ],
}, },
{ {
@@ -89,7 +99,16 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/deliveries/xiaofeixia', label: '小飞侠联调' }, { key: '/deliveries/xiaofeixia', label: '小飞侠联调' },
], ],
}, },
{ key: '/tickets', icon: <CarOutlined />, 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: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{ {
key: 'logs-group', key: 'logs-group',
@@ -108,6 +127,83 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' }, { 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() { export default function AdminLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
@@ -135,6 +231,12 @@ export default function AdminLayout() {
? '/promo-codes' ? '/promo-codes'
: location.pathname; : 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 ( return (
<Layout style={{ height: '100vh', overflow: 'hidden' }}> <Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Sider <Sider
@@ -153,8 +255,8 @@ export default function AdminLayout() {
theme="dark" theme="dark"
mode="inline" mode="inline"
selectedKeys={[selectedKey]} selectedKeys={[selectedKey]}
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']} defaultOpenKeys={[]}
items={MENU_ITEMS} items={menuItems}
onClick={({ key }) => { onClick={({ key }) => {
if (key.startsWith('/')) navigate(key); if (key.startsWith('/')) navigate(key);
}} }}
+30 -1
View File
@@ -7,6 +7,7 @@ export type HqProfile = {
name: string; name: string;
adminRole: string; adminRole: string;
status: string; status: string;
permissionKeys?: string[];
}; };
export function getToken() { export function getToken() {
@@ -79,6 +80,11 @@ export type DashboardStats = {
partnersTotal: number; partnersTotal: number;
redeemToday: number; redeemToday: number;
deliveriesTotal: number; deliveriesTotal: number;
pendingPayouts?: number;
/** 合伙人已确认、待总部审核打款 */
pendingBills?: number;
pendingPartnerDraftBills?: number;
openTickets?: number;
ordersByStatus: Array<{ status: string; count: number }>; ordersByStatus: Array<{ status: string; count: number }>;
}; };
@@ -116,15 +122,38 @@ export type AdminUserRow = {
orderCount: number; orderCount: number;
}; };
export type AdminOrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
export type AdminOrderRow = { export type AdminOrderRow = {
id: string; id: string;
orderNo: string; orderNo: string;
status: string; status: string;
deliveryType: string; deliveryType: string;
payAmount: number; payAmount: number;
productName?: string;
productSpec?: string;
quantity?: number;
receiverName: string; receiverName: string;
receiverPhone: string; receiverPhone: string;
createdAt: 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 }; user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null }; delivery?: {
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
logisticsCompany?: string | null;
manualQueryUrl?: string | null;
};
}; };
+33
View File
@@ -79,6 +79,39 @@ export function districtCodeToCityCode(districtCode: string): string {
return `${districtCode.slice(0, 4)}00`; 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 { export function parseRegionCodes(codes?: string[]): ParsedChinaRegion | null {
if (!codes || codes.length < 3) return null; if (!codes || codes.length < 3) return null;
const [provinceCode, cityCode, districtCode] = codes; const [provinceCode, cityCode, districtCode] = codes;
+22 -5
View File
@@ -34,8 +34,8 @@ export const ORDER_STATUS_COLORS: Record<string, string> = {
export const STORE_STATUS_LABELS: Record<string, string> = { export const STORE_STATUS_LABELS: Record<string, string> = {
OPEN: '营业中', OPEN: '营业中',
PAUSED: '暂停', PAUSED: '临时闭店',
CLOSED: '关闭', CLOSED: '永久关闭',
}; };
export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = { export const STORE_AUDIT_STATUS_LABELS: Record<string, string> = {
@@ -62,9 +62,11 @@ export const CITY_STATUS_LABELS: Record<string, string> = {
}; };
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = { export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿', PENDING_REVIEW: '待审核',
CONFIRMED: '确认', AWAITING_CONFIRM: '待合伙人确认',
PAID: '已结算', UNPAID: '未打款',
PAID: '已打款',
REJECTED: '已驳回',
}; };
export const MEDIA_TYPE_LABELS: Record<string, string> = { export const MEDIA_TYPE_LABELS: Record<string, string> = {
@@ -126,3 +128,18 @@ export const LEDGER_TYPE_LABELS: Record<string, string> = {
export function fmtTime(v?: string | null) { export function fmtTime(v?: string | null) {
return v ? new Date(v).toLocaleString('zh-CN') : '—'; 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 '****';
}
+21
View File
@@ -27,6 +27,11 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'STORE_AUDIT', label: '门店审核' }, { value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' }, { value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', 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_CREATE', label: '新增商品' },
{ value: 'PRODUCT_UPDATE', label: '编辑商品' }, { value: 'PRODUCT_UPDATE', label: '编辑商品' },
{ value: 'PRODUCT_DELETE', label: '删除商品' }, { value: 'PRODUCT_DELETE', label: '删除商品' },
@@ -34,11 +39,27 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' }, { value: 'DELIVERY_UPDATE', label: '编辑配送单' },
{ value: 'TICKET_APPROVE', label: '工单通过' }, { value: 'TICKET_APPROVE', label: '工单通过' },
{ value: 'TICKET_REJECT', 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_CONFIRM', label: '门店打款确认' },
{ value: 'STORE_PAYOUT_BATCH_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_GENERATE', label: '生成合伙人账单' },
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' }, { value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
{ value: 'PARTNER_BILL_MARK_PAID', 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; ] as const;
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries( export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
+59 -2
View File
@@ -6,10 +6,20 @@ export type StoreCreateForm = {
city?: string; city?: string;
district: string; district: string;
districtCode?: string; districtCode?: string;
categoryParentId?: string;
categoryId: string;
name: string; name: string;
phone: string; phone: string;
address: string; address: string;
latitude?: number | null;
longitude?: number | null;
intro?: string; intro?: string;
benefitUsageRule?: string;
openTime?: string;
closeTime?: string;
openTime2?: string;
closeTime2?: string;
avgPrice?: number | null;
coverUrl?: string; coverUrl?: string;
envPhotoUrls?: string[]; envPhotoUrls?: string[];
contractUrl?: string; contractUrl?: string;
@@ -21,20 +31,67 @@ export type StoreCreateForm = {
const PHONE_RE = /^1\d{10}$/; const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/; 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( export function validateStoreCreateStep1(
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>, form: Pick<
StoreCreateForm,
| 'partnerAccountId'
| 'cityId'
| 'regionCodes'
| 'categoryId'
| 'name'
| 'phone'
| 'address'
| 'intro'
| 'benefitUsageRule'
| 'openTime'
| 'closeTime'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
>,
): string | null { ): string | null {
if (!form.partnerAccountId) return '请选择开城合伙人'; if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划'; if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
if (!form.name?.trim()) return '请填写门店名称'; if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号'; if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
if (!form.address?.trim()) return '请填写详细地址'; 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()) { if (form.intro?.trim()) {
const len = form.intro.trim().length; const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字'; if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
if (form.benefitUsageRule?.trim() && form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
} }
return null; 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,
};
}
+237 -15
View File
@@ -33,6 +33,34 @@ type Row = {
order?: { orderNo: 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() { export default function BenefitCouponsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>(); const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
@@ -50,8 +78,25 @@ export default function BenefitCouponsPage() {
}, },
[filters], [filters],
); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<CouponDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); 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> = [ const columns: ColumnsType<Row> = [
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, { title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
@@ -197,7 +242,7 @@ export default function BenefitCouponsPage() {
<Drawer <Drawer
title="权益券详情" title="权益券详情"
width={560} width={720}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={ extra={
@@ -218,25 +263,202 @@ export default function BenefitCouponsPage() {
} }
> >
{detail && ( {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 column={1} bordered size="small">
<Descriptions.Item label="号">{String(detail.couponNo)}</Descriptions.Item> <Descriptions.Item label="核销号">{String(redeemDetail.redeemNo ?? '—')}</Descriptions.Item>
<Descriptions.Item label="用户"> <Descriptions.Item label="核销额">
{String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')} ¥{Number(redeemDetail.amount ?? 0).toFixed(2)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="手机号"> <Descriptions.Item label="结算额">
{String((detail.user as { phone?: string } | undefined)?.phone ?? '—')} ¥{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>
<Descriptions.Item label="关联订单"> <Descriptions.Item label="关联订单">
{String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')} {String(
(redeemDetail.coupon as { order?: { orderNo?: string } } | undefined)?.order?.orderNo ??
'—',
)}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item> {Array.isArray(redeemDetail.allocations) &&
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item> (redeemDetail.allocations as unknown[]).length > 0 ? (
<Descriptions.Item label="状态"> <Descriptions.Item label="券分摊">
{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)} {(
</Descriptions.Item> redeemDetail.allocations as Array<{
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item> 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> </Descriptions>
)} ) : null}
</Drawer> </Drawer>
</div> </div>
); );
+271 -6
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Button, Button,
Descriptions, Descriptions,
@@ -17,7 +18,7 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types'; import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type HqProfile, type Paginated } from '../lib/api';
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region'; import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -50,6 +51,40 @@ type Row = {
type PartnerOption = { id: string; companyName: string; cityId?: string | null }; 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 })); const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() { export default function CitiesPage() {
@@ -82,6 +117,18 @@ export default function CitiesPage() {
const createRegionCodes = Form.useWatch('regionCodes', createForm); const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = 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 loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`); const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
@@ -129,22 +176,85 @@ export default function CitiesPage() {
setDrawerOpen(true); 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> = [ const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 }, { title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 }, { title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 }, { title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 }, { title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
{ title: '门店', dataIndex: 'storeCount', width: 70 }, {
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: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 80, width: 140,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={() => void openDetail(row)}> <Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
</Button>
{canDeleteCities ? (
<Button type="link" size="small" danger onClick={() => void openDelete(row)}>
</Button>
) : null}
</Space>
), ),
}, },
]; ];
@@ -264,6 +374,26 @@ export default function CitiesPage() {
setDetail(refreshed); setDetail(refreshed);
void reload(); void reload();
}}></Button> }}></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> </Form>
</> </>
), ),
@@ -274,6 +404,7 @@ export default function CitiesPage() {
children: detail?.id ? ( children: detail?.id ? (
<CityPartnersPanel <CityPartnersPanel
cityId={String(detail.id)} cityId={String(detail.id)}
cityCode={detail.code != null ? String(detail.code) : undefined}
maxPartnerCommissionRate={ maxPartnerCommissionRate={
detail.maxPartnerCommissionRate != null detail.maxPartnerCommissionRate != null
? Number(detail.maxPartnerCommissionRate) ? Number(detail.maxPartnerCommissionRate)
@@ -406,6 +537,140 @@ export default function CitiesPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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> </div>
); );
} }
+134 -73
View File
@@ -3,7 +3,6 @@ import { Link } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Cascader,
Checkbox, Checkbox,
Descriptions, Descriptions,
Drawer, Drawer,
@@ -32,9 +31,10 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList'; import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
type SubRow = PartnerSubAccountRow; type SubRow = PartnerSubAccountRow;
@@ -48,6 +48,7 @@ type Row = {
cityId?: string | null; cityId?: string | null;
cityName?: string | null; cityName?: string | null;
scopeType?: string; scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number; orderCommissionRate?: number;
redeemCommissionRate?: number; redeemCommissionRate?: number;
bindingStatus?: string; bindingStatus?: string;
@@ -98,13 +99,25 @@ function flattenDistrictCodes(values: string[] | string[][] | undefined): string
} }
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null { function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
const sum = orderPercent / 100 + redeemPercent / 100; // API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
if (sum > maxRate + 1e-9) { const max = Number(maxRate);
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`; 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; 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() { export default function CityPartnersPage() {
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
@@ -135,6 +148,7 @@ export default function CityPartnersPage() {
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05); const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05); const [createMaxRate, setCreateMaxRate] = useState(0.05);
const [createCityCode, setCreateCityCode] = useState<string | undefined>();
const loadCities = useCallback(async () => { const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -145,11 +159,15 @@ export default function CityPartnersPage() {
void loadCities(); void loadCities();
}, [loadCities]); }, [loadCities]);
const editCityCode = detail?.cityId
? cities.find((c) => c.id === detail.cityId)?.code
: undefined;
async function openPartner(id: string) { async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`); const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d); setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE); setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05); setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
editForm.setFieldsValue({ editForm.setFieldsValue({
name: d.name, name: d.name,
phone: d.phone, phone: d.phone,
@@ -171,28 +189,33 @@ export default function CityPartnersPage() {
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const err = commissionSumError( const v = await editForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 0), Number(v.orderCommissionRate ?? 0),
maxCommissionRate, Number(v.redeemCommissionRate ?? 0),
); maxCommissionRate,
if (err) { );
message.error(err); if (err) {
return; 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);
} }
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();
} }
async function refreshPartnerContext(parentId: string) { async function refreshPartnerContext(parentId: string) {
@@ -224,18 +247,29 @@ export default function CityPartnersPage() {
async function openAddSubAccount(parentId: string) { async function openAddSubAccount(parentId: string) {
await openPartner(parentId); await openPartner(parentId);
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
} }
async function onCreateCityChange(cityId: string) { 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}`); const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05); setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 }, { 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: 'name', width: 100, ellipsis: true },
{ title: '登录手机', dataIndex: 'phone', width: 120 }, { title: '登录手机', dataIndex: 'phone', width: 120 },
{ {
@@ -267,7 +301,16 @@ export default function CityPartnersPage() {
ellipsis: true, ellipsis: true,
render: (v) => v || '—', render: (v) => v || '—',
}, },
{ title: '门店', dataIndex: 'storeCount', width: 60 }, {
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: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{ {
@@ -299,6 +342,7 @@ export default function CityPartnersPage() {
}); });
setCreateScopeType(CityPartnerScopeType.CITY_WIDE); setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05); setCreateMaxRate(0.05);
setCreateCityCode(undefined);
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
@@ -327,12 +371,6 @@ export default function CityPartnersPage() {
setPage(1); setPage(1);
}} }}
> >
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item name="cityId" label="城市"> <Form.Item name="cityId" label="城市">
<Select <Select
allowClear allowClear
@@ -343,6 +381,12 @@ export default function CityPartnersPage() {
options={cities.map((c) => ({ value: c.id, label: c.name }))} options={cities.map((c) => ({ value: c.id, label: c.name }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item> <Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -409,7 +453,11 @@ export default function CityPartnersPage() {
<> <>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item> <Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item> <Descriptions.Item label="门店数">
<Link to={`/stores?partnerId=${detail.id}`} title="查看该城市合伙人门店">
{detail.storeCount ?? 0}
</Link>
</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}> <Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? ( {detail.managedWarehouseName ?? (
<Typography.Text type="secondary"> <Typography.Text type="secondary">
@@ -425,8 +473,8 @@ export default function CityPartnersPage() {
label: '主账号', label: '主账号',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}> <Form.Item name="companyName" label="公司名">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input /> <Input />
@@ -437,8 +485,8 @@ export default function CityPartnersPage() {
<Form.Item name="contactPhone" label="业务联系电话"> <Form.Item name="contactPhone" label="业务联系电话">
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}> <Form.Item name="address" label="地址">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}> <Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} /> <Select options={BINDING_OPTIONS} />
@@ -447,8 +495,12 @@ export default function CityPartnersPage() {
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -482,7 +534,7 @@ export default function CityPartnersPage() {
subs={detail.children ?? []} subs={detail.children ?? []}
onAdd={() => { onAdd={() => {
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
}} }}
onEdit={(sub) => openSubEdit(sub, detail.id)} onEdit={(sub) => openSubEdit(sub, detail.id)}
@@ -502,29 +554,34 @@ export default function CityPartnersPage() {
width={560} width={560}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
const v = await createForm.validateFields(); try {
const err = commissionSumError( const v = await createForm.validateFields();
Number(v.orderCommissionRate ?? 0), const err = commissionSumError(
Number(v.redeemCommissionRate ?? 3), Number(v.orderCommissionRate ?? 0),
createMaxRate, Number(v.redeemCommissionRate ?? 3),
); createMaxRate,
if (err) { );
message.error(err); if (err) {
return; 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);
} }
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();
}} }}
> >
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
@@ -536,8 +593,8 @@ export default function CityPartnersPage() {
onChange={(id) => void onCreateCityChange(id)} onChange={(id) => void onCreateCityChange(id)}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}> <Form.Item name="companyName" label="公司名">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}> <Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input /> <Input />
@@ -545,15 +602,19 @@ export default function CityPartnersPage() {
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input /> <Input />
</Form.Item> </Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}> <Form.Item name="address" label="地址">
<Input /> <Input placeholder="选填" />
</Form.Item> </Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+151 -42
View File
@@ -6,10 +6,12 @@ import {
Descriptions, Descriptions,
Form, Form,
Input, Input,
InputNumber,
Modal, Modal,
Popconfirm, Popconfirm,
Select, Select,
Space, Space,
Switch,
Table, Table,
Tag, Tag,
Typography, Typography,
@@ -17,10 +19,13 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { import {
WAREHOUSE_FULFILLMENT_MODE_LABELS,
WAREHOUSE_MANAGER_LABELS, WAREHOUSE_MANAGER_LABELS,
WAREHOUSE_STATUS_LABELS, WAREHOUSE_STATUS_LABELS,
WarehouseFulfillmentMode,
WarehouseManagerType, WarehouseManagerType,
WarehouseStatus, WarehouseStatus,
type FulfillmentProviderDto,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
@@ -39,6 +44,13 @@ type Row = {
partnerAccountId: string | null; partnerAccountId: string | null;
partnerCompanyName?: string | null; partnerCompanyName?: string | null;
status: string; status: string;
fulfillmentMode?: string;
fulfillmentProviderId?: string | null;
fulfillmentProviderName?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
createdAt: string; createdAt: string;
}; };
@@ -48,6 +60,79 @@ type PartnerOption = { id: string; companyName: string };
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label })); 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 })); 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() { export default function CityWarehousesPage() {
const [filterForm] = Form.useForm(); const [filterForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
@@ -73,6 +158,13 @@ export default function CityWarehousesPage() {
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ); const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [createCityId, setCreateCityId] = useState<string | undefined>(); 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 loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -88,6 +180,9 @@ export default function CityWarehousesPage() {
useEffect(() => { useEffect(() => {
void loadCities(); void loadCities();
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
.then(setProviderOptions)
.catch(() => {});
}, [loadCities]); }, [loadCities]);
async function openEdit(row: Row) { async function openEdit(row: Row) {
@@ -102,23 +197,29 @@ export default function CityWarehousesPage() {
managerType: row.managerType, managerType: row.managerType,
partnerAccountId: row.partnerAccountId, partnerAccountId: row.partnerAccountId,
status: row.status, 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); setEditOpen(true);
} }
function onManagerTypeChange( function onManagerTypeChange(
type: WarehouseManagerType, v: WarehouseManagerType,
form: typeof createForm | typeof editForm, form: typeof createForm,
setType: (v: WarehouseManagerType) => void, setType: (t: WarehouseManagerType) => void,
) { ) {
setType(type); setType(v);
if (type !== WarehouseManagerType.PARTNER) { if (v !== WarehouseManagerType.PARTNER) {
form.setFieldValue('partnerAccountId', undefined); form.setFieldValue('partnerAccountId', undefined);
} }
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
{ {
title: '城市', title: '城市',
width: 100, width: 100,
@@ -128,17 +229,17 @@ export default function CityWarehousesPage() {
</span> </span>
), ),
}, },
{ title: '地址', dataIndex: 'address', ellipsis: true }, { title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 }, { title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 }, { title: '电话', dataIndex: 'contactPhone', width: 120 },
{ {
title: '管仓类型', title: '管仓',
dataIndex: 'managerType', width: 90,
width: 100, render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
}, },
{ {
title: '管仓合伙人', title: '合伙人',
dataIndex: 'partnerCompanyName', dataIndex: 'partnerCompanyName',
width: 120, width: 120,
ellipsis: true, ellipsis: true,
@@ -149,6 +250,16 @@ export default function CityWarehousesPage() {
'—' '—'
), ),
}, },
{
title: '自动发货',
width: 130,
render: (_, row) =>
row.fulfillmentMode === 'API_AUTO' ? (
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
) : (
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
),
},
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
@@ -190,21 +301,23 @@ export default function CityWarehousesPage() {
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
</Typography.Title>
<Button <Button
type="primary" type="primary"
onClick={() => { onClick={() => {
createForm.resetFields(); createForm.resetFields();
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
setCreateManagerType(WarehouseManagerType.HQ); setCreateManagerType(WarehouseManagerType.HQ);
setCreateCityId(undefined); setCreateCityId(undefined);
setPartners([]); setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
createForm.setFieldsValue({
managerType: WarehouseManagerType.HQ,
status: WarehouseStatus.ACTIVE,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
});
setCreateOpen(true); setCreateOpen(true);
}} }}
> >
</Button> </Button>
</Space> </Space>
@@ -212,7 +325,7 @@ export default function CityWarehousesPage() {
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。" message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
/> />
<Form <Form
@@ -225,7 +338,7 @@ export default function CityWarehousesPage() {
}} }}
> >
<Form.Item name="name" label="仓库名"> <Form.Item name="name" label="仓库名">
<Input allowClear placeholder="仓库名" /> <Input allowClear />
</Form.Item> </Form.Item>
<Form.Item name="cityId" label="城市"> <Form.Item name="cityId" label="城市">
<Select <Select
@@ -233,47 +346,33 @@ export default function CityWarehousesPage() {
showSearch showSearch
optionFilterProp="label" optionFilterProp="label"
style={{ width: 140 }} style={{ width: 140 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))} options={cities.map((c) => ({ value: c.id, label: c.name }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="managerType" label="管仓类型"> <Form.Item name="managerType" label="管仓">
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} /> <Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} /> <Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Space> <Button type="primary" htmlType="submit">
<Button type="primary" htmlType="submit">
</Button>
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item> </Form.Item>
</Form> </Form>
<Table <Table
rowKey="id" rowKey="id"
className="admin-table-nowrap"
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1100 }} scroll={{ x: 1400 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
total: data?.total ?? 0, total: data?.total ?? 0,
showSizeChanger: true, showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => { onChange: (p, ps) => {
setPage(p); setPage(p);
setPageSize(ps); setPageSize(ps);
@@ -282,7 +381,7 @@ export default function CityWarehousesPage() {
/> />
<Modal <Modal
title="新仓库" title="新仓库"
open={createOpen} open={createOpen}
width={520} width={520}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
@@ -341,6 +440,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}> <Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<FulfillmentFields
mode={createFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setCreateFulfillmentMode}
/>
</Form> </Form>
</Modal> </Modal>
@@ -399,6 +503,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select options={STATUS_OPTIONS} /> <Select options={STATUS_OPTIONS} />
</Form.Item> </Form.Item>
<FulfillmentFields
mode={editFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setEditFulfillmentMode}
/>
</Form> </Form>
</Modal> </Modal>
</div> </div>
+64 -12
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message, Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
} from 'antd'; } from 'antd';
@@ -51,8 +52,18 @@ export default function DashboardPage() {
request<DashboardStats>('/admin/dashboard/stats') request<DashboardStats>('/admin/dashboard/stats')
.then(setStats) .then(setStats)
.finally(() => setLoading(false)); .finally(() => setLoading(false));
void loadVersion(); request<HqProfile>('/admin/auth/me')
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {}); .then((p) => {
setProfile(p);
if (p.adminRole === 'SUPER_ADMIN') {
void loadVersion();
} else {
setVersionLoading(false);
}
})
.catch(() => {
setVersionLoading(false);
});
}, [loadVersion]); }, [loadVersion]);
function handleDeploy() { function handleDeploy() {
@@ -82,6 +93,7 @@ export default function DashboardPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
{isSuperAdmin ? (
<Card <Card
title="系统版本" title="系统版本"
loading={versionLoading} loading={versionLoading}
@@ -91,16 +103,14 @@ export default function DashboardPage() {
<Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}> <Button icon={<ReloadOutlined />} onClick={() => void loadVersion()}>
</Button> </Button>
{isSuperAdmin && ( <Button
<Button type="primary"
type="primary" icon={<CloudUploadOutlined />}
icon={<CloudUploadOutlined />} loading={deploying}
loading={deploying} onClick={handleDeploy}
onClick={handleDeploy} >
>
</Button>
</Button>
)}
</Space> </Space>
} }
> >
@@ -121,6 +131,7 @@ export default function DashboardPage() {
<Typography.Text type="secondary"></Typography.Text> <Typography.Text type="secondary"></Typography.Text>
)} )}
</Card> </Card>
) : null}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}> <Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
@@ -164,6 +175,47 @@ export default function DashboardPage() {
</Card> </Card>
</Col> </Col>
</Row> </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]}> <Row gutter={[16, 16]}>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<Card title="已合并访客账号" loading={loading}> <Card title="已合并访客账号" loading={loading}>
@@ -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>
);
}
+113 -34
View File
@@ -33,6 +33,15 @@ type AccountPermRes = {
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label])); 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({ function PermissionChecklist({
value, value,
onChange, onChange,
@@ -49,13 +58,44 @@ function PermissionChecklist({
disabled={disabled} disabled={disabled}
onChange={(checked) => onChange(checked as string[])} onChange={(checked) => onChange(checked as string[])}
> >
<Row gutter={[8, 8]}> {CATALOG_GROUPS.map((group) => {
{HQ_PERMISSION_CATALOG.map((item) => ( const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
<Col key={item.key} span={8}> const span = groupColSpan(items.length);
<Checkbox value={item.key}>{item.label}</Checkbox> const compact = items.length <= 3;
</Col> return (
))} <div
</Row> 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> </Checkbox.Group>
); );
} }
@@ -74,6 +114,12 @@ export default function HqPermissionsPage() {
const [accountLoading, setAccountLoading] = useState(false); const [accountLoading, setAccountLoading] = useState(false);
const [accountSaving, setAccountSaving] = 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( const previewEffectiveKeys = useMemo(
() => [...new Set([...roleInheritedKeys, ...accountKeys])], () => [...new Set([...roleInheritedKeys, ...accountKeys])],
[roleInheritedKeys, accountKeys], [roleInheritedKeys, accountKeys],
@@ -158,7 +204,9 @@ export default function HqPermissionsPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
= =
//
//
</Typography.Paragraph> </Typography.Paragraph>
<Tabs <Tabs
@@ -183,7 +231,11 @@ export default function HqPermissionsPage() {
</Form.Item> </Form.Item>
</Form> </Form>
{role === 'SUPER_ADMIN' ? ( {role === 'SUPER_ADMIN' ? (
<Alert type="info" showIcon message="超级管理员拥有全部权限,无需配置" /> <Alert
type="info"
showIcon
message="超级管理员基础权限固定(不含危险操作)。删除用户/订单/城市请到「按用户分配」为具体账号勾选。"
/>
) : ( ) : (
<> <>
<PermissionChecklist value={roleKeys} onChange={setRoleKeys} /> <PermissionChecklist value={roleKeys} onChange={setRoleKeys} />
@@ -212,12 +264,10 @@ export default function HqPermissionsPage() {
value={accountId} value={accountId}
onChange={setAccountId} onChange={setAccountId}
optionFilterProp="label" optionFilterProp="label"
options={accounts options={accounts.map((a) => ({
.filter((a) => a.adminRole !== 'SUPER_ADMIN') value: a.id,
.map((a) => ({ label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
value: a.id, }))}
label: `${a.name} · ${a.loginName || a.phone} · ${ROLE_LABELS[a.adminRole] || a.adminRole}`,
}))}
/> />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -225,28 +275,57 @@ export default function HqPermissionsPage() {
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" /> <Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
) : ( ) : (
<> <>
<Space wrap style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<span></span> <span style={{ marginRight: 8 }}></span>
{roleInheritedKeys.map((key) => { {selectedIsSuperAdmin ? (
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key); <Tag color="blue"></Tag>
return ( ) : (
<Tag key={key} color="blue"> <Space wrap size={[4, 4]}>
{item?.label || key} {roleInheritedKeys.map((key) => {
</Tag> const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
); return (
})} <Tag key={key} color="blue">
</Space> {item?.label || key}
</Tag>
);
})}
{!roleInheritedKeys.length ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
</Space>
)}
</div>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
//
</Typography.Paragraph> </Typography.Paragraph>
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} /> <PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
<Space wrap style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
<span></span> <span style={{ marginRight: 8 }}></span>
{previewEffectiveKeys.map((key) => { {selectedIsSuperAdmin ? (
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey)); <Space wrap size={[4, 4]}>
return <Tag key={key}>{item?.label || key}</Tag>; <Tag></Tag>
})} {accountKeys.map((key) => {
</Space> 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 }}> <div style={{ marginTop: 16 }}>
<Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}> <Button type="primary" loading={accountSaving} onClick={() => void saveAccountPermissions()}>
+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>
);
}
@@ -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
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) { function openAddSub(parent: AccountTreeRow) {
setSubParent(parent); setSubParent(parent);
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true); setSubOpen(true);
} }
+177 -32
View File
@@ -4,6 +4,7 @@ import {
Card, Card,
DatePicker, DatePicker,
Form, Form,
Input,
Modal, Modal,
Select, Select,
Space, Space,
@@ -29,6 +30,7 @@ type Row = {
status: string; status: string;
periodStart: string; periodStart: string;
periodEnd: string; periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string }; partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string }; partnerAccount?: { companyName?: string; phone?: string };
}; };
@@ -36,15 +38,19 @@ type Row = {
type PartnerOption = { id: string; companyName: string; phone?: string }; type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿', PENDING_REVIEW: '待审核',
CONFIRMED: '确认', AWAITING_CONFIRM: '待合伙人确认',
UNPAID: '未打款',
PAID: '已打款', PAID: '已打款',
REJECTED: '已驳回',
}; };
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default', PENDING_REVIEW: 'gold',
CONFIRMED: 'blue', AWAITING_CONFIRM: 'blue',
UNPAID: 'red',
PAID: 'green', PAID: 'green',
REJECTED: 'red',
}; };
export default function PartnerBillsPage() { export default function PartnerBillsPage() {
@@ -66,6 +72,11 @@ export default function PartnerBillsPage() {
const [genOpen, setGenOpen] = useState(false); const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm(); const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false); 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(() => { useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -73,10 +84,10 @@ export default function PartnerBillsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) { async function generateBill(values: { partnerId?: string; month: Dayjs }) {
const year = values.month.year(); const year = values.month.year();
const month = values.month.month() + 1; const month = values.month.month() + 1;
if (values.all || !values.partnerId) { if (!values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>( const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all', '/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) }, { method: 'POST', body: JSON.stringify({ year, month }) },
@@ -87,25 +98,88 @@ export default function PartnerBillsPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }), body: JSON.stringify({ partnerId: values.partnerId, year, month }),
}); });
message.success('账单已生成'); message.success('账单已生成(待审核)');
} }
setGenOpen(false); setGenOpen(false);
reload(); reload();
} }
async function confirmBill(id: string) { function sendBills(ids: string[]) {
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' }); Modal.confirm({
message.success('已确认'); title: '发送给合伙人?',
reload(); 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();
},
});
} }
async function markPaid(id: string) { function markPaid(ids: string[], amountHint?: number) {
await request(`/admin/partner-bills/${id}/mark-paid`, { Modal.confirm({
method: 'POST', title: '确认打款?',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }), 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();
},
}); });
message.success('已标记打款'); }
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() { async function exportExcel() {
@@ -127,6 +201,10 @@ export default function PartnerBillsPage() {
const summary = data?.summary; const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; 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> = [ const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, { title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
@@ -163,23 +241,42 @@ export default function PartnerBillsPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 90, width: 130,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, 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: '操作', title: '操作',
width: 160, width: 220,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0} wrap>
{row.status === 'DRAFT' && ( {(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}> <Button type="link" size="small" onClick={() => sendBills([row.id])}>
</Button> </Button>
)} )}
{row.status === 'CONFIRMED' && ( {row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => void markPaid(row.id)}> <>
<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> </Button>
)} )}
</Space> </Space>
@@ -194,7 +291,7 @@ export default function PartnerBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
T+30 × × 1 8:00
</Typography.Text> </Typography.Text>
</Space> </Space>
@@ -242,7 +339,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 150 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
@@ -272,6 +369,20 @@ export default function PartnerBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </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> </Form>
<Table <Table
@@ -280,7 +391,11 @@ export default function PartnerBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
}}
scroll={{ x: 1280 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -293,7 +408,7 @@ export default function PartnerBillsPage() {
}} }}
/> />
<Modal title="生成合伙人账单T+30" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}> <Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form <Form
form={genForm} form={genForm}
layout="vertical" layout="vertical"
@@ -316,7 +431,37 @@ export default function PartnerBillsPage() {
/> />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" block> <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> </Button>
</Form> </Form>
</Modal> </Modal>
+80 -37
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
Button, Button,
Cascader,
Drawer, Drawer,
Form, Form,
Input, Input,
@@ -24,9 +23,10 @@ import {
type PartnerPermissionKey, type PartnerPermissionKey,
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region'; import { districtCodeLabel, formatDistrictLabels } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants'; import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityDistrictMultiSelect from '../components/CityDistrictMultiSelect';
type Row = { type Row = {
id: string; id: string;
@@ -37,6 +37,7 @@ type Row = {
cityId?: string | null; cityId?: string | null;
cityName?: string | null; cityName?: string | null;
scopeType?: string; scopeType?: string;
districtCodes?: string[] | null;
orderCommissionRate?: number; orderCommissionRate?: number;
redeemCommissionRate?: number; redeemCommissionRate?: number;
storeCount: number; storeCount: number;
@@ -102,6 +103,18 @@ export default function PartnersPage() {
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE); const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>(); 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 loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items); setCities(res.items);
@@ -142,22 +155,35 @@ export default function PartnersPage() {
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); try {
const body = { const v = await editForm.validateFields();
...v, const body = {
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, ...v,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, 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('已保存'); await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
setDrawerOpen(false); message.success('已保存');
void reload(); setDrawerOpen(false);
void reload();
} catch (e) {
const msg = formatApiError(e);
if (msg) message.error(msg);
}
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 100 }, { 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: 'phone', width: 130 },
{ {
title: '管辖', title: '管辖',
@@ -222,17 +248,21 @@ export default function PartnersPage() {
label: '基本信息', label: '基本信息',
children: ( children: (
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" 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="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item> </Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && ( {editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县"> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={editCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
@@ -286,39 +316,52 @@ export default function PartnersPage() {
width={560} width={560}
onCancel={() => setCreateOpen(false)} onCancel={() => setCreateOpen(false)}
onOk={async () => { onOk={async () => {
const v = await createForm.validateFields(); try {
await request('/admin/partners', { const v = await createForm.validateFields();
method: 'POST', await request('/admin/partners', {
body: JSON.stringify({ method: 'POST',
...v, body: JSON.stringify({
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100, ...v,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100, orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined, redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
}), districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}); }),
message.success('已创建'); });
setCreateOpen(false); message.success('已创建');
createForm.resetFields(); setCreateOpen(false);
void reload(); 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 form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}> <Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select <Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))} options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }} onChange={(id) => {
setCreateCityId(id);
createForm.setFieldsValue({ districtCodes: undefined });
void loadWarehouses(id);
}}
/> />
</Form.Item> </Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item> <Form.Item name="address" label="地址"><Input placeholder="选填" /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}> <Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} /> <Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item> </Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && ( {createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}> <Form.Item
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect /> name="districtCodes"
label="区县"
extra="选填;仅作标识,可多选当前城市下的区县(不做互斥)"
>
<CityDistrictMultiSelect cityCode={createCityCode} />
</Form.Item> </Form.Item>
)} )}
<Space style={{ width: '100%' }} size="large"> <Space style={{ width: '100%' }} size="large">
+17 -3
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Popconfirm, Select, Space,
Table, Tabs, Tag, Typography, message, Switch, Table, Tabs, Tag, Typography, message,
} from 'antd'; } from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'; import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
@@ -31,6 +31,7 @@ type Row = {
benefitAmount: number; benefitAmount: number;
status: string; status: string;
sortOrder: number; sortOrder: number;
allowOnSitePickup?: boolean;
mainImageUrl?: string | null; mainImageUrl?: string | null;
carouselUrls?: string[]; carouselUrls?: string[];
detailImageUrls?: string[]; detailImageUrls?: string[];
@@ -49,6 +50,7 @@ type ProductFormValues = {
benefitAmount?: number; benefitAmount?: number;
status?: string; status?: string;
sortOrder?: number; sortOrder?: number;
allowOnSitePickup?: boolean;
coverUrl?: string; coverUrl?: string;
carouselUrls?: string[]; carouselUrls?: string[];
detailImageUrls?: string[]; detailImageUrls?: string[];
@@ -100,6 +102,7 @@ function buildProductPayload(v: ProductFormValues) {
benefitAmount: v.benefitAmount, benefitAmount: v.benefitAmount,
status: v.status, status: v.status,
sortOrder: v.sortOrder, sortOrder: v.sortOrder,
allowOnSitePickup: !!v.allowOnSitePickup,
coverUrl: v.coverUrl, coverUrl: v.coverUrl,
carouselUrls, carouselUrls,
detailImageUrls, detailImageUrls,
@@ -217,6 +220,9 @@ function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
<Form.Item name="sortOrder" label="排序"> <Form.Item name="sortOrder" label="排序">
<InputNumber min={0} style={{ width: '100%' }} /> <InputNumber min={0} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="allowOnSitePickup" label="允许现场取货" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item name="coverUrl" label="封面"> <Form.Item name="coverUrl" label="封面">
<OssUpload bizType="COVER" mediaType="IMAGE" /> <OssUpload bizType="COVER" mediaType="IMAGE" />
</Form.Item> </Form.Item>
@@ -265,7 +271,15 @@ export default function ProductsPage() {
{ title: '规格', dataIndex: 'spec', width: 120, ellipsis: true }, { title: '规格', dataIndex: 'spec', width: 120, ellipsis: true },
{ title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` }, { title: '售价', dataIndex: 'price', width: 80, render: (v) => `¥${v}` },
{ title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` }, { title: '权益额', dataIndex: 'benefitAmount', width: 80, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{PRODUCT_STATUS_LABELS[s] || s}</Tag> }, { 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: 'sortOrder', width: 60 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
@@ -357,7 +371,7 @@ export default function ProductsPage() {
void reload(); void reload();
}} width={720}> }} width={720}>
<Form form={createForm} layout="vertical" initialValues={{ <Form form={createForm} layout="vertical" initialValues={{
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0, allowOnSitePickup: false,
carouselUrls: [''], detailImageUrls: [''], carouselUrls: [''], detailImageUrls: [''],
features: [{ icon: 'water_drop', title: '', desc: '' }], features: [{ icon: 'water_drop', title: '', desc: '' }],
}}> }}>
+244 -236
View File
@@ -1,236 +1,244 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom'; import {
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message,
import { } from 'antd';
import type { ColumnsType } from 'antd/es/table';
Button, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, import {
PROMO_CODE_SCENE_LABELS,
} from 'antd'; PROMO_CODE_STATUS_LABELS,
promoConversion,
import type { ColumnsType } from 'antd/es/table'; type PromoCodeItem,
type PromoCodeScene,
import { } from '@dukang/shared-types';
import { request } from '../lib/api';
PROMO_CODE_SCENE_LABELS, import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
PROMO_CODE_STATUS_LABELS,
type Row = PromoCodeItem;
promoConversion,
type SceneOption = { value: PromoCodeScene; label: string };
type PromoCodeItem,
async function downloadQrcode(url: string, filename: string) {
type PromoCodeScene, try {
const res = await fetch(url);
} from '@dukang/shared-types'; const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
import { request } from '../lib/api'; const a = document.createElement('a');
a.href = objectUrl;
import { fmtTime } from '../lib/constants'; a.download = filename;
a.click();
import { useAdminList } from '../lib/useAdminList'; URL.revokeObjectURL(objectUrl);
} catch {
window.open(url, '_blank');
}
type Row = PromoCodeItem; }
export default function PromoCodesPage() {
const navigate = useNavigate();
type SceneOption = { value: PromoCodeScene; label: string }; const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [scenes, setScenes] = useState<SceneOption[]>(
async function downloadQrcode(url: string, filename: string) { Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
value: value as PromoCodeScene,
try { label,
})),
const res = await fetch(url); );
const [createOpen, setCreateOpen] = useState(false);
const blob = await res.blob(); const [creating, setCreating] = useState(false);
const objectUrl = URL.createObjectURL(blob); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/promo-codes',
const a = document.createElement('a'); () => {
const qs = new URLSearchParams();
a.href = objectUrl; if (filters.name) qs.set('name', filters.name);
if (filters.code) qs.set('code', filters.code);
a.download = filename; if (filters.status) qs.set('status', filters.status);
if (filters.scene) qs.set('scene', filters.scene);
a.click(); return qs;
},
URL.revokeObjectURL(objectUrl); [filters],
);
} catch {
async function loadScenes() {
window.open(url, '_blank'); try {
const list = await request<SceneOption[]>('/admin/promo-codes/scenes');
} if (list.length) setScenes(list);
} catch {
} /* 使用本地默认场景 */
}
}
export default function PromoCodesPage() { useEffect(() => {
void loadScenes();
const navigate = useNavigate(); }, []);
const [filterForm] = Form.useForm(); const columns: ColumnsType<Row> = [
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true },
const [createForm] = Form.useForm(); { title: '码值', dataIndex: 'code', width: 110 },
{
const [filters, setFilters] = useState<Record<string, string>>({}); title: '场景',
dataIndex: 'scene',
const [scenes, setScenes] = useState<SceneOption[]>( width: 110,
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s,
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ },
{
value: value as PromoCodeScene, title: '状态',
dataIndex: 'status',
label, width: 90,
render: (s) => (
})), <Tag color={s === 'ACTIVE' ? 'green' : 'default'}>
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s}
); </Tag>
),
const [createOpen, setCreateOpen] = useState(false); },
{ title: '扫码', dataIndex: 'scanCount', width: 70 },
const [creating, setCreating] = useState(false); { title: '订单', dataIndex: 'orderCount', width: 70 },
{
title: '转化率',
width: 90,
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( render: (_, row) => promoConversion(row.scanCount, row.orderCount),
},
'/admin/promo-codes', {
title: '渠道负责人',
() => { width: 120,
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
const qs = new URLSearchParams(); },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
if (filters.name) qs.set('name', filters.name); {
title: '操作',
if (filters.code) qs.set('code', filters.code); width: 220,
fixed: 'right',
if (filters.status) qs.set('status', filters.status); render: (_, row) => (
<Space size="small" wrap>
if (filters.scene) qs.set('scene', filters.scene); <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}>
return qs; </Button>
<Button
}, type="link"
size="small"
[filters], disabled={!row.qrcodeUrl}
onClick={() => {
); if (!row.qrcodeUrl) return;
void downloadQrcode(row.qrcodeUrl, `${row.code}-wxacode.png`);
}}
>
async function loadScenes() {
</Button>
try { <Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}/users`)}>
const list = await request<SceneOption[]>('/admin/promo-codes/scenes'); </Button>
</Space>
if (list.length) setScenes(list); ),
},
} catch { ];
/* 使用本地默认场景 */ 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,
useEffect(() => { scene: values.scene,
ownerUserId: values.ownerUserId?.trim() || undefined,
void loadScenes(); remark: values.remark?.trim() || undefined,
page: values.page?.trim() || undefined,
}, []); }),
});
message.success('推广码已创建');
setCreateOpen(false);
const columns: ColumnsType<Row> = [ createForm.resetFields();
void reload();
{ title: '名称', dataIndex: 'name', width: 160, ellipsis: true }, navigate(`/promo-codes/${created.id}`);
} catch (e) {
{ title: '码值', dataIndex: 'code', width: 110 }, message.error(e instanceof Error ? e.message : '创建失败');
} finally {
{ setCreating(false);
}
title: '场景', }
dataIndex: 'scene', return (
<div>
width: 110, <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>广</Typography.Title>
render: (s: PromoCodeScene) => PROMO_CODE_SCENE_LABELS[s] || s, <Button type="primary" onClick={() => setCreateOpen(true)}>广</Button>
</div>
},
<Form
{ form={filterForm}
layout="inline"
title: '状态', style={{ marginBottom: 16 }}
onFinish={(v) => { setFilters(v); setPage(1); }}
dataIndex: 'status', >
<Form.Item name="name" label="名称"><Input allowClear /></Form.Item>
width: 90, <Form.Item name="code" label="码值"><Input allowClear /></Form.Item>
<Form.Item name="scene" label="场景">
render: (s) => ( <Select allowClear style={{ width: 130 }} options={scenes} />
</Form.Item>
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}> <Form.Item name="status" label="状态">
<Select
{PROMO_CODE_STATUS_LABELS[s as keyof typeof PROMO_CODE_STATUS_LABELS] || s} allowClear
style={{ width: 100 }}
</Tag> 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>
{ title: '扫码', dataIndex: 'scanCount', width: 70 }, <Table
rowKey="id"
{ title: '订单', dataIndex: 'orderCount', width: 70 }, className="admin-table-nowrap"
loading={loading}
{ columns={columns}
dataSource={data?.items ?? []}
title: '转化率', scroll={{ x: 1200 }}
pagination={{
width: 90, current: page,
pageSize,
render: (_, row) => promoConversion(row.scanCount, row.orderCount), total: data?.total ?? 0,
showSizeChanger: true,
}, onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
{ />
title: '渠道负责人', <Modal
title="创建推广码"
width: 120, open={createOpen}
onCancel={() => setCreateOpen(false)}
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—', footer={null}
destroyOnClose
}, >
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ scene: 'ONLINE_LINK' }}>
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, <Form.Item name="name" label="推广码名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:郑州品鉴会、门店地推" />
{ </Form.Item>
<Form.Item name="scene" label="场景" rules={[{ required: true }]}>
title: '操作', <Select options={scenes} />
</Form.Item>
width: 220, <Form.Item name="code" label="自定义码值(选填)">
<Input placeholder="留空自动生成,如 DKDEMO1" />
fixed: 'right', </Form.Item>
<Form.Item
render: (_, row) => ( name="page"
label="小程序落地页(选填)"
<Space size="small" wrap> extra="如 pages/home/index;留空则使用服务端环境变量 WX_MINI_PROMO_PAGE"
>
<Button type="link" size="small" onClick={() => navigate(`/promo-codes/${row.id}`)}> <Input placeholder="pages/home/index" />
</Form.Item>
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
<Input placeholder="渠道负责人,填写用户数据库 ID" />
</Button> </Form.Item>
<Form.Item name="remark" label="备注">
<Button <Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
</Button>
</Form>
</Modal>
</div>
);
}
+45 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tag, Typography, message, Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
@@ -45,12 +45,33 @@ export default function StoreAccountsPage() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [stores, setStores] = useState<StoreOption[]>([]); const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
async function loadStores() { async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items); 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> = [ const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 }, { title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 }, { title: '手机', dataIndex: 'phone', width: 120 },
@@ -218,10 +239,32 @@ export default function StoreAccountsPage() {
dataIndex: 'status', dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>, 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>
),
},
]} ]}
/> />
</> </>
) : null} ) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
</> </>
)} )}
</Drawer> </Drawer>
+134 -92
View File
@@ -6,6 +6,7 @@ import {
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
Modal,
Select, Select,
Space, Space,
Statistic, Statistic,
@@ -23,26 +24,26 @@ import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; id: string;
billNo: string;
billDate: string;
redeemCount: number;
redeemAmount: number; redeemAmount: number;
payoutAmount: number;
settlementRate: number; settlementRate: number;
payoutAmount: number;
status: string; status: string;
expectedPayAt: string; paidAt?: string | null;
paidAt?: string;
createdAt: string;
store?: { id: string; name: string; cityName: string; phone?: string }; store?: { id: string; name: string; cityName: string; phone?: string };
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
}; };
type StoreOption = { id: string; name: string; phone: string }; type StoreOption = { id: string; name: string; phone: string };
const PAYOUT_STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
PENDING: '打款', UNPAID: '打款',
PAID: '已打款', PAID: '已打款',
}; };
const PAYOUT_STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
PENDING: 'orange', UNPAID: 'red',
PAID: 'green', PAID: 'green',
}; };
@@ -51,7 +52,7 @@ export default function StoreBillsPage() {
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const [stores, setStores] = useState<StoreOption[]>([]); const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-payouts', '/admin/store-bills',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
@@ -65,6 +66,8 @@ export default function StoreBillsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -72,13 +75,37 @@ export default function StoreBillsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function confirmPayout(id: string) { function confirmPay(ids: string[], amountHint?: number) {
await request(`/admin/store-payouts/${id}/confirm`, { Modal.confirm({
method: 'POST', title: '确认打款?',
body: JSON.stringify({ remark: '财务确认打款' }), 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();
},
}); });
message.success('已确认打款'); }
reload();
async function openDetail(id: string) {
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
} }
async function exportExcel() { async function exportExcel() {
@@ -89,10 +116,8 @@ export default function StoreBillsPage() {
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo); if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>( const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
`/admin/store-payouts/export?${qs}`, downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
);
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`); message.success(`已导出 ${result.count}`);
} finally { } finally {
setExporting(false); setExporting(false);
@@ -100,62 +125,56 @@ export default function StoreBillsPage() {
} }
const summary = data?.summary; 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> = [ const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{ {
title: '核销日期', title: '账单日',
dataIndex: 'billDate',
width: 110, width: 110,
render: (_, r) => render: (v) => String(v || '').slice(0, 10),
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
}, },
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true }, { title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, { title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
{ {
title: '核销单号', title: '核销金额',
dataIndex: ['redeemRecord', 'redeemNo'], dataIndex: 'redeemAmount',
width: 160, width: 110,
ellipsis: true, render: (v) => `¥${Number(v).toFixed(2)}`,
render: (v) => v || '—',
}, },
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{ {
title: '核销比例', title: '结算比例',
dataIndex: 'settlementRate', dataIndex: 'settlementRate',
width: 90, width: 90,
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'), render: (v) => `${Math.round(Number(v) * 100)}%`,
}, },
{ {
title: '应付门店', title: '应付金额',
dataIndex: 'payoutAmount', dataIndex: 'payoutAmount',
width: 100, width: 110,
render: (v) => `¥${v}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '打款状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 100, width: 90,
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
}, },
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 140, width: 160,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button <Button type="link" size="small" onClick={() => void openDetail(row.id)}>
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-payouts/${row.id}`));
setDrawerOpen(true);
}}
>
</Button> </Button>
{row.status === 'PENDING' && ( {row.status === 'UNPAID' && (
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}> <Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
</Button> </Button>
)} )}
@@ -168,19 +187,19 @@ export default function StoreBillsPage() {
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
T+1 = × 8:00 绿
</Typography.Text> </Typography.Text>
</Space> </Space>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap> <Space size="large" wrap>
<Statistic title="明细笔数" value={summary.count} /> <Statistic title="账单数" value={summary.count} />
<Statistic title="核销额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="核销额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} /> <Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -189,16 +208,12 @@ export default function StoreBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v: { onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
storeId?: string;
status?: string;
dateRange?: [Dayjs, Dayjs];
}) => {
setFilters({ setFilters({
storeId: v.storeId || '', storeId: v.storeId || '',
status: v.status || '', status: v.status || '',
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '', dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '', dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
}); });
setPage(1); setPage(1);
}} }}
@@ -210,21 +225,17 @@ export default function StoreBillsPage() {
placeholder="全部门店" placeholder="全部门店"
style={{ width: 200 }} style={{ width: 200 }}
optionFilterProp="label" optionFilterProp="label"
options={stores.map((s) => ({ options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
value: s.id,
label: `${s.name}${s.phone}`,
}))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="status" label="打款状态"> <Form.Item name="status" label="状态">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 120 }}
placeholder="全部" options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="dateRange" label="核销日期"> <Form.Item name="range" label="账单日">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -248,6 +259,16 @@ export default function StoreBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
loading={batchLoading}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
>
({selectedKeys.length})
</Button>
</Form.Item>
</Form> </Form>
<Table <Table
@@ -256,7 +277,12 @@ export default function StoreBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1300 }} rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1200 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -269,28 +295,44 @@ export default function StoreBillsPage() {
}} }}
/> />
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}> <Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
{detail && ( {detail && (
<Descriptions column={1} bordered size="small"> <>
<Descriptions.Item label="门店"> <Descriptions column={1} size="small" bordered>
{String((detail.store as { name?: string })?.name ?? '—')} <Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="核销单号"> <Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')} <Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="打款时间">
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item> {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="结算比例"> </Descriptions>
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'} <Typography.Title level={5} style={{ marginTop: 16 }}>
</Descriptions.Item>
<Descriptions.Item label="状态"> </Typography.Title>
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)} <Table
</Descriptions.Item> size="small"
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item> rowKey="id"
<Descriptions.Item label="实际打款"> pagination={false}
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
</Descriptions.Item> columns={[
</Descriptions> {
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> </Drawer>
</div> </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>
);
}
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>
);
}
+130 -294
View File
@@ -1,573 +1,409 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import { import {
Alert, Alert,
Button, Button,
Card, Card,
Collapse, Collapse,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Modal,
Space, Space,
Switch, Switch,
Table, Table,
Tag, Tag,
Typography, Typography,
message, message,
} from 'antd'; } from 'antd';
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types'; import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
import { request, type HqProfile } from '../lib/api';
import { request } from '../lib/api'; import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
const { TextArea } = Input; const { TextArea } = Input;
function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) { function MockSmsCodePanel({ codes, loading }: { codes: MockSmsCodeItem[]; loading?: boolean }) {
return ( return (
<div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}> <div style={{ marginTop: -8, marginBottom: 16, marginLeft: 0 }}>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}> <Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
Mock 50 Mock 50
</Typography.Text> </Typography.Text>
<Table<MockSmsCodeItem> <Table<MockSmsCodeItem>
size="small" size="small"
rowKey="id" rowKey="id"
loading={loading} loading={loading}
pagination={false} pagination={false}
scroll={{ y: 240 }} scroll={{ y: 240 }}
locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }} locale={{ emptyText: '暂无记录,触发短信发送后将显示在此' }}
columns={[ columns={[
{ {
title: '时间', title: '时间',
dataIndex: 'createdAt', dataIndex: 'createdAt',
width: 168, width: 168,
render: (v: string) => new Date(v).toLocaleString(), render: (v: string) => new Date(v).toLocaleString(),
}, },
{ title: '手机号', dataIndex: 'phone', width: 120 }, { title: '手机号', dataIndex: 'phone', width: 120 },
{ title: '场景', dataIndex: 'scene', width: 160 }, { title: '场景', dataIndex: 'scene', width: 160 },
{ {
title: '验证码', title: '验证码',
dataIndex: 'code', dataIndex: 'code',
width: 88, width: 88,
render: (code: string) => ( render: (code: string) => (
<Typography.Text copyable strong> <Typography.Text copyable strong>
{code} {code}
</Typography.Text> </Typography.Text>
), ),
}, },
]} ]}
dataSource={codes} dataSource={codes}
/> />
</div> </div>
); );
} }
function renderField( function renderField(
field: SystemConfigFieldMeta, field: SystemConfigFieldMeta,
configuredSecrets: string[], configuredSecrets: string[],
extra?: ReactNode, extra?: ReactNode,
) { ) {
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key); const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
if (field.type === 'boolean') { if (field.type === 'boolean') {
return ( return (
<div key={field.key}> <div key={field.key}>
<Form.Item <Form.Item
name={field.key} name={field.key}
label={ label={
<Space size={4}> <Space size={4}>
<span>{field.label}</span> <span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}> <Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key} {field.key}
</Typography.Text> </Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>} {field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
</Space> </Space>
} }
tooltip={field.description} tooltip={field.description}
valuePropName="checked" valuePropName="checked"
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')} getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })} getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
> >
<Switch /> <Switch />
</Form.Item> </Form.Item>
{extra} {extra}
</div> </div>
); );
} }
if (field.type === 'image' || field.type === 'imageList') {
return (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={4} wrap>
<span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key}
</Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
</Space>
}
tooltip={field.description}
trigger="onChange"
getValueFromEvent={(v: unknown) => (typeof v === 'string' ? v : '')}
normalize={(v) => (typeof v === 'string' ? v : '')}
>
{field.type === 'image' ? (
<ConfigImageField bizType="footer" />
) : (
<ConfigImageListField bizType="swiper" />
)}
</Form.Item>
);
}
const input = const input =
field.type === 'textarea' ? ( field.type === 'textarea' ? (
<TextArea rows={3} placeholder={field.placeholder} /> <TextArea rows={3} placeholder={field.placeholder} />
) : field.type === 'number' ? ( ) : field.type === 'number' ? (
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} /> <InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
) : field.secret ? ( ) : field.secret ? (
<Input.Password <Input.Password
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder} placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
autoComplete="new-password" autoComplete="new-password"
/> />
) : ( ) : (
<Input placeholder={field.placeholder} /> <Input placeholder={field.placeholder} />
); );
return ( return (
<Form.Item <Form.Item
key={field.key} key={field.key}
name={field.key} name={field.key}
label={ label={
<Space size={4} wrap> <Space size={4} wrap>
<span>{field.label}</span> <span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}> <Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key} {field.key}
</Typography.Text> </Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>} {field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
{isConfiguredSecret ? <Tag></Tag> : null} {isConfiguredSecret ? <Tag></Tag> : null}
</Space> </Space>
} }
tooltip={field.description} tooltip={field.description}
> >
{input} {input}
</Form.Item> </Form.Item>
); );
} }
export default function SystemSettingsPage() { export default function SystemSettingsPage() {
const navigate = useNavigate();
const [form] = Form.useForm<Record<string, string>>(); const [form] = Form.useForm<Record<string, string>>();
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null); const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [dirty, setDirty] = useState(false);
const dirtyRef = useRef(false);
const bypassLeaveRef = useRef(false);
const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true'; const mockSmsEnabled = Form.useWatch('MOCK_SMS', form) === 'true';
dirtyRef.current = dirty;
async function load(silent = false) { async function load(silent = false) {
if (!silent) setLoading(true); if (!silent) setLoading(true);
try { try {
const data = await request<SystemConfigFormResponse>('/admin/system-config'); const data = await request<SystemConfigFormResponse>('/admin/system-config');
if (silent) {
setMeta((prev) =>
prev
? { ...prev, mockSmsCodes: data.mockSmsCodes, updatedAt: data.updatedAt }
: data,
);
return;
}
setMeta(data); setMeta(data);
form.setFieldsValue(data.values); form.setFieldsValue(data.values);
setDirty(false);
} catch (e) { } catch (e) {
if (!silent) message.error(e instanceof Error ? e.message : '加载失败'); if (!silent) message.error(e instanceof Error ? e.message : '加载失败');
} finally { } finally {
if (!silent) setLoading(false); if (!silent) setLoading(false);
} }
} }
useEffect(() => { useEffect(() => {
void load(); void load();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!mockSmsEnabled) return; if (!mockSmsEnabled) return;
const timer = window.setInterval(() => void load(true), 5000); const timer = window.setInterval(() => void load(true), 5000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [mockSmsEnabled]); }, [mockSmsEnabled]);
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (!dirtyRef.current) return;
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, []);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (!dirtyRef.current || bypassLeaveRef.current) return;
const target = e.target as HTMLElement | null;
const anchor = target?.closest?.('a');
if (!anchor || !(anchor instanceof HTMLAnchorElement)) return;
if (anchor.target === '_blank' || anchor.hasAttribute('download')) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin) return;
if (url.pathname === window.location.pathname && url.search === window.location.search) return;
e.preventDefault();
e.stopPropagation();
Modal.confirm({
title: '有未保存的更改',
content: '离开前请先保存,否则更改将丢失。',
okText: '仍要离开',
cancelText: '留下',
onOk: () => {
bypassLeaveRef.current = true;
setDirty(false);
navigate(`${url.pathname}${url.search}${url.hash}`);
window.setTimeout(() => {
bypassLeaveRef.current = false;
}, 0);
},
});
};
document.addEventListener('click', onDocClick, true);
return () => document.removeEventListener('click', onDocClick, true);
}, [navigate]);
const collapseItems = useMemo(() => { const collapseItems = useMemo(() => {
if (!meta) return []; if (!meta) return [];
return meta.groups.map((group) => ({ return meta.groups.map((group) => ({
key: group.key, key: group.key,
label: group.label, label: group.label,
forceRender: true,
children: ( children: (
<div style={{ maxWidth: 720 }}> <div style={{ maxWidth: 720 }}>
{meta.fields {meta.fields
.filter((f) => f.group === group.key) .filter((f) => f.group === group.key)
.map((f) => .map((f) =>
renderField( renderField(
f, f,
meta.configuredSecrets, meta.configuredSecrets,
f.key === 'MOCK_SMS' && mockSmsEnabled ? ( f.key === 'MOCK_SMS' && mockSmsEnabled ? (
<MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} /> <MockSmsCodePanel codes={meta.mockSmsCodes ?? []} loading={loading} />
) : undefined, ) : undefined,
), ),
)} )}
</div> </div>
), ),
})); }));
}, [meta, mockSmsEnabled, loading]); }, [meta, mockSmsEnabled, loading]);
async function onSave() { async function onSave() {
await form.validateFields();
const values = await form.validateFields(); const values = form.getFieldsValue(true);
const payload: Record<string, string> = {}; const payload: Record<string, string> = {};
for (const [k, v] of Object.entries(values)) { for (const [k, v] of Object.entries(values)) {
payload[k] = v === undefined || v === null ? '' : String(v); payload[k] = v === undefined || v === null ? '' : String(v);
} }
setSaving(true); setSaving(true);
try { try {
const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>( const res = await request<{ updatedKeys: string[]; requiresRestartKeys: string[] }>(
'/admin/system-config', '/admin/system-config',
{ method: 'PUT', body: JSON.stringify({ values: payload }) }, { method: 'PUT', body: JSON.stringify({ values: payload }) },
); );
message.success(`已保存 ${res.updatedKeys.length}`); message.success(`已保存 ${res.updatedKeys.length}`);
if (res.requiresRestartKeys.length) { if (res.requiresRestartKeys.length) {
message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`); message.warning(`以下配置需重启 API 后生效:${res.requiresRestartKeys.join(', ')}`);
} }
setDirty(false);
await load(); await load();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '保存失败'); message.error(e instanceof Error ? e.message : '保存失败');
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
async function onSyncEnv() { async function onSyncEnv() {
setSyncing(true); setSyncing(true);
try { try {
const res = await request<{ message: string; envFilePath: string }>( const res = await request<{ message: string; envFilePath: string }>(
'/admin/system-config/sync-env', '/admin/system-config/sync-env',
{ method: 'POST' }, { method: 'POST' },
); );
message.success(res.message || '已同步到 env 文件'); message.success(res.message || '已同步到 env 文件');
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '同步失败'); message.error(e instanceof Error ? e.message : '同步失败');
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
} }
async function onImportEnv() { async function onImportEnv() {
try { try {
const res = await request<{ imported: number }>('/admin/system-config/import-env', { const res = await request<{ imported: number }>('/admin/system-config/import-env', {
method: 'POST', method: 'POST',
}); });
message.success(`已从当前进程环境导入 ${res.imported}`); message.success(`已从当前进程环境导入 ${res.imported}`);
await load(); await load();
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '导入失败'); message.error(e instanceof Error ? e.message : '导入失败');
} }
} }
return ( return (
<div style={{ paddingBottom: 88 }}>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 0, marginTop: 8 }}>
<code>system_config</code> {' '} <code>system_config</code> {' '}
<code>{meta?.envFilePath ?? '.env'}</code> 便 <code>{meta?.envFilePath ?? '.env'}</code> 便
</Typography.Paragraph> </Typography.Paragraph>
</div> </div>
<Space> <Space>
{profile?.adminRole === 'SUPER_ADMIN' ? (
<Button onClick={() => void onImportEnv()}></Button> <>
<Button onClick={() => void onImportEnv()}></Button>
<Button loading={syncing} onClick={() => void onSyncEnv()}> <Button loading={syncing} onClick={() => void onSyncEnv()}>
env
env </Button>
</>
</Button> ) : null}
<Button type="primary" loading={saving} onClick={() => void onSave()}>
</Button>
</Space> </Space>
</div> </div>
<Alert <Alert
type="info" type="info"
showIcon showIcon
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
message="生效说明" message="生效说明"
description={ description={
<ul style={{ margin: '8px 0 0', paddingLeft: 20 }}> <ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
<li> <li>
<Tag color="green"></Tag> <code>process.env</code>Mock <Tag color="green"></Tag> <code>process.env</code>Mock
</li> </li>
<li> <li>
<Tag color="orange"></Tag>/OSS <strong> API </strong> <Tag color="orange"></Tag>/OSS <strong> API </strong>
</li> </li>
<li>OSS </li> <li>OSS </li>
<li></li>
</ul> </ul>
} }
/> />
<Card loading={loading}> <Card loading={loading}>
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
<Form form={form} layout="vertical"> <Collapse defaultActiveKey={[]} items={collapseItems} />
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
</Form> </Form>
{meta?.updatedAt ? ( {meta?.updatedAt ? (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}> <Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
{new Date(meta.updatedAt).toLocaleString()} {new Date(meta.updatedAt).toLocaleString()}
</Typography.Text> </Typography.Text>
) : null} ) : null}
</Card> </Card>
<div
style={{
position: 'fixed',
right: 32,
bottom: 32,
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 8,
}}
>
{dirty ? <Tag color="orange"></Tag> : null}
<Button
type="primary"
size="large"
loading={saving}
onClick={() => void onSave()}
style={{
minWidth: 120,
boxShadow: '0 6px 16px rgba(0,0,0,0.18)',
}}
>
</Button>
</div>
</div> </div>
); );
} }
+198 -29
View File
@@ -1,6 +1,20 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd'; import {
Button,
Descriptions,
Drawer,
Form,
Image,
Input,
Modal,
Select,
Space,
Table,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -8,11 +22,12 @@ import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; id: string;
ticketNo: string; ticketNo: string;
ticketType: string; ticketType: TicketTypeDto;
status: string; status: string;
refType: string; refType: string;
refId: string; refId: string;
remark?: string; remark?: string;
extraJson?: { evidenceUrls?: string[] };
createdAt: string; createdAt: string;
}; };
@@ -28,8 +43,15 @@ export default function TicketsPage() {
}, },
[filters], [filters],
); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm] = Form.useForm<{
ticketType: TicketTypeDto;
orderNo: string;
remark?: string;
}>();
async function approve(id: string) { async function approve(id: string) {
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }); await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
@@ -48,57 +70,204 @@ export default function TicketsPage() {
setDrawerOpen(false); setDrawerOpen(false);
} }
async function submitCreate() {
const values = await createForm.validateFields();
setCreating(true);
try {
await request('/admin/tickets', {
method: 'POST',
body: JSON.stringify({
ticketType: values.ticketType,
orderNo: values.orderNo.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> = [ const columns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 }, { title: '工单号', dataIndex: 'ticketNo', width: 180 },
{ title: '类型', dataIndex: 'ticketType', width: 100 }, {
title: '类型',
dataIndex: 'ticketType',
width: 110,
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
},
{ title: '状态', dataIndex: 'status', width: 90 }, { title: '状态', dataIndex: 'status', width: 90 },
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 80, title: '操作',
width: 80,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={async () => { <Button
setDetail(await request(`/admin/tickets/${row.id}`)); type="link"
setDrawerOpen(true); size="small"
}}></Button> onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
), ),
}, },
]; ];
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> <div
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> 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="类型"> <Form.Item name="ticketType" label="类型">
<Select allowClear style={{ width: 120 }} options={[ <Select
{ value: 'REFUND', label: '退款' }, allowClear
{ value: 'RESHIPMENT', label: '补发' }, style={{ width: 140 }}
{ value: 'ALERT', label: '异常' }, options={[
]} /> { value: 'REFUND', label: '仅退款' },
{ value: 'RESHIPMENT', label: '破损补发' },
{ value: 'DAMAGE_RETURN', label: '破损退货' },
{ value: 'RETURN_REFUND', label: '退货退款' },
{ value: 'ALERT', label: '异常' },
]}
/>
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item> <Form.Item name="status" label="状态">
<Button type="primary" htmlType="submit"></Button> <Input allowClear placeholder="PENDING" />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form> </Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }} <Table
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> rowKey="id"
<Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)} loading={loading}
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? ( columns={columns}
<> dataSource={data?.items ?? []}
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}></Button> scroll={{ x: 900 }}
<Button danger onClick={() => reject(String(detail.id))}></Button> pagination={{
</> current: page,
) : null}> pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="工单详情"
width={480}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<Space>
<Button type="primary" onClick={() => approve(String(detail.id))}>
</Button>
<Button danger onClick={() => reject(String(detail.id))}>
</Button>
</Space>
) : null
}
>
{detail && ( {detail && (
<Descriptions column={1} bordered size="small"> <Descriptions column={1} bordered size="small">
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item> <Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item> <Descriptions.Item label="类型">
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item> <Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item> <Descriptions.Item label="关联">
{String(detail.refType)} #{String(detail.refId)}
</Descriptions.Item>
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item> <Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
<Descriptions.Item label="凭证">
{evidenceUrls.length ? (
<Image.PreviewGroup>
{evidenceUrls.map((url) => (
<Image key={url} src={url} width={72} style={{ marginRight: 8 }} />
))}
</Image.PreviewGroup>
) : (
'—'
)}
</Descriptions.Item>
</Descriptions> </Descriptions>
)} )}
</Drawer> </Drawer>
<Modal
title="创建工单"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => void submitCreate()}
confirmLoading={creating}
destroyOnClose
okText="提交"
>
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'REFUND' }}>
<Form.Item
name="ticketType"
label="工单类型"
rules={[{ required: true, message: '请选择类型' }]}
>
<Select
options={[
{ value: 'REFUND', label: '仅退款' },
{ value: 'RESHIPMENT', label: '破损补发' },
{ value: 'DAMAGE_RETURN', label: '破损退货' },
{ value: 'RETURN_REFUND', label: '退货退款' },
{ value: 'ALERT', label: '异常' },
]}
/>
</Form.Item>
<Form.Item
name="orderNo"
label="订单号"
rules={[{ required: true, message: '请填写订单号' }]}
>
<Input placeholder="关联订单号" allowClear />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} placeholder="可选" maxLength={512} showCount />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
+25 -17
View File
@@ -18,8 +18,8 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types'; import { USER_SOURCE_TYPE_LABELS, type UserSourceType } from '@dukang/shared-types';
import { request, type AdminUserRow, type Paginated } from '../lib/api'; import { request, type AdminUserRow, type HqProfile, type Paginated } from '../lib/api';
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants'; import { ORDER_STATUS_LABELS, fmtTime, maskPhone } from '../lib/constants';
type UserOrderRow = { type UserOrderRow = {
id: string; id: string;
@@ -66,6 +66,7 @@ export default function UsersPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [profile, setProfile] = useState<HqProfile | null>(null);
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null); const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -82,6 +83,11 @@ export default function UsersPage() {
const [batchPreviewLoading, setBatchPreviewLoading] = useState(false); const [batchPreviewLoading, setBatchPreviewLoading] = useState(false);
const [batchDeleting, setBatchDeleting] = useState(false); const [batchDeleting, setBatchDeleting] = useState(false);
const [batchRiskAck, setBatchRiskAck] = useState(false); const [batchRiskAck, setBatchRiskAck] = useState(false);
const canDeleteUsers = (profile?.permissionKeys ?? []).includes('users_delete');
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -222,7 +228,7 @@ export default function UsersPage() {
title: '手机', title: '手机',
dataIndex: 'phone', dataIndex: 'phone',
width: 120, width: 120,
render: (v) => v || '—', render: (v) => maskPhone(v),
}, },
{ {
title: '验手机', title: '验手机',
@@ -309,13 +315,15 @@ export default function UsersPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button {canDeleteUsers ? (
danger <Button
disabled={!selectedRowKeys.length} danger
onClick={() => void openBatchDeleteModal()} disabled={!selectedRowKeys.length}
> onClick={() => void openBatchDeleteModal()}
{selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''} >
</Button> {selectedRowKeys.length ? ` (${selectedRowKeys.length})` : ''}
</Button>
) : null}
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={() => { setPage(1); void load(); }}>
<Form.Item name="phone" label="手机号"> <Form.Item name="phone" label="手机号">
@@ -353,11 +361,11 @@ export default function UsersPage() {
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1500 }} scroll={{ x: 1500 }}
rowSelection={{ rowSelection={canDeleteUsers ? {
selectedRowKeys, selectedRowKeys,
preserveSelectedRowKeys: true, preserveSelectedRowKeys: true,
onChange: (keys) => setSelectedRowKeys(keys as string[]), onChange: (keys) => setSelectedRowKeys(keys as string[]),
}} } : undefined}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -375,9 +383,9 @@ export default function UsersPage() {
width={640} width={640}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && canDeleteUsers ? (
<Button danger onClick={openDeleteModal}></Button> <Button danger onClick={openDeleteModal}></Button>
)} ) : undefined}
> >
{detail && ( {detail && (
<> <>
@@ -385,7 +393,7 @@ export default function UsersPage() {
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item> <Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
<Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item> <Descriptions.Item label="用户编号">{detail.userNo}</Descriptions.Item>
<Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item> <Descriptions.Item label="昵称">{detail.nickname || '—'}</Descriptions.Item>
<Descriptions.Item label="手机号">{detail.phone || '—'}</Descriptions.Item> <Descriptions.Item label="手机号">{maskPhone(detail.phone)}</Descriptions.Item>
<Descriptions.Item label="验手机时间"> <Descriptions.Item label="验手机时间">
{detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'} {detail.phoneVerifiedAt ? new Date(detail.phoneVerifiedAt).toLocaleString('zh-CN') : '未验证'}
</Descriptions.Item> </Descriptions.Item>
@@ -419,7 +427,7 @@ export default function UsersPage() {
<Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item> <Descriptions.Item label="deviceKey">{detail.deviceKey || '—'}</Descriptions.Item>
<Descriptions.Item label="合并至"> <Descriptions.Item label="合并至">
{detail.mergedInto {detail.mergedInto
? `${detail.mergedInto.userNo} (${detail.mergedInto.phone || '无手机'})` ? `${detail.mergedInto.userNo} (${detail.mergedInto.phone ? maskPhone(detail.mergedInto.phone) : '无手机'})`
: '—'} : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item> <Descriptions.Item label="被合并访客数">{detail.mergedFromCount ?? 0}</Descriptions.Item>
@@ -593,7 +601,7 @@ export default function UsersPage() {
columns={[ columns={[
{ title: '用户编号', dataIndex: 'userNo', width: 120 }, { title: '用户编号', dataIndex: 'userNo', width: 120 },
{ title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' }, { title: '昵称', dataIndex: 'nickname', width: 100, render: (v) => v || '—' },
{ title: '手机', dataIndex: 'phone', width: 120, render: (v) => v || '—' }, { title: '手机', dataIndex: 'phone', width: 120, render: (v) => maskPhone(v) },
{ {
title: '风险', title: '风险',
width: 200, width: 200,
@@ -5,7 +5,7 @@ import {
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { fmtTime } from '../lib/constants'; import { fmtTime, maskPhone } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ'; type ActorType = 'USER' | 'STORE' | 'PARTNER' | 'HQ';
@@ -119,7 +119,7 @@ export default function WechatBindingsPage() {
title: '手机号', title: '手机号',
dataIndex: 'primaryPhone', dataIndex: 'primaryPhone',
width: 140, width: 140,
render: (v) => v || '—', render: (v) => maskPhone(v),
}, },
{ {
title: '身份摘要', title: '身份摘要',
@@ -128,7 +128,7 @@ export default function WechatBindingsPage() {
<AdminCellLine <AdminCellLine
primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')} primary={r.identities.map((i) => ACTOR_TYPE_LABELS[i.actorType]).join(' / ')}
secondary={r.identities secondary={r.identities
.map((i) => i.refLabel || i.name || i.phone) .map((i) => i.refLabel || i.name || (i.phone ? maskPhone(i.phone) : ''))
.filter(Boolean) .filter(Boolean)
.join(' · ')} .join(' · ')}
/> />
@@ -164,7 +164,7 @@ export default function WechatBindingsPage() {
render: (_, r) => ( render: (_, r) => (
<AdminCellLine <AdminCellLine
primary={r.name || '—'} primary={r.name || '—'}
secondary={[r.phone, `#${r.actorId}`].filter(Boolean).join(' ')} secondary={[r.phone ? maskPhone(r.phone) : null, `#${r.actorId}`].filter(Boolean).join(' ')}
/> />
), ),
}, },
+512
View File
@@ -0,0 +1,512 @@
import { useEffect, useState } from 'react';
import {
Avatar,
Button,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WECOM_BOT_PERMISSIONS,
WECOM_BOT_PERMISSION_LABELS,
WECOM_BOT_ROLE_DEFAULT_PERMISSIONS,
WECOM_BOT_ROLE_LABELS,
WECOM_BOT_ROLES,
type LlmApiConfigOptionDto,
type KnowledgeBaseOptionDto,
type WecomBotDto,
type WecomBotPermission,
type WecomBotRole,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import OssUpload from '../components/OssUpload';
type ListRes = {
items: WecomBotDto[];
total: number;
page: number;
pageSize: number;
runtime?: {
masterEnabled: boolean;
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
};
};
type FormValues = {
name: string;
role: WecomBotRole;
botId: string;
secret?: string;
avatarUrl?: string;
welcome?: string;
permissions: WecomBotPermission[];
aiEnabled: boolean;
llmConfigId?: string | null;
knowledgeBaseId?: string | null;
enabled: boolean;
sortOrder: number;
};
export default function WecomBotsPage() {
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<WecomBotDto | null>(null);
const [saving, setSaving] = useState(false);
const [detail, setDetail] = useState<WecomBotDto | null>(null);
const [runtime, setRuntime] = useState<ListRes['runtime']>();
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
const roleWatch = Form.useWatch('role', form);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<WecomBotDto>(
'/admin/wecom-bots',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.role) qs.set('role', filters.role);
if (filters.enabled) qs.set('enabled', filters.enabled);
return qs;
},
[filters],
);
useEffect(() => {
// useAdminList returns items; runtime comes from same API — fetch once for banner
void request<ListRes>('/admin/wecom-bots?page=1&pageSize=1')
.then((res) => setRuntime(res.runtime))
.catch(() => {});
}, [data]);
useEffect(() => {
void request<{
llmConfigs: LlmApiConfigOptionDto[];
knowledgeBases: KnowledgeBaseOptionDto[];
}>('/admin/wecom-bots/ai-options')
.then((res) => {
setLlmOptions(res.llmConfigs);
setKbOptions(res.knowledgeBases);
})
.catch(() => {
setLlmOptions([]);
setKbOptions([]);
});
}, []);
function openCreate() {
setEditing(null);
form.setFieldsValue({
name: '',
role: 'CUSTOMER_SERVICE',
botId: '',
secret: '',
avatarUrl: '',
welcome: '',
permissions: [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS.CUSTOMER_SERVICE],
aiEnabled: false,
llmConfigId: null,
knowledgeBaseId: null,
enabled: true,
sortOrder: 0,
});
setModalOpen(true);
}
function openEdit(row: WecomBotDto) {
setEditing(row);
form.setFieldsValue({
name: row.name,
role: row.role,
botId: row.botId,
secret: '',
avatarUrl: row.avatarUrl || '',
welcome: row.welcome || '',
permissions: row.permissions,
aiEnabled: row.aiEnabled,
llmConfigId: row.llmConfigId,
knowledgeBaseId: row.knowledgeBaseId,
enabled: row.enabled,
sortOrder: row.sortOrder,
});
setModalOpen(true);
}
async function submit() {
const values = await form.validateFields();
setSaving(true);
try {
const payload = {
name: values.name.trim(),
role: values.role,
botId: values.botId.trim(),
avatarUrl: values.avatarUrl?.trim() || null,
welcome: values.welcome?.trim() || null,
permissions: values.permissions,
aiEnabled: values.aiEnabled,
llmConfigId: values.llmConfigId || null,
knowledgeBaseId: values.knowledgeBaseId || null,
enabled: values.enabled,
sortOrder: values.sortOrder,
};
if (editing) {
await request(`/admin/wecom-bots/${editing.id}`, {
method: 'PUT',
body: JSON.stringify({
...payload,
secret: values.secret?.trim() || undefined,
}),
});
message.success('已更新');
} else {
if (!values.secret?.trim()) {
message.error('请填写 Secret');
return;
}
await request('/admin/wecom-bots', {
method: 'POST',
body: JSON.stringify({
...payload,
secret: values.secret.trim(),
}),
});
message.success('已创建');
}
setModalOpen(false);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function remove(id: string) {
try {
await request(`/admin/wecom-bots/${id}`, { method: 'DELETE' });
message.success('已删除');
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function reloadConnections() {
try {
const st = await request<{
masterEnabled: boolean;
bots: Array<{ id: string; connected: boolean; lastError: string | null }>;
}>('/admin/wecom-bots/reload', { method: 'POST', body: '{}' });
message.success('已重载长连接');
setRuntime(st);
reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '重载失败');
}
}
const runtimeMap = new Map((runtime?.bots ?? []).map((b) => [b.id, b]));
const columns: ColumnsType<WecomBotDto> = [
{
title: '头像',
dataIndex: 'avatarUrl',
width: 64,
render: (url: string | null, row) => (
<Avatar src={url || undefined} shape="square" size={40}>
{row.name.slice(0, 1)}
</Avatar>
),
},
{ title: '名称', dataIndex: 'name', width: 140, ellipsis: true },
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (r: WecomBotRole) => WECOM_BOT_ROLE_LABELS[r] || r,
},
{ title: 'BotID', dataIndex: 'botId', width: 160, ellipsis: true },
{
title: '权限',
dataIndex: 'permissions',
ellipsis: true,
render: (perms: WecomBotPermission[]) =>
perms.map((p) => (
<Tag key={p} style={{ marginBottom: 2 }}>
{WECOM_BOT_PERMISSION_LABELS[p] || p}
</Tag>
)),
},
{
title: 'AI',
width: 100,
render: (_, row) =>
row.aiEnabled ? (
<Tag color="blue">{row.llmConfigName || '已开'}</Tag>
) : (
<Tag></Tag>
),
},
{
title: '启用',
dataIndex: 'enabled',
width: 70,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '是' : '否'}</Tag>,
},
{
title: '连接',
width: 80,
render: (_, row) => {
const rt = runtimeMap.get(row.id);
if (!runtime?.masterEnabled) return <Tag></Tag>;
if (!row.enabled) return <Tag></Tag>;
return (
<Tag color={rt?.connected ? 'green' : 'orange'}>{rt?.connected ? '已连接' : '未连接'}</Tag>
);
},
},
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
{
title: '操作',
width: 180,
fixed: 'right',
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request<WecomBotDto>(`/admin/wecom-bots/${row.id}`));
}}
>
</Button>
<Popconfirm title="确认删除该机器人?" onConfirm={() => remove(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} wrap>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
BotID / Secret / / /
{runtime ? ` 当前总开关:${runtime.masterEnabled ? '开' : '关'}` : ''}
</Typography.Text>
</div>
<Space>
<Button onClick={() => void reloadConnections()}></Button>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
</Space>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="name" label="名称">
<Input allowClear placeholder="名称" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select
allowClear
style={{ width: 160 }}
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
/>
</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>
<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);
},
}}
/>
<Modal
title={editing ? '编辑企微机器人' : '创建企微机器人'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={() => void submit()}
confirmLoading={saving}
width={640}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如:客服机器人" maxLength={64} />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<Select
options={WECOM_BOT_ROLES.map((r) => ({ value: r, label: WECOM_BOT_ROLE_LABELS[r] }))}
onChange={(role: WecomBotRole) => {
form.setFieldValue('permissions', [...WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[role]]);
}}
/>
</Form.Item>
<Form.Item name="avatarUrl" label="头像">
<OssUpload bizType="WECOM_BOT_AVATAR" />
</Form.Item>
<Form.Item name="botId" label="BotID" rules={[{ required: true, message: '请填写 BotID' }]}>
<Input placeholder="企业微信后台长连接 BotID" />
</Form.Item>
<Form.Item
name="secret"
label="Secret"
rules={editing ? [] : [{ required: true, message: '请填写 Secret' }]}
extra={editing ? '留空表示不修改' : undefined}
>
<Input.Password placeholder={editing ? '留空不修改' : '长连接专用 Secret'} />
</Form.Item>
<Form.Item
name="permissions"
label="权限"
rules={[{ required: true, message: '请至少选择一项权限' }]}
extra={
roleWatch
? `角色默认:${WECOM_BOT_ROLE_DEFAULT_PERMISSIONS[roleWatch as WecomBotRole]?.map((p) => WECOM_BOT_PERMISSION_LABELS[p]).join('、') || '无'}`
: undefined
}
>
<Checkbox.Group
options={WECOM_BOT_PERMISSIONS.map((p) => ({
value: p,
label: WECOM_BOT_PERMISSION_LABELS[p],
}))}
/>
</Form.Item>
<Form.Item name="welcome" label="欢迎语">
<Input.TextArea rows={3} placeholder="进入会话时的欢迎语,可空" />
</Form.Item>
<Form.Item
name="aiEnabled"
label="启用 AI 问答"
valuePropName="checked"
extra="开启后,未匹配指令的自然语言将调用绑定的语言模型(可挂知识库)"
>
<Switch />
</Form.Item>
<Form.Item name="llmConfigId" label="语言模型">
<Select
allowClear
placeholder="选择已生效的配置"
options={llmOptions.map((o) => ({
value: o.id,
label: `${o.name}${o.modelName}`,
}))}
/>
</Form.Item>
<Form.Item name="knowledgeBaseId" label="知识库">
<Select
allowClear
placeholder="可选,供 AI 检索"
options={kbOptions.map((o) => ({
value: o.id,
label: `${o.name}${o.documentCount} 篇)`,
}))}
/>
</Form.Item>
<Space size="large">
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="sortOrder" label="排序">
<InputNumber style={{ width: 120 }} />
</Form.Item>
</Space>
</Form>
</Modal>
<Drawer title="机器人详情" width={480} open={!!detail} onClose={() => setDetail(null)}>
{detail && (
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="头像">
<Avatar src={detail.avatarUrl || undefined} size={64} shape="square">
{detail.name.slice(0, 1)}
</Avatar>
</Descriptions.Item>
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="角色">
{WECOM_BOT_ROLE_LABELS[detail.role] || detail.role}
</Descriptions.Item>
<Descriptions.Item label="BotID">{detail.botId}</Descriptions.Item>
<Descriptions.Item label="Secret">
{detail.secretConfigured ? '已配置' : '未配置'}
</Descriptions.Item>
<Descriptions.Item label="权限">
{detail.permissions.map((p) => WECOM_BOT_PERMISSION_LABELS[p] || p).join('、')}
</Descriptions.Item>
<Descriptions.Item label="欢迎语">{detail.welcome || '—'}</Descriptions.Item>
<Descriptions.Item label="AI 问答">{detail.aiEnabled ? '开' : '关'}</Descriptions.Item>
<Descriptions.Item label="语言模型">{detail.llmConfigName || '—'}</Descriptions.Item>
<Descriptions.Item label="知识库">{detail.knowledgeBaseName || '—'}</Descriptions.Item>
<Descriptions.Item label="启用">{detail.enabled ? '是' : '否'}</Descriptions.Item>
<Descriptions.Item label="排序">{detail.sortOrder}</Descriptions.Item>
<Descriptions.Item label="创建">{fmtTime(detail.createdAt)}</Descriptions.Item>
<Descriptions.Item label="更新">{fmtTime(detail.updatedAt)}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+302 -54
View File
@@ -1,9 +1,13 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
Button, Button,
Card, Card,
DatePicker, DatePicker,
Descriptions,
Drawer,
Form, Form,
Input,
Modal,
Select, Select,
Space, Space,
Statistic, Statistic,
@@ -14,24 +18,44 @@ import {
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types'; import {
WINERY_SETTLEMENT_RATE,
type SystemConfigFormResponse,
} from '@dukang/shared-types';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel'; import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api'; import { request, type HqProfile } from '../lib/api';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
orderId: string; id: string;
orderNo: string; billNo: string;
deliveryType: string; billDate: string;
cityName?: string; orderCount: number;
receiverCity?: string; orderAmount: number;
payAmount: number;
wineryRate: number; wineryRate: number;
wineryAmount: number; wineryAmount: number;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string;
deliveryType: string;
payAmount: number;
wineryAmount: number;
paidAt: string; paidAt: string;
productName?: string; };
quantity?: number;
const STATUS_LABELS: Record<string, string> = {
UNPAID: '未打款',
PAID: '已打款',
};
const STATUS_COLORS: Record<string, string> = {
UNPAID: 'red',
PAID: 'green',
}; };
const DELIVERY_LABELS: Record<string, string> = { const DELIVERY_LABELS: Record<string, string> = {
@@ -39,57 +63,146 @@ const DELIVERY_LABELS: Record<string, string> = {
CROSS_CITY: '跨城', CROSS_CITY: '跨城',
}; };
const WINERY_BANK_KEYS = [
'WINERY_BANK_ACCOUNT_NAME',
'WINERY_BANK_NAME',
'WINERY_BANK_BRANCH',
'WINERY_BANK_ACCOUNT_NO',
] as const;
export default function WineryBillsPage() { export default function WineryBillsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [bankForm] = Form.useForm<Record<string, string>>();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/winery-bills', '/admin/winery-bills',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.year) qs.set('year', filters.year); if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month); if (filters.month) qs.set('month', filters.month);
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs; return qs;
}, },
[filters], [filters],
); );
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [bankOpen, setBankOpen] = useState(false);
const [bankLoading, setBankLoading] = useState(false);
const [bankSaving, setBankSaving] = useState(false);
useEffect(() => {
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
const canEditWineryBank =
profile?.adminRole === 'SUPER_ADMIN' ||
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
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/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('/admin/winery-bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
message.success('已确认打款');
setSelectedKeys([]);
reload();
},
});
}
async function openDetail(id: string) {
const d = await request<Row & { items?: BillItem[] }>(`/admin/winery-bills/${id}`);
setDetail(d);
setDrawerOpen(true);
}
async function exportExcel() { async function exportExcel() {
setExporting(true); setExporting(true);
try { try {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.year) qs.set('year', filters.year); if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month); if (filters.month) qs.set('month', filters.month);
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType); 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/winery-bills/export?${qs}`); const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`); downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`); message.success(`已导出 ${result.count}`);
} finally { } finally {
setExporting(false); setExporting(false);
} }
} }
async function openBankModal() {
setBankOpen(true);
setBankLoading(true);
try {
const cfg = await request<SystemConfigFormResponse>('/admin/system-config');
const values: Record<string, string> = {};
for (const key of WINERY_BANK_KEYS) {
values[key] = cfg.values[key] ?? '';
}
bankForm.setFieldsValue(values);
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败');
setBankOpen(false);
} finally {
setBankLoading(false);
}
}
async function saveBank() {
const values = await bankForm.validateFields();
setBankSaving(true);
try {
await request('/admin/system-config', {
method: 'PUT',
body: JSON.stringify({ values }),
});
message.success('酒厂银行账户已保存');
setBankOpen(false);
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setBankSaving(false);
}
}
const summary = data?.summary; const summary = data?.summary;
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100); const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '单号', dataIndex: 'orderNo', width: 180, ellipsis: true }, { title: '单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{ {
title: '配送类型', title: '账单日',
dataIndex: 'deliveryType', dataIndex: 'billDate',
width: 90,
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
},
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
{ title: '数量', dataIndex: 'quantity', width: 70 },
{
title: '酒单实付',
dataIndex: 'payAmount',
width: 110, width: 110,
render: (v) => String(v || '').slice(0, 10),
},
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
{
title: '酒单实付合计',
dataIndex: 'orderAmount',
width: 120,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
@@ -105,30 +218,62 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '支付时间', title: '状态',
dataIndex: 'paidAt', dataIndex: 'status',
width: 170, width: 90,
render: (v) => (v ? fmtTime(v) : '—'), 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.wineryAmount))}>
</Button>
)}
</Space>
),
}, },
]; ];
return ( return (
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <div
<Typography.Title level={4} style={{ margin: 0 }}> style={{
display: 'flex',
</Typography.Title> justifyContent: 'space-between',
<Typography.Text type="secondary"> alignItems: 'flex-start',
T+30 / = × {ratePct}% marginBottom: 16,
</Typography.Text> gap: 16,
</Space> }}
>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
8:00 × {ratePct}%绿
</Typography.Text>
</Space>
{canEditWineryBank ? (
<Button type="default" onClick={() => void openBankModal()}>
</Button>
) : null}
</div>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap> <Space size="large" wrap>
<Statistic title="单数" value={summary.count} /> <Statistic title="单数" value={summary.count} />
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} /> <Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -137,28 +282,30 @@ export default function WineryBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => { onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => {
setFilters({ setFilters({
status: v.status || '',
year: v.month ? String(v.month.year()) : '', year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '', month: v.month ? String(v.month.month() + 1) : '',
deliveryType: v.deliveryType || '', dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
}); });
setPage(1); setPage(1);
}} }}
> >
<Form.Item name="month" label="账期月"> <Form.Item name="status" label="状态">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="deliveryType" label="配送类型">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 120 }}
options={[ options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
]}
/> />
</Form.Item> </Form.Item>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="range" label="账单日">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -180,15 +327,29 @@ export default function WineryBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
>
({selectedKeys.length})
</Button>
</Form.Item>
</Form> </Form>
<Table <Table
rowKey="orderId" rowKey="id"
className="admin-table-nowrap" className="admin-table-nowrap"
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
scroll={{ x: 1200 }} rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1100 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -200,6 +361,93 @@ export default function WineryBillsPage() {
}, },
}} }}
/> />
<Drawer title="酒厂对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={640}>
{detail && (
<>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).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>
<Typography.Title level={5} style={{ marginTop: 16 }}>
</Typography.Title>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={detail.items ?? []}
columns={[
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
{
title: '配送',
dataIndex: 'deliveryType',
width: 70,
render: (v) => DELIVERY_LABELS[v] || v,
},
{
title: '实付',
dataIndex: 'payAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '酒厂应付',
dataIndex: 'wineryAmount',
width: 90,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 150,
render: (v) => (v ? fmtTime(v) : '—'),
},
]}
/>
</>
)}
</Drawer>
<Modal
title="酒厂银行账户信息配置"
open={bankOpen}
onCancel={() => setBankOpen(false)}
onOk={() => void saveBank()}
confirmLoading={bankSaving}
destroyOnClose
okText="保存"
>
<Form form={bankForm} layout="vertical" disabled={bankLoading}>
<Form.Item
name="WINERY_BANK_ACCOUNT_NAME"
label="户名"
rules={[{ required: true, message: '请填写户名' }]}
>
<Input placeholder="收款账户户名" />
</Form.Item>
<Form.Item
name="WINERY_BANK_NAME"
label="开户银行"
rules={[{ required: true, message: '请填写开户银行' }]}
>
<Input placeholder="如:中国工商银行" />
</Form.Item>
<Form.Item name="WINERY_BANK_BRANCH" label="开户支行">
<Input placeholder="可选" />
</Form.Item>
<Form.Item
name="WINERY_BANK_ACCOUNT_NO"
label="银行账号"
rules={[{ required: true, message: '请填写银行账号' }]}
>
<Input placeholder="银行卡号" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { import {
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space, Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
Tabs, Tag, Typography, message, Tabs, Tag, Typography, message,
@@ -15,6 +16,8 @@ type XfxConfig = {
hasApiKey: boolean; hasApiKey: boolean;
signType: string; signType: string;
ready: boolean; ready: boolean;
source?: string;
hint?: string;
}; };
type ApiResult = { type ApiResult = {
@@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary"> <Typography.Paragraph type="secondary">
HQ APIcmd 100101~100301 HQ APIcmd 100101~100301
{' '}<code>XIAOFEIXIA_MCH_ID</code><code>XIAOFEIXIA_API_KEY</code> <Link to="/fulfillment-providers"></Link>
使
</Typography.Paragraph> </Typography.Paragraph>
{config && ( {config && (
@@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() {
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
{config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>} {config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="来源" span={3}>
{config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'}
{config.hint ? ` · ${config.hint}` : ''}
</Descriptions.Item>
</Descriptions> </Descriptions>
{!config.ready && ( {!config.ready && (
<Alert <Alert
type="warning" type="warning"
showIcon showIcon
style={{ marginTop: 12 }} style={{ marginTop: 12 }}
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API" message="请先在仓配管理中注册小飞侠并填写 API 地址、商户号与 API Key"
/> />
)} )}
</Card> </Card>
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, type CSSProperties } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { import {
Button, Button,
@@ -25,6 +25,15 @@ import { request } from '../../lib/api';
import { fmtTime } from '../../lib/constants'; import { fmtTime } from '../../lib/constants';
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout'; import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
const descLabelStyle: CSSProperties = {
whiteSpace: 'nowrap',
width: 108,
};
const descContentStyle: CSSProperties = {
wordBreak: 'break-all',
};
async function downloadQrcode(url: string, filename: string) { async function downloadQrcode(url: string, filename: string) {
try { try {
const res = await fetch(url); const res = await fetch(url);
@@ -72,46 +81,25 @@ export default function PromoCodeDetailPage() {
return ( return (
<> <>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]} align="top">
<Col xs={24} lg={8}> <Col flex="1 1 480px" style={{ minWidth: 0 }}>
<Card title="推广二维码" size="small">
{detail.qrcodeUrl ? (
<div style={{ textAlign: 'center' }}>
<img
src={detail.qrcodeUrl}
alt="推广二维码"
style={{ width: 200, height: 200, marginBottom: 12 }}
/>
<Space direction="vertical" style={{ width: '100%' }}>
<Button
block
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-qrcode.png`)}
>
</Button>
</Space>
</div>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
</Col>
<Col xs={24} lg={16}>
<Card <Card
title="基础信息" title="基础信息"
size="small" size="small"
styles={{ body: { paddingTop: 12 } }}
extra={( extra={(
<Space> <Space>
<Button size="small" onClick={() => { <Button
editForm.setFieldsValue({ size="small"
name: detail.name, onClick={() => {
scene: detail.scene, editForm.setFieldsValue({
remark: detail.remark, name: detail.name,
ownerUserId: detail.ownerUser?.id, scene: detail.scene,
}); remark: detail.remark,
setEditOpen(true); ownerUserId: detail.ownerUser?.id,
}} });
setEditOpen(true);
}}
> >
</Button> </Button>
@@ -135,7 +123,18 @@ export default function PromoCodeDetailPage() {
</Space> </Space>
)} )}
> >
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small"> <Descriptions
column={2}
bordered
size="small"
layout="horizontal"
labelStyle={descLabelStyle}
contentStyle={descContentStyle}
styles={{
label: descLabelStyle,
content: descContentStyle,
}}
>
<Descriptions.Item label="名称">{detail.name}</Descriptions.Item> <Descriptions.Item label="名称">{detail.name}</Descriptions.Item>
<Descriptions.Item label="码值">{detail.code}</Descriptions.Item> <Descriptions.Item label="码值">{detail.code}</Descriptions.Item>
<Descriptions.Item label="场景"> <Descriptions.Item label="场景">
@@ -144,34 +143,81 @@ export default function PromoCodeDetailPage() {
<Descriptions.Item label="状态"> <Descriptions.Item label="状态">
{PROMO_CODE_STATUS_LABELS[detail.status] || detail.status} {PROMO_CODE_STATUS_LABELS[detail.status] || detail.status}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="二维码 ID" span={2}> <Descriptions.Item label="活动 ID">
<Typography.Text copyable={{ text: detail.qrcodeId }}>{detail.qrcodeId}</Typography.Text> <Typography.Text copyable={{ text: String(detail.id) }} style={{ whiteSpace: 'nowrap' }}>
{detail.id}
</Typography.Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="落地链接" span={2}> <Descriptions.Item label="二维码 ID">
<Typography.Text copyable={{ text: detail.landingUrl }}>{detail.landingUrl}</Typography.Text> <Typography.Text copyable={{ text: detail.qrcodeId }} style={{ whiteSpace: 'nowrap' }}>
{detail.qrcodeId}
</Typography.Text>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="OSS 地址" span={2}> <Descriptions.Item label="小程序码 OSS" span={2}>
{detail.qrcodeUrl ? ( {detail.qrcodeUrl ? (
<Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis> <Typography.Text copyable={{ text: detail.qrcodeUrl }} ellipsis style={{ maxWidth: '100%' }}>
{detail.qrcodeUrl} {detail.qrcodeUrl}
</Typography.Text> </Typography.Text>
) : '—'} ) : '—'}
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="渠道负责人"> <Descriptions.Item label="渠道负责人">
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'} <span style={{ whiteSpace: 'nowrap' }}>
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
</span>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item> <Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(detail.createdAt)}</Descriptions.Item> <Descriptions.Item label="创建时间">
<Descriptions.Item label="更新时间">{fmtTime(detail.updatedAt)}</Descriptions.Item> <span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.createdAt)}</span>
</Descriptions.Item>
<Descriptions.Item label="更新时间">
<span style={{ whiteSpace: 'nowrap' }}>{fmtTime(detail.updatedAt)}</span>
</Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
</Col> </Col>
<Col flex="0 0 220px">
<Card title="小程序码" size="small" styles={{ body: { textAlign: 'center', padding: 12 } }}>
{detail.qrcodeUrl ? (
<>
<img
src={detail.qrcodeUrl}
alt="推广小程序码"
style={{ width: 168, height: 168, display: 'block', margin: '0 auto 8px' }}
/>
<Typography.Paragraph
type="secondary"
style={{ marginBottom: 8, fontSize: 12, whiteSpace: 'nowrap' }}
>
scene={detail.id}
</Typography.Paragraph>
<Button
block
size="small"
onClick={() => void downloadQrcode(detail.qrcodeUrl!, `${detail.code}-wxacode.png`)}
>
</Button>
</>
) : (
<Typography.Text type="secondary"></Typography.Text>
)}
</Card>
</Col>
</Row> </Row>
<Row gutter={16} style={{ marginTop: 16 }}> <Row gutter={16} style={{ marginTop: 16 }}>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
<Card size="small"> <Card size="small">
<Statistic title="扫码次数" value={stats?.scanCount ?? detail.scanCount} /> <Statistic title="扫码进入次数" value={stats?.scanCount ?? detail.scanCount} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic
title="扫码注册用户数"
value={stats?.registerCount ?? stats?.sourceMarkedCount ?? 0}
/>
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}> <Col xs={12} sm={6}>
@@ -187,11 +233,6 @@ export default function PromoCodeDetailPage() {
/> />
</Card> </Card>
</Col> </Col>
<Col xs={12} sm={6}>
<Card size="small">
<Statistic title="来源标记用户" value={stats?.sourceMarkedCount ?? '—'} />
</Card>
</Col>
</Row> </Row>
<Modal <Modal
+3
View File
@@ -1,6 +1,7 @@
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import AuthGate from './components/AuthGate'; import AuthGate from './components/AuthGate';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import PartnerAppRoutes from './PartnerAppRoutes'; import PartnerAppRoutes from './PartnerAppRoutes';
export default function App() { export default function App() {
@@ -8,6 +9,8 @@ export default function App() {
<AuthGate> <AuthGate>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route path="/*" element={<PartnerAppRoutes />} /> <Route path="/*" element={<PartnerAppRoutes />} />
</Routes> </Routes>
</AuthGate> </AuthGate>
+24 -9
View File
@@ -1,6 +1,6 @@
import { Navigate, Route, Routes } from 'react-router-dom'; import { Navigate, Route, Routes } from 'react-router-dom';
import { usePartnerSession } from './contexts/PartnerSessionContext'; import { usePartnerSession } from './contexts/PartnerSessionContext';
import { isSubAccount } from './lib/partnerAccess'; import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
import TabLayout from './layouts/TabLayout'; import TabLayout from './layouts/TabLayout';
import SubAccountLayout from './layouts/SubAccountLayout'; import SubAccountLayout from './layouts/SubAccountLayout';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
@@ -13,9 +13,9 @@ import CenterPage from './pages/CenterPage';
import PartnerMePage from './pages/PartnerMePage'; import PartnerMePage from './pages/PartnerMePage';
import BillsPage from './pages/BillsPage'; import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage'; import SettlementPage from './pages/SettlementPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage'; import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage'; import ProxyOrderPage from './pages/ProxyOrderPage';
import ProxyOrderProductsPage from './pages/ProxyOrderProductsPage';
import StaffListPage from './pages/StaffListPage'; import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage'; import StaffCreatePage from './pages/StaffCreatePage';
import LeaderboardPage from './pages/LeaderboardPage'; import LeaderboardPage from './pages/LeaderboardPage';
@@ -26,7 +26,6 @@ function PrimaryRoutes() {
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stores" element={<StoreListPage />} /> <Route path="/stores" element={<StoreListPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/center" element={<CenterPage />} /> <Route path="/center" element={<CenterPage />} />
</Route> </Route>
<Route path="/stores/new" element={<StoreCreatePage />} /> <Route path="/stores/new" element={<StoreCreatePage />} />
@@ -35,10 +34,11 @@ function PrimaryRoutes() {
<Route path="/center/staff" element={<StaffListPage />} /> <Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} /> <Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} /> <Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/reshipments" element={<ReshipPage />} /> <Route path="/proxy-order/products" element={<ProxyOrderProductsPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} /> <Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} /> <Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} /> <Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} /> <Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
@@ -46,15 +46,30 @@ function PrimaryRoutes() {
} }
function SubAccountRoutes() { function SubAccountRoutes() {
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
return ( return (
<Routes> <Routes>
<Route element={<SubAccountLayout />}> <Route element={<SubAccountLayout navKind={navKind} />}>
<Route path="/stores" element={<StoreListPage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stores/new" element={<StoreCreatePage />} /> {isWarehouse ? (
<Route path="/stores/:id" element={<StoreDetailPage />} /> <Route path="/orders" element={<OrderListPage tabRoot />} />
) : (
<Route path="/stores" element={<StoreListPage />} />
)}
<Route path="/me" element={<PartnerMePage />} /> <Route path="/me" element={<PartnerMePage />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} /> {!isWarehouse && (
<>
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
</>
)}
<Route path="/leaderboard" element={<LeaderboardPage />} />
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
); );
} }
+2 -6
View File
@@ -1,9 +1,9 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getPartnerProfile, hasPartnerWxSession } from '../lib/api'; import { getPartnerProfile } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
const PUBLIC_PATHS = new Set(['/login']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
export default function AuthGate({ children }: { children: React.ReactNode }) { export default function AuthGate({ children }: { children: React.ReactNode }) {
const { ready, authenticated, account } = usePartnerSession(); const { ready, authenticated, account } = usePartnerSession();
@@ -18,10 +18,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
const profile = getPartnerProfile();
if (profile && hasPartnerWxSession() && profile.hasWechat) {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
}
return <Navigate to="/login" replace state={{ from: location }} />; return <Navigate to="/login" replace state={{ from: location }} />;
} }
+22 -157
View File
@@ -1,17 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useRef, useState } from 'react';
import { uploadFileToOss, type OssMediaType } from '../lib/upload'; import { uploadFileToOss, type OssMediaType } from '../lib/upload';
import { enqueueUpload } from '../lib/upload-lock'; import { enqueueUpload } from '../lib/upload-lock';
import {
authorizePartnerWechat,
fetchClientConfig,
fetchPartnerProfile,
needsWechatAuth,
type PartnerProfile,
} from '../lib/wechat-auth';
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError } from '../lib/toast'; import { toastError } from '../lib/toast';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
type OssUploadFieldProps = { type OssUploadFieldProps = {
value?: string; value?: string;
@@ -22,27 +12,10 @@ type OssUploadFieldProps = {
wide?: boolean; wide?: boolean;
compact?: boolean; compact?: boolean;
label?: string; label?: string;
/** 父级已确认微信授权时可跳过检查 */
wechatReady?: boolean;
onWechatReadyChange?: (ready: boolean) => void;
}; };
const DEFAULT_MAX_MB = 10; const DEFAULT_MAX_MB = 10;
function formatWechatUploadError(e: unknown): string {
const msg = e instanceof Error ? e.message : '无法打开相册';
const formatted = formatChooseImageFailMessage(msg);
if (formatted) return formatted;
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
}
return msg;
}
function acceptsImages(accept: string) {
return accept.includes('image');
}
export default function OssUploadField({ export default function OssUploadField({
value, value,
onChange, onChange,
@@ -52,15 +25,10 @@ export default function OssUploadField({
wide, wide,
compact, compact,
label, label,
wechatReady,
onWechatReadyChange,
}: OssUploadFieldProps) { }: OssUploadFieldProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [authorizing, setAuthorizing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [profile, setProfile] = useState<PartnerProfile | null>(null);
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
function showUploadError(text: string) { function showUploadError(text: string) {
setError(text); setError(text);
@@ -69,35 +37,6 @@ export default function OssUploadField({
const resolvedAccept = const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*'); accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
const inWechat = isWechatEnv();
const useWechatPicker =
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
const onWechatReadyChangeRef = useRef(onWechatReadyChange);
onWechatReadyChangeRef.current = onWechatReadyChange;
useEffect(() => {
if (!useWechatPicker) return;
void fetchClientConfig()
.then(setClientConfig)
.catch(() => {
/* 未登录等场景由上传接口报错 */
});
if (wechatReady === true) return;
void fetchPartnerProfile()
.then(setProfile)
.catch(() => {
/* 未登录等场景由上传接口报错 */
});
}, [useWechatPicker, wechatReady]);
useEffect(() => {
if (!useWechatPicker || needsAuth) return;
weixinSdk.reset();
void weixinSdk.init().catch(() => {
/* 点击上传时会再次初始化 */
});
}, [useWechatPicker, needsAuth]);
async function persistUpload(file: File) { async function persistUpload(file: File) {
if (!file.size) { if (!file.size) {
@@ -125,112 +64,38 @@ export default function OssUploadField({
} }
} }
async function startWechatAuth() { function pickFile() {
setAuthorizing(true); if (uploading) return;
setError(''); setError('');
try {
const result = await authorizePartnerWechat();
if (result) {
const me = await fetchPartnerProfile();
setProfile(me);
if (me.hasWechat) onWechatReadyChangeRef.current?.(true);
}
} catch (e) {
const text = e instanceof Error ? e.message : '微信授权失败';
showUploadError(text);
} finally {
setAuthorizing(false);
}
}
async function pickWechatImage() {
setUploading(true);
setError('');
try {
weixinSdk.reset();
await weixinSdk.init();
// 选图 + 上传须在同一个队列任务内完成,避免嵌套 enqueueUpload 死锁
await enqueueUpload(async () => {
const files = await weixinSdk.chooseImages({
count: 1,
sourceType: ['album', 'camera'],
});
if (!files?.[0]) return;
await persistUpload(files[0]);
});
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e));
} finally {
setUploading(false);
}
}
async function pickFile() {
if (uploading || authorizing) return;
setError('');
if (useWechatPicker && needsAuth) {
const text = '请先完成微信授权后再上传照片';
showUploadError(text);
return;
}
if (useWechatPicker) {
try {
await pickWechatImage();
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e));
}
return;
}
inputRef.current?.click(); inputRef.current?.click();
} }
const isImage = mediaType === 'IMAGE' && value; const isImage = mediaType === 'IMAGE' && value;
const isFile = mediaType === 'FILE' && value; const isFile = mediaType === 'FILE' && value;
const busy = uploading || authorizing; const busy = uploading;
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传'); const pickerLabel = label ?? (mediaType === 'IMAGE' ? '从系统相册选择' : '点击上传');
const triggerProps = { const triggerProps = {
type: 'button' as const, type: 'button' as const,
disabled: busy || needsAuth, disabled: busy,
onClick: () => void pickFile(), onClick: pickFile,
}; };
return ( return (
<div className="partner-oss-upload"> <div className="partner-oss-upload">
{needsAuth && ( <input
<div className="partner-wechat-auth-hint" role="status"> ref={inputRef}
<p className="body-md"></p> type="file"
<button accept={resolvedAccept}
type="button" className="partner-oss-upload-input"
className="partner-btn-outline" disabled={busy}
style={{ marginTop: 8, width: '100%' }} onChange={(e) => {
disabled={authorizing} const file = e.target.files?.[0];
onClick={() => void startWechatAuth()} if (file) {
> void uploadSelectedFile(file);
{authorizing ? '跳转授权中…' : '微信授权绑定'} }
</button> }}
</div> />
)}
{!useWechatPicker && (
<input
ref={inputRef}
type="file"
accept={resolvedAccept}
className="partner-oss-upload-input"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void uploadSelectedFile(file);
}}
/>
)}
{isImage ? ( {isImage ? (
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}> <button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
<img src={value} alt={label ?? '已上传'} /> <img src={value} alt={label ?? '已上传'} />
@@ -255,10 +120,10 @@ export default function OssUploadField({
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`} className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
> >
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}> <span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
{busy ? 'hourglass_top' : 'add_a_photo'} {busy ? 'hourglass_top' : 'photo_library'}
</span> </span>
<span className="text-primary" style={{ fontWeight: 500 }}> <span className="text-primary" style={{ fontWeight: 500 }}>
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel} {uploading ? '上传中…' : pickerLabel}
</span> </span>
</button> </button>
)} )}
@@ -0,0 +1,264 @@
import { useEffect, useRef, useState } from 'react';
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;
};
export default function TencentLocPickerOverlay({
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 (Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0)) {
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[] }>(
'PARTNER_H5',
`/common/lbs/nearby?lat=${encodeURIComponent(String(lat))}&lng=${encodeURIComponent(String(lng))}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `附近 ${res.items.length} 个地点` : '附近暂无地点,请搜索');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '加载附近地点失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
async function runSearch() {
const trimmed = keyword.trim();
if (!trimmed) {
setHint('请输入搜索关键词');
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[] }>(
'PARTNER_H5',
`/common/lbs/suggest?${params.toString()}`,
{ silent: true },
);
if (seq !== seqRef.current) return;
setItems(res.items ?? []);
setHint(res.items?.length ? `找到 ${res.items.length} 个结果` : '无匹配结果');
} catch (e) {
if (seq !== seqRef.current) return;
setItems([]);
setHint(e instanceof Error ? e.message : '搜索失败');
} finally {
if (seq === seqRef.current) setLoading(false);
}
}
function useBrowserLocation() {
if (!navigator.geolocation) {
setHint('当前浏览器不支持定位');
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);
setHint('定位失败,请检查定位权限');
},
{ enableHighAccuracy: true, timeout: 12000 },
);
}
if (!open) return null;
function confirmPick() {
if (!pending) return;
onPick(pending);
onClose();
}
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
background: '#fff',
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: '1px solid rgba(0,0,0,0.06)',
flexShrink: 0,
gap: 8,
}}
>
<button type="button" className="partner-btn-outline" style={{ padding: '6px 12px' }} onClick={onClose}>
</button>
<span style={{ fontWeight: 600 }}></span>
<button
type="button"
className="partner-btn-primary"
style={{ padding: '6px 12px', opacity: pending ? 1 : 0.45, width: 'auto' }}
disabled={!pending}
onClick={confirmPick}
>
</button>
</div>
<div style={{ padding: '12px 16px', borderBottom: '1px solid rgba(0,0,0,0.04)', flexShrink: 0 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void runSearch();
}}
placeholder="输入小区 / 写字楼 / 门店名称"
style={{
flex: 1,
padding: '10px 12px',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 8,
fontSize: 14,
}}
/>
<button
type="button"
className="partner-btn-primary"
style={{ width: 'auto', padding: '8px 14px' }}
disabled={loading}
onClick={() => void runSearch()}
>
</button>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8, alignItems: 'center' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '6px 12px' }}
disabled={loading}
onClick={useBrowserLocation}
>
</button>
<span className="label-md text-muted">{hint}</span>
</div>
{pending ? (
<p className="label-md text-muted" style={{ margin: '8px 0 0' }}>
{pending.latitude.toFixed(6)}, {pending.longitude.toFixed(6)}
{pending.name ? ` · ${pending.name}` : ''}
</p>
) : null}
</div>
<div style={{ flex: 1, minHeight: 0, overflow: 'auto', WebkitOverflowScrolling: 'touch' }}>
{loading && !items.length ? (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
</p>
) : items.length ? (
<ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
{items.map((item) => {
const active =
pending?.latitude === item.latitude && pending?.longitude === item.longitude;
return (
<li key={`${item.id}-${item.latitude}-${item.longitude}`}>
<button
type="button"
onClick={() => setPending(placeToPicked(item))}
style={{
width: '100%',
textAlign: 'left',
padding: '12px 16px',
border: 0,
borderBottom: '1px solid rgba(0,0,0,0.06)',
background: active ? 'rgba(166, 29, 36, 0.06)' : '#fff',
cursor: 'pointer',
}}
>
<div style={{ fontWeight: 600, fontSize: 15 }}>{item.title}</div>
<div className="label-md text-muted" style={{ marginTop: 4 }}>
{item.address}
</div>
<div className="label-md text-muted" style={{ marginTop: 2, fontSize: 12 }}>
{item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
</div>
</button>
</li>
);
})}
</ul>
) : (
<p className="label-md text-muted" style={{ padding: 24, textAlign: 'center' }}>
{hint}
</p>
)}
</div>
</div>
);
}
@@ -50,7 +50,13 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
staffRole: profile.staffRole, staffRole: profile.staffRole,
permissions: profile.permissions, permissions: profile.permissions,
primaryAccountId: profile.primaryAccountId, primaryAccountId: profile.primaryAccountId,
primaryPhone: profile.primaryPhone,
primaryName: profile.primaryName,
hasWechat: profile.hasWechat, hasWechat: profile.hasWechat,
wxNickname: profile.wxNickname,
wxAvatarUrl: profile.wxAvatarUrl,
managedWarehouseId: profile.managedWarehouseId,
hasWarehouseAccess: profile.hasWarehouseAccess,
}; };
} }
@@ -1,17 +1,30 @@
import { NavLink, Outlet } from 'react-router-dom'; import { NavLink, Outlet } from 'react-router-dom';
import type { PartnerNavKind } from '../lib/partnerAccess';
const TABS = [ const STORE_STAFF_TABS = [
{ to: '/stores', end: true, icon: 'store', label: '门店管理' }, { to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/stores/new', icon: 'add_business', label: '录入门店' }, { to: '/stores', icon: 'store', label: '门店管理' },
{ to: '/me', icon: 'person', label: '我的' }, { to: '/me', icon: 'person', label: '个人中心' },
] as const; ] as const;
export default function SubAccountLayout() { const WAREHOUSE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
type SubAccountLayoutProps = {
navKind: PartnerNavKind;
};
export default function SubAccountLayout({ navKind }: SubAccountLayoutProps) {
const tabs = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_TABS : STORE_STAFF_TABS;
return ( return (
<> <>
<Outlet /> <Outlet />
<nav className="app-tabbar"> <nav className="app-tabbar">
{TABS.map((tab) => ( {tabs.map((tab) => (
<NavLink <NavLink
key={tab.to} key={tab.to}
to={tab.to} to={tab.to}
+29 -5
View File
@@ -12,7 +12,7 @@ const PARTNER_PROFILE = 'partnerProfile';
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt'; const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
export const PARTNER_WX_BOUND = 'partnerWxBound'; export const PARTNER_WX_BOUND = 'partnerWxBound';
/** 微信验证通过后的免登录时长 */ /** 手机号或微信验证通过后的免登录时长 */
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [ const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -24,7 +24,20 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
export type PartnerSessionProfile = Pick< export type PartnerSessionProfile = Pick<
PartnerMe, PartnerMe,
'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'hasWechat' | 'id'
| 'name'
| 'phone'
| 'companyName'
| 'isPrimary'
| 'permissions'
| 'primaryAccountId'
| 'primaryPhone'
| 'primaryName'
| 'hasWechat'
| 'wxNickname'
| 'wxAvatarUrl'
| 'managedWarehouseId'
| 'hasWarehouseAccess'
> & { > & {
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
}; };
@@ -64,7 +77,7 @@ export function isPartnerSessionExpired() {
} }
export function touchPartnerSession() { export function touchPartnerSession() {
if (!hasPartnerWxSession()) return; if (!localStorage.getItem(REFRESH_TOKEN)) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
} }
@@ -77,11 +90,16 @@ export function saveAuth(data: PartnerSessionPayload) {
} }
} }
/** 手机号验证成功后写入 7 天免验证码会话 */
export function saveRememberedSession(data: PartnerSessionPayload) {
saveAuth(data);
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
/** 微信登录/绑定成功后写入 7 天免登录 session */ /** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: PartnerSessionPayload) { export function saveWechatSession(data: PartnerSessionPayload) {
saveAuth(data); saveRememberedSession(data);
localStorage.setItem(PARTNER_WX_BOUND, '1'); localStorage.setItem(PARTNER_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
} }
export function clearAuth(options?: { keepProfile?: boolean }) { export function clearAuth(options?: { keepProfile?: boolean }) {
@@ -109,7 +127,13 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
staffRole: me.staffRole ?? undefined, staffRole: me.staffRole ?? undefined,
permissions: me.permissions, permissions: me.permissions,
primaryAccountId: me.primaryAccountId, primaryAccountId: me.primaryAccountId,
primaryPhone: me.primaryPhone,
primaryName: me.primaryName,
hasWechat: me.hasWechat, hasWechat: me.hasWechat,
wxNickname: me.wxNickname,
wxAvatarUrl: me.wxAvatarUrl,
managedWarehouseId: me.managedWarehouseId,
hasWarehouseAccess: me.hasWarehouseAccess,
}; };
} }
+17
View File
@@ -0,0 +1,17 @@
/** 合伙人端客服热线(可后续改为环境变量) */
export const PARTNER_SUPPORT_PHONE = '400-000-0000';
export function dialPhone(phone: string) {
const normalized = phone.replace(/\s+/g, '');
if (!normalized) return false;
window.location.href = `tel:${normalized}`;
return true;
}
export function contactSupport() {
return dialPhone(PARTNER_SUPPORT_PHONE);
}
export function contactPartnerPhone(phone?: string) {
return dialPhone(phone ?? '');
}
+1
View File
@@ -5,6 +5,7 @@ export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'tota
return request<PartnerLeaderboardResponse>( return request<PartnerLeaderboardResponse>(
'PARTNER_H5', 'PARTNER_H5',
`/partner/dashboard/leaderboard?period=${period}`, `/partner/dashboard/leaderboard?period=${period}`,
{ silent: true },
); );
} }
+80 -5
View File
@@ -1,5 +1,7 @@
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types'; import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean { export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
return account?.isPrimary !== false; return account?.isPrimary !== false;
} }
@@ -17,15 +19,88 @@ export function hasPartnerPermission(
return account.permissions?.includes(permission) ?? false; return account.permissions?.includes(permission) ?? false;
} }
export function partnerHomePath(account: PartnerMe | null | undefined): string { export function hasAnyPartnerPermission(
return isSubAccount(account) ? '/stores/new?step=1' : '/'; account: PartnerMe | null | undefined,
permissions: PartnerPermissionKey[],
): boolean {
return permissions.some((p) => hasPartnerPermission(account, p));
} }
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/me', '/login']; /** 与后端 GET /partner/dashboard 权限点一致 */
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, [
'store:create',
'store:manage',
'order:view',
'warehouse:manage',
]);
}
export function isSubAccountPath(pathname: string): boolean { /** 门店列表/拓店统计所需权限 */
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
}
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 录入新店 */
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasPartnerPermission(account, 'store:create')) return true;
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 与后端 GET /partner/orders 权限点一致 */
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
}
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
if (!account || isPrimaryAccount(account)) return false;
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
const hasStore =
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
return false;
}
export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
if (!account || isPrimaryAccount(account)) return 'primary';
if (isWarehouseStaff(account)) return 'warehouse_staff';
return 'store_staff';
}
/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */
export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (account.hasWarehouseAccess === false) return false;
if (account.hasWarehouseAccess === true) return true;
// 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单
return false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return '/';
}
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
if (pathname === '/login') return true; if (pathname === '/login') return true;
return SUB_ACCOUNT_ALLOWED_PREFIXES.some( const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
return prefixes.some(
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)), (prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
); );
} }
+6 -7
View File
@@ -1,9 +1,5 @@
import { import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
PartnerStaffRole, import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
type CreatePartnerStaffRequest,
type PartnerStaffItem,
type UpdatePartnerStaffRequest,
} from '@dukang/shared-types';
import { request } from './api'; import { request } from './api';
export function listPartnerStaff() { export function listPartnerStaff() {
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
name: body.name, name: body.name,
phone: body.phone, phone: body.phone,
smsCode: body.smsCode, smsCode: body.smsCode,
staffRole: PartnerStaffRole.INTERNAL, staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: body.permissions?.length
? body.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
}), }),
}); });
} }
+104 -43
View File
@@ -6,9 +6,23 @@ export type StoreDraftForm = {
district: string; district: string;
name: string; name: string;
phone: string; phone: string;
storeSmsCode: string;
address: string; address: string;
/** 门店坐标(定位或地理编码) */
latitude: string;
longitude: string;
openTime: string;
closeTime: string;
/** 是否启用第二段营业时间 */
dualHours: boolean;
openTime2: string;
closeTime2: string;
/** 人均费用(选填) */
avgPrice: string;
categoryParentId: string;
categoryId: string;
intro: string; intro: string;
/** 好客权益券使用规则 */
benefitUsageRule: string;
coverUrl: string; coverUrl: string;
envPhotoUrls: string[]; envPhotoUrls: string[];
contractUrl: string; contractUrl: string;
@@ -35,9 +49,19 @@ export const defaultStoreForm = (): StoreDraftForm => ({
cityId: '', cityId: '',
name: '', name: '',
phone: '', phone: '',
storeSmsCode: '',
address: '', address: '',
latitude: '',
longitude: '',
openTime: '10:00',
closeTime: '22:00',
dualHours: false,
openTime2: '17:00',
closeTime2: '21:00',
avgPrice: '',
categoryParentId: '',
categoryId: '',
intro: '', intro: '',
benefitUsageRule: '',
coverUrl: '', coverUrl: '',
envPhotoUrls: ['', '', ''], envPhotoUrls: ['', '', ''],
contractUrl: '', contractUrl: '',
@@ -46,35 +70,38 @@ export const defaultStoreForm = (): StoreDraftForm => ({
bankBranch: '', bankBranch: '',
}); });
export function normalizeStringArray(raw: unknown, length: number): string[] { const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
if (!Array.isArray(raw)) return Array.from({ length }, () => ''); const PHONE_RE = /^1[3-9]\d{9}$/;
const items = raw.map((item) => String(item ?? '')); const BANK_RE = /^\d{16,19}$/;
while (items.length < length) items.push('');
return items.slice(0, length); function timeToMinutes(hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
} }
function normalizeForm(raw: Record<string, unknown>): StoreDraftForm { export function normalizeStringArray(urls: unknown, minLen: number): string[] {
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
while (arr.length < minLen) arr.push('');
return arr;
}
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
const base = defaultStoreForm(); const base = defaultStoreForm();
const regionCodes = Array.isArray(raw.regionCodes) && raw.regionCodes.length >= 3 if (!raw) return base;
? raw.regionCodes.map((code) => String(code))
: base.regionCodes;
return { return {
regionCodes, ...base,
...raw,
regionCodes: Array.isArray(raw.regionCodes) ? raw.regionCodes.map(String) : base.regionCodes,
cityId: String(raw.cityId ?? base.cityId), cityId: String(raw.cityId ?? base.cityId),
province: String(raw.province ?? base.province), latitude: raw.latitude != null && raw.latitude !== '' ? String(raw.latitude) : base.latitude,
city: String(raw.city ?? base.city), longitude: raw.longitude != null && raw.longitude !== '' ? String(raw.longitude) : base.longitude,
district: String(raw.district ?? base.district), openTime: String(raw.openTime ?? base.openTime),
name: String(raw.name ?? base.name), closeTime: String(raw.closeTime ?? base.closeTime),
phone: String(raw.phone ?? base.phone), dualHours: Boolean(raw.dualHours),
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode), openTime2: String(raw.openTime2 ?? base.openTime2),
address: String(raw.address ?? base.address), closeTime2: String(raw.closeTime2 ?? base.closeTime2),
intro: String(raw.intro ?? base.intro), avgPrice: String(raw.avgPrice ?? base.avgPrice),
coverUrl: String(raw.coverUrl ?? base.coverUrl),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3), envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
contractUrl: String(raw.contractUrl ?? base.contractUrl),
bankAccountName: String(raw.bankAccountName ?? base.bankAccountName),
bankAccountNo: String(raw.bankAccountNo ?? base.bankAccountNo),
bankBranch: String(raw.bankBranch ?? base.bankBranch),
}; };
} }
@@ -90,14 +117,11 @@ export function loadStoreDraft(accountId?: string): StoreDraft | null {
} }
} }
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Record<string, unknown>; const parsed = JSON.parse(raw) as StoreDraft;
if (parsed.form && typeof parsed.form === 'object') { return {
return { step: Number(parsed.step) || 0,
step: Math.min(3, Math.max(1, Number(parsed.step) || 1)), form: normalizeStoreDraftForm(parsed.form),
form: normalizeForm(parsed.form as Record<string, unknown>), };
};
}
return { step: 1, form: normalizeForm(parsed) };
} catch { } catch {
return null; return null;
} }
@@ -117,26 +141,58 @@ export function clearAllStoreDrafts(accountId?: string) {
localStorage.removeItem(STORE_DRAFT_KEY); localStorage.removeItem(STORE_DRAFT_KEY);
} }
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
export function validateStoreStep1( export function validateStoreStep1(
form: Pick< form: Pick<
StoreDraftForm, StoreDraftForm,
'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro' | 'regionCodes'
| 'cityId'
| 'name'
| 'address'
| 'openTime'
| 'closeTime'
| 'dualHours'
| 'openTime2'
| 'closeTime2'
| 'avgPrice'
| 'categoryId'
| 'intro'
| 'benefitUsageRule'
>, >,
): string | null { ): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划'; if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
if (!form.name.trim()) return '请填写门店名称'; if (!form.name.trim()) return '请填写门店名称';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
if (!form.address.trim()) return '请填写详细地址'; if (!form.address.trim()) return '请填写详细地址';
if (!form.openTime.trim()) return '请填写营业开始时间';
if (!TIME_RE.test(form.openTime.trim())) return '营业开始时间格式须为 HH:MM';
if (!form.closeTime.trim()) return '请填写营业结束时间';
if (!TIME_RE.test(form.closeTime.trim())) return '营业结束时间格式须为 HH:MM';
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
return '营业结束时间须晚于开始时间';
}
if (form.dualHours) {
if (!form.openTime2.trim() || !form.closeTime2.trim()) return '请完整填写第二段营业时间';
if (!TIME_RE.test(form.openTime2.trim()) || !TIME_RE.test(form.closeTime2.trim())) {
return '第二段时间格式须为 HH:MM';
}
if (timeToMinutes(form.openTime2.trim()) >= timeToMinutes(form.closeTime2.trim())) {
return '第二段结束时间须晚于开始时间';
}
if (timeToMinutes(form.closeTime.trim()) >= timeToMinutes(form.openTime2.trim())) {
return '第二段开始时间须晚于第一段结束时间';
}
}
if (form.avgPrice.trim()) {
const n = Number(form.avgPrice);
if (Number.isNaN(n) || n < 0) return '人均费用须为非负数字';
}
if (!form.categoryId.trim()) return '请选择店铺类型';
if (form.intro.trim()) { if (form.intro.trim()) {
const len = form.intro.trim().length; const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字'; if (len < 2 || len > 500) return '门店简介须为 2~500 字';
}
if (form.benefitUsageRule.trim().length > 1000) {
return '好客权益券使用规则最多 1000 字';
} }
return null; return null;
} }
@@ -158,11 +214,16 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
} }
export function validateStoreStep3( export function validateStoreStep3(
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>, form: Pick<
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone'
>,
): string | null { ): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名'; if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号'; if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字'; if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行'; if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
return null; return null;
} }
+26
View File
@@ -0,0 +1,26 @@
import { weixinSdk } from './weixin';
export type StorePosition = {
latitude: number;
longitude: number;
};
/** 获取当前 GPS 坐标(微信 JSSDK / 浏览器 Geolocation */
export async function locateStorePosition(): Promise<StorePosition> {
const outcome = await weixinSdk.getLocationDetailed();
if (!outcome.location) {
throw new Error(outcome.errMsg || '定位失败,请允许位置权限后重试');
}
const { latitude, longitude } = outcome.location;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error('定位结果无效');
}
return { latitude, longitude };
}
export function formatStoreCoords(lat?: string | number | null, lng?: string | number | null): string {
const a = lat != null && lat !== '' ? Number(lat) : NaN;
const b = lng != null && lng !== '' ? Number(lng) : NaN;
if (!Number.isFinite(a) || !Number.isFinite(b)) return '';
return `${a.toFixed(6)}, ${b.toFixed(6)}`;
}
-8
View File
@@ -8,12 +8,4 @@ export function checkStorePhoneAvailable(phone: string) {
); );
} }
export function sendStorePhoneSms(phone: string) {
return request<{ ok: boolean; maskedPhone: string }>('PARTNER_H5', '/partner/stores/send-phone-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
}
export type { PartnerStorePhoneAvailableResponse }; export type { PartnerStorePhoneAvailableResponse };
+2 -2
View File
@@ -20,8 +20,8 @@ export type StoreStatusValue = 'OPEN' | 'PAUSED' | 'CLOSED';
export function storeStatusLabel(status: string): string { export function storeStatusLabel(status: string): string {
const s = String(status).toUpperCase(); const s = String(status).toUpperCase();
if (s === 'OPEN') return '营业中'; if (s === 'OPEN') return '营业中';
if (s === 'PAUSED') return '时闭店'; if (s === 'PAUSED') return '时闭店';
if (s === 'CLOSED') return '关闭'; if (s === 'CLOSED') return '永久关闭';
return status; return status;
} }
@@ -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,
};
}
+4
View File
@@ -14,6 +14,8 @@ export type PartnerProfile = {
phone: string; phone: string;
companyName?: string; companyName?: string;
hasWechat?: boolean; hasWechat?: boolean;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
isPrimary?: boolean; isPrimary?: boolean;
}; };
@@ -82,6 +84,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio
companyName: partner.companyName ? String(partner.companyName) : undefined, companyName: partner.companyName ? String(partner.companyName) : undefined,
isPrimary: partner.isPrimary !== false, isPrimary: partner.isPrimary !== false,
hasWechat: true, hasWechat: true,
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
} }
: undefined, : undefined,
}; };
+1
View File
@@ -6,6 +6,7 @@ import App from './App';
import { PartnerSessionProvider } from './contexts/PartnerSessionContext'; import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
import { PartnerToastProvider } from './contexts/PartnerToastContext'; import { PartnerToastProvider } from './contexts/PartnerToastContext';
import './styles.css'; import './styles.css';
import './styles/legal.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+203 -57
View File
@@ -1,42 +1,114 @@
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function billStatusLabel(status: string) {
switch (status) {
case 'AWAITING_CONFIRM': return '待确认';
case 'UNPAID': return '未打款';
case 'PAID': return '已打款';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function canConfirm(status: string) {
return status === 'AWAITING_CONFIRM';
}
export default function BillsPage() { export default function BillsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]); const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
async function loadBills() {
setLoading(true);
try {
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
setBills(Array.isArray(list) ? list : []);
} catch {
setBills([]);
} finally {
setLoading(false);
}
}
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills); void loadBills();
}, [navigate]); }, [navigate]);
const billId = searchParams.get('id'); const billId = searchParams.get('id');
const actionable = useMemo(
() => bills.filter((b) => canConfirm(b.status)),
[bills],
);
const bill = billId const bill = billId
? bills.find((b) => String(b.id) === billId) ?? bills[0] ? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
: bills[0]; : actionable[0] ?? bills[0];
function confirmBill() { const status = String(bill?.status || '');
if (!confirmed) return; const showApplyForm = !!bill && canConfirm(status);
const isUnpaid = status === 'UNPAID';
const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID';
function askConfirm(ids: string[]) {
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
return;
}
void doConfirm(ids);
}
async function doConfirm(ids: string[]) {
setSubmitting(true); setSubmitting(true);
setTimeout(() => { try {
if (ids.length === 1) {
await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' });
} else {
await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', {
method: 'POST',
body: JSON.stringify({ ids }),
});
}
toastSuccess('已确认,等待总部打款');
setConfirmed(false);
setSelectedIds([]);
await loadBills();
} catch (e) {
toastError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false); setSubmitting(false);
alert('申请已提交,请耐心等待打款审核。(preV1 演示)'); }
}, 1500); }
function toggleSelect(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
} }
return ( return (
<div className="partner-bills-page"> <PullToRefresh onRefresh={loadBills} className="partner-bills-page">
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} /> <PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper"> <div className="partner-bill-stepper">
<div className="partner-stepper-inner"> <div className="partner-stepper-inner">
<div className="partner-stepper-line" aria-hidden> <div className="partner-stepper-line" aria-hidden>
<div className="partner-stepper-line-fill" style={{ width: '50%' }} /> <div
className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
/>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done"> <div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
@@ -45,17 +117,28 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--active">2</div> <div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{showApplyForm && !confirmed ? '2' : (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
)}
</div>
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm">3</div> <div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
<span className="partner-step-label"></span> {isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'}
</div>
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'}
</span>
</div> </div>
</div> </div>
</div> </div>
{!bill && <div className="empty"></div>} {loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>}
{bill && ( {bill && (
<section className="partner-bill-card"> <section className="partner-bill-card">
@@ -66,73 +149,136 @@ export default function BillsPage() {
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p> <p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2> <h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
</div> </div>
<span className="partner-status-pill partner-status-pill--paused">{String(bill.status || '待确认')}</span> <span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</div> </div>
{isRejected && bill.rejectReason && (
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}></p>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div>
</div>
)}
{isUnpaid && (
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
)}
<div className="partner-bill-amount"> <div className="partner-bill-amount">
<p className="text-muted body-md" style={{ marginBottom: 8 }}></p> <p className="text-muted body-md" style={{ marginBottom: 8 }}></p>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span> <span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
<span className="amount-xl">{Number(bill.totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
</div> </div>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-info-row"> <div className="partner-info-row">
<span className="text-variant body-md"></span> <span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.82).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
</div> </div>
<div className="partner-info-row"> <div className="partner-info-row">
<span className="text-variant body-md"></span> <span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.18).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span> <span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
)} )}
{bills.length > 1 && ( {bills.length > 0 && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
{bills.slice(1).map((b) => ( <h3 className="headline-md" style={{ margin: 0 }}></h3>
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}> {actionable.length > 1 && (
<div className="partner-store-card-header" style={{ marginBottom: 0 }}> <button
<div> type="button"
<p className="body-md">{String(b.billNo)}</p> className="partner-fill-max"
<p className="label-md text-muted">{String(b.status)}</p> style={{ fontSize: 13 }}
</div> disabled={!selectedIds.length || submitting}
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span> onClick={() => askConfirm(selectedIds)}
>
({selectedIds.length})
</button>
)}
</div>
{bills.map((b) => {
const id = String(b.id);
const selectable = canConfirm(b.status);
return (
<div
key={id}
className="partner-store-card"
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
>
{selectable ? (
<input
type="checkbox"
checked={selectedIds.includes(id)}
onChange={() => toggleSelect(id)}
style={{ marginTop: 4 }}
/>
) : (
<span style={{ width: 16 }} />
)}
<button
type="button"
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
) : null}
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div>
</button>
</div> </div>
</div> );
))} })}
</div> </div>
)} )}
<div className="partner-info-banner" style={{ marginTop: 16 }}> {showApplyForm && (
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span> <>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}> <div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
</p> <p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</div>
</p>
</div>
{bill && ( <footer className="partner-bill-footer">
<footer className="partner-bill-footer"> <label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}> <input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} /> <span></span>
<span></span> </label>
</label> <button
<button type="button"
type="button" className="partner-btn-primary"
className="partner-btn-primary" disabled={!confirmed || submitting}
disabled={!confirmed || submitting} style={{ opacity: confirmed ? 1 : 0.5 }}
style={{ opacity: confirmed ? 1 : 0.5 }} onClick={() => askConfirm([String(bill!.id)])}
onClick={confirmBill} >
> {submitting ? '正在提交...' : '确认账单'}
{submitting ? '正在提交...' : '确认并申请打款'} {!submitting && <span className="material-symbols-outlined">payments</span>}
{!submitting && <span className="material-symbols-outlined">payments</span>} </button>
</button> </footer>
</footer> </>
)} )}
</div> </PullToRefresh>
); );
} }
+276 -134
View File
@@ -1,159 +1,301 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact';
import { isPrimaryAccount } from '../lib/partnerAccess'; import { isPrimaryAccount } from '../lib/partnerAccess';
import { listPartnerStaff } from '../lib/staff';
import { toastError, toastSuccess } from '../lib/toast';
import {
authorizePartnerWechat,
fetchClientConfig,
handlePartnerWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
export default function CenterPage() { type CenterPageProps = {
const navigate = useNavigate(); /** 子账号个人中心复用 */
const { account, logout } = usePartnerSession(); variant?: 'primary' | 'sub';
const showPrimaryMenus = isPrimaryAccount(account); roleLabel?: string;
const me = account as unknown as Record<string, unknown> | null; };
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
const { account, logout, refresh, applySession } = usePartnerSession();
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
const [authorizing, setAuthorizing] = useState(false);
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [staffCount, setStaffCount] = useState(0);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } document.title = isPrimary ? '合伙人中心' : '个人中心';
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills); }, [isPrimary]);
}, [navigate]);
const pendingBills = bills.filter((b) => String(b.status).includes('PENDING') || String(b.status).includes('CONFIRM')); const loadCenter = useCallback(() => {
const tasks: Promise<unknown>[] = [Promise.resolve(refresh())];
if (isPrimary) {
tasks.push(
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
.then(setBills)
.catch(() => setBills([])),
listPartnerStaff()
.then((list) => setStaffCount(list.length))
.catch(() => setStaffCount(0)),
);
}
return Promise.all(tasks).then(() => undefined);
}, [isPrimary, refresh]);
useEffect(() => {
void loadCenter();
}, [loadCenter]);
const finance = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
const settled = bills.filter((b) => b.status === 'PAID');
const paidCount = settled.length;
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
return {
pendingBillCount: pending.length,
rejectedCount,
pendingTotal: bills
.filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED')
.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
paidCount,
};
}, [bills]);
function handleContactPartner() {
if (isPrimary) {
if (!contactSupport()) toastError('暂无客服电话');
return;
}
if (!account?.primaryPhone) {
toastError('暂无合伙人联系方式');
return;
}
window.location.href = `tel:${account.primaryPhone}`;
}
async function handleAvatarAuth() {
if (authorizing) return;
if (!isWechatEnv()) {
toastError('请在微信内打开以授权头像昵称');
return;
}
setAuthorizing(true);
try {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) {
toastError('微信授权暂未开启');
return;
}
const result = await authorizePartnerWechat();
if (result) {
const session = handlePartnerWechatLoginResult(result);
if (session) applySession(session);
await refresh();
toastSuccess('微信授权成功');
}
// result 为空表示已跳转 OAuth,无需额外处理
} catch (e) {
toastError(e instanceof Error ? e.message : '微信授权失败');
} finally {
setAuthorizing(false);
}
}
const locationLabel = [account?.companyName].filter(Boolean).join(' · ') || '—';
const displayName = account?.wxNickname?.trim() || account?.name || '合伙人';
const avatarUrl = account?.wxAvatarUrl?.trim() || '';
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
return ( return (
<div className="page partner-center-page"> <PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<section className="partner-profile-card"> <section className="partner-profile-card">
<div className="partner-profile-avatar"> <button
<span className="material-symbols-outlined">person</span> type="button"
</div> className="partner-profile-avatar"
<div style={{ flex: 1 }}> onClick={() => void handleAvatarAuth()}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> disabled={authorizing}
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(me?.name || '合伙人')}</h2> aria-label={avatarUrl ? '更新微信头像' : '微信授权获取头像'}
<span className="partner-role-badge"></span> >
{avatarUrl ? (
<img src={avatarUrl} alt="" className="partner-profile-avatar-img" referrerPolicy="no-referrer" />
) : (
<span className="material-symbols-outlined">person</span>
)}
{!avatarUrl && (
<span className="partner-profile-avatar-hint"></span>
)}
</button>
<div className="partner-profile-main">
<div className="partner-profile-title-row">
<h2 className="partner-profile-name">{displayName}</h2>
<span className="partner-role-badge">{badgeLabel}</span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}> <div className="partner-profile-location">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span> <span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
<span className="body-md">{String(me?.companyName || '郑州')}</span> <span className="body-md line-2-clamp">{locationLabel}</span>
</div> </div>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(me?.phone || '')}</p> {!isPrimary && (
</div> <p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
</section>
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 20px 8px' }}>
<h3 className="headline-md"></h3>
<Link to="/center/bills" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center' }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link>
</div>
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">pending_actions</span>
</div>
<div>
<p className="body-md" style={{ fontWeight: 500 }}></p>
<p className="label-md text-primary"> {pendingBills.length || bills.length} </p>
</div>
</div>
<span className="material-symbols-outlined text-primary">chevron_right</span>
</Link>
<div className="partner-finance-grid">
<div className="partner-finance-card partner-finance-card--hero">
<p className="label-md" style={{ opacity: 0.8, marginBottom: 4 }}> ()</p>
<span className="amount-xl" style={{ color: '#fff', fontSize: 32 }}>
{bills.length > 0 ? Number(bills[0].totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) : '0.00'}
</span>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-md">
<span className="text-primary">¥</span>
{pendingBills.reduce((s, b) => s + Number(b.totalAmount || 0), 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</p>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="headline-md">{bills.length} </span>
<span className="material-symbols-outlined text-muted">history</span>
</div>
</div>
</div>
</section>
<section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card">
<Link to="/stores" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/orders" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">receipt_long</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/center/settlement" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/reports/weekly" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/reshipments" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/leaderboard" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
{showPrimaryMenus && (
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
)} )}
</div> </div>
</section> </section>
{isPrimary && (
<>
<section className="partner-assets-section">
<div className="partner-section-head">
<h3 className="headline-md"></h3>
<Link to="/center/settlement" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
{finance.pendingBillCount > 0 && (
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">receipt_long</span>
</div>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600 }}>
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
</p>
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
{finance.rejectedCount > 0
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
: `您有 ${finance.pendingBillCount} 笔账单待确认`}
</p>
</div>
</div>
<span className="material-symbols-outlined text-primary">chevron_right</span>
</Link>
)}
<div className="partner-finance-grid">
<div className="partner-finance-card partner-finance-card--hero">
<p className="partner-finance-hero-label"> ()</p>
<p className="partner-finance-hero-amount">{fmtMoney(finance.balance)}</p>
</div>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted"></p>
<p className="partner-finance-value text-primary">¥ {fmtMoney(finance.pendingTotal)}</p>
</Link>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>history</span>
</p>
<p className="partner-finance-value">{finance.paidCount} </p>
</Link>
</div>
</section>
<section className="partner-menu-section">
<div className="partner-section-head">
<h3 className="headline-md"></h3>
</div>
<div className="partner-menu-card">
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">badge</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
{staffCount > 0 && <span className="partner-menu-badge">{staffCount}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/stores?audit=pending" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">storefront</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/stores" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">description</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
<span className="partner-menu-status"></span>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<button
type="button"
className="partner-menu-item"
onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}
>
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">support_agent</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
</div>
</section>
</>
)}
{!isPrimary && (
<section className="partner-menu-section">
<div className="partner-menu-card">
<Link to="/leaderboard" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">emoji_events</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
</div>
</section>
)}
<button type="button" className="partner-logout-btn" onClick={logout}> <button type="button" className="partner-logout-btn" onClick={logout}>
<span className="material-symbols-outlined">logout</span> <span className="material-symbols-outlined">logout</span>
退 退
</button> </button>
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}> <div className="partner-center-footer">
<p className="label-md text-muted"> · </p> <p className="label-md text-muted"> · </p>
</div> </div>
</div> </PullToRefresh>
); );
} }
+327 -238
View File
@@ -1,280 +1,369 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus'; import {
canAccessPartnerDashboard,
canAccessPartnerOrders,
canAccessPartnerStores,
getPartnerNavKind,
hasWarehouseAccess,
} from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
function fmtMoney(n: number) { function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
const HOME_STORE_PREVIEW_LIMIT = 6; const LEADERBOARD_PREVIEW_LIMIT = 3;
function OrderSummarySection({
title,
todayCount,
pendingShip,
shipping,
completed,
ordersLink = '/orders',
}: {
title?: string;
todayCount: number;
pendingShip: number;
shipping: number;
completed: number;
ordersLink?: string;
}) {
return (
<section>
<div className="partner-order-summary-header">
<h2 className="headline-md">{title || `今日订单 ${todayCount}`}</h2>
<Link to={ordersLink} className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
<div className="partner-order-stats">
<div className="partner-order-stat">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
</div>
<div className="partner-order-stat partner-order-stat--blue">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
</div>
<div className="partner-order-stat partner-order-stat--green">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
</div>
</div>
</section>
);
}
function isPendingShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
function isShipping(status: string) {
const s = status.toUpperCase();
return s.includes('SHIP') || s === 'OUT_WAREHOUSE' || s === 'PENDING_RECEIVE';
}
function isCompleted(status: string) {
return status.toUpperCase() === 'COMPLETED';
}
function isToday(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function summarizeOrders(orders: Array<Record<string, unknown>>) {
let pendingShip = 0;
let shipping = 0;
let completed = 0;
let todayCount = 0;
for (const o of orders) {
const status = String(o.status || '');
if (isToday(String(o.createdAt || ''))) todayCount += 1;
if (isPendingShip(status)) pendingShip += 1;
else if (isShipping(status)) shipping += 1;
else if (isCompleted(status)) completed += 1;
}
return { pendingShip, shipping, completed, todayCount };
}
function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) {
return (
<section className="partner-bento-card partner-leaderboard-preview">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
{entries.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
entries.map((entry) => (
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className={`partner-leaderboard-avatar partner-leaderboard-avatar--rank-${Math.min(entry.rank, 3)}`}>
<span className="material-symbols-outlined">person</span>
<span className="partner-leaderboard-avatar-badge">{entry.rank}</span>
</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
</p>
<p className="label-md text-muted">: {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))
)}
</section>
);
}
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isPrimary = navKind === 'primary';
const isWarehouse = navKind === 'warehouse_staff';
const warehouseOk = hasWarehouseAccess(account);
const canOrders = canAccessPartnerOrders(account) && warehouseOk && (isPrimary || isWarehouse);
const canDashboard = canAccessPartnerDashboard(account) && !isWarehouse;
const canStores = canAccessPartnerStores(account) && !isWarehouse;
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]); const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]); const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [notifOpen, setNotifOpen] = useState(false); const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } document.title = '工作台';
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash); }, []);
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
fetchPartnerLeaderboard('month') const loadHome = useCallback(() => {
.then((data) => setLeaderboardPreview(data.list.slice(0, 2))) if (!isLoggedIn()) {
.catch(() => setLeaderboardPreview([])); navigate('/login');
}, [navigate]); return Promise.resolve();
}
if (!account) return Promise.resolve();
const tasks: Promise<unknown>[] = [];
if (canOrders) {
tasks.push(
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
.catch(() => setOrders([])),
);
} else {
setOrders([]);
}
if (canDashboard) {
tasks.push(
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
.then(setDash)
.catch(() => setDash(null)),
);
} else {
setDash(null);
}
if (canStores) {
tasks.push(
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
.then((data) => setStores(Array.isArray(data) ? data : []))
.catch(() => setStores([])),
);
} else {
setStores([]);
}
tasks.push(
fetchPartnerLeaderboard('month')
.then((data) => {
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
})
.catch(() => {
setLeaderboardEntries([]);
}),
);
return Promise.all(tasks).then(() => undefined);
}, [navigate, account, canOrders, canDashboard, canStores]);
useEffect(() => {
void loadHome();
}, [loadHome]);
const storeCount = Number(dash?.storeCount || stores.length || 0); const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderCount = Number(dash?.orderCount || 0); const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
const pendingAuditCount = Number(dash?.pendingAuditCount || 0); const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
const notifications = Array.isArray(dash?.notifications)
? (dash.notifications as Array<Record<string, unknown>>)
: [];
const hasUnread = notifications.length > 0;
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length; const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores); const abnormalStores = Math.max(0, storeCount - activeStores);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
const pendingShip = Math.ceil(orderCount * 0.04);
const shipping = Math.ceil(orderCount * 0.12);
const completed = Math.max(0, orderCount - pendingShip - shipping);
const monthNew = stores.filter((s) => { const monthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || '')); const created = new Date(String(s.createdAt || ''));
const now = new Date(); const now = new Date();
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear(); return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
}).length; }).length;
const lastMonthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || ''));
const now = new Date();
const last = new Date(now.getFullYear(), now.getMonth() - 1, 1);
return created.getMonth() === last.getMonth() && created.getFullYear() === last.getFullYear();
}).length;
const monthGrowthPct = lastMonthNew > 0
? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100)
: (monthNew > 0 ? 100 : 0);
const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT); const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
return ( return (
<div className="page partner-home"> <PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
<header className="partner-home-header">
<h1 className="app-page-title"></h1>
<div className="partner-home-header-actions">
<button
type="button"
className="partner-notif-btn"
aria-label="通知"
onClick={() => setNotifOpen((v) => !v)}
>
<span className="material-symbols-outlined">notifications</span>
{hasUnread && <span className="partner-notif-dot" />}
</button>
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
</div>
</div>
</header>
{notifOpen && (
<section className="partner-form-card" style={{ margin: '0 16px 12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 className="headline-md"></h2>
<button type="button" className="label-md text-muted" onClick={() => setNotifOpen(false)}></button>
</div>
{notifications.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
notifications.slice(0, 8).map((n) => (
<Link
key={String(n.id)}
to={`/stores/${n.storeId}`}
className="partner-home-store-row"
style={{ marginBottom: 8 }}
onClick={() => setNotifOpen(false)}
>
<div className="partner-home-store-info">
<p className="body-md" style={{ fontWeight: 600 }}>{String(n.title || '门店审核')}</p>
<p className="label-md text-muted">{String(n.content || '')}</p>
</div>
</Link>
))
)}
</section>
)}
<main className="partner-home-body"> <main className="partner-home-body">
<section className="partner-revenue-card"> {isPrimary && (
<p className="partner-revenue-label"> <>
(CNY) <section className="partner-revenue-card">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span> <p className="partner-revenue-label">
</p> (CNY)
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div> <span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
<div className="partner-revenue-grid"> </p>
<div> <div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<p className="partner-revenue-label"></p> <div className="partner-revenue-grid">
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div>
</div>
</section>
<div className="partner-bento">
<section className="partner-bento-card">
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<span className="partner-dot partner-dot--green" />
<div> <div>
<p className="label-md text-muted"></p> <p className="partner-revenue-label"></p>
<p className="headline-md">{activeStores}</p> <p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div> </div>
</div> </div>
<div className="partner-store-stat"> </section>
<span className="partner-dot partner-dot--red" /> <section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div> <div className="partner-quick-actions">
<p className="label-md text-muted">/</p> <Link to="/proxy-order" className="partner-quick-action">
<p className="headline-md">{abnormalStores}</p> <div className="partner-quick-action-icon partner-quick-action-icon--red">
</div> <span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
</div>
</section>
<section className="partner-bento-card">
<div className="partner-quick-actions">
<Link to="/proxy-order" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/stores/new" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">add_business</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reshipments" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
<span className="material-symbols-outlined">assignment_return</span>
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/center/settlement" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--green">
<span className="material-symbols-outlined">account_balance</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
</div>
</section>
</div>
<section>
<div className="partner-order-summary-header">
<h2 className="headline-md"> {orderCount}</h2>
<Link to="/orders" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
<div className="partner-order-stats">
<div className="partner-order-stat">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
</div>
<div className="partner-order-stat partner-order-stat--blue">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
</div>
<div className="partner-order-stat partner-order-stat--green">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
</div>
</div>
</section>
<section className="partner-home-store-section">
<div className="partner-home-store-header">
<h2 className="headline-md"></h2>
<Link to="/stores" className="label-md text-primary"></Link>
</div>
{previewStores.length === 0 ? (
<div className="partner-home-store-empty">
<p className="label-md text-muted"></p>
<Link to="/stores/new" className="label-md text-primary"></Link>
</div>
) : (
previewStores.map((s) => {
const storeId = String(s.id);
const status = String(s.status || 'PAUSED').toUpperCase();
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
return (
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
<div className="partner-home-store-info">
<p className="headline-md">{String(s.name || '未命名门店')}</p>
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
</div> </div>
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}> <span className="partner-quick-action-label"></span>
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
</span>
</Link> </Link>
); <Link to="/stores/new" className="partner-quick-action">
}) <div className="partner-quick-action-icon partner-quick-action-icon--red">
)} <span className="material-symbols-outlined">add_business</span>
</section>
<section className="partner-expansion-card">
<h2 className="headline-md" style={{ marginBottom: 16 }}></h2>
<div className="partner-expansion-split">
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
</div>
</div>
<div className="partner-leaderboard-section">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary"></Link>
</div>
{leaderboardPreview.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
leaderboardPreview.map((entry) => (
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
</div> </div>
<div className="partner-leaderboard-stat"> <span className="partner-quick-action-label"></span>
<p className="headline-md text-primary">{entry.periodStores} </p> </Link>
<p className="label-md text-muted"></p> <Link to="/center/settlement" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--green">
<span className="material-symbols-outlined">account_balance</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
</div>
</section>
</>
)}
{isWarehouse && (
<p className="headline-md" style={{ marginBottom: 8 }}></p>
)}
{canOrders && (
<OrderSummarySection
title={isWarehouse ? '今日订单' : undefined}
todayCount={orderStats.todayCount}
pendingShip={orderStats.pendingShip}
shipping={orderStats.shipping}
completed={orderStats.completed}
/>
)}
{(isPrimary || isWarehouse) && !warehouseOk && (
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-warehouse-denied">
<span className="material-symbols-outlined text-primary">warehouse</span>
<p className="body-md" style={{ fontWeight: 600, marginTop: 8 }}></p>
<p className="label-md text-muted" style={{ marginTop: 4, lineHeight: 1.5 }}>
</p>
</div>
</section>
)}
{canStores && (
<>
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<span className="partner-dot partner-dot--green" />
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{activeStores}</p>
</div> </div>
</div> </div>
)) <div className="partner-store-stat">
)} <span className="partner-dot partner-dot--red" />
</div> <div>
<p className="label-md text-muted">/</p>
<p className="headline-md">{abnormalStores}</p>
</div>
</div>
</div>
</section>
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}> <section className="partner-expansion-card">
{String(dash?.companyName || '郑州合伙人')} · <h2 className="headline-md" style={{ marginBottom: 16 }}></h2>
</p> <div className="partner-expansion-split">
</section> <div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div className="partner-expansion-value-row">
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
{monthGrowthPct !== 0 && (
<span className={`partner-expansion-trend${monthGrowthPct > 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>
{monthGrowthPct > 0 ? 'trending_up' : 'trending_down'}
</span>
{Math.abs(monthGrowthPct)}%
</span>
)}
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
</div>
</div>
</section>
</>
)}
<LeaderboardPreview entries={leaderboardEntries} />
</main> </main>
</div> </PullToRefresh>
); );
} }
+11 -6
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types'; import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isPrimaryAccount } from '../lib/partnerAccess'; import { isPrimaryAccount } from '../lib/partnerAccess';
@@ -33,10 +34,10 @@ export default function LeaderboardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { const loadLeaderboard = useCallback(() => {
setLoading(true); setLoading(true);
setError(''); setError('');
fetchPartnerLeaderboard(period) return fetchPartnerLeaderboard(period)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
setData(null); setData(null);
@@ -45,9 +46,13 @@ export default function LeaderboardPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [period]); }, [period]);
useEffect(() => {
void loadLeaderboard();
}, [loadLeaderboard]);
return ( return (
<div className="page-no-tab"> <PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab">
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} /> <PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
<div className="partner-leaderboard-tabs"> <div className="partner-leaderboard-tabs">
{PERIOD_TABS.map((tab) => ( {PERIOD_TABS.map((tab) => (
@@ -124,6 +129,6 @@ export default function LeaderboardPage() {
<span className="material-symbols-outlined">add</span> <span className="material-symbols-outlined">add</span>
</Link> </Link>
)} )}
</div> </PullToRefresh>
); );
} }
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
type LegalPageProps = {
docId: LegalDocument['id'];
/** 返回登录页的路径,如 /login */
backTo?: string;
};
/** H5 各端共用的协议/隐私正文页 */
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
const doc = getLegalDocument(docId);
return (
<div className="legal-h5-page">
<header className="legal-h5-header">
<Link to={backTo} className="legal-h5-back" aria-label="返回">
</Link>
<h1 className="legal-h5-title">{doc.title}</h1>
</header>
<main className="legal-h5-body">
<p className="legal-h5-updated">{doc.updatedAt}</p>
<p className="legal-h5-intro">{doc.intro}</p>
{doc.sections.map((section) => (
<section key={section.heading} className="legal-h5-section">
<h2>{section.heading}</h2>
{section.paragraphs.map((p, i) => (
<p key={`${section.heading}-${i}`}>{p}</p>
))}
</section>
))}
</main>
</div>
);
}
+40 -173
View File
@@ -1,32 +1,42 @@
import { useEffect, useState } from 'react'; import { useRef, useState, type RefObject } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { import {
getLastPhone, getLastPhone,
getPartnerProfile, getPartnerProfile,
hasPartnerWxSession,
request, request,
saveAuth, saveRememberedSession,
type PartnerSessionPayload, type PartnerSessionPayload,
} from '../lib/api'; } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
import {
bindPartnerWechatAfterSmsLogin,
canPartnerUseWechatLogin,
fetchClientConfig,
loginPartnerWithWechat,
PARTNER_WECHAT_LOGIN_HINT,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
const REMEMBER_PHONE_KEY = 'partner_remember_phone'; const REMEMBER_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account'; const REMEMBER_FLAG_KEY = 'partner_remember_account';
function maskPhone(phone: string) { function AgreementCheckbox({
if (phone.length < 7) return phone; agreed,
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`; onChange,
inputRef,
}: {
agreed: boolean;
onChange: (next: boolean) => void;
inputRef?: RefObject<HTMLLabelElement>;
}) {
return (
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
<input type="checkbox" checked={agreed} onChange={(e) => onChange(e.target.checked)} />
<span>
{' '}
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>{' '}
{' '}
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
);
} }
function loadRememberedPhone(): { phone: string; remember: boolean } { function loadRememberedPhone(): { phone: string; remember: boolean } {
@@ -50,44 +60,24 @@ function formatPartnerError(e: unknown): string {
return text; return text;
} }
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession, refresh, account } = usePartnerSession(); const { applySession, refresh, account } = usePartnerSession();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getPartnerProfile(); const savedProfile = getPartnerProfile();
const remembered = loadRememberedPhone(); const remembered = loadRememberedPhone();
const [phone, setPhone] = useState(remembered.phone || getLastPhone()); const [phone, setPhone] = useState(remembered.phone || getLastPhone());
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [rememberAccount, setRememberAccount] = useState(remembered.remember); const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true); const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false); const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
const quickName = savedProfile?.name ?? '城市合伙人';
const quickCompany = savedProfile?.companyName ?? '';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先勾选并同意用户协议'); setMsg('请先勾选并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false; return false;
} }
return true; return true;
@@ -147,14 +137,9 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone, code }),
silent: true, silent: true,
}); });
saveAuth(data); saveRememberedSession(data);
applySession(data); applySession(data);
persistRememberAccount(phone); persistRememberAccount(phone);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
return;
}
await finishLoginNavigate(); await finishLoginNavigate();
} catch (e) { } catch (e) {
setMsg(formatPartnerError(e)); setMsg(formatPartnerError(e));
@@ -163,110 +148,6 @@ export default function LoginPage() {
} }
} }
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg(WECHAT_INAPP_REQUIRED_MSG);
return;
}
const canUseWechat = await canPartnerUseWechatLogin({
profile: savedProfile,
phone,
});
if (!canUseWechat) {
setMsg(PARTNER_WECHAT_LOGIN_HINT);
return;
}
setWxLoading(true);
try {
const session = await loginPartnerWithWechat();
if (session) {
applySession(session);
await finishLoginNavigate();
}
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
const canWechatQuick =
wxAuthorize &&
isWechatEnv() &&
hasPartnerWxSession() &&
!!savedProfile &&
savedProfile.hasWechat === true;
return (
<div className="partner-auth-page partner-auth-page--quick">
<header className="partner-auth-brand">
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
</div>
<h1 className="partner-auth-title" style={{ fontSize: 20 }}></h1>
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}></p>
</header>
<section className="partner-glass-card">
<div className="partner-quick-badge"></div>
<div className="partner-quick-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ textAlign: 'center' }}>
<h2 className="headline-md">
{quickName}
{quickCompany ? (
<span className="text-muted body-md" style={{ fontWeight: 400 }}> ({quickCompany})</span>
) : null}
</h2>
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>
{quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'}
</p>
</div>
</section>
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
{canWechatQuick ? (
<button
type="button"
className="partner-btn-primary"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="partner-auth-msg" style={{ textAlign: 'center', marginBottom: 12 }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
</Link>
)}
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}></Link>
</nav>
<footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span>
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
</span>
</div>
</footer>
</div>
);
}
return ( return (
<div className="partner-auth-page"> <div className="partner-auth-page">
<div className="partner-auth-brand"> <div className="partner-auth-brand">
@@ -321,38 +202,24 @@ export default function LoginPage() {
<span></span> <span></span>
</label> </label>
<AgreementCheckbox
agreed={agreed}
onChange={setAgreed}
inputRef={agreementRef}
/>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>} {msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}> <button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
<span>{loading ? '登录中...' : '登录'}</span> <span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>} {!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button> </button>
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
{wxAuthorize && ( 7
<> </p>
<div className="partner-auth-divider"><span></span></div>
<button type="button" className="partner-btn-wechat" onClick={() => void wechatLogin()} disabled={wxLoading}>
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
</svg>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
</>
)}
<label className="partner-checkbox-row">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span>
<span className="text-primary" style={{ fontWeight: 600 }}></span> <span className="text-primary" style={{ fontWeight: 600 }}></span>
</span>
</label>
</div> </div>
</main> </main>
{hasPartnerWxSession() && savedProfile?.hasWechat && (
<Link to="/login?quick=1" className="partner-link"></Link>
)}
<footer className="partner-auth-footer"> <footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span> <span className="material-symbols-outlined">verified_user</span>
+63 -50
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
const TIMELINE = [ const TIMELINE = [
{ key: 'confirm', label: '待确认' }, { key: 'confirm', label: '待确认' },
@@ -28,21 +29,55 @@ function statusBanner(status: string) {
return { title: status, desc: '', icon: 'receipt_long' }; return { title: status, desc: '', icon: 'receipt_long' };
} }
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
export default function OrderDetailPage() { export default function OrderDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [order, setOrder] = useState<Record<string, unknown> | null>(null); const [order, setOrder] = useState<Record<string, unknown> | null>(null);
const [shipping, setShipping] = useState(false);
useEffect(() => { useEffect(() => {
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); document.title = '订单详情';
}, []);
useEffect(() => {
if (id) void request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
}, [id]); }, [id]);
async function reload() {
if (!id) return;
const next = await request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`);
setOrder(next);
}
async function advance(status: string) { async function advance(status: string) {
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, { if (!id) return;
method: 'POST', setShipping(true);
body: JSON.stringify({ targetStatus: status }), try {
}); await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder); method: 'POST',
body: JSON.stringify({ targetStatus: status }),
});
await reload();
toastSuccess('已更新配送状态');
} finally {
setShipping(false);
}
}
function handleContactCourier() {
const delivery = order?.delivery as Record<string, unknown> | undefined;
const trackingNo = String(delivery?.trackingNo || delivery?.providerOrderNo || '').trim();
if (trackingNo) {
void navigator.clipboard?.writeText(trackingNo);
toastSuccess(`运单号已复制:${trackingNo}`);
return;
}
toastError('暂无配送员联系方式');
} }
if (!order) return <div className="empty">...</div>; if (!order) return <div className="empty">...</div>;
@@ -50,6 +85,7 @@ export default function OrderDetailPage() {
const banner = statusBanner(String(order.status)); const banner = statusBanner(String(order.status));
const currentIdx = statusIndex(String(order.status)); const currentIdx = statusIndex(String(order.status));
const payAmount = Number(order.payAmount || 0); const payAmount = Number(order.payAmount || 0);
const showShip = canShip(String(order.status));
return ( return (
<div className="partner-order-detail"> <div className="partner-order-detail">
@@ -91,9 +127,6 @@ export default function OrderDetailPage() {
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}> <div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span> <span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
</div> </div>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(166,29,36,0.8)', textAlign: 'center', padding: '2px 0' }}>
<span className="label-md" style={{ color: '#fff', fontSize: 10 }}></span>
</div>
</div> </div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3> <h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
@@ -106,39 +139,6 @@ export default function OrderDetailPage() {
</div> </div>
</section> </section>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, margin: '0 20px' }}>
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-heritage-red)' }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<div className="partner-info-row">
<span className="label-md text-muted"></span>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
</div>
<div className="partner-info-row">
<span className="label-md text-muted"></span>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
</div>
</div>
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-aged-amber)' }}>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="amount-lg" style={{ color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</span>
</div>
</div>
<section className="partner-detail-section">
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
</h4>
<div className="partner-info-row">
<span className="text-muted body-md"></span>
<span className="body-md">{String(order.orderNo)}</span>
</div>
<div className="partner-info-row">
<span className="text-muted body-md"></span>
<span className="body-md">{String(order.status)}</span>
</div>
</section>
<section className="partner-detail-section"> <section className="partner-detail-section">
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}> <h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} /> <span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
@@ -156,21 +156,34 @@ export default function OrderDetailPage() {
</section> </section>
<footer className="partner-order-footer"> <footer className="partner-order-footer">
<div> <div className="partner-order-footer-actions">
<span className="label-md text-muted"></span> {showShip && (
<p className="headline-md text-primary" style={{ fontWeight: 700 }}>¥{(payAmount * 0.08).toFixed(2)}</p> <button
type="button"
className="btn btn-primary partner-order-ship-btn--footer"
disabled={shipping}
onClick={() => void advance('OUT_WAREHOUSE')}
>
{shipping ? '发货中…' : '确认发货'}
</button>
)}
<button
type="button"
className="btn btn-outline"
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}
onClick={handleContactCourier}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
</button>
</div> </div>
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
</button>
</footer> </footer>
{import.meta.env.DEV && ( {import.meta.env.DEV && (
<details className="partner-dev-tools"> <details className="partner-dev-tools">
<summary>Dev: Mock </summary> <summary>Dev: Mock </summary>
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => ( {['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
<button key={s} type="button" onClick={() => advance(s)}>{s}</button> <button key={s} type="button" onClick={() => void advance(s)}>{s}</button>
))} ))}
</details> </details>
)} )}
+184 -45
View File
@@ -1,7 +1,10 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess';
type DateFilter = 'today' | '7d' | '30d'; type DateFilter = 'today' | '7d' | '30d';
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL'; type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
@@ -29,17 +32,59 @@ const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ABNORMAL', label: '异常' }, { key: 'ABNORMAL', label: '异常' },
]; ];
export default function OrderListPage() { type OrderListPageProps = {
/** Tab 根页:无返回顶栏 */
tabRoot?: boolean;
};
type OrdersResponse = {
list: Array<Record<string, unknown>>;
hasWarehouseAccess?: boolean;
message?: string;
};
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] }); const { account } = usePartnerSession();
const warehouseOk = hasWarehouseAccess(account);
const [data, setData] = useState<OrdersResponse>({ list: [] });
const [tab, setTab] = useState<'orders' | 'coupons'>('orders'); const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
const [dateFilter, setDateFilter] = useState<DateFilter>('today'); const [dateFilter, setDateFilter] = useState<DateFilter>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [shippingId, setShippingId] = useState<string | null>(null);
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
const [shipModalId, setShipModalId] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } document.title = '订单管理';
request('PARTNER_H5', '/partner/orders').then(setData); }, []);
}, [navigate]);
const loadOrders = useCallback(() => {
if (!isLoggedIn()) {
navigate('/login');
return Promise.resolve();
}
if (!warehouseOk) {
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
setLoaded(true);
return Promise.resolve();
}
return request<OrdersResponse>('PARTNER_H5', '/partner/orders')
.then((res) => {
setData({
list: Array.isArray(res.list) ? res.list : [],
hasWarehouseAccess: res.hasWarehouseAccess,
message: res.message,
});
})
.catch(() => setData({ list: [] }))
.finally(() => setLoaded(true));
}, [navigate, warehouseOk]);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
const filtered = useMemo(() => data.list.filter((o) => { const filtered = useMemo(() => data.list.filter((o) => {
if (statusFilter === 'ALL') return true; if (statusFilter === 'ALL') return true;
@@ -51,13 +96,68 @@ export default function OrderListPage() {
return true; return true;
}), [data.list, statusFilter]); }), [data.list, statusFilter]);
async function handleShip(orderId: string, e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setShipModalId(orderId);
setShipForm({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
}
async function submitManualShip() {
if (!shipModalId) return;
if (!shipForm.logisticsCompany.trim() || !shipForm.trackingNo.trim()) {
window.alert('请填写快递公司和运单号');
return;
}
setShippingId(shipModalId);
try {
await request('PARTNER_H5', `/partner/orders/${shipModalId}/manual-ship`, {
method: 'POST',
body: JSON.stringify(shipForm),
});
setShipModalId(null);
const next = await request<OrdersResponse>('PARTNER_H5', '/partner/orders');
setData({
list: Array.isArray(next.list) ? next.list : [],
hasWarehouseAccess: next.hasWarehouseAccess,
message: next.message,
});
} catch (err) {
window.alert(err instanceof Error ? err.message : '发货失败');
} finally {
setShippingId(null);
}
}
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
if (loaded && !warehouseOk) {
return (
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-warehouse-denied" style={{ margin: 20, padding: 32, textAlign: 'center' }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 40 }}>warehouse</span>
<p className="headline-md" style={{ marginTop: 12 }}></p>
<p className="body-md text-muted" style={{ marginTop: 8, lineHeight: 1.6 }}>
</p>
</div>
</div>
);
}
return ( return (
<div className="page-no-tab partner-orders-page"> <PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
<PageHeader title="订单中心" onBack={() => navigate('/')} /> {!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment"> <div className="partner-segment">
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}></button> <button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}></button>
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button> {!tabRoot && (
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button>
)}
</div> </div>
{tab === 'orders' && ( {tab === 'orders' && (
@@ -82,50 +182,45 @@ export default function OrderListPage() {
{filtered.map((o) => { {filtered.map((o) => {
const st = orderStatusLabel(String(o.status)); const st = orderStatusLabel(String(o.status));
const payAmount = Number(o.payAmount || 0); const payAmount = Number(o.payAmount || 0);
const orderId = String(o.id);
return ( return (
<Link key={String(o.id)} to={`/orders/${o.id}`} className="partner-order-card"> <div key={orderId} className="partner-order-card-wrap">
<div className="partner-order-card-top"> <Link to={`/orders/${orderId}`} className="partner-order-card">
<span className="label-md text-muted">NO. {String(o.orderNo)}</span> <div className="partner-order-card-top">
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span> <span className="label-md text-muted">NO. {String(o.orderNo)}</span>
</div> <span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
<div className="partner-order-product">
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="material-symbols-outlined text-muted">liquor</span>
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div className="partner-order-product">
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3> <div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p> <span className="material-symbols-outlined text-muted">liquor</span>
<span className="partner-benefit-tag"> </div>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span> <div style={{ flex: 1, minWidth: 0 }}>
¥{Math.round(payAmount * 0.2)} <h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
</span> <p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
</div>
</div> </div>
</div> <div className="partner-order-address">
<div className="partner-order-address"> <span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span> <p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
</div>
<div className="partner-order-commission">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥{(payAmount * 0.04).toFixed(2)}</p>
</div> </div>
<div> </Link>
<p className="label-md text-muted"></p> {canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p> <button
</div> type="button"
<div> className="partner-order-ship-btn"
<p className="label-md text-muted"></p> disabled={shippingId === orderId}
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p> onClick={(e) => void handleShip(orderId, e)}
</div> >
</div>
</Link> </button>
)}
</div>
); );
})} })}
</> </>
)} )}
{tab === 'coupons' && ( {tab === 'coupons' && !tabRoot && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<div className="partner-revenue-card" style={{ marginBottom: 16 }}> <div className="partner-revenue-card" style={{ marginBottom: 16 }}>
<p className="partner-revenue-label"></p> <p className="partner-revenue-label"></p>
@@ -134,6 +229,50 @@ export default function OrderListPage() {
<p className="text-muted body-md text-center"> preV1 </p> <p className="text-muted body-md text-center"> preV1 </p>
</div> </div>
)} )}
</div>
{shipModalId && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setShipModalId(null)}>
<div className="partner-ship-modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md"></h3>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.logisticsCompany}
onChange={(e) => setShipForm((f) => ({ ...f, logisticsCompany: e.target.value }))}
placeholder="如 顺丰速运"
/>
</label>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.trackingNo}
onChange={(e) => setShipForm((f) => ({ ...f, trackingNo: e.target.value }))}
/>
</label>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.manualQueryUrl}
onChange={(e) => setShipForm((f) => ({ ...f, manualQueryUrl: e.target.value }))}
placeholder="https://..."
/>
</label>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setShipModalId(null)}>
</button>
<button
type="button"
className="partner-btn-primary"
disabled={shippingId === shipModalId}
onClick={() => void submitManualShip()}
>
{shippingId === shipModalId ? '提交中…' : '确认发货'}
</button>
</div>
</div>
</div>
)}
</PullToRefresh>
); );
} }
+7 -97
View File
@@ -1,102 +1,12 @@
import { useEffect, useState } from 'react'; import { getPartnerNavKind } from '../lib/partnerAccess';
import { PARTNER_STAFF_ROLE_LABELS, type PartnerStaffRole } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { toastError, toastSuccess } from '../lib/toast'; import CenterPage from './CenterPage';
/** 子账号个人中心:复用精简版 Center 布局 */
export default function PartnerMePage() { export default function PartnerMePage() {
const { account, refresh, logout } = usePartnerSession(); const { account } = usePartnerSession();
const [name, setName] = useState(account?.name ?? ''); const navKind = getPartnerNavKind(account);
const [saving, setSaving] = useState(false); const isWarehouse = navKind === 'warehouse_staff';
useEffect(() => { return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
setName(account?.name ?? '');
}, [account?.name]);
const roleLabel = account?.staffRole
? PARTNER_STAFF_ROLE_LABELS[account.staffRole as PartnerStaffRole] || account.staffRole
: '拓店账号';
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
toastError('请输入姓名');
return;
}
if (trimmed === account?.name) {
toastSuccess('已保存');
return;
}
setSaving(true);
try {
await request('PARTNER_H5', '/partner/me', {
method: 'PUT',
body: JSON.stringify({ name: trimmed }),
});
await refresh();
toastSuccess('已保存');
} catch (e) {
toastError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<div className="page partner-me-page">
<section className="partner-profile-card" style={{ margin: '16px var(--space-page)' }}>
<div className="partner-profile-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ flex: 1 }}>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '我的'}</h2>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{roleLabel}</p>
{account?.companyName ? (
<p className="body-md text-muted" style={{ marginTop: 4 }}>{account.companyName}</p>
) : null}
</div>
</section>
<section className="partner-form-section" style={{ padding: '0 var(--space-page)' }}>
<label className="partner-form-label" htmlFor="me-name"></label>
<input
id="me-name"
className="partner-form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名"
maxLength={32}
/>
<label className="partner-form-label" style={{ marginTop: 16 }}></label>
<input
className="partner-form-input"
value={account?.phone ?? ''}
readOnly
disabled
/>
<p className="label-md text-muted" style={{ marginTop: 4 }}></p>
</section>
<div style={{ padding: '24px var(--space-page) 100px' }}>
<button
type="button"
className="partner-btn-primary"
style={{ width: '100%' }}
disabled={saving}
onClick={() => void handleSave()}
>
{saving ? '保存中…' : '保存'}
</button>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={logout}
>
退
</button>
</div>
</div>
);
} }
+286 -112
View File
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker'; import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region'; import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import type { import type {
PartnerProxyDeliveryMode,
PartnerProxyOrderCreateRequest, PartnerProxyOrderCreateRequest,
PartnerProxyOrderOptions, PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult, PartnerProxyOrderPreviewResult,
@@ -17,19 +18,25 @@ function fmtMoney(n: number) {
export default function ProxyOrderPage() { export default function ProxyOrderPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null); const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true); const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState(''); const [customerSmsCode, setCustomerSmsCode] = useState('');
const [partnerSmsCode, setPartnerSmsCode] = useState('');
const [receiverName, setReceiverName] = useState(''); const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]); const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState(''); const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState(''); const [productId, setProductId] = useState('');
const [quantity, setQuantity] = useState(2); const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState(''); const [promoCodeId, setPromoCodeId] = useState('');
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
const [autoReceive, setAutoReceive] = useState(false);
const [confirmStep, setConfirmStep] = useState(false);
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null); const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false); const [previewLoading, setPreviewLoading] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0); const [customerCooldown, setCustomerCooldown] = useState(0);
const [partnerCooldown, setPartnerCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
@@ -39,12 +46,26 @@ export default function ProxyOrderPage() {
useEffect(() => { useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options') request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => { .then((data) => {
setOptions(data); setOptions({
if (data.products[0]) setProductId(data.products[0].id); ...data,
stores: Array.isArray(data.stores) ? data.stores : [],
});
const fromQuery = searchParams.get('productId');
if (fromQuery && data.products.some((p) => p.id === fromQuery)) {
setProductId(fromQuery);
} else if (data.products[0] && !productId) {
setProductId(data.products[0].id);
}
}) })
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败')) .catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false)); .finally(() => setLoadingOptions(false));
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps -- hydrate once; productId from query
}, [searchParams]);
useEffect(() => {
const fromQuery = searchParams.get('productId');
if (fromQuery) setProductId(fromQuery);
}, [searchParams]);
useEffect(() => { useEffect(() => {
if (!productId || quantity < 1) { if (!productId || quantity < 1) {
@@ -58,8 +79,9 @@ export default function ProxyOrderPage() {
body: JSON.stringify({ body: JSON.stringify({
productId, productId,
quantity, quantity,
receiverCity: region.city || undefined, deliveryMode,
receiverDistrict: region.district || undefined, receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
}), }),
silent: true, silent: true,
}) })
@@ -68,67 +90,109 @@ export default function ProxyOrderPage() {
.finally(() => setPreviewLoading(false)); .finally(() => setPreviewLoading(false));
}, 300); }, 300);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [productId, quantity, region.city, region.district]); }, [productId, quantity, deliveryMode, region?.city, region?.district]);
async function sendSms() { function startCooldown(setter: (n: number | ((s: number) => number)) => void) {
setter(60);
const timer = setInterval(() => {
setter((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
}
async function sendCustomerSms() {
setMsg(''); setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) { if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号'); setMsg('请输入有效手机号');
return; return;
} }
try { try {
const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', { const res = await request<{ maskedPhone: string }>(
method: 'POST', 'PARTNER_H5',
body: JSON.stringify({ phone: phone.trim() }), '/partner/proxy-orders/send-customer-sms',
silent: true, {
}); method: 'POST',
setMsg(`验证码已发送至 ${res.maskedPhone}`); body: JSON.stringify({ phone: phone.trim() }),
setSmsCooldown(60); silent: true,
const timer = setInterval(() => { },
setSmsCooldown((s) => { );
if (s <= 1) { setMsg(`客户验证码已发送至 ${res.maskedPhone}`);
clearInterval(timer); startCooldown(setCustomerCooldown);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败'); setMsg(e instanceof Error ? e.message : '发送失败');
} }
} }
async function sendPartnerSms() {
setMsg('');
try {
const res = await request<{ maskedPhone: string }>(
'PARTNER_H5',
'/partner/proxy-orders/send-partner-sms',
{ method: 'POST', body: '{}', silent: true },
);
setMsg(`确认验证码已发送至合伙人手机 ${res.maskedPhone}`);
startCooldown(setPartnerCooldown);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
function validateForm(): string | null {
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
if (!customerSmsCode.trim()) return '请输入客户验证码';
if (!productId) return '请选择商品';
if (deliveryMode !== 'ADDRESS') {
// 现场提货不需要地址与门店
} else {
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
if (!addressDetail.trim()) return '请填写详细地址';
if (!autoReceive) return '配送到址须勾选同意自动收货';
}
if (!preview) return '请等待费用计算完成';
return null;
}
async function openConfirmStep() {
setMsg('');
const err = validateForm();
if (err) {
setMsg(err);
return;
}
setConfirmStep(true);
setPartnerSmsCode('');
await sendPartnerSms();
}
async function submit() { async function submit() {
setMsg(''); setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) { const err = validateForm();
setMsg('请输入有效手机号'); if (err) {
setMsg(err);
return; return;
} }
if (!smsCode.trim()) { if (!partnerSmsCode.trim()) {
setMsg('请输入验证码'); setMsg('请输入合伙人确认验证码');
return;
}
if (!region?.province || !region.city || !region.district) {
setMsg('请选择省市区');
return;
}
if (!addressDetail.trim()) {
setMsg('请填写详细地址');
return;
}
if (!productId) {
setMsg('请选择商品');
return; return;
} }
const payload: PartnerProxyOrderCreateRequest = { const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(), phone: phone.trim(),
smsCode: smsCode.trim(), customerSmsCode: customerSmsCode.trim(),
partnerSmsCode: partnerSmsCode.trim(),
deliveryMode,
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
receiverName: receiverName.trim() || undefined, receiverName: receiverName.trim() || undefined,
province: region.province, province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
city: region.city, city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
district: region.district, district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
addressDetail: addressDetail.trim(), addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
productId, productId,
quantity, quantity,
promoCodeId: promoCodeId || undefined, promoCodeId: promoCodeId || undefined,
@@ -151,6 +215,12 @@ export default function ProxyOrderPage() {
} }
const selectedProduct = options?.products.find((p) => p.id === productId); const selectedProduct = options?.products.find((p) => p.id === productId);
const deliveryLabel =
preview?.deliveryType === 'ON_SITE_PICKUP'
? '现场提货'
: preview?.deliveryType === 'CROSS_CITY'
? '跨城配送'
: '同城配送';
return ( return (
<div className="page partner-proxy-order-page"> <div className="page partner-proxy-order-page">
@@ -158,7 +228,7 @@ export default function ProxyOrderPage() {
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}> <main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
{loadingOptions ? ( {loadingOptions ? (
<p className="label-md text-muted">广</p> <p className="label-md text-muted"></p>
) : ( ) : (
<> <>
<section className="partner-form-section"> <section className="partner-form-section">
@@ -175,70 +245,49 @@ export default function ProxyOrderPage() {
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
disabled={smsCooldown > 0} disabled={customerCooldown > 0}
onClick={() => void sendSms()} onClick={() => void sendCustomerSms()}
> >
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'} {customerCooldown > 0 ? `${customerCooldown}s` : '获取验证码'}
</button> </button>
</div> </div>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
<label className="partner-form-label"></label> <label className="partner-form-label"></label>
<div className="partner-input-wrap"> <div className="partner-input-wrap">
<input <input
className="partner-input" className="partner-input"
placeholder="线下代发货确认码" placeholder="发至客户手机"
value={smsCode} value={customerSmsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))} onChange={(e) => setCustomerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/> />
</div> </div>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
<label className="partner-form-label"></label> <label className="partner-form-label"></label>
<div className="partner-input-wrap"> <button
<input type="button"
className="partner-input" className="partner-proxy-product-picker"
placeholder="默认:用户+手机尾号" onClick={() =>
value={receiverName} navigate(
onChange={(e) => setReceiverName(e.target.value)} `/proxy-order/products${productId ? `?selected=${encodeURIComponent(productId)}` : ''}`,
/> )
</div> }
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel && <p className="label-md text-muted" style={{ marginTop: 8 }}>{regionLabel}</p>}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<select
className="partner-input"
value={productId}
onChange={(e) => setProductId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
> >
{(options?.products ?? []).map((p) => ( {selectedProduct ? (
<option key={p.id} value={p.id}> <>
{p.name}{p.spec}¥{fmtMoney(p.price)} <span className="partner-proxy-product-name">{selectedProduct.name}</span>
</option> <span className="label-md text-muted">
))} {selectedProduct.spec} · ¥{fmtMoney(selectedProduct.price)}
</select> </span>
</>
) : (
<span className="label-md text-muted"></span>
)}
<span className="partner-proxy-product-picker-arrow"></span>
</button>
</section> </section>
<section className="partner-form-section"> <section className="partner-form-section">
@@ -261,7 +310,12 @@ export default function ProxyOrderPage() {
className="partner-input" className="partner-input"
value={promoCodeId} value={promoCodeId}
onChange={(e) => setPromoCodeId(e.target.value)} onChange={(e) => setPromoCodeId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }} style={{
width: '100%',
padding: '12px 14px',
borderRadius: 12,
border: '1px solid var(--color-border)',
}}
> >
<option value=""></option> <option value=""></option>
{options!.promoCodes.map((p) => ( {options!.promoCodes.map((p) => (
@@ -273,6 +327,73 @@ export default function ProxyOrderPage() {
</section> </section>
)} )}
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-proxy-mode-tabs">
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ADDRESS' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ADDRESS')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${deliveryMode === 'ON_SITE_PICKUP' ? ' is-active' : ''}`}
onClick={() => setDeliveryMode('ON_SITE_PICKUP')}
>
</button>
</div>
</section>
{deliveryMode === 'ADDRESS' ? (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="默认:用户+手机尾号"
value={receiverName}
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{regionLabel}
</p>
) : null}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<label className="partner-proxy-auto-receive">
<input
type="checkbox"
checked={autoReceive}
onChange={(e) => setAutoReceive(e.target.checked)}
/>
<span>线</span>
</label>
</>
) : null}
<section className="partner-proxy-fee-card"> <section className="partner-proxy-fee-card">
<h3 className="headline-md"></h3> <h3 className="headline-md"></h3>
{previewLoading ? ( {previewLoading ? (
@@ -288,8 +409,8 @@ export default function ProxyOrderPage() {
<span className="body-md">×{quantity}</span> <span className="body-md">×{quantity}</span>
</div> </div>
<div className="partner-proxy-fee-row"> <div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span> <span className="label-md text-muted"></span>
<span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span> <span className="body-md">{deliveryLabel}</span>
</div> </div>
<div className="partner-proxy-fee-row"> <div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span> <span className="label-md text-muted"></span>
@@ -302,25 +423,78 @@ export default function ProxyOrderPage() {
</> </>
) : ( ) : (
<p className="label-md text-muted"> <p className="label-md text-muted">
{selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'} {selectedProduct ? '请确认数量与履约信息后查看费用' : '请选择商品'}
</p> </p>
)} )}
</section> </section>
{msg && <p className="partner-form-error" role="alert">{msg}</p>} {confirmStep ? (
<section className="partner-form-section partner-proxy-confirm">
<label className="partner-form-label"></label>
<p className="label-md text-muted" style={{ marginBottom: 8 }}>
线
</p>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<input
className="partner-input"
placeholder="发至合伙人手机"
value={partnerSmsCode}
onChange={(e) => setPartnerSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={partnerCooldown > 0}
onClick={() => void sendPartnerSms()}
>
{partnerCooldown > 0 ? `${partnerCooldown}s` : '重新发送'}
</button>
</div>
</section>
) : null}
<button {msg ? (
type="button" <p className="partner-form-error" role="alert">
className="btn btn-primary btn-block" {msg}
disabled={submitting || !preview} </p>
onClick={() => void submit()} ) : null}
style={{ marginTop: 16 }}
> {!confirmStep ? (
{submitting ? '提交中…' : '验证码确认并代下单'} <button
</button> type="button"
className="btn btn-primary btn-block"
disabled={!preview}
onClick={() => void openConfirmStep()}
style={{ marginTop: 16 }}
>
</button>
) : (
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button
type="button"
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => setConfirmStep(false)}
>
</button>
<button
type="button"
className="btn btn-primary"
style={{ flex: 1 }}
disabled={submitting || !preview}
onClick={() => void submit()}
>
{submitting ? '提交中…' : '验证并提交'}
</button>
</div>
)}
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}> <p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
/线 线/
</p> </p>
</> </>
)} )}
@@ -0,0 +1,69 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError } from '../lib/toast';
import type { PartnerProxyOrderOptions, PartnerProxyOrderProductOption } from '@dukang/shared-types';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderProductsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectedId = searchParams.get('selected') || '';
const [products, setProducts] = useState<PartnerProxyOrderProductOption[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => setProducts(Array.isArray(data.products) ? data.products : []))
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, []);
function pick(product: PartnerProxyOrderProductOption) {
navigate(`/proxy-order?productId=${encodeURIComponent(product.id)}`, { replace: true });
}
return (
<div className="page partner-proxy-order-page">
<PageHeader title="选择酒品" onBack={() => navigate(-1)} />
<main className="partner-proxy-product-list">
{loading ? <p className="label-md text-muted"></p> : null}
{!loading && products.length === 0 ? (
<p className="label-md text-muted"></p>
) : null}
{products.map((p) => {
const benefit = p.benefitAmount != null ? Number(p.benefitAmount) : Number(p.price);
const active = p.id === selectedId;
return (
<button
key={p.id}
type="button"
className={`partner-proxy-product-card${active ? ' partner-proxy-product-card--active' : ''}`}
onClick={() => pick(p)}
>
<div className="partner-proxy-product-thumb">
{p.coverUrl ? (
<img src={p.coverUrl} alt="" />
) : (
<span className="partner-proxy-product-thumb-empty" />
)}
</div>
<div className="partner-proxy-product-main">
<div className="partner-proxy-product-row">
<span className="partner-proxy-product-name">{p.name}</span>
<span className="partner-proxy-product-price">¥{fmtMoney(p.price)}</span>
</div>
<p className="partner-proxy-product-spec">{p.spec}</p>
<p className="partner-proxy-product-benefit"> ¥{fmtMoney(benefit)}</p>
</div>
</button>
);
})}
</main>
</div>
);
}
+32 -18
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing'; type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
function fmtMoney(n: number) { function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -12,18 +13,20 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) { function billStatusLabel(status: string) {
switch (status) { switch (status) {
case 'DRAFT': return '待结算'; case 'AWAITING_CONFIRM': return '待确认';
case 'CONFIRMED': return '审核中'; case 'UNPAID': return '未打款';
case 'PAID': return '已结算'; case 'PAID': return '已打款';
case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
} }
function billStatusClass(status: string) { function billStatusClass(status: string) {
switch (status) { switch (status) {
case 'DRAFT': return 'partner-settlement-status--pending'; case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending';
case 'CONFIRMED': return 'partner-settlement-status--reviewing'; case 'UNPAID': return 'partner-settlement-status--reviewing';
case 'PAID': return 'partner-settlement-status--settled'; case 'PAID': return 'partner-settlement-status--settled';
case 'REJECTED': return 'partner-settlement-status--rejected';
default: return ''; default: return '';
} }
} }
@@ -45,13 +48,17 @@ export default function SettlementPage() {
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 }); const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const loadBills = useCallback(() => {
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, []);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills); void loadBills();
}, [navigate]); }, [navigate, loadBills]);
const summary = useMemo(() => { const summary = useMemo(() => {
const pending = bills.filter((b) => b.status === 'DRAFT'); const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
const settled = bills.filter((b) => b.status === 'PAID'); const settled = bills.filter((b) => b.status === 'PAID');
const currentMonth = bills.filter((b) => { const currentMonth = bills.filter((b) => {
const d = new Date(b.periodStart); const d = new Date(b.periodStart);
@@ -70,9 +77,10 @@ export default function SettlementPage() {
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false; if (!matchMonth) return false;
if (statusFilter === 'all') return true; if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'DRAFT'; if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
if (statusFilter === 'settled') return b.status === 'PAID'; if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED'; if (statusFilter === 'reviewing') return b.status === 'UNPAID';
if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true; return true;
}); });
}, [bills, month, statusFilter]); }, [bills, month, statusFilter]);
@@ -92,13 +100,14 @@ export default function SettlementPage() {
const statusTabs: Array<{ key: StatusFilter; label: string }> = [ const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
{ key: 'pending', label: '待结算' }, { key: 'pending', label: '待确认' },
{ key: 'settled', label: '已结算' }, { key: 'reviewing', label: '未打款' },
{ key: 'reviewing', label: '审核中' }, { key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已打款' },
]; ];
return ( return (
<div className="page-no-tab partner-settlement-page"> <PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page">
<PageHeader title="财务对账中心" onBack={() => navigate('/')} /> <PageHeader title="财务对账中心" onBack={() => navigate('/')} />
<main className="partner-settlement-body"> <main className="partner-settlement-body">
@@ -188,7 +197,12 @@ export default function SettlementPage() {
<span className="partner-settlement-dot" /> <span className="partner-settlement-dot" />
¥{fmtMoney(Number(bill.orderCommission || 0))} ¥{fmtMoney(Number(bill.orderCommission || 0))}
</p> </p>
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' ? ' text-primary' : ''}`}> {bill.status === 'REJECTED' && bill.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 6 }}>
{bill.rejectReason}
</p>
) : null}
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))} ¥{fmtMoney(Number(bill.totalAmount || 0))}
</p> </p>
</div> </div>
@@ -202,6 +216,6 @@ export default function SettlementPage() {
)} )}
</section> </section>
</main> </main>
</div> </PullToRefresh>
); );
} }
+3 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { import {
AccountStatus, AccountStatus,
PARTNER_STAFF_ROLE_LABELS, PARTNER_STAFF_ROLE_LABELS,
@@ -86,7 +87,7 @@ export default function StaffListPage() {
} }
return ( return (
<div className="page-no-tab partner-staff-page"> <PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page">
<PageHeader title="子账号管理" onBack={() => navigate('/center')} /> <PageHeader title="子账号管理" onBack={() => navigate('/center')} />
<div style={{ padding: '0 20px 16px' }}> <div style={{ padding: '0 20px 16px' }}>
@@ -151,6 +152,6 @@ export default function StaffListPage() {
</Link> </Link>
</div> </div>
</div> </PullToRefresh>
); );
} }
+373 -327
View File
@@ -1,9 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker'; import ChinaRegionPicker from '../components/ChinaRegionPicker';
import OssUploadField from '../components/OssUploadField'; import OssUploadField from '../components/OssUploadField';
@@ -13,14 +11,15 @@ import { toastError, toastSuccess } from '../lib/toast';
import { resolveRegionBinding } from '../lib/china-region'; import { resolveRegionBinding } from '../lib/china-region';
import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone'; import { checkStorePhoneAvailable } from '../lib/storePhone';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import { import {
@@ -48,14 +47,14 @@ import {
const STEPS = ['基本信息', '照片上传', '结算资质'] as const; const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
type StoreCategoryNode = {
id: string;
name: string;
children?: StoreCategoryNode[];
};
type FieldErrors = { type FieldErrors = {
phone?: string; phone?: string;
storeSmsCode?: string;
}; };
@@ -70,7 +69,7 @@ function isPhoneConflictMessage(message: string) {
function isPhoneValidationMessage(message: string) { function isPhoneValidationMessage(message: string) {
return message.includes('联系电话') || message.includes('手机号') || message.includes('验证码'); return message.includes('联系电话') || message.includes('手机号');
} }
@@ -80,18 +79,10 @@ export default function StoreCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { account, refresh } = usePartnerSession(); const { account } = usePartnerSession();
const subAccountLayout = isSubAccount(account);
const accountId = account?.id; const accountId = account?.id;
const wechatReady = !!account?.hasWechat;
const handleWechatReadyChange = useCallback(() => {
void refresh();
}, [refresh]);
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const saved = loadStoreDraft(accountId); const saved = loadStoreDraft(accountId);
@@ -104,15 +95,15 @@ export default function StoreCreatePage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [checkingPhone, setCheckingPhone] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]); const [cities, setCities] = useState<OpenCityOption[]>([]);
const [citiesError, setCitiesError] = useState(''); const [citiesError, setCitiesError] = useState('');
const [smsCooldown, setSmsCooldown] = useState(0); const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const [smsHint, setSmsHint] = useState(''); const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const draftSaveDisabledRef = useRef(false); const draftSaveDisabledRef = useRef(false);
@@ -145,17 +136,6 @@ export default function StoreCreatePage() {
useEffect(() => {
if (step !== 2 || !isWechatEnv()) return;
void refresh();
weixinSdk.reset();
void weixinSdk.init().catch(() => {
/* OssUploadField 点击时会再次初始化 */
});
}, [step, refresh]);
useEffect(() => { useEffect(() => {
void fetchPartnerCities() void fetchPartnerCities()
@@ -175,6 +155,26 @@ export default function StoreCreatePage() {
}, []); }, []);
useEffect(() => {
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
.then((list) => {
const tree = Array.isArray(list) ? list : [];
setCategoryTree(tree);
if (form.categoryId && !form.categoryParentId) {
const parent = tree.find((root) =>
(root.children ?? []).some((child) => child.id === form.categoryId),
);
if (parent) patchForm({ categoryParentId: parent.id });
}
})
.catch(() => setCategoryTree([]));
}, []);
const categoryChildren = useMemo(() => {
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
return Array.isArray(parent?.children) ? parent!.children! : [];
}, [categoryTree, form.categoryParentId]);
useEffect(() => { useEffect(() => {
@@ -225,25 +225,13 @@ export default function StoreCreatePage() {
setSubmitError(''); setSubmitError('');
let nextPatch = patch;
if ('phone' in patch) { if ('phone' in patch) {
setFieldErrors((prev) => ({ ...prev, phone: undefined, storeSmsCode: undefined })); setFieldErrors((prev) => ({ ...prev, phone: undefined }));
setSmsHint('');
nextPatch = { ...patch, storeSmsCode: '' };
} }
if ('storeSmsCode' in patch) { setForm((prev) => ({ ...prev, ...patch }));
setFieldErrors((prev) => ({ ...prev, storeSmsCode: undefined }));
}
setForm((prev) => ({ ...prev, ...nextPatch }));
} }
@@ -303,72 +291,6 @@ export default function StoreCreatePage() {
} }
async function sendStorePhoneCode() {
const phone = form.phone.trim();
if (!/^1\d{10}$/.test(phone)) {
setFieldErrors({ phone: '请先填写正确的11位手机号' });
return;
}
setSmsHint('');
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
try {
const phoneCheck = await checkStorePhoneAvailable(phone);
if (!phoneCheck.available) {
setFieldErrors({ phone: phoneCheck.message ?? '该手机号不可用于门店账号' });
return;
}
const res = await sendStorePhoneSms(phone);
setSmsHint(`验证码已发送至 ${res.maskedPhone}`);
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
const msg = e instanceof Error ? e.message : '验证码发送失败';
setFieldErrors({ phone: msg });
}
}
async function handleNext() { async function handleNext() {
if (step === 1) { if (step === 1) {
@@ -376,54 +298,10 @@ export default function StoreCreatePage() {
const msg = validateStoreStep1(form); const msg = validateStoreStep1(form);
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) { reportFormError(msg);
if (msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
} else {
reportFormError(msg);
}
return; return;
} }
setCheckingPhone(true);
setSubmitError('');
setFieldErrors({});
try {
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
if (!phoneCheck.available) {
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
setFieldErrors({ phone: phoneMsg });
return;
}
if (phoneCheck.needConfirm) {
const ok = window.confirm(
phoneCheck.message ??
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
);
if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
return;
}
}
} catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
return;
} finally {
setCheckingPhone(false);
}
} }
if (step === 2) { if (step === 2) {
@@ -448,6 +326,10 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form); const msg = validateStoreStep3(form);
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) {
setFieldErrors({ phone: msg });
return;
}
reportFormError(msg); reportFormError(msg);
return; return;
} }
@@ -455,15 +337,6 @@ export default function StoreCreatePage() {
const step1Msg = validateStoreStep1(form); const step1Msg = validateStoreStep1(form);
if (step1Msg) { if (step1Msg) {
if (isPhoneValidationMessage(step1Msg)) {
if (step1Msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: step1Msg });
} else {
setFieldErrors({ phone: step1Msg });
}
goStep(1);
return;
}
reportFormError(step1Msg); reportFormError(step1Msg);
goStep(1); goStep(1);
return; return;
@@ -503,6 +376,8 @@ export default function StoreCreatePage() {
}); });
setSubmitting(false);
return; return;
} }
@@ -514,6 +389,7 @@ export default function StoreCreatePage() {
); );
if (!ok) { if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' }); setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
setSubmitting(false);
return; return;
} }
confirmBindExisting = true; confirmBindExisting = true;
@@ -521,12 +397,15 @@ export default function StoreCreatePage() {
} catch (e) { } catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败'); reportFormError(e instanceof Error ? e.message : '手机号校验失败');
setSubmitting(false);
return; return;
} }
try { try {
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean); const envPhotoUrls = Array.from(
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
).slice(0, 3);
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', { const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
@@ -544,13 +423,31 @@ export default function StoreCreatePage() {
phone: form.phone.trim(), phone: form.phone.trim(),
smsCode: form.storeSmsCode.trim(),
district: form.district.trim(), district: form.district.trim(),
address: form.address.trim(), address: form.address.trim(),
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
openTime: form.openTime.trim(),
closeTime: form.closeTime.trim(),
...(form.dualHours
? { openTime2: form.openTime2.trim(), closeTime2: form.closeTime2.trim() }
: {}),
...(form.avgPrice.trim() ? { avgPrice: Number(form.avgPrice) } : {}),
categoryId: form.categoryId.trim(),
intro: form.intro.trim() || undefined, intro: form.intro.trim() || undefined,
benefitUsageRule: form.benefitUsageRule.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined, coverUrl: form.coverUrl.trim() || undefined,
@@ -575,7 +472,6 @@ export default function StoreCreatePage() {
setForm(defaultStoreForm()); setForm(defaultStoreForm());
setFieldErrors({}); setFieldErrors({});
setSubmitError(''); setSubmitError('');
setSmsHint('');
setParams({ step: '1' }, { replace: true }); setParams({ step: '1' }, { replace: true });
toastSuccess('门店录入成功'); toastSuccess('门店录入成功');
navigate(`/stores/${result.store.id}`); navigate(`/stores/${result.store.id}`);
@@ -585,11 +481,6 @@ export default function StoreCreatePage() {
setFieldErrors({ phone: message }); setFieldErrors({ phone: message });
return; return;
} }
if (/验证码/.test(message)) {
setFieldErrors({ storeSmsCode: message });
goStep(1);
return;
}
setSubmitError(message); setSubmitError(message);
toastError(message); toastError(message);
} finally { } finally {
@@ -604,17 +495,13 @@ export default function StoreCreatePage() {
const progress = step === 1 ? 0 : step === 2 ? 50 : 100; const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
const nextDisabled = submitting || checkingPhone; const nextDisabled = submitting;
return ( return (
<div className={`partner-page-sticky${subAccountLayout ? ' partner-page-sticky--tabbar' : ''}`}> <div className="partner-page-sticky partner-home--flush-top">
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
<nav className="partner-stepper"> <nav className="partner-stepper">
@@ -714,100 +601,58 @@ export default function StoreCreatePage() {
<div className="partner-field"> <div className="partner-field">
<label> <span className="text-primary">*</span></label> <label> <span className="text-primary">*</span></label>
<div className="partner-field-input"> <div className="partner-input-row" style={{ gap: 8 }}>
<span className="material-symbols-outlined">call</span> <select
<input className="partner-field-input partner-field-input--block"
type="tel" value={form.categoryParentId}
placeholder="请输入11位手机号" onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
value={form.phone} aria-label="一级店铺类型"
onChange={(e) => patchForm({ phone: e.target.value })}
/>
</div>
{fieldErrors.phone && (
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input
className="partner-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="请输入短信验证码"
value={form.storeSmsCode}
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
/>
</div>
<button
type="button"
className="partner-code-btn"
disabled={smsCooldown > 0 || checkingPhone}
onClick={() => void sendStorePhoneCode()}
> >
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'} <option value=""></option>
</button> {categoryTree.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
<select
className="partner-field-input partner-field-input--block"
value={form.categoryId}
onChange={(e) => patchForm({ categoryId: e.target.value })}
disabled={!form.categoryParentId}
aria-label="二级店铺类型"
>
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
{categoryChildren.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</div> </div>
{smsHint && (
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
)}
{fieldErrors.storeSmsCode && (
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
)}
</div> </div>
<div className="partner-field"> <div className="partner-field">
@@ -816,13 +661,178 @@ export default function StoreCreatePage() {
<textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} /> <textarea rows={2} placeholder="请输入详细门牌号" value={form.address} onChange={(e) => patchForm({ address: e.target.value })} />
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
disabled={locating}
onClick={() => {
void (async () => {
setLocating(true);
try {
const pos = await locateStorePosition();
patchForm({
latitude: String(pos.latitude),
longitude: String(pos.longitude),
});
toastSuccess('已获取当前坐标');
} catch (e) {
toastError(e instanceof Error ? e.message : '定位失败');
} finally {
setLocating(false);
}
})();
}}
>
{locating ? '定位中…' : '获取当前位置'}
</button>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
onClick={() => setMapPickerOpen(true)}
>
</button>
<span className="label-md text-muted">
{formatStoreCoords(form.latitude, form.longitude)
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
: '未定位(可定位或地图选点)'}
</span>
</div>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.openTime}
onChange={(e) => patchForm({ openTime: e.target.value })}
aria-label="营业开始时间"
/>
<span className="label-md text-muted"></span>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.closeTime}
onChange={(e) => patchForm({ closeTime: e.target.value })}
aria-label="营业结束时间"
/>
</div>
<label className="partner-checkbox-row" style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<input
type="checkbox"
checked={form.dualHours}
onChange={(e) => patchForm({ dualHours: e.target.checked })}
/>
<span className="label-md">/</span>
</label>
{form.dualHours ? (
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.openTime2}
onChange={(e) => patchForm({ openTime2: e.target.value })}
aria-label="第二段开始"
/>
<span className="label-md text-muted"></span>
<input
className="partner-field-input partner-field-input--block"
type="time"
value={form.closeTime2}
onChange={(e) => patchForm({ closeTime2: e.target.value })}
aria-label="第二段结束"
/>
</div>
) : null}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
1 09:00-22:00 2 09:00-14:0017:00-21:00
</p>
</div>
<div className="partner-field">
<label></label>
<input
className="partner-field-input partner-field-input--block"
type="number"
min={0}
step={1}
placeholder="如 88,用户端门店页展示"
value={form.avgPrice}
onChange={(e) => patchForm({ avgPrice: e.target.value })}
/>
</div> </div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea rows={4} placeholder="请输入门店简介 (10-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} /> <textarea rows={4} placeholder="请输入门店简介 (2-500字)" value={form.intro} onChange={(e) => patchForm({ intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}> <div style={{ textAlign: 'right', marginTop: 4 }}>
@@ -832,6 +842,26 @@ export default function StoreCreatePage() {
</div> </div>
<div className="partner-field">
<label>使</label>
<textarea
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => patchForm({ benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
</div>
</div>
</section> </section>
<div className="partner-info-banner"> <div className="partner-info-banner">
@@ -896,13 +926,9 @@ export default function StoreCreatePage() {
value={form.coverUrl} value={form.coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(coverUrl) => patchForm({ coverUrl })} onChange={(coverUrl) => patchForm({ coverUrl })}
label="点击或拖拽上传" label="从系统相册选择"
/> />
@@ -932,10 +958,6 @@ export default function StoreCreatePage() {
value={url} value={url}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)} onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
/> />
@@ -952,7 +974,7 @@ export default function StoreCreatePage() {
<h3 className="headline-md"> <span className="text-primary">*</span></h3> <h3 className="headline-md"> <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}></p> <p className="label-md text-muted" style={{ margin: '4px 0 12px' }}></p>
<OssUploadField <OssUploadField
@@ -964,10 +986,6 @@ export default function StoreCreatePage() {
value={form.contractUrl} value={form.contractUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(contractUrl) => patchForm({ contractUrl })} onChange={(contractUrl) => patchForm({ contractUrl })}
label="上传合同副本" label="上传合同副本"
@@ -1008,6 +1026,54 @@ export default function StoreCreatePage() {
</div> </div>
<section className="partner-form-card">
<div className="partner-field">
<label> *</label>
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input
className="partner-field-input partner-field-input--block"
inputMode="numeric"
autoComplete="cc-number"
maxLength={19}
placeholder="请输入16-19位银行卡号"
value={form.bankAccountNo}
onChange={(e) => patchForm({ bankAccountNo: e.target.value.replace(/\D/g, '').slice(0, 19) })}
/>
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
</div>
</section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
<span className="material-symbols-outlined text-secondary">info</span>
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
</p>
</div>
<section className="partner-form-card"> <section className="partner-form-card">
<div className="partner-field"> <div className="partner-field">
@@ -1040,63 +1106,26 @@ export default function StoreCreatePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}> <p className="label-md text-muted" style={{ marginTop: 8 }}>
</p> </p>
</div> </div>
</section> </section>
<section className="partner-form-card">
<div className="partner-field">
<label> *</label>
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
</div>
<div className="partner-field">
<label> *</label>
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
</div>
</section>
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
<span className="material-symbols-outlined text-secondary">info</span>
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
</p>
</div>
</> </>
)} )}
<footer className={`partner-sticky-footer${subAccountLayout ? ' partner-sticky-footer--above-tabbar' : ''}`}> <footer className="partner-sticky-footer">
{step > 1 && ( {step === 1 ? (
<button type="button" className="partner-btn-outline" onClick={() => navigate('/stores')} disabled={nextDisabled}></button>
) : (
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}></button> <button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}></button>
@@ -1106,7 +1135,7 @@ export default function StoreCreatePage() {
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}> <button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
<span>{checkingPhone ? '校验中…' : '下一步'}</span> <span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span> <span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
@@ -1124,6 +1153,23 @@ export default function StoreCreatePage() {
</footer> </footer>
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
patchForm({
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !form.address.trim() ? { address: loc.address } : {}),
});
toastSuccess(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
}}
/>
</div> </div>
); );
+290 -33
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess'; import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import { formatStoreCoords, locateStorePosition } from '../lib/storeLocate';
import OssUploadField from '../components/OssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import { import {
canPartnerOpenStore, canPartnerOpenStore,
storeAuditLabel, storeAuditLabel,
@@ -16,19 +19,46 @@ import {
} from '../lib/storeStatus'; } from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED']; const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
const ENV_SLOT_COUNT = 3;
function uniqueEnvUrls(urls: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of urls) {
const url = raw.trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
export default function StoreDetailPage() { export default function StoreDetailPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = usePartnerSession(); const { account } = usePartnerSession();
const subReadonly = isSubAccount(account); const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null); const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' }); const [form, setForm] = useState({
name: '',
phone: '',
address: '',
intro: '',
benefitUsageRule: '',
latitude: '',
longitude: '',
});
const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN'); const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false); const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [locating, setLocating] = useState(false);
const [mapPickerOpen, setMapPickerOpen] = useState(false);
const [actionError, setActionError] = useState(''); const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
function applyStore(data: Record<string, unknown>) { function applyStore(data: Record<string, unknown>) {
setStore(data); setStore(data);
@@ -37,8 +67,23 @@ export default function StoreDetailPage() {
phone: String(data.phone || ''), phone: String(data.phone || ''),
address: String(data.address || ''), address: String(data.address || ''),
intro: String(data.intro || ''), intro: String(data.intro || ''),
benefitUsageRule: (() => {
const raw = String(data.benefitUsageRule || '').trim();
return raw && !/^null$/i.test(raw) ? raw : '';
})(),
latitude: data.latitude != null && data.latitude !== '' ? String(data.latitude) : '',
longitude: data.longitude != null && data.longitude !== '' ? String(data.longitude) : '',
}); });
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue); setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
setCoverUrl(String(data.coverUrl || ''));
const envFromMedia = Array.isArray(data.media)
? uniqueEnvUrls(
(data.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || '')),
)
: [];
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
} }
useEffect(() => { useEffect(() => {
@@ -65,8 +110,8 @@ export default function StoreDetailPage() {
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?'); setCloseConfirmOpen(true);
if (!ok) return; return;
} }
setStatusSaving(true); setStatusSaving(true);
setActionError(''); setActionError('');
@@ -77,7 +122,27 @@ export default function StoreDetailPage() {
}); });
setStatus(next); setStatus(next);
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev)); setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
toastSuccess('状态已更新'); toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新');
} catch (e) {
setActionError(e instanceof Error ? e.message : '状态更新失败');
} finally {
setStatusSaving(false);
}
}
async function confirmCloseStore() {
if (!id || statusSaving) return;
setCloseConfirmOpen(false);
setStatusSaving(true);
setActionError('');
try {
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status: 'CLOSED' }),
});
setStatus('CLOSED');
setStore((prev) => (prev ? { ...prev, ...updated, status: 'CLOSED' } : prev));
toastSuccess('门店已关闭');
} catch (e) { } catch (e) {
setActionError(e instanceof Error ? e.message : '状态更新失败'); setActionError(e instanceof Error ? e.message : '状态更新失败');
} finally { } finally {
@@ -102,6 +167,13 @@ export default function StoreDetailPage() {
phone: form.phone.trim(), phone: form.phone.trim(),
address: form.address.trim(), address: form.address.trim(),
intro: form.intro.trim(), intro: form.intro.trim(),
benefitUsageRule: form.benefitUsageRule.trim() || null,
...(form.latitude.trim() && form.longitude.trim()
? {
latitude: Number(form.latitude),
longitude: Number(form.longitude),
}
: {}),
}), }),
}); });
applyStore(data); applyStore(data);
@@ -113,10 +185,45 @@ export default function StoreDetailPage() {
} }
} }
async function saveMedia() {
if (!id || mediaSaving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料');
return;
}
const nextCover = coverUrl.trim();
const nextEnv = uniqueEnvUrls(envPhotoUrls);
if (!nextCover) {
setActionError('请上传门头照');
return;
}
if (nextEnv.length < ENV_SLOT_COUNT) {
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
return;
}
setMediaSaving(true);
setActionError('');
try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
method: 'PUT',
body: JSON.stringify({
coverUrl: nextCover,
envPhotoUrls: nextEnv,
}),
});
applyStore(data);
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
} catch (e) {
setActionError(e instanceof Error ? e.message : '照片更新失败');
} finally {
setMediaSaving(false);
}
}
if (loadError) { if (loadError) {
return ( return (
<div className="partner-detail-page"> <div className="partner-detail-page partner-home--flush-top">
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
<div className="empty">{loadError}</div> <div className="empty">{loadError}</div>
</div> </div>
); );
@@ -124,20 +231,22 @@ export default function StoreDetailPage() {
if (!store) return <div className="empty">...</div>; if (!store) return <div className="empty">...</div>;
const envPhotos = Array.isArray(store.media) const envPhotos = uniqueEnvUrls(
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV') Array.isArray(store.media)
: []; ? (store.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || ''))
: [],
);
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase(); const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
const auditPending = auditStatus === 'PENDING'; const auditPending = auditStatus === 'PENDING';
const auditRejected = auditStatus === 'REJECTED'; const auditRejected = auditStatus === 'REJECTED';
const readOnly = subReadonly || status === 'CLOSED' || auditPending; const readOnly = !canMutate || status === 'CLOSED' || auditPending;
const canOpen = canPartnerOpenStore(auditStatus); const canOpen = canPartnerOpenStore(auditStatus);
return ( return (
<div className="partner-detail-page"> <div className="partner-detail-page partner-home--flush-top">
<PageHeader title="门店详情" onBack={() => navigate('/stores')} /> <main style={{ padding: '12px 20px 16px' }}>
<main style={{ padding: '16px 20px' }}>
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>} {actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
@@ -170,7 +279,7 @@ export default function StoreDetailPage() {
)} )}
</section> </section>
{!subReadonly && ( {canMutate && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3> <h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
@@ -200,13 +309,27 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}></h3> <h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}></h3>
<div className="partner-cover"> {!canMutate || readOnly ? (
<AppImage <div className="partner-cover">
src={store.coverUrl ? String(store.coverUrl) : null} <AppImage
alt={form.name} src={coverUrl || null}
wrapperClassName="app-image--fill" alt={form.name}
/> wrapperClassName="app-image--fill"
</div> />
</div>
) : (
<div style={{ marginBottom: 16 }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<OssUploadField
wide
bizType="STORE_TITLE"
mediaType="IMAGE"
value={coverUrl}
onChange={setCoverUrl}
label="从系统相册选择"
/>
</div>
)}
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} /> <input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
@@ -221,26 +344,123 @@ export default function StoreDetailPage() {
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} /> <textarea disabled={readOnly} rows={2} value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
{!readOnly ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
disabled={locating}
onClick={() => {
void (async () => {
setLocating(true);
setActionError('');
try {
const pos = await locateStorePosition();
setForm((prev) => ({
...prev,
latitude: String(pos.latitude),
longitude: String(pos.longitude),
}));
toastSuccess('已获取当前坐标');
} catch (e) {
const msg = e instanceof Error ? e.message : '定位失败';
setActionError(msg);
toastError(msg);
} finally {
setLocating(false);
}
})();
}}
>
{locating ? '定位中…' : '获取当前位置'}
</button>
<button
type="button"
className="partner-btn-outline"
style={{ padding: '8px 12px', fontSize: 13 }}
onClick={() => setMapPickerOpen(true)}
>
</button>
<span className="label-md text-muted">
{formatStoreCoords(form.latitude, form.longitude)
? `坐标:${formatStoreCoords(form.latitude, form.longitude)}`
: '未定位'}
</span>
</div>
) : formatStoreCoords(form.latitude, form.longitude) ? (
<p className="label-md text-muted" style={{ marginTop: 6 }}>
{formatStoreCoords(form.latitude, form.longitude)}
</p>
) : null}
</div> </div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
<textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(10-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} /> <textarea disabled={readOnly} rows={4} placeholder="请输入门店简介(2-500字)" value={form.intro} onChange={(e) => setForm({ ...form, intro: e.target.value })} />
<div style={{ textAlign: 'right', marginTop: 4 }}> <div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.intro.length} / 500</span> <span className="label-md text-muted">{form.intro.length} / 500</span>
</div> </div>
</div> </div>
<div className="partner-field">
<label>使</label>
<textarea
disabled={readOnly}
rows={4}
placeholder="选填,展示在用户端门店详情,最多1000字"
maxLength={1000}
value={form.benefitUsageRule}
onChange={(e) => setForm({ ...form, benefitUsageRule: e.target.value })}
/>
<div style={{ textAlign: 'right', marginTop: 4 }}>
<span className="label-md text-muted">{form.benefitUsageRule.length} / 1000</span>
</div>
</div>
</section> </section>
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3> <h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3>
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length}` : '暂无照片'}</span> <span className="label-md text-muted">
{canMutate && !readOnly
? `${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length}`
: envPhotos.length
? `已上传 ${envPhotos.length}`
: '暂无照片'}
</span>
</div> </div>
{envPhotos.length > 0 ? ( {canMutate && !readOnly ? (
<>
<div className="partner-upload-grid">
{envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 16 }}
disabled={mediaSaving}
onClick={() => void saveMedia()}
>
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
upload
</span>
{mediaSaving ? '上传中…' : '重新上传照片'}
</button>
</>
) : envPhotos.length > 0 ? (
<div className="partner-photo-grid"> <div className="partner-photo-grid">
{envPhotos.map((photo, index) => ( {envPhotos.map((url, index) => (
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}> <div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" /> <AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
</div> </div>
))} ))}
</div> </div>
@@ -264,13 +484,50 @@ export default function StoreDetailPage() {
<footer className="partner-save-footer"> <footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button> <button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{!subReadonly && ( {canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}> <button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span> <span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'} {saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
</button> </button>
)} )}
</footer> </footer>
{closeConfirmOpen && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseConfirmOpen(false)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseConfirmOpen(false)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
</div>
)}
<TencentLocPickerOverlay
open={mapPickerOpen}
onClose={() => setMapPickerOpen(false)}
latitude={form.latitude ? Number(form.latitude) : undefined}
longitude={form.longitude ? Number(form.longitude) : undefined}
onPick={(loc) => {
setForm((prev) => ({
...prev,
latitude: String(loc.latitude),
longitude: String(loc.longitude),
...(loc.address && !prev.address.trim() ? { address: loc.address } : {}),
}));
toastSuccess(
`已选点 ${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}${loc.name ? `${loc.name}` : ''}`,
);
}}
/>
</div> </div>
); );
} }
+59 -18
View File
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess'; import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import { import {
canPartnerOpenStore, canPartnerOpenStore,
storeAuditLabel, storeAuditLabel,
@@ -25,13 +27,17 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
export default function StoreListPage() { export default function StoreListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession(); const { account } = usePartnerSession();
const readonly = isSubAccount(account); const canMutate = canManagePartnerStore(account);
const canCreate = canCreatePartnerStore(account);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]); const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [filter, setFilter] = useState<StatusFilter>('ALL'); const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
const [updatingId, setUpdatingId] = useState<string | null>(null); const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [closeTarget, setCloseTarget] = useState<string | null>(null);
const loadStores = useCallback(() => { const loadStores = useCallback(() => {
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores); return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
@@ -42,6 +48,10 @@ export default function StoreListPage() {
void loadStores(); void loadStores();
}, [navigate, loadStores]); }, [navigate, loadStores]);
useEffect(() => {
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => { const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q); const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const audit = String(s.auditStatus || 'APPROVED').toUpperCase(); const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
@@ -59,8 +69,8 @@ export default function StoreListPage() {
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?'); setCloseTarget(storeId);
if (!ok) return; return;
} }
setUpdatingId(storeId); setUpdatingId(storeId);
setError(''); setError('');
@@ -70,6 +80,26 @@ export default function StoreListPage() {
body: JSON.stringify({ status: next }), body: JSON.stringify({ status: next }),
}); });
await loadStores(); await loadStores();
if (next === 'OPEN') toastSuccess('开店成功');
} catch {
/* request 已 toast */
} finally {
setUpdatingId(null);
}
}
async function confirmCloseStore() {
if (!closeTarget) return;
const storeId = closeTarget;
setCloseTarget(null);
setUpdatingId(storeId);
setError('');
try {
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
method: 'PUT',
body: JSON.stringify({ status: 'CLOSED' }),
});
await loadStores();
} catch { } catch {
/* request 已 toast */ /* request 已 toast */
} finally { } finally {
@@ -78,16 +108,7 @@ export default function StoreListPage() {
} }
return ( return (
<div className="page partner-store-page"> <PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<div className="partner-page-title-block">
<h2>{readonly ? '我的门店' : '门店管理'}</h2>
<p className="text-muted body-md">{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}</p>
</div>
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>} {error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div className="partner-sticky-filter"> <div className="partner-sticky-filter">
@@ -104,12 +125,14 @@ export default function StoreListPage() {
</div> </div>
</div> </div>
{canCreate && (
<Link to="/stores/new" className="partner-fab-link"> <Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary"> <button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span> <span className="material-symbols-outlined">add_business</span>
</button> </button>
</Link> </Link>
)}
{filtered.length === 0 && <div className="empty"></div>} {filtered.length === 0 && <div className="empty"></div>}
@@ -149,7 +172,7 @@ export default function StoreListPage() {
</div> </div>
</div> </div>
</Link> </Link>
{!readonly && ( {canMutate && (
<div className="partner-store-card-actions"> <div className="partner-store-card-actions">
<button <button
type="button" type="button"
@@ -188,6 +211,24 @@ export default function StoreListPage() {
</div> </div>
); );
})} })}
</div> {closeTarget && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
</p>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
</button>
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
</button>
</div>
</div>
</div>
)}
</PullToRefresh>
); );
} }
+10 -5
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types'; import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport'; import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
function fmtMoney(n: number) { function fmtMoney(n: number) {
@@ -20,10 +21,10 @@ export default function WeeklyReportPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
useEffect(() => { const loadWeeklyReport = useCallback(() => {
setLoading(true); setLoading(true);
setError(''); setError('');
fetchPartnerWeeklyReport(selectedStart) return fetchPartnerWeeklyReport(selectedStart)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
setData(null); setData(null);
@@ -32,6 +33,10 @@ export default function WeeklyReportPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [selectedStart]); }, [selectedStart]);
useEffect(() => {
void loadWeeklyReport();
}, [loadWeeklyReport]);
const maxDailyGmv = useMemo(() => { const maxDailyGmv = useMemo(() => {
if (!data?.dailyGmv.length) return 1; if (!data?.dailyGmv.length) return 1;
return Math.max(1, ...data.dailyGmv.map((item) => item.amount)); return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
@@ -42,7 +47,7 @@ export default function WeeklyReportPage() {
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0; const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
return ( return (
<div className="page-no-tab"> <PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
<PageHeader title="数据周报" onBack={() => navigate('/')} /> <PageHeader title="数据周报" onBack={() => navigate('/')} />
<main className="partner-weekly-page"> <main className="partner-weekly-page">
@@ -211,6 +216,6 @@ export default function WeeklyReportPage() {
</> </>
)} )}
</main> </main>
</div> </PullToRefresh>
); );
} }
+559 -34
View File
@@ -13,13 +13,23 @@ html {
/* ── Partner auth ── */ /* ── Partner auth ── */
.partner-auth-page { .partner-auth-page {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: flex-start;
padding: 48px var(--space-page) var(--space-page); padding: 48px var(--space-page) calc(24px + env(safe-area-inset-bottom, 0px));
background-color: var(--color-background); background-color: var(--color-background);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png"); background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
box-sizing: border-box;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@media (min-height: 720px) {
.partner-auth-page {
justify-content: center;
}
} }
.partner-auth-page--quick { .partner-auth-page--quick {
@@ -170,10 +180,23 @@ html {
gap: 12px; gap: 12px;
margin-top: var(--space-md); margin-top: var(--space-md);
font-size: 12px; font-size: 12px;
line-height: 1.5;
color: var(--color-on-surface-variant); color: var(--color-on-surface-variant);
} }
.partner-checkbox-row input { margin-top: 2px; accent-color: var(--color-heritage-red); } .partner-checkbox-row input {
margin-top: 2px;
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-heritage-red);
}
.partner-checkbox-row--agreement {
margin-top: 8px;
margin-bottom: 4px;
padding: 8px 0;
}
.partner-auth-footer { .partner-auth-footer {
margin-top: var(--space-lg); margin-top: var(--space-lg);
@@ -621,9 +644,6 @@ html {
.partner-expansion-split { .partner-expansion-split {
display: flex; display: flex;
border-bottom: 1px solid rgba(226, 190, 188, 0.2);
padding-bottom: var(--space-md);
margin-bottom: var(--space-md);
} }
.partner-expansion-split > div { .partner-expansion-split > div {
@@ -635,6 +655,28 @@ html {
border-left: 1px solid rgba(226, 190, 188, 0.2); border-left: 1px solid rgba(226, 190, 188, 0.2);
} }
.partner-expansion-value-row {
display: flex;
align-items: center;
gap: 8px;
}
.partner-expansion-trend {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 600;
}
.partner-expansion-trend--up {
color: #16a34a;
}
.partner-expansion-trend--down {
color: var(--color-heritage-red);
}
.partner-form-error { .partner-form-error {
margin: 0 20px 12px; margin: 0 20px 12px;
padding: 10px 12px; padding: 10px 12px;
@@ -845,6 +887,57 @@ html {
color: #fff; color: #fff;
} }
.partner-leaderboard-preview .partner-leaderboard-header {
margin-bottom: 12px;
}
.partner-leaderboard-avatar {
position: relative;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-surface-container-low);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.partner-leaderboard-avatar .material-symbols-outlined {
font-size: 22px;
color: var(--color-subtle-gray);
font-variation-settings: 'FILL' 1;
}
.partner-leaderboard-avatar-badge {
position: absolute;
left: -2px;
top: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #9ca3af;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px #fff;
}
.partner-leaderboard-avatar--rank-1 .partner-leaderboard-avatar-badge {
background: #d4a017;
}
.partner-leaderboard-avatar--rank-2 .partner-leaderboard-avatar-badge {
background: #9ca3af;
}
.partner-leaderboard-avatar--rank-3 .partner-leaderboard-avatar-badge {
background: #cd7f32;
}
.partner-leaderboard-info { .partner-leaderboard-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1296,7 +1389,7 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-footer--above-tabbar { .partner-sticky-footer--above-tabbar {
bottom: calc(56px + env(safe-area-inset-bottom, 0px)); bottom: calc(56px + env(safe-area-inset-bottom, 0px));
z-index: 90; z-index: 110;
} }
.partner-stepper { .partner-stepper {
@@ -1752,12 +1845,13 @@ nav.app-tabbar .app-tabbar-label {
max-width: 480px; max-width: 480px;
margin: 0 auto; margin: 0 auto;
transform: none; transform: none;
background: rgba(250, 249, 247, 0.92); background: rgba(250, 249, 247, 0.96);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
border-top: 1px solid rgba(226, 190, 188, 0.2); border-top: 1px solid rgba(226, 190, 188, 0.2);
box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05); box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05);
padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px)); padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 60; /* 高于 .app-tabbar(100),防止偶发叠层遮挡「下一步/提交」 */
z-index: 110;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -1801,7 +1895,7 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-filter { .partner-sticky-filter {
position: sticky; position: sticky;
top: 56px; top: 0;
z-index: 40; z-index: 40;
background: rgba(250, 249, 247, 0.95); background: rgba(250, 249, 247, 0.95);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
@@ -2401,6 +2495,14 @@ nav.app-tabbar .app-tabbar-label {
justify-content: center; justify-content: center;
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
}
.partner-profile-avatar:disabled {
opacity: 0.7;
cursor: wait;
} }
.partner-profile-avatar .material-symbols-outlined { .partner-profile-avatar .material-symbols-outlined {
@@ -2409,22 +2511,81 @@ nav.app-tabbar .app-tabbar-label {
font-variation-settings: 'FILL' 1; font-variation-settings: 'FILL' 1;
} }
.partner-profile-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.partner-profile-avatar-hint {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2px 0;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
text-align: center;
color: #fff;
background: rgba(166, 29, 36, 0.82);
}
.partner-profile-main {
flex: 1;
min-width: 0;
}
.partner-profile-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.partner-profile-name {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.3;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.partner-profile-location {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: var(--color-subtle-gray);
}
.partner-role-badge { .partner-role-badge {
flex-shrink: 0;
background: var(--color-secondary-container); background: var(--color-secondary-container);
color: var(--color-on-secondary-container); color: var(--color-on-secondary-container);
padding: 4px 8px; padding: 4px 8px;
border-radius: 8px; border-radius: 8px;
font-family: var(--font-label); font-family: var(--font-label);
font-size: 10px; font-size: 10px;
text-transform: uppercase; font-weight: 600;
letter-spacing: 0.05em; letter-spacing: 0.02em;
white-space: nowrap;
line-height: 1.2;
}
.partner-assets-section {
margin-top: var(--space-lg);
} }
.partner-bills-banner { .partner-bills-banner {
margin: 0 var(--space-page) 12px; margin: 0 var(--space-page) 12px;
padding: 16px; padding: 16px;
background: rgba(166, 29, 36, 0.05); background: rgba(166, 29, 36, 0.06);
border: 1px solid rgba(166, 29, 36, 0.1); border: 1px solid rgba(166, 29, 36, 0.12);
border-radius: var(--radius-md); border-radius: var(--radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2463,13 +2624,100 @@ nav.app-tabbar .app-tabbar-label {
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
border: 1px solid rgba(166, 29, 36, 0.05); border: 1px solid rgba(166, 29, 36, 0.05);
text-decoration: none;
color: inherit;
display: block;
} }
.partner-finance-card--hero { .partner-finance-card--hero {
grid-column: span 2; grid-column: 1 / -1;
background: var(--color-heritage-red); background: linear-gradient(135deg, #a61d24 0%, #820012 100%);
color: #fff; color: #fff;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25); box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25);
border: none;
padding: 20px 24px;
}
.partner-finance-hero-label {
font-size: 13px;
opacity: 0.88;
margin: 0 0 8px;
}
.partner-finance-hero-amount {
font-size: 32px;
font-weight: 700;
letter-spacing: 0.02em;
margin: 0;
line-height: 1.2;
}
.partner-finance-value {
margin-top: 8px;
font-size: 18px;
font-weight: 700;
color: var(--color-on-surface);
}
.partner-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 var(--space-page) 8px;
}
.partner-menu-section .partner-section-head {
margin: 0 0 8px;
}
.partner-menu-item-right {
display: flex;
align-items: center;
gap: 6px;
}
.partner-menu-badge {
font-size: 12px;
font-weight: 600;
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.08);
padding: 2px 8px;
border-radius: 999px;
}
.partner-menu-status {
font-size: 12px;
color: #2563eb;
font-weight: 500;
}
.partner-logout-btn {
width: calc(100% - 2 * var(--space-page));
margin: var(--space-lg) var(--space-page) 0;
padding: 16px;
border: 1px solid rgba(166, 29, 36, 0.08);
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
color: var(--color-heritage-red);
font-family: var(--font-headline);
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.partner-logout-btn .material-symbols-outlined {
color: var(--color-heritage-red);
}
.partner-center-footer {
text-align: center;
opacity: 0.35;
padding: 24px 0 8px;
} }
.partner-menu-section { .partner-menu-section {
@@ -2488,14 +2736,28 @@ nav.app-tabbar .app-tabbar-label {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%;
padding: 16px; padding: 16px;
border: none;
border-bottom: 1px solid var(--color-surface-container); border-bottom: 1px solid var(--color-surface-container);
border-radius: 0;
background: transparent;
color: inherit; color: inherit;
font: inherit;
text-align: left;
text-decoration: none; text-decoration: none;
cursor: pointer;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
} }
.partner-menu-item:last-child { border-bottom: none; } .partner-menu-item:last-child { border-bottom: none; }
.partner-menu-item:active {
background: rgba(166, 29, 36, 0.04);
}
.partner-menu-item-left { .partner-menu-item-left {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2513,24 +2775,6 @@ nav.app-tabbar .app-tabbar-label {
color: var(--color-heritage-red); color: var(--color-heritage-red);
} }
.partner-logout-btn {
width: calc(100% - 40px);
margin: var(--space-lg) var(--space-page);
padding: 16px;
border: none;
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
color: var(--color-error);
font-size: 16px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
/* ── Settlement page ── */ /* ── Settlement page ── */
.partner-settlement-page { .partner-settlement-page {
min-height: 100vh; min-height: 100vh;
@@ -2778,6 +3022,11 @@ nav.app-tabbar .app-tabbar-label {
color: var(--color-success-green); color: var(--color-success-green);
} }
.partner-settlement-status--rejected {
background: rgba(166, 29, 36, 0.12);
color: var(--color-heritage-red);
}
.partner-settlement-item-meta { .partner-settlement-item-meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -3065,3 +3314,279 @@ body {
padding-top: 12px; padding-top: 12px;
border-top: 1px dashed rgba(166, 29, 36, 0.15); border-top: 1px dashed rgba(166, 29, 36, 0.15);
} }
.partner-proxy-mode-tabs {
display: flex;
gap: 8px;
}
.partner-proxy-mode-tab {
flex: 1;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #fff;
font-size: 14px;
color: var(--color-ink-black);
}
.partner-proxy-mode-tab.is-active {
border-color: var(--color-heritage-red);
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.06);
font-weight: 600;
}
.partner-proxy-auto-receive {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 4px 0 16px;
font-size: 13px;
line-height: 1.45;
color: var(--color-ink-black);
}
.partner-proxy-auto-receive input {
margin-top: 3px;
flex-shrink: 0;
}
.partner-proxy-product-picker {
position: relative;
width: 100%;
text-align: left;
padding: 12px 36px 12px 14px;
border-radius: 12px;
border: 1px solid var(--color-border);
background: #fff;
display: flex;
flex-direction: column;
gap: 4px;
}
.partner-proxy-product-picker-arrow {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 22px;
color: var(--color-muted, #999);
}
.partner-proxy-product-list {
padding: 0 16px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.partner-proxy-product-card {
display: flex;
gap: 12px;
width: 100%;
text-align: left;
padding: 12px;
border-radius: 16px;
border: 1px solid var(--color-border);
background: #fff;
}
.partner-proxy-product-card--active {
border-color: var(--color-heritage-red);
box-shadow: 0 0 0 1px rgba(166, 29, 36, 0.2);
}
.partner-proxy-product-thumb {
width: 72px;
height: 72px;
border-radius: 12px;
overflow: hidden;
background: #f3f1ee;
flex-shrink: 0;
}
.partner-proxy-product-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.partner-proxy-product-thumb-empty {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f3f1ee, #e8e4de);
}
.partner-proxy-product-main {
flex: 1;
min-width: 0;
}
.partner-proxy-product-row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.partner-proxy-product-name {
font-weight: 600;
font-size: 15px;
color: var(--color-ink-black);
}
.partner-proxy-product-price {
color: var(--color-heritage-red);
font-weight: 600;
flex-shrink: 0;
}
.partner-proxy-product-spec,
.partner-proxy-product-benefit {
margin: 4px 0 0;
font-size: 12px;
color: var(--color-muted, #888);
}
.partner-proxy-confirm {
padding-top: 8px;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
}
.partner-warehouse-denied {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 20px 12px;
}
.partner-warehouse-denied .material-symbols-outlined {
font-size: 36px;
font-variation-settings: 'FILL' 0;
}
.partner-home--flush-top.partner-store-page,
.partner-home--flush-top.partner-center-page,
.partner-home--flush-top.partner-detail-page,
.partner-home--flush-top.partner-page-sticky {
padding-top: 12px;
}
.partner-home--flush-top.partner-page-sticky .partner-stepper {
padding-top: 4px;
}
.partner-home--flush-top .partner-sticky-filter {
padding-top: 12px;
}
.partner-leaderboard-self-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.12);
border-radius: 4px;
vertical-align: middle;
}
.partner-order-card-wrap {
margin: 0 var(--space-page) 12px;
}
.partner-order-ship-btn {
display: block;
width: 100%;
margin-top: -4px;
padding: 10px 16px;
border: none;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--color-heritage-red);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.partner-order-ship-btn:disabled {
opacity: 0.6;
}
.partner-order-footer-actions {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.partner-order-ship-btn--footer {
width: 100%;
justify-content: center;
}
.partner-ship-modal-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 16px;
}
.partner-ship-modal {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: 20px;
}
.partner-ship-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
font-size: 13px;
}
.partner-ship-field input {
padding: 10px 12px;
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-sm);
}
.partner-ship-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.partner-ship-actions .partner-btn-secondary,
.partner-ship-actions .partner-btn-primary {
flex: 1;
}
/* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */
.app-page-title {
display: none !important;
}
/* 仅含标题的顶栏一并收起,去掉空白条;有返回/操作的顶栏保留 */
header:has(> .app-page-title:only-child),
.header.app-page-header:has(> .app-page-title:only-child),
.app-page-header:has(> .app-page-title:only-child) {
display: none !important;
height: 0 !important;
min-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
overflow: hidden;
border: none !important;
box-shadow: none !important;
}
+78
View File
@@ -0,0 +1,78 @@
.legal-h5-page {
min-height: 100vh;
background: #faf9f7;
color: #1f1a17;
}
.legal-h5-header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
height: 52px;
padding: 0 16px;
background: #faf9f7;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.legal-h5-back {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
line-height: 1;
color: #1f1a17;
text-decoration: none;
}
.legal-h5-title {
margin: 0;
/* 与微信系统标题重复,仅保留顶栏高度与返回 */
visibility: hidden;
font-size: 0;
line-height: 0;
color: transparent;
}
.legal-h5-body {
padding: 16px 20px 40px;
max-width: 720px;
margin: 0 auto;
}
.legal-h5-updated {
margin: 0 0 12px;
font-size: 12px;
color: #8d706e;
}
.legal-h5-intro {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.7;
}
.legal-h5-section {
margin-bottom: 18px;
}
.legal-h5-section h2 {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
}
.legal-h5-section p {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.7;
color: #5c504c;
}
+2 -1
View File
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig({
base: '/partner/', // 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
+4 -2
View File
@@ -2,9 +2,9 @@ import { Routes, Route, Navigate } from 'react-router-dom';
import AuthGate from './components/AuthGate'; import AuthGate from './components/AuthGate';
import TabLayout from './layouts/TabLayout'; import TabLayout from './layouts/TabLayout';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import SelectStorePage from './pages/SelectStorePage'; import SelectStorePage from './pages/SelectStorePage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import RedeemConfirmPage from './pages/RedeemConfirmPage';
import PhoneRedeemPage from './pages/PhoneRedeemPage'; import PhoneRedeemPage from './pages/PhoneRedeemPage';
import RedeemSuccessPage from './pages/RedeemSuccessPage'; import RedeemSuccessPage from './pages/RedeemSuccessPage';
import RecordsPage from './pages/RecordsPage'; import RecordsPage from './pages/RecordsPage';
@@ -17,9 +17,11 @@ export default function App() {
<AuthGate> <AuthGate>
<Routes> <Routes>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/legal/user-agreement" element={<LegalPage docId="user-agreement" />} />
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route path="/select-store" element={<SelectStorePage />} /> <Route path="/select-store" element={<SelectStorePage />} />
<Route path="/staff" element={<StaffPage />} /> <Route path="/staff" element={<StaffPage />} />
<Route path="/redeem" element={<RedeemConfirmPage />} /> <Route path="/redeem" element={<Navigate to="/redeem/phone" replace />} />
<Route path="/redeem/phone" element={<PhoneRedeemPage />} /> <Route path="/redeem/phone" element={<PhoneRedeemPage />} />
<Route path="/redeem/success" element={<RedeemSuccessPage />} /> <Route path="/redeem/success" element={<RedeemSuccessPage />} />
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
+1 -6
View File
@@ -1,8 +1,7 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
const PUBLIC_PATHS = new Set(['/login']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
const SELECT_STORE_PATH = '/select-store'; const SELECT_STORE_PATH = '/select-store';
export default function AuthGate({ children }: { children: React.ReactNode }) { export default function AuthGate({ children }: { children: React.ReactNode }) {
@@ -26,10 +25,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
const profile = getStoreProfile();
if (profile && hasShopWxSession() && location.pathname !== '/login') {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
}
return <Navigate to="/login" replace state={{ from: location }} />; return <Navigate to="/login" replace state={{ from: location }} />;
} }
@@ -19,6 +19,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
const [photoResourceId, setPhotoResourceId] = useState(''); const [photoResourceId, setPhotoResourceId] = useState('');
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null); const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
async function handleFile(file: File) { async function handleFile(file: File) {
setUploading(true); setUploading(true);
@@ -49,7 +50,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
} }
} catch (e) { } catch (e) {
const text = e instanceof Error ? e.message : '选图失败'; const text = e instanceof Error ? e.message : '选图失败';
if (!/cancel/i.test(text)) setMsg(text); if (!/cancel/i.test(text)) {
setMsg(`${text},可改从系统相册选择`);
setShowAlbumFallback(true);
inputRef.current?.click();
}
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -120,11 +125,13 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
ref={inputRef} ref={inputRef}
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
hidden hidden
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) void handleFile(file); if (file) {
setShowAlbumFallback(false);
void handleFile(file);
}
}} }}
/> />
{previewUrl && ( {previewUrl && (
@@ -134,6 +141,16 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}> <button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'} {uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
</button> </button>
{showAlbumFallback && isWechatEnv() && (
<button
type="button"
className="shop-btn-outline"
disabled={uploading}
onClick={() => inputRef.current?.click()}
>
</button>
)}
<button <button
type="button" type="button"
className="shop-redeem-confirm-btn" className="shop-redeem-confirm-btn"
+9 -4
View File
@@ -53,7 +53,7 @@ const STORE_PROFILE = 'shopStoreProfile';
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt'; const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
export const SHOP_WX_BOUND = 'shopWxBound'; export const SHOP_WX_BOUND = 'shopWxBound';
/** 微信验证通过后的免登录时长 */ /** 手机号或微信验证通过后的免登录时长 */
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [ const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -87,7 +87,7 @@ export function isShopSessionExpired() {
} }
export function touchShopSession() { export function touchShopSession() {
if (!hasShopWxSession()) return; if (!localStorage.getItem(REFRESH_TOKEN)) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS)); localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
} }
@@ -119,11 +119,16 @@ export function saveAuth(data: ShopSessionPayload) {
} }
} }
/** 手机号验证成功后写入 7 天免验证码会话 */
export function saveRememberedSession(data: ShopSessionPayload) {
saveAuth(data);
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
}
/** 微信登录/绑定成功后写入 7 天免登录 session */ /** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: ShopSessionPayload) { export function saveWechatSession(data: ShopSessionPayload) {
saveAuth(data); saveRememberedSession(data);
localStorage.setItem(SHOP_WX_BOUND, '1'); localStorage.setItem(SHOP_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
} }
export function clearAuth(options?: { keepProfile?: boolean }) { export function clearAuth(options?: { keepProfile?: boolean }) {
+25 -2
View File
@@ -9,6 +9,8 @@ export type ShopAccountProfile = {
storeId: string; storeId: string;
name: string; name: string;
phone: string; phone: string;
/** 是否已绑定微信(来自 /shop/auth/me account.hasWechat */
hasWechat?: boolean;
wxOpenId?: string | null; wxOpenId?: string | null;
store?: { name: string }; store?: { name: string };
}; };
@@ -18,7 +20,27 @@ export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
} }
export async function fetchShopAccount(): Promise<ShopAccountProfile> { export async function fetchShopAccount(): Promise<ShopAccountProfile> {
return request<ShopAccountProfile>('SHOP_H5', '/shop/auth/me'); const me = await request<{
id?: string;
storeId?: string;
name?: string;
phone?: string;
account?: {
id?: string;
name?: string;
phone?: string;
hasWechat?: boolean;
};
store?: { name?: string } | null;
}>('SHOP_H5', '/shop/auth/me');
return {
id: String(me.account?.id ?? me.id ?? ''),
storeId: String(me.storeId ?? ''),
name: String(me.account?.name ?? me.name ?? ''),
phone: String(me.account?.phone ?? me.phone ?? ''),
hasWechat: !!me.account?.hasWechat,
store: me.store?.name ? { name: String(me.store.name) } : undefined,
};
} }
export function needsWechatAuth( export function needsWechatAuth(
@@ -26,7 +48,8 @@ export function needsWechatAuth(
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null, config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
): boolean { ): boolean {
if (config && !isWxAuthorizeEnabled(config)) return false; if (config && !isWxAuthorizeEnabled(config)) return false;
return isWechatEnv() && !!profile && !profile.wxOpenId; const unbound = !!profile && !(profile.hasWechat || profile.wxOpenId);
return isWechatEnv() && unbound;
} }
export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> { export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<boolean> {
+1
View File
@@ -5,6 +5,7 @@ import { getRouterBasename } from '@dukang/weixin-sdk';
import { StoreSessionProvider } from './contexts/StoreSessionContext'; import { StoreSessionProvider } from './contexts/StoreSessionContext';
import App from './App'; import App from './App';
import './styles.css'; import './styles.css';
import './styles/legal.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+41 -142
View File
@@ -1,141 +1,58 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext'; import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import {
authorizeShopWechat,
checkNeedsWechatAuth,
fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import WechatScanAuthModal from '../components/WechatScanAuthModal';
const PENDING_SCAN_KEY = 'shop_pending_scan';
function formatMoney(n: number) { function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
} }
function formatScanError(e: unknown): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
}
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
}
return msg;
}
export default function HomePage() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [open, setOpen] = useState(true);
const [scanMsg, setScanMsg] = useState('');
const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
useEffect(() => { const loadDashboard = useCallback(() => {
request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard').then((d) => { return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
setDash(d); .then((d) => {
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN'); setDash(d);
}); })
.catch(() => {});
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !searchParams.get('code')) return; void loadDashboard();
void handleShopWechatCallback() }, [loadDashboard]);
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
}
setAuthModalOpen(false);
setAuthError('');
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) {
window.setTimeout(() => void runScan(), 0);
}
})
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败');
});
}, [searchParams, applySession, setSearchParams]);
async function runScan() { useEffect(() => {
if (!isWechatEnv()) { function onResume() {
setScanMsg('请在微信内打开门店端进行扫码核销'); void loadDashboard();
return;
} }
setScanning(true); function onVisibility() {
setScanMsg(''); if (document.visibilityState === 'visible') onResume();
try {
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode();
if (!raw) return;
const token = parseRedeemTokenFromScan(raw);
if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return;
}
navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) {
setScanMsg(formatScanError(e));
} finally {
setScanning(false);
} }
} document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('pageshow', onResume);
async function handleScan() { window.addEventListener('focus', onResume);
setScanMsg(''); return () => {
if (!isWechatEnv()) { document.removeEventListener('visibilitychange', onVisibility);
setScanMsg('请在微信内打开门店端进行扫码核销'); window.removeEventListener('pageshow', onResume);
return; window.removeEventListener('focus', onResume);
} };
try { }, [loadDashboard]);
const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) {
setAuthModalOpen(true);
return;
}
await runScan();
} catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
}
}
async function startWechatAuth() {
setAuthLoading(true);
setAuthError('');
try {
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
await authorizeShopWechat();
} catch (e) {
sessionStorage.removeItem(PENDING_SCAN_KEY);
setAuthError(e instanceof Error ? e.message : '微信授权失败');
setAuthLoading(false);
}
}
const store = dash?.store as Record<string, unknown> | undefined; const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || []; const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const openTime = String(store?.openTime || '10:00'); const status = String(store?.status || '');
const closeTime = String(store?.closeTime || '22:00'); const open = status === 'OPEN';
const hoursParts: string[] = [];
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '10:00 - 22:00';
const statusText =
status === 'CLOSED' ? '永久关闭' : open ? '当前正在营业中' : '当前临时闭店';
return ( return (
<div className="shop-home-page"> <PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
<header className="shop-home-header"> <header className="shop-home-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -162,20 +79,13 @@ export default function HomePage() {
</section> </section>
<section className="shop-home-scan"> <section className="shop-home-scan">
<button <Link
type="button" to="/redeem/phone"
className="shop-home-scan-btn" className="shop-home-scan-btn"
disabled={scanning}
onClick={() => void handleScan()}
> >
<span className="material-symbols-outlined">qr_code_scanner</span>
</button>
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
<Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span> <span className="material-symbols-outlined">smartphone</span>
</Link> </Link>
<p className="shop-home-scan-label"></p>
</section> </section>
<section className="shop-home-status"> <section className="shop-home-status">
@@ -185,12 +95,12 @@ export default function HomePage() {
</div> </div>
<div> <div>
<p className="shop-home-status-title"></p> <p className="shop-home-status-title"></p>
<p className="shop-home-status-sub">{open ? '当前正在营业中' : '当前已停止营业'}</p> <p className="shop-home-status-sub">{statusText}</p>
<p className="shop-home-status-sub">: {openTime} - {closeTime}</p> <p className="shop-home-status-sub">: {hoursText}</p>
</div> </div>
</div> </div>
<label className="shop-home-switch" onClick={() => navigate('/status')}> <label className="shop-home-switch" onClick={() => navigate('/status')}>
<input type="checkbox" checked={open} readOnly tabIndex={-1} /> <input type="checkbox" checked={open && status !== 'CLOSED'} readOnly tabIndex={-1} />
<span className="shop-home-switch-track" /> <span className="shop-home-switch-track" />
</label> </label>
</section> </section>
@@ -222,17 +132,6 @@ export default function HomePage() {
</section> </section>
</div> </div>
<WechatScanAuthModal </PullToRefresh>
open={authModalOpen}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError('');
sessionStorage.removeItem(PENDING_SCAN_KEY);
}}
/>
</div>
); );
} }
+36
View File
@@ -0,0 +1,36 @@
import { Link } from 'react-router-dom';
import { getLegalDocument, type LegalDocument } from '@dukang/shared-types';
type LegalPageProps = {
docId: LegalDocument['id'];
/** 返回登录页的路径,如 /login */
backTo?: string;
};
/** H5 各端共用的协议/隐私正文页 */
export default function LegalPage({ docId, backTo = '/login' }: LegalPageProps) {
const doc = getLegalDocument(docId);
return (
<div className="legal-h5-page">
<header className="legal-h5-header">
<Link to={backTo} className="legal-h5-back" aria-label="返回">
</Link>
<h1 className="legal-h5-title">{doc.title}</h1>
</header>
<main className="legal-h5-body">
<p className="legal-h5-updated">{doc.updatedAt}</p>
<p className="legal-h5-intro">{doc.intro}</p>
{doc.sections.map((section) => (
<section key={section.heading} className="legal-h5-section">
<h2>{section.heading}</h2>
{section.paragraphs.map((p, i) => (
<p key={`${section.heading}-${i}`}>{p}</p>
))}
</section>
))}
</main>
</div>
);
}
+49 -192
View File
@@ -1,76 +1,60 @@
import { useEffect, useState } from 'react'; import { useRef, useState, type RefObject } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
import { routeAfterShopLogin } from './SelectStorePage';
import { import {
bindShopWechatAfterSmsLogin, getLastPhone,
fetchClientConfig, request,
handleShopWechatCallback, saveRememberedSession,
handleShopWechatLoginResult, type ShopSessionPayload,
loginShopWithWechat, } from '../lib/api';
} from '../lib/wechat-auth'; import { routeAfterShopLogin } from './SelectStorePage';
import { isWechatEnv } from '../lib/weixin';
function maskPhone(phone: string) { function ShopAgreementCheckbox({
if (phone.length < 7) return phone; agreed,
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`; onChange,
} labelRef,
}: {
function formatWechatError(e: unknown): string { agreed: boolean;
const text = e instanceof Error ? e.message : '微信登录失败'; onChange: (next: boolean) => void;
if (text.includes('首次登录') || text.includes('手机验证码')) { labelRef?: RefObject<HTMLLabelElement>;
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信'; }) {
} return (
return text; <label className="shop-login-agreement" ref={labelRef}>
<input
type="checkbox"
checked={agreed}
onChange={(e) => onChange(e.target.checked)}
/>
<span>
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
</Link>
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
);
} }
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession(); const { applySession } = useStoreSession();
const [params, setSearchParams] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone()); const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(true); const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false); const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
routeAfterShopLogin(session, navigate);
}
})
.catch((e) => setMsg(formatWechatError(e)));
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先阅读并同意用户协议'); setMsg('请先阅读并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false; return false;
} }
return true; return true;
@@ -109,13 +93,8 @@ export default function LoginPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone, code }),
}); });
saveAuth(data); saveRememberedSession(data);
applySession(data); applySession(data);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin();
return;
}
routeAfterShopLogin(data, navigate); routeAfterShopLogin(data, navigate);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败'); setMsg(e instanceof Error ? e.message : '登录失败');
@@ -124,98 +103,6 @@ export default function LoginPage() {
} }
} }
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg('请在微信内打开以使用微信一键登录');
return;
}
setWxLoading(true);
try {
const session = await loginShopWithWechat();
if (session) {
applySession(session);
routeAfterShopLogin(session, navigate);
}
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
return (
<div className="shop-quick-login-page">
<header className="shop-quick-header">
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
<h1 className="shop-quick-welcome"></h1>
<div className="shop-quick-welcome-line" />
</header>
<section className="shop-quick-store-card">
<div className="shop-quick-store-inner">
<div className="shop-quick-store-icon">
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
</div>
<div>
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
</div>
<span className="shop-quick-verified">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
</span>
<div className="shop-quick-switch">
<Link to="/login">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
</Link>
</div>
</div>
</section>
<div className="shop-quick-actions">
{msg && <p className="shop-login-msg">{msg}</p>}
{canWechatQuick ? (
<button
type="button"
className="shop-quick-login-btn shop-quick-login-btn--wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
</Link>
)}
<div className="shop-quick-secure">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
</div>
</div>
<footer className="shop-quick-footer">
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 </p>
</footer>
</div>
);
}
return ( return (
<div className="shop-login-page"> <div className="shop-login-page">
<header className="shop-login-hero"> <header className="shop-login-hero">
@@ -270,6 +157,12 @@ export default function LoginPage() {
{msg && <p className="shop-login-msg">{msg}</p>} {msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
<button <button
type="button" type="button"
className="shop-login-submit" className="shop-login-submit"
@@ -279,41 +172,10 @@ export default function LoginPage() {
<span>{loading ? '登录中...' : '登录'}</span> <span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>} {!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button> </button>
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
{wxAuthorize && ( 7
<> </p>
<div className="shop-login-divider">
<span className="shop-login-divider-line" />
<span className="shop-login-divider-text"></span>
<span className="shop-login-divider-line" />
</div>
<button
type="button"
className="shop-login-wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
</>
)}
</div> </div>
<label className="shop-login-agreement">
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
<span>
<a href="#user-agreement"></a>
<a href="#privacy"></a>
</span>
</label>
</main> </main>
<footer className="shop-login-footer"> <footer className="shop-login-footer">
@@ -321,11 +183,6 @@ export default function LoginPage() {
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}> <span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
security security
</span> </span>
{hasShopWxSession() && savedProfile && (
<p style={{ marginTop: 16, textAlign: 'center' }}>
<Link to="/login?quick=1" className="text-primary body-md"></Link>
</p>
)}
</footer> </footer>
</div> </div>
); );
+101 -10
View File
@@ -1,24 +1,101 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api'; import { getStoreProfile, request } from '../lib/api';
import {
authorizeShopWechat,
fetchClientConfig,
fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
export default function MinePage() { export default function MinePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { resetSession, store: sessionStore } = useStoreSession(); const [searchParams, setSearchParams] = useSearchParams();
const { resetSession, store: sessionStore, applySession } = useStoreSession();
const profile = sessionStore ?? getStoreProfile(); const profile = sessionStore ?? getStoreProfile();
const [store, setStore] = useState<Record<string, unknown> | null>(null); const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [hasWechat, setHasWechat] = useState<boolean | null>(null);
const [wxAuthorize, setWxAuthorize] = useState(false);
const [binding, setBinding] = useState(false);
const [bindMsg, setBindMsg] = useState('');
useEffect(() => { const loadMine = useCallback(() => {
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)); return Promise.all([
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)),
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false)),
fetchShopAccount()
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
.catch(() => setHasWechat(null)),
]);
}, []); }, []);
const openTime = String(store?.openTime || '09:30'); useEffect(() => {
const closeTime = String(store?.closeTime || '22:00'); void loadMine();
}, [loadMine]);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
setHasWechat(true);
setBindMsg('微信绑定成功');
}
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
})
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
const hoursParts: string[] = [];
if (store?.openTime && store?.closeTime) hoursParts.push(`${store.openTime} - ${store.closeTime}`);
if (store?.openTime2 && store?.closeTime2) hoursParts.push(`${store.openTime2} - ${store.closeTime2}`);
const hoursText = hoursParts.length ? hoursParts.join('') : '09:30 - 22:00';
const multiStore = (profile?.stores?.length ?? 0) > 1; const multiStore = (profile?.stores?.length ?? 0) > 1;
const showBindWechat = wxAuthorize && hasWechat === false;
async function bindWechat() {
if (binding) return;
setBindMsg('');
if (!isWechatEnv()) {
setBindMsg('请在微信内打开门店端以绑定微信');
return;
}
setBinding(true);
try {
const result = await authorizeShopWechat();
if (!result) {
// 跳转公众号 OAuth,回跳后由上面 useEffect 处理
return;
}
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
setHasWechat(true);
setBindMsg('微信绑定成功');
} else {
setBindMsg('绑定未完成,请重试');
}
} catch (e) {
setBindMsg(e instanceof Error ? e.message : '微信绑定失败');
} finally {
setBinding(false);
}
}
return ( return (
<div className="shop-mine-page"> <PullToRefresh onRefresh={loadMine} className="shop-mine-page">
<header className="shop-mine-header"> <header className="shop-mine-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -51,7 +128,7 @@ export default function MinePage() {
<div className="shop-mine-info-row"> <div className="shop-mine-info-row">
<div> <div>
<p className="shop-mine-info-label"></p> <p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value">{openTime} - {closeTime}</p> <p className="shop-mine-info-value">{hoursText}</p>
</div> </div>
<span className="material-symbols-outlined shop-mine-lock">lock</span> <span className="material-symbols-outlined shop-mine-lock">lock</span>
</div> </div>
@@ -77,6 +154,20 @@ export default function MinePage() {
<p></p> <p></p>
</div> </div>
{bindMsg ? <p className="shop-mine-bind-msg">{bindMsg}</p> : null}
{showBindWechat ? (
<button
type="button"
className="shop-mine-bind-wechat"
disabled={binding}
onClick={() => void bindWechat()}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>chat</span>
{binding ? '绑定中…' : '绑定微信'}
</button>
) : null}
<button <button
type="button" type="button"
className="shop-mine-logout" className="shop-mine-logout"
@@ -86,6 +177,6 @@ export default function MinePage() {
退 退
</button> </button>
</div> </div>
</div> </PullToRefresh>
); );
} }
+85 -210
View File
@@ -1,33 +1,22 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
type BalanceResult = {
sessionId: string;
totalBalance: number;
maskedPhone: string;
user?: { nickname?: string; phone?: string; userNo?: string };
};
function formatAmount(n: number) { function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
type Step = 'lookup' | 'amount' | 'confirm';
export default function PhoneRedeemPage() { export default function PhoneRedeemPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [step, setStep] = useState<Step>('lookup');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [lookupCode, setLookupCode] = useState('');
const [confirmCode, setConfirmCode] = useState('');
const [amount, setAmount] = useState(''); const [amount, setAmount] = useState('');
const [balance, setBalance] = useState<BalanceResult | null>(null); const [confirmCode, setConfirmCode] = useState('');
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
const [storeClosed, setStoreClosed] = useState(false); const [storeClosed, setStoreClosed] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [lookupCooldown, setLookupCooldown] = useState(0);
const [confirmCooldown, setConfirmCooldown] = useState(0); const [confirmCooldown, setConfirmCooldown] = useState(0);
useEffect(() => { useEffect(() => {
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
.catch(() => setStoreName('当前门店')); .catch(() => setStoreName('当前门店'));
}, []); }, []);
useEffect(() => {
if (lookupCooldown <= 0) return;
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [lookupCooldown]);
useEffect(() => { useEffect(() => {
if (confirmCooldown <= 0) return; if (confirmCooldown <= 0) return;
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000); const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [confirmCooldown]); }, [confirmCooldown]);
async function sendLookupSms() { async function sendConfirmSms() {
if (!/^1\d{10}$/.test(phone.trim())) { if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号'); setMsg('请输入正确的手机号');
return; return;
} }
setLoading(true);
setMsg('');
try {
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
});
setLookupCooldown(60);
setMsg('验证码已发送至用户手机');
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
} finally {
setLoading(false);
}
}
async function queryBalance() {
if (!lookupCode.trim()) {
setMsg('请输入验证码');
return;
}
setLoading(true);
setMsg('');
try {
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
});
setBalance(res);
setStep('amount');
setMsg('');
} catch (e) {
setMsg(e instanceof Error ? e.message : '查询失败');
} finally {
setLoading(false);
}
}
async function prepareRedeem() {
if (storeClosed) { if (storeClosed) {
setMsg('门店未营业,无法核销'); setMsg('门店未营业,无法核销');
return; return;
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
setMsg('请输入有效核销金额'); setMsg('请输入有效核销金额');
return; return;
} }
if (balance && value > balance.totalBalance) {
setMsg('核销金额不能超过可用权益');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
await request('SHOP_H5', '/shop/redeem/phone/prepare', { const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
method: 'POST', method: 'POST',
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }), body: JSON.stringify({ phone: phone.trim(), amount: value }),
}); });
setPrepared(result);
setConfirmCode('');
setConfirmCooldown(60); setConfirmCooldown(60);
setStep('confirm'); setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '发起核销失败'); setPrepared(null);
setMsg(e instanceof Error ? e.message : '发送验证码失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
async function confirmRedeem() { async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
return;
}
if (!confirmCode.trim()) { if (!confirmCode.trim()) {
setMsg('请输入确认验证码'); setMsg('请输入确认验证码');
return; return;
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', { const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
sessionId: balance?.sessionId, sessionId: prepared.sessionId,
code: confirmCode.trim(), code: confirmCode.trim(),
}), }),
}); });
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result)); sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
navigate('/redeem/success', { navigate('/redeem/success', {
state: { result, storeName, user: balance?.user }, state: { result, storeName, user: prepared.user },
}); });
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '核销失败'); setMsg(e instanceof Error ? e.message : '核销失败');
@@ -151,7 +97,9 @@ export default function PhoneRedeemPage() {
} }
} }
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—'; const amountValue = Number(amount);
const canSendCode =
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
return ( return (
<div className="shop-redeem-page"> <div className="shop-redeem-page">
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
</div> </div>
<div className="shop-redeem-body"> <div className="shop-redeem-body">
{step === 'lookup' && ( <div className="shop-phone-field">
<> <label className="shop-phone-label"></label>
<div className="shop-phone-field"> <input
<label className="shop-phone-label"></label> className="shop-phone-input"
<input type="tel"
className="shop-phone-input" maxLength={11}
type="tel" placeholder="请输入用户手机号"
maxLength={11} value={phone}
placeholder="请输入用户手机号" disabled={loading}
value={phone} onChange={(e) => {
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))} setPhone(e.target.value.replace(/\D/g, ''));
/> setPrepared(null);
</div> setConfirmCode('');
<div className="shop-phone-field"> setConfirmCooldown(0);
<label className="shop-phone-label"></label> }}
<div className="shop-phone-code-row"> />
<input </div>
className="shop-phone-input"
type="text"
maxLength={6}
placeholder="用户收到的验证码"
value={lookupCode}
onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
/>
<button
type="button"
className="shop-phone-code-btn"
disabled={loading || lookupCooldown > 0 || !phone.trim()}
onClick={() => void sendLookupSms()}
>
{lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
</button>
</div>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed}
onClick={() => void queryBalance()}
>
</button>
</>
)}
{step === 'amount' && balance && ( <div className="shop-phone-field">
<> <label className="shop-phone-label"></label>
<div className="shop-redeem-user"> <input
<div className="shop-redeem-user-left"> className="shop-phone-input"
<span className="material-symbols-outlined">person</span> type="number"
<span></span> min={0.01}
</div> step={0.01}
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}> placeholder="请输入待核销金额"
{userLabel} value={amount}
</span> disabled={loading}
</div> onChange={(e) => {
<div className="shop-redeem-amount-section"> setAmount(e.target.value);
<p className="shop-redeem-amount-label"></p> setPrepared(null);
<div className="shop-redeem-amount"> setConfirmCode('');
<span className="shop-redeem-amount-symbol">¥</span> setConfirmCooldown(0);
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span> }}
</div> />
</div> </div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="number"
min={0.01}
step={0.01}
placeholder="请输入核销金额"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed || balance.totalBalance <= 0}
onClick={() => void prepareRedeem()}
>
</button>
<button
type="button"
className="shop-phone-link-btn"
onClick={() => {
setStep('lookup');
setBalance(null);
setAmount('');
setLookupCode('');
}}
>
</button>
</>
)}
{step === 'confirm' && balance && ( <div className="shop-phone-field">
<> <label className="shop-phone-label"></label>
<div className="shop-redeem-details"> <div className="shop-phone-code-row">
<div className="shop-redeem-detail-row"> <input
<span></span> className="shop-phone-input"
<span>{userLabel}</span> type="text"
</div> inputMode="numeric"
<div className="shop-redeem-detail-row"> maxLength={6}
<span></span> placeholder="输入用户收到的验证码"
<span>¥{formatAmount(Number(amount))}</span> value={confirmCode}
</div> onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
</div> />
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="text"
maxLength={6}
placeholder="用户手机收到的确认码"
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
/>
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
</p>
</div>
<button <button
type="button" type="button"
className="shop-redeem-confirm-btn" className="shop-phone-code-btn"
disabled={loading || storeClosed} disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
onClick={() => void confirmRedeem()} onClick={() => void sendConfirmSms()}
> >
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`} {confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
</button> </button>
<button </div>
type="button" <p className="shop-redeem-hint" style={{ marginTop: 8 }}>
className="shop-phone-link-btn"
onClick={() => { </p>
setStep('amount'); </div>
setConfirmCode('');
}} <button
> type="button"
className="shop-redeem-confirm-btn"
</button> disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
</> onClick={() => void confirmRedeem()}
)} >
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
</button>
{msg && <p className="shop-redeem-error">{msg}</p>} {msg && <p className="shop-redeem-error">{msg}</p>}
</div> </div>
+17 -10
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api'; import { request } from '../lib/api';
type RangeKey = 'today' | '7d' | '30d'; type RangeKey = 'today' | '7d' | '30d';
@@ -28,15 +29,21 @@ export default function RecordsPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
useEffect(() => { const loadRecords = useCallback(() => {
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => { return Promise.all([
setRecords(d.list || []); request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
}); setRecords(d.list || []);
request<Record<string, unknown>>('SHOP_H5', '/shop/store') }),
.then((s) => setStoreName(String(s.name || ''))) request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.catch(() => {}); .then((s) => setStoreName(String(s.name || '')))
.catch(() => {}),
]);
}, []); }, []);
useEffect(() => {
void loadRecords();
}, [loadRecords]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return records.filter((r) => { return records.filter((r) => {
if (!inRange(String(r.createdAt), range)) return false; if (!inRange(String(r.createdAt), range)) return false;
@@ -55,7 +62,7 @@ export default function RecordsPage() {
}, [filtered]); }, [filtered]);
return ( return (
<div className="shop-records-page"> <PullToRefresh onRefresh={loadRecords} className="shop-records-page">
<header className="shop-records-header"> <header className="shop-records-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -176,6 +183,6 @@ export default function RecordsPage() {
</div> </div>
)} )}
</div> </div>
</div> </PullToRefresh>
); );
} }
+14 -8
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import {
needsStoreSelection, needsStoreSelection,
@@ -19,15 +20,19 @@ export default function SelectStorePage() {
const currentStoreId = store?.storeId || ''; const currentStoreId = store?.storeId || '';
const canGoBack = Boolean(currentStoreId); const canGoBack = Boolean(currentStoreId);
const loadStores = useCallback(() => {
return request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
.then((list) => setStores(list))
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
}, []);
useEffect(() => { useEffect(() => {
if (!authenticated) { if (!authenticated) {
navigate('/login', { replace: true }); navigate('/login', { replace: true });
return; return;
} }
void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores') void loadStores();
.then((list) => setStores(list)) }, [authenticated, navigate, loadStores]);
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
}, [authenticated, navigate]);
async function onSelect(storeId: string) { async function onSelect(storeId: string) {
if (loadingId) return; if (loadingId) return;
@@ -58,12 +63,13 @@ export default function SelectStorePage() {
function statusLabel(status: string) { function statusLabel(status: string) {
if (status === 'OPEN') return '营业中'; if (status === 'OPEN') return '营业中';
if (status === 'PAUSED') return '暂停营业'; if (status === 'PAUSED') return '临时闭店';
if (status === 'CLOSED') return '永久关闭';
return status || '门店'; return status || '门店';
} }
return ( return (
<div className="shop-select-store-page"> <PullToRefresh onRefresh={loadStores} className="shop-select-store-page">
<header className="shop-subpage-header"> <header className="shop-subpage-header">
{canGoBack ? ( {canGoBack ? (
<button <button
@@ -151,7 +157,7 @@ export default function SelectStorePage() {
) : null} ) : null}
</section> </section>
</div> </div>
</div> </PullToRefresh>
); );
} }

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