Compare commits
576 Commits
82435634d7
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e3a27256ba | |||
| 4bdb09068c | |||
| a9c466930d | |||
| 1677386c60 | |||
| 192a401227 | |||
| 5ae1816414 | |||
| 0e711be6c6 | |||
| 9e0db27326 | |||
| 8a196c377e | |||
| 418e13a5e3 | |||
| 7fa69c2521 | |||
| b0874f07f6 | |||
| c26ebdde1b | |||
| b60b16a84f | |||
| b74dfd391a | |||
| ec9b7efcd6 | |||
| d04ca615ff | |||
| ebb34ad112 | |||
| d5994af431 | |||
| c29eb2c383 | |||
| 29e95bd5a3 | |||
| e5d7e468cc | |||
| 62d75a6869 | |||
| 8d70f0da72 | |||
| 92dfbf5722 | |||
| 42c1b65632 | |||
| de7bd34aa8 | |||
| 68a4b7f984 | |||
| e5fe8b903a | |||
| c6b01def4c | |||
| 63fcf33416 | |||
| 101b27c92c | |||
| 5ed9f79a5e | |||
| 6880b5daf4 | |||
| 1000b489a0 | |||
| 72fef35807 | |||
| f570383717 | |||
| fe557c912d | |||
| 77e9917a18 | |||
| 44bfbe0bef | |||
| 84fb2f314a | |||
| 11a9bb2414 | |||
| a4530a64df | |||
| c905b3f930 | |||
| af3c4b6f08 | |||
| c1f502a5e3 | |||
| 52816e3874 | |||
| 7f7719f281 | |||
| a88fd6c29a | |||
| bbb13cfd1a | |||
| 2e0518b5b7 | |||
| f8f652a0a6 | |||
| 04d903c554 | |||
| 30bfcc0d93 | |||
| 283e6651aa | |||
| 0e883d3e70 | |||
| 6a95f47860 | |||
| cdc4b82931 | |||
| 9a35c4aea9 | |||
| 99c7f8caea | |||
| eeee55e215 | |||
| 971b8aaa7b | |||
| d555240295 | |||
| f35e1a3ba5 | |||
| 4b9ab79acb | |||
| d9725b1914 | |||
| ac94f5f5de | |||
| 874ae93a9b | |||
| fbb92e00d5 | |||
| e26206b93c | |||
| bb831f0580 | |||
| 3166467518 | |||
| 0ff61c2cd1 | |||
| fb66258b6a | |||
| e68eb4d38c | |||
| b375fab44a | |||
| 58010feed8 | |||
| 9f14c59f22 | |||
| 9c8d5f2cad | |||
| 870494563d | |||
| 4c9b3f72bc | |||
| 3b669f7e38 | |||
| 14c5c8c3f3 | |||
| a7f3283ae5 | |||
| da19c39965 | |||
| c06251f0a4 | |||
| faa1830bde | |||
| b226af2adc | |||
| 2b5f42acdf | |||
| 71149044ca | |||
| fccfd7abbe | |||
| 1ecc6a11df | |||
| cfd42dfc32 | |||
| 737efb89a3 | |||
| a423fb71e8 | |||
| 4111e5c0a6 | |||
| 3dbb73e428 | |||
| 3a9f27eb38 | |||
| e2f5aa9dd8 | |||
| 58a8b47532 | |||
| 6a5c0cb090 | |||
| 448b9983d3 | |||
| 3afbdfe6b9 | |||
| 632539e8dc | |||
| c7cd0f6cf2 | |||
| 00bb6a2227 | |||
| 1f06530894 | |||
| 1f4506f60b | |||
| 93390ccf75 | |||
| a2976cdc8f | |||
| cc0c0a6ef8 | |||
| 8f2ebbfef4 | |||
| ee1a3f4b6c | |||
| 52a7d3789d | |||
| f2fbf95c53 | |||
| 98d52ff173 | |||
| 797b20979d | |||
| 43de361e61 | |||
| b1511ecb92 | |||
| fed8ff3d3a | |||
| 0b548a2764 | |||
| 30f8d649cb | |||
| f4da71e952 | |||
| 40e8a12596 | |||
| d7241d54b1 | |||
| 282da24bc4 | |||
| 3d819dc10b | |||
| 5e0691ab35 | |||
| 5935024ea8 | |||
| 7304c7a8e1 | |||
| 8984465893 | |||
| b90a4e9f8f | |||
| a3f17cbf94 | |||
| 3f208d27a9 | |||
| 6973e3d7f5 | |||
| 114d77ca48 | |||
| fa69ced448 | |||
| a144a35c3d | |||
| 7e12eb3ca6 | |||
| 608e3e5ec5 | |||
| d98b0aefb0 | |||
| 18c5abea79 | |||
| 012626f6bd | |||
| 22cd00da42 | |||
| 5d4ed566de | |||
| 3004a65b35 | |||
| f9161368e1 | |||
| 8a061f085f | |||
| b20e566a65 | |||
| 5fdddae41c | |||
| 7d2e4ef57e | |||
| 4d434c9c67 | |||
| 22c0b03a47 | |||
| 73fd27bdf6 | |||
| 62e61a9e63 | |||
| f2d03e2595 | |||
| cd9696bc1c | |||
| a01217539c | |||
| 0c28c204b2 | |||
| b5d4f8b27c | |||
| 26334ed072 | |||
| 9a80f0f8d6 | |||
| e7f49a9639 | |||
| fc2e5b65de | |||
| f941dcf072 | |||
| e8b585e623 | |||
| ee493823bf | |||
| 5702266276 | |||
| 3382d36a6c | |||
| 8b5bb6a981 | |||
| 0a36188b35 | |||
| 9b8e3f1347 | |||
| a8a37fbc4a | |||
| 6cf66c722c | |||
| 1440a59fc8 | |||
| e34416a9b3 | |||
| 4db21cf1a1 | |||
| 2f633b307c | |||
| 62353a6800 | |||
| 11659484c6 | |||
| 6c44e9c56f | |||
| 233ed0af3b | |||
| 0fb7ab7abb | |||
| 905e145af3 | |||
| 7dd5fdfb12 | |||
| 48d6900ab6 | |||
| 5ca9aa00aa | |||
| c0d04ee500 | |||
| 3ac008a217 | |||
| cd1e9ae488 | |||
| 18147e157f | |||
| f7c94b4f16 | |||
| cc750dc1d6 | |||
| 984dec79ee | |||
| f84614bbb4 | |||
| c0ce494732 | |||
| 69e201f927 | |||
| ecd9973d8f | |||
| bbd7e52e70 | |||
| 1c91e6bae9 | |||
| 029fd690a5 | |||
| 0bfc19b1a4 | |||
| bafb342600 | |||
| bac5c7ad80 | |||
| c05cd0e96d | |||
| 8de41796fb | |||
| 4b3b1f76ae | |||
| 75d820b574 | |||
| 0f48d7abda | |||
| 4a0e18472b | |||
| 491f03e825 | |||
| 88053353ab | |||
| ceacce9ed5 | |||
| 3913bf3bf3 | |||
| 478291e2b3 | |||
| 6754caf076 | |||
| 92c3175acf | |||
| 46361ec713 | |||
| 5586ed90cd | |||
| 4cec5b1fe5 | |||
| 07630c9046 | |||
| 4488b0c006 | |||
| 99c74f20d2 | |||
| a598d1cd30 | |||
| da6a38daa4 | |||
| c5926bd593 | |||
| df546ff39f | |||
| d6d4a77473 | |||
| 0518976953 | |||
| 8f83624ffe | |||
| 7c6dae472c | |||
| 209db59422 | |||
| 21d44026d9 | |||
| 3b192b0b88 | |||
| 1b3a7f3a36 | |||
| 34a1897c09 | |||
| 36017a19b1 | |||
| 9c7550a1c4 | |||
| fe1c6c8158 | |||
| acc841bb21 | |||
| 61a99aeea9 | |||
| 76375eff83 | |||
| 713dce52b4 | |||
| e41bf838d4 | |||
| 0692bebf07 | |||
| b19f875e7b | |||
| 090002b6c0 | |||
| 8726cf14e3 | |||
| 7298b1a5d5 | |||
| 1401f1dcd2 | |||
| 155426b669 | |||
| b4d6f2bd91 | |||
| 029c17347e | |||
| b626db5d84 | |||
| d98ef6b808 | |||
| d13a6ee3cd | |||
| 10fa361983 | |||
| 1a615ffe93 | |||
| 0269802e59 | |||
| e4e9eb2169 | |||
| b5fb9a0bb2 | |||
| 9d800a4cbd | |||
| 2a9493165b | |||
| b32c68d56b | |||
| 6816835fb8 | |||
| 571959900b | |||
| 7bc710f139 | |||
| 397bdad73f | |||
| 83b1b0e5f6 | |||
| 91fed31b10 | |||
| 1933b9b608 | |||
| a2abbb6169 | |||
| 53a79d1255 | |||
| 0181af09c5 | |||
| 9cd0c9508a | |||
| 4f363fa9e7 | |||
| 291137a15d | |||
| e2a5f21f9d | |||
| 3ef4432b2f | |||
| 33b868dfac | |||
| 5c4ad1d8db | |||
| a346fae7a4 | |||
| 37bc297d1a | |||
| bc1616c3f7 | |||
| 46a200e0ba | |||
| 429eaabe6b | |||
| 607a0224cd | |||
| 53e0db6a98 | |||
| 456b3af461 | |||
| 1f7ecffe4c | |||
| 8df0a2bea7 | |||
| 672a995c59 | |||
| 69f52921a7 | |||
| 9cb88dc181 | |||
| 345d5db9c9 | |||
| 7c9240fc84 | |||
| 2610288df0 | |||
| 62652cd072 | |||
| 9d49ed5e70 | |||
| bdbd135318 | |||
| 8ca893efab | |||
| d0ab838da6 | |||
| 50ab0f1748 | |||
| fa6fb80d19 | |||
| bd1930f6b1 | |||
| dc4295d1bd | |||
| 256526c5ec | |||
| 1c4b986924 | |||
| bf501d2374 | |||
| 442500b1a1 | |||
| 490c26a7e1 | |||
| b40eb83f27 | |||
| f80a0f2c27 | |||
| 528669f662 | |||
| f5e523a425 | |||
| 16fa07ceaf | |||
| b21403a47c | |||
| 02d89e6385 | |||
| f9a5b6e81a | |||
| 16c386f165 | |||
| 1c787ed576 | |||
| 4372018c09 | |||
| aca3df4bf2 | |||
| 71f508e02b | |||
| 9d96c73246 | |||
| fa0d928980 | |||
| c8ea5a3119 | |||
| 6f427cb553 | |||
| 89fd333702 | |||
| ab10431001 | |||
| e93e4b8f84 | |||
| d90847bad0 | |||
| ffa753707b | |||
| ac2f9f4793 | |||
| 3ba7b179e8 | |||
| 4e7079c221 | |||
| 3c19b7dd97 | |||
| a7d55b7293 | |||
| 7e3dc131ad | |||
| f09e00e16f | |||
| ff462bca9a | |||
| aa0e140fec | |||
| dc30932012 | |||
| f8d8e2d6d5 | |||
| 54fca99208 | |||
| 9859abba7e | |||
| 26472f8678 | |||
| c89560e4f8 | |||
| f3353bbce5 | |||
| 666bcb8633 | |||
| ffed560d43 | |||
| a7a0a54688 | |||
| 5199b495e0 | |||
| 04fd4d50d0 | |||
| 82b98aa4e3 | |||
| 2088a1ee19 | |||
| 7dffd40f3f | |||
| 021b9e0329 | |||
| 0304201e80 | |||
| fd05621660 | |||
| dd4362c397 | |||
| 2ebdb08a2f | |||
| 87515decb3 | |||
| 4a5b9eaffa | |||
| e06d9f4711 | |||
| 28ef916a22 | |||
| fa3484041e | |||
| 06cbcdeb9c | |||
| ebd8c07147 | |||
| 39e38aac7e | |||
| 87c4af7364 | |||
| 9f67221457 | |||
| 23587418c1 | |||
| daf534b4db | |||
| 0621ac18fb | |||
| 442f1ea842 | |||
| 8e25e23e81 | |||
| 118993a01f | |||
| a017a27b60 | |||
| 8f361981bf | |||
| 847a3f0dc3 | |||
| 49a5c057db | |||
| 4bb6ae20cd | |||
| 5deffbdbfb | |||
| ad757e9ba8 | |||
| 07fbb74485 | |||
| ca6fa2244b | |||
| e63b57db19 | |||
| 205c1110c2 | |||
| fb4432b307 | |||
| 3328c52cb4 | |||
| ab44515e8b | |||
| cb6ecaaba6 | |||
| 78365d648c | |||
| 4ba51c502b | |||
| e9eddbb26e | |||
| ab3b230d01 | |||
| c00ca3620d | |||
| 45d3d8164d | |||
| defdcb409f | |||
| 4d76ee6d0e | |||
| bdc9dbde8d | |||
| 3fc2f23e10 | |||
| 09e732bfa3 | |||
| 50ba6e9c56 | |||
| 6a23e79f4c | |||
| 2dbe665bdc | |||
| 8d6c0f9ac0 | |||
| 51f2b5fbfa | |||
| 2cd4e25682 | |||
| 1a0afb6d39 | |||
| 05dffbfa15 | |||
| a0a6c14758 | |||
| ed7fd4e013 | |||
| bf9cd1bffd | |||
| ca621c9db8 | |||
| 507a21a318 | |||
| f396cb1fef | |||
| 2b85f63e2b | |||
| e742f1e88b | |||
| 2ccadce34a | |||
| b0ff254cd8 | |||
| c3362851ff | |||
| 804054df33 | |||
| 1bc3bec977 | |||
| 309b180087 | |||
| 118d57d710 | |||
| 0cb2b2cebb | |||
| daeb24bbeb | |||
| f4766d32ff | |||
| b920c894b9 | |||
| 37038a7591 | |||
| 944306e45c | |||
| 9ed0c24d11 | |||
| 50349dd8e9 | |||
| e2fd28a35b | |||
| 15c35c49de | |||
| a49d8d99b6 | |||
| aa9d689c76 | |||
| b59d4484ec | |||
| cd23119470 | |||
| 18ab639669 | |||
| fc03905777 | |||
| ea8152ebf3 | |||
| 78973e3fbf | |||
| 317f910f3c | |||
| d36fe13bcd | |||
| 15b878f2a4 | |||
| 09eda9a11e | |||
| 23c0cc7a5f | |||
| 1725b9e4b4 | |||
| cc9933d1ce | |||
| b810cabc6d | |||
| 25aee745cc | |||
| 097c7dec4b | |||
| 92cf51ba3d | |||
| 94d2a88581 | |||
| da21cd3daa | |||
| 19cb312465 | |||
| 2b7ef65cce | |||
| 745c192693 | |||
| 34532dc9bf | |||
| f053908acb | |||
| 959e0b66cb | |||
| ac1e0a9f13 | |||
| 2a208d5dea | |||
| 82c447b193 | |||
| 75765bf9d4 | |||
| 6479365d6a | |||
| d11757c854 | |||
| 479de04f7c | |||
| cebeefdb22 | |||
| e93e4a7721 | |||
| cd6b82abd8 | |||
| 047ffd7b16 | |||
| bca1d8afea | |||
| 0418370c31 | |||
| cd206d6baa | |||
| aeb72154a7 | |||
| e0d3c840a2 | |||
| f2303f7fb8 | |||
| fb530e2ff5 | |||
| 8c4419e04e | |||
| 85f2c156b0 | |||
| addfcfb0b1 | |||
| b392c28787 | |||
| b534a569f8 | |||
| b0f565103f | |||
| 5e33dd9847 | |||
| 9a550cbaaf | |||
| 823e439101 | |||
| 6c299f1a1d | |||
| 7c9827875f | |||
| ae7c63c08d | |||
| 270cebc79a | |||
| ba21cc89c6 | |||
| 56dfffd126 | |||
| bdf80e577b | |||
| eb96b36d0b | |||
| 36bec94639 | |||
| c11c7647b9 | |||
| 83ed90ef67 | |||
| 73dbf6effb | |||
| b8adfe98a3 | |||
| 6c6fb10490 | |||
| 935ab0d1d4 | |||
| 350a086a73 | |||
| c5f526d51d | |||
| e1f5130c18 | |||
| bc0b0cafd6 | |||
| ade68972a9 | |||
| 47b39e3ba0 | |||
| cf73bbae23 | |||
| d1b207422c | |||
| 4a29d8297c | |||
| 792b543ba8 | |||
| 81a6e3674b | |||
| 76270b20bf | |||
| c2914c37e5 | |||
| 9f9b7cb2bd | |||
| de396442a4 | |||
| 02efe6fa14 | |||
| 14b867a3a5 | |||
| f750fea667 | |||
| 55e91158b3 | |||
| 19cb13d26c | |||
| 04253b9120 | |||
| aaf6ee81d5 | |||
| 972bdde650 | |||
| c0d66cfe50 | |||
| 00b52b9aaa | |||
| e9537ce052 | |||
| c204fb0a92 | |||
| c643b0b979 | |||
| ed44e88e93 | |||
| 57172d54d5 | |||
| 608e405468 | |||
| b36f2ea63a | |||
| 7fa2c6fbed | |||
| bde85a7b82 | |||
| b4ec9387a0 | |||
| f6d97b4ee8 | |||
| f1dc23c54c | |||
| 9e6dc8c85c | |||
| fd5427c7ea | |||
| 4de570f5f4 | |||
| 22845e5d5f | |||
| 8d7507e2d8 | |||
| 48995aace4 | |||
| 5a26841d3a | |||
| 92ad0e16cf | |||
| 7b14f4a0cc | |||
| d69bfa359a | |||
| d3d56150c5 | |||
| 6a5ddcc70a | |||
| 5c55d3c385 | |||
| b30adfea8e | |||
| 02aecbeaab | |||
| 734a15c9ce | |||
| 9a3ccabcc9 | |||
| fd62b5bdbe | |||
| 966ddc9219 | |||
| 52a809649c | |||
| e307550ead | |||
| 372c29bc3a | |||
| 9c45011bd4 | |||
| acc5b78471 | |||
| d70f2d8f62 | |||
| 5fe1ff1fc3 | |||
| 0f5f0f4c2b | |||
| f15d2057f2 | |||
| 68aa7d3ce2 | |||
| f6c77e60b6 | |||
| 5a2e8dfcf8 | |||
| 2d5ecd1263 | |||
| 7062aeb8f8 |
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
description: 杜康好客全局约束 — Monorepo 布局、文档优先级、2 人 OWNER 边界
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# 杜康好客 · 核心规则
|
|
||||||
|
|
||||||
## Monorepo 布局
|
|
||||||
|
|
||||||
- `apps/` — preV1:`h5-user`、`h5-shop`、`h5-partner`、`admin-web`
|
|
||||||
- `packages/` — `shared-types`、`domain`、`shared-ui`
|
|
||||||
- `server/dukang-api/` — NestJS 单体 API
|
|
||||||
- `pages/` — UI 原型(**只读参照**)
|
|
||||||
|
|
||||||
> V2 目标为 Taro 四端(`mini-user` 等),编码以 **当前实际目录** 为准。
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
- **V3.0 业务事实源**:`杜康好客-v3-PRD.md`
|
|
||||||
- V3 实现验收:`杜康好客-v3编码手册.md`
|
|
||||||
- V2 蓝图:`杜康好客-V2编码手册.md`(与 V3 冲突时 V3 PRD 优先)
|
|
||||||
- preV1 裁剪:`杜康好客-preV1编码手册.md`
|
|
||||||
- 协作:`conventions.md` · AI 入口:`AGENTS.md`
|
|
||||||
|
|
||||||
**禁止**:编码时修改业务规则;需求变更只改 `杜康好客-v3-PRD.md`。
|
|
||||||
|
|
||||||
## 边界(2 人团队)
|
|
||||||
|
|
||||||
> **临时(2026-07)**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
|
|
||||||
|
|
||||||
| 负责人 | 路径 |
|
|
||||||
|--------|------|
|
|
||||||
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
|
|
||||||
| ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
|
|
||||||
|
|
||||||
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
|
|
||||||
- `apps/*` 禁止 import `server/*` 或其他 app
|
|
||||||
- Mock 只在 `integrations/*`,不在业务 Service 散落
|
|
||||||
|
|
||||||
## 共享契约
|
|
||||||
|
|
||||||
- 枚举/DTO → `packages/shared-types`
|
|
||||||
- 纯规则 → `packages/domain`(无 IO)
|
|
||||||
|
|
||||||
## 核心业务常量
|
|
||||||
|
|
||||||
- 权益额 = `benefit_amount ?? price`
|
|
||||||
- 核销:直接核销 `0 < amount ≤ 全部 ACTIVE 权益总余额`;带单据核销 `0 < amount ≤ 该单据可用金额`;Redis 码 3 分钟
|
|
||||||
- 订单 Tab(V3.0):`待付款 | 已付款 | 已完成`
|
|
||||||
- API:`/api/v1`,响应 `{ code, message, data }`
|
|
||||||
|
|
||||||
## 提交
|
|
||||||
|
|
||||||
- Conventional Commits:`feat(trade):` 等
|
|
||||||
- 不提交 `.env`
|
|
||||||
@@ -39,6 +39,9 @@ src/
|
|||||||
|
|
||||||
## UI 约束
|
## UI 约束
|
||||||
|
|
||||||
|
- 使用 shared-ui CSS 变量与组件,勿各端自造设计 token
|
||||||
|
- **mini-user 微信 `openType` Button**:祖先禁止 `e.stopPropagation()`(Taro→`catchtap`,选头像/手机号等会静默失效);遮罩与 sheet 拆开绑关闭。见 `.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||||
|
|
||||||
- C 端订单 **5 Tab**(含 pending_ship)
|
- C 端订单 **5 Tab**(含 pending_ship)
|
||||||
- 门店列表仅 `OPEN` 状态
|
- 门店列表仅 `OPEN` 状态
|
||||||
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
- 原型 `pages/` 只读;路由对照 `pages/ROUTE_MAP.md`
|
||||||
|
|||||||
@@ -1,32 +1,11 @@
|
|||||||
---
|
---
|
||||||
description: Git 提交与 PR 协作规范
|
description: 杜康提交 scope 与发版默认(生产)
|
||||||
alwaysApply: true
|
alwaysApply: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# Git 规范
|
# Git / 发版(杜康专属)
|
||||||
|
|
||||||
## 提交格式
|
- Conventional Commits,scope = 端或模块:`feat(trade):`、`fix(redeem):`
|
||||||
|
- 只说「发布/发版」→ 生产(`main` + `deploy-prod.sh`);明确说「发测试」才 staging。流程见 `/dukang-release`
|
||||||
Conventional Commits,scope = 端或模块名:
|
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
|
||||||
|
- Prisma 迁移须 Review
|
||||||
```
|
|
||||||
feat(trade): add order preview API
|
|
||||||
fix(redeem): validate redeem amount against balance
|
|
||||||
chore(shared-types): add OrderStatus enum
|
|
||||||
```
|
|
||||||
|
|
||||||
## 禁止提交
|
|
||||||
|
|
||||||
- `.env`、`.env.local` 等敏感配置
|
|
||||||
- `node_modules/`、`dist/`(已在 .gitignore)
|
|
||||||
|
|
||||||
## PR 与 Review
|
|
||||||
|
|
||||||
- feature 分支 + 小步 PR
|
|
||||||
- 跨模块改动需相关双 Owner Review
|
|
||||||
- Prisma 迁移必须多 Owner Review
|
|
||||||
|
|
||||||
## Agent 行为
|
|
||||||
|
|
||||||
- **仅用户明确要求时** 才执行 git commit / push
|
|
||||||
- 不 amend 已推送的 commit,不 force push main/master
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
description: 小程序子页不要画与原生导航栏重复的二级 title,标题只用 navigationBarTitleText
|
||||||
|
globs: apps/mini-user/**/*.{tsx,ts}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# mini-user · 小程序导航标题
|
||||||
|
|
||||||
|
微信小程序已有原生导航栏。页面内再画一层 `SubPageHeader` / 自定义 title,会与原生标题叠成**二级 title**。
|
||||||
|
|
||||||
|
## weapp
|
||||||
|
|
||||||
|
- 页面标题只走 `index.config.ts` 的 `navigationBarTitleText`
|
||||||
|
- **不要**设 `navigationStyle: 'custom'`(除非该页需要完全自定义导航,如滚动透明顶栏、商品详情)
|
||||||
|
- **不要**在页面内再渲染与原生标题重复的 `SubPageHeader`
|
||||||
|
- H5 没有原生导航栏:可保留 `SubPageHeader`(仅返回键;H5 实现已隐藏 title 文案)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ weapp 用原生标题;H5 保留返回栏
|
||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '申请发票',
|
||||||
|
});
|
||||||
|
|
||||||
|
{process.env.TARO_ENV === 'h5' ? (
|
||||||
|
<SubPageHeader title="申请发票" onBack={...} />
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 未配页标题 + 再画一层 SubPageHeader(weapp 会双标题)
|
||||||
|
<SubPageHeader title="申请发票" />
|
||||||
|
```
|
||||||
|
|
||||||
|
弹层/区块标题(如「编辑发票抬头」)不是导航二级 title,可保留。
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: 微信小程序 open-type 按钮踩坑 — 禁止祖先 catchtap / stopPropagation
|
||||||
|
globs: apps/mini-user/**/*.{tsx,ts,css,scss}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# mini-user · 微信 open-type 硬规则
|
||||||
|
|
||||||
|
## 禁止(必踩坑)
|
||||||
|
|
||||||
|
**现象**:`Button openType="chooseAvatar" | getPhoneNumber | getUserInfo | share | contact"` 点击无反应、无回调。
|
||||||
|
|
||||||
|
**根因**:祖先节点上的 `onClick={(e) => e.stopPropagation()}` 在 Taro 微信端会编译成 **`catchtap`**,拦截子级 `button` 的原生 open-type 能力。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ❌ 弹层内容上 stopPropagation — 内部 chooseAvatar 会失效
|
||||||
|
<View className="mask" onClick={close}>
|
||||||
|
<View className="sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Button openType="chooseAvatar" onChooseAvatar={...}>选头像</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 正确写法
|
||||||
|
|
||||||
|
遮罩与内容拆开:只在 **backdrop** 上关弹层,**sheet 不要**绑 stopPropagation / catchtap。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅
|
||||||
|
<View className="mask">
|
||||||
|
<View className="backdrop" onClick={close} />
|
||||||
|
<View className="sheet">
|
||||||
|
<Button openType="chooseAvatar" plain hoverClass="none" onChooseAvatar={...}>
|
||||||
|
...
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 附加
|
||||||
|
|
||||||
|
- `Button` 内 `Image` / 文案加 `pointer-events: none`(或父级 `> * { pointer-events: none }`),避免抢触摸
|
||||||
|
- 同类能力:`getPhoneNumber`、`contact`、`share` 同样忌祖先 `catchtap`
|
||||||
|
- 详情见知识库「C 端 · 踩坑 · chooseAvatar」
|
||||||
|
|
||||||
|
参照实现:`apps/mini-user/src/pages/mine/index.tsx` 资料弹层。
|
||||||
@@ -21,7 +21,7 @@ disable-model-invocation: true
|
|||||||
## 会话启动(按序)
|
## 会话启动(按序)
|
||||||
|
|
||||||
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`;preV1 → preV1 手册;V2 蓝图 → V2 手册
|
1. 确认阶段:**V3.0** → 读 `杜康好客-v3-PRD.md` + `@dukang-v3`;preV1 → preV1 手册;V2 蓝图 → V2 手册
|
||||||
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 表 → **只改本 OWNER 路径**
|
2. 读 [`AGENTS.md`](../../AGENTS.md) OWNER 段 + 对应 `apps/` 或 `server/dukang-api/AGENTS.md` → **只改本 OWNER 路径**
|
||||||
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
|
3. 有任务卡 ID → 配合 `dukang-task-card` skill 或查 preV1 §9 / V2 §七
|
||||||
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
|
4. UI 任务 → `pages/ROUTE_MAP.md` + 对应 `pages/{端}/` 原型
|
||||||
5. 编码前过一遍下方 Checklist
|
5. 编码前过一遍下方 Checklist
|
||||||
@@ -89,7 +89,7 @@ await this.prisma.order.update({ ... });
|
|||||||
```typescript
|
```typescript
|
||||||
const benefitAmount = product.benefitAmount ?? product.price;
|
const benefitAmount = product.benefitAmount ?? product.price;
|
||||||
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
// 核销:直接核销按全部 ACTIVE 权益总余额;带单据核销按该单据可用金额
|
||||||
// 同城 min 2 瓶 / 跨城 min 6 瓶
|
// 起购:提货/同城 min 2 瓶 / 跨城 min 6 瓶(城市配置,非承运商)
|
||||||
|
|
||||||
// 支付成功 → log_third_party + user_order.pay_status=PAID
|
// 支付成功 → log_third_party + user_order.pay_status=PAID
|
||||||
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
|
// → user_benefit_coupon + common_event(BENEFIT_LEDGER, GRANT)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
| Guard | 用途 |
|
| Guard | 用途 |
|
||||||
|-------|------|
|
|-------|------|
|
||||||
| JwtAuthGuard | 需登录 |
|
| JwtAuthGuard | 需登录 |
|
||||||
| PhoneVerifiedGuard | C 端需绑定手机(下单/支付) |
|
| PhoneVerifiedGuard | (已废弃强制)C 端手机号改为下单页可选绑定 |
|
||||||
| OptionalJwtAuthGuard | 可选登录(bootstrap) |
|
| OptionalJwtAuthGuard | 可选登录(bootstrap) |
|
||||||
|
|
||||||
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
Admin 路由在 `modules/ops/` 下,前缀 `/admin/*`。
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
> **h5-partner 与 admin-web 端口同为 5175**,勿同时 `dev:partner` + `dev:admin`。
|
||||||
|
|
||||||
Vite 代理:`/api` → `localhost:3000`。
|
Vite 代理:`/api` → `localhost:3010`(可用 `VITE_API_TARGET` 覆盖)。
|
||||||
|
|
||||||
## 共享包
|
## 共享包
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ description: >-
|
|||||||
MOCK_SMS/MOCK_PAY/MOCK_DELIVERY flags, Seed data, and V2 upgrade path.
|
MOCK_SMS/MOCK_PAY/MOCK_DELIVERY flags, Seed data, and V2 upgrade path.
|
||||||
Use when working on Mock providers, feature flags, preV1 task cards P1-*,
|
Use when working on Mock providers, feature flags, preV1 task cards P1-*,
|
||||||
or smoke tests — not for V2 WeChat/payment production integration.
|
or smoke tests — not for V2 WeChat/payment production integration.
|
||||||
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# 杜康好客 · preV1 Mock Skill
|
# 杜康好客 · preV1 Mock Skill
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: dukang-release
|
||||||
|
description: >-
|
||||||
|
杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。
|
||||||
|
Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产,
|
||||||
|
or @杜康发布 / @dukang-release.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 杜康发布
|
||||||
|
|
||||||
|
同机双栈:`/opt/dukang-staging`(测试)与 `/opt/dukang`(生产)。**仅用户明确要求发布时执行。**
|
||||||
|
|
||||||
|
## 分支与环境
|
||||||
|
|
||||||
|
| 分支 | 环境 | 目录 | 端口 | 域名 |
|
||||||
|
|------|------|------|------|------|
|
||||||
|
| `dev_jacy` | 日常开发 | 本机 | — | — |
|
||||||
|
| `dev` | **staging 测试** | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` |
|
||||||
|
| `main` | **production 生产** | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` |
|
||||||
|
|
||||||
|
默认:`dev_jacy` → merge → `dev` → merge → `main` → **发生产**。勿 force push `main`/`dev`。
|
||||||
|
|
||||||
|
> **团队约定(2026-08)**:用户说「发布 / 发版 / 直接发版」且**未特别强调**时 → **发生产(production)**。
|
||||||
|
> 仅当用户明确说「发测试 / staging / 测试环境」时 → 发 staging。
|
||||||
|
|
||||||
|
用户说「发生产 / 上线生产 / 发版」→ 用 `deploy-prod.sh`。
|
||||||
|
用户说「发测试」→ 用 `deploy-staging.sh`。
|
||||||
|
|
||||||
|
## 标准流程(Windows)
|
||||||
|
|
||||||
|
PowerShell 下 **分支名必须加引号**。
|
||||||
|
|
||||||
|
### A. 发测试(日常)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 在 dev_jacy 提交(用户要求时)
|
||||||
|
git push -u origin HEAD
|
||||||
|
git checkout "dev"
|
||||||
|
git pull origin "dev"
|
||||||
|
git merge "dev_jacy" -m "merge(dev_jacy): <简述>"
|
||||||
|
git push origin "dev"
|
||||||
|
git checkout "dev_jacy"
|
||||||
|
|
||||||
|
# 2. 发 staging
|
||||||
|
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy-staging.sh" -- --skip-db
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. 发生产(验收后)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git checkout "main"
|
||||||
|
git pull origin "main"
|
||||||
|
git merge "dev" -m "merge(dev): <简述>"
|
||||||
|
git push origin "main"
|
||||||
|
git checkout "dev_jacy"
|
||||||
|
|
||||||
|
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy-prod.sh" -- --skip-db
|
||||||
|
```
|
||||||
|
|
||||||
|
- 默认加 `--skip-db`(无迁移时)
|
||||||
|
- 有 Prisma 迁移:先在 **staging** 跑通(可 `--seed` / `--accept-data-loss`),再生产去掉 `--skip-db`
|
||||||
|
- Staging 微信:正式号 + 全 Mock(`.env.staging`)
|
||||||
|
- `deploy/deploy.env` 提供 `DEPLOY_HOST`、`STAGING_APP_ROOT`、`PROD_APP_ROOT`(勿提交密钥)
|
||||||
|
|
||||||
|
### 首次启用 Staging
|
||||||
|
|
||||||
|
服务器一次性:`bash deploy/bootstrap-staging.sh`(见脚本说明)→ 配 `.env.staging` → DNS `*-test` → nginx → `deploy-staging.sh -- --seed`
|
||||||
|
|
||||||
|
### 同步环境变量
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
bash deploy/sync-api-env.sh staging
|
||||||
|
bash deploy/sync-api-env.sh production
|
||||||
|
```
|
||||||
|
|
||||||
|
## 等待完成
|
||||||
|
|
||||||
|
- 出现 `发版完成`
|
||||||
|
- `exit_code: 0`
|
||||||
|
- `mini-user(h5): 200` / `api: 200`
|
||||||
|
|
||||||
|
## 回复用户
|
||||||
|
|
||||||
|
说明:环境(测试/生产)、commit、建议硬刷验证。测试后台有橙色「测试环境」条。
|
||||||
|
|
||||||
|
## 禁止
|
||||||
|
|
||||||
|
- 不 amend 已推送 commit;不 force push `main`/`dev`
|
||||||
|
- 不把 `.env*` 私钥打进仓库
|
||||||
|
- **禁止**再用 `dev` 直发生产
|
||||||
|
- 用户只说「修好」未说发布 → 只改代码,不发版
|
||||||
@@ -4,6 +4,7 @@ description: >-
|
|||||||
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
|
Executes Dukang Haoke task cards (P1-M* or M0-M6) one at a time with DoD
|
||||||
verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003,
|
verification. Use when the user provides a task ID like P1-M2-002 or M2-FE-U-003,
|
||||||
or asks to start/implement a milestone card from the coding manuals.
|
or asks to start/implement a milestone card from the coding manuals.
|
||||||
|
disable-model-invocation: true
|
||||||
---
|
---
|
||||||
|
|
||||||
# 杜康好客 · 任务卡 Skill
|
# 杜康好客 · 任务卡 Skill
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ disable-model-invocation: true
|
|||||||
|
|
||||||
```
|
```
|
||||||
权益额 = 订单实付 1:1(永久)
|
权益额 = 订单实付 1:1(永久)
|
||||||
同城起购 2 瓶 / 跨城 1 箱(6瓶)
|
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 1 箱(6瓶);属城市,非承运商
|
||||||
门店结算 = 核销额 × 60%
|
门店结算 = 核销额 × 60%
|
||||||
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
|
单合伙人:订单佣金 + 核销佣金 ≤ 5%(默认 0% + 3%)
|
||||||
核销码 TTL = 3 分钟;试核销固定 ¥100
|
核销码 TTL = 3 分钟;试核销固定 ¥100
|
||||||
|
|||||||
@@ -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,14 +7,14 @@
|
|||||||
| 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 款酒祖杜康;大图、标题加粗、价格标红、权益金额 |
|
||||||
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
|
| REQ-U-006 | 商品详情 | 规格瓶/箱、度数、价格、权益、详情图 |
|
||||||
| REQ-U-007 | 同城 | 小飞侠;≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
| REQ-U-007 | 同城 | 小飞侠;起购 ≥2 瓶(城市 `localMinQty`);≥2 瓶免运费;物流节点+拍照;24h 自动完成 |
|
||||||
| REQ-U-008 | 现场提货 | 隐藏入口;支付即完成;强提示在现场 |
|
| REQ-U-008 | 现场提货 | 隐藏入口;起购 ≥2 瓶(城市 `pickupMinQty`);支付即完成;强提示在现场 |
|
||||||
| REQ-U-009 | 跨城 | 未开通城市;≥1 箱;总部物流到付 |
|
| REQ-U-009 | 跨城 | 未开通城市;起购 ≥1 箱(城市 `crossMinQty`=6 瓶当量);总部物流到付 |
|
||||||
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
|
| REQ-U-010 | 锁单 | 待付款 30 分钟取消 |
|
||||||
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
|
| REQ-U-011 | 权益 | 支付成功 1:1 永久 |
|
||||||
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
|
| REQ-U-012 | 客服 | 已付款起电话/微信客服 |
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
| REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 |
|
| REQ-U-024 | 推广码 | 扫码归因;现场提货有码归码 |
|
||||||
| REQ-U-025 | 分享 | 微信商品分享+渠道参数 |
|
| REQ-U-025 | 分享 | 微信商品分享+渠道参数 |
|
||||||
| REQ-U-026 | 门店规则 | 附件一完整展示 |
|
| REQ-U-026 | 门店规则 | 附件一完整展示 |
|
||||||
|
| REQ-U-027 | 门店套餐 **3.4.10** | 门店详情展示生效套餐;套餐异议申诉入口 |
|
||||||
|
|
||||||
## 门店端 REQ-S
|
## 门店端 REQ-S
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
| REQ-S-018 | 权限 | §4.6 矩阵 |
|
| REQ-S-018 | 权限 | §4.6 矩阵 |
|
||||||
| REQ-S-019 | 多店选店 | 记住上次所选 |
|
| REQ-S-019 | 多店选店 | 记住上次所选 |
|
||||||
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
|
| REQ-S-020 | 待处理核销 | Wave 3 弱网单 |
|
||||||
|
| REQ-S-021 | 门店套餐 **3.4.10** | 套餐列表/编辑/提审 |
|
||||||
|
|
||||||
## 合伙人端 REQ-P
|
## 合伙人端 REQ-P
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@
|
|||||||
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
|
| REQ-P-024 | 负责人复核 | 待复核→待总部审 |
|
||||||
| REQ-P-025 | 合同同日 | 同日提交校验 |
|
| REQ-P-025 | 合同同日 | 同日提交校验 |
|
||||||
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
|
| REQ-P-026 | 我的仓库 | Wave 3 管仓只读 |
|
||||||
|
| REQ-P-027 | 门店套餐 **3.4.10** | 拓店套餐页;门店套餐列表/编辑/提审 |
|
||||||
|
|
||||||
## 总部端 REQ-H
|
## 总部端 REQ-H
|
||||||
|
|
||||||
@@ -122,3 +125,4 @@
|
|||||||
| REQ-H-022 | 签约主体 | 山西领势酒业 |
|
| REQ-H-022 | 签约主体 | 山西领势酒业 |
|
||||||
| REQ-H-023 | 热力图 | 腾讯位置服务 |
|
| REQ-H-023 | 热力图 | 腾讯位置服务 |
|
||||||
| REQ-H-024 | 结算异议 | 门店 3 日异议处理 |
|
| REQ-H-024 | 结算异议 | 门店 3 日异议处理 |
|
||||||
|
| REQ-H-025 | 门店套餐 **3.4.10** | 门店详情套餐 Tab 直存;套餐变更审核通过/驳回 |
|
||||||
|
|||||||
+12
@@ -8,10 +8,22 @@ server/dukang-api/.env
|
|||||||
server/dukang-api/.env.development
|
server/dukang-api/.env.development
|
||||||
server/dukang-api/.env.production
|
server/dukang-api/.env.production
|
||||||
server/dukang-api/.env.production.local
|
server/dukang-api/.env.production.local
|
||||||
|
server/dukang-api/.env.staging
|
||||||
|
server/dukang-api/.env.staging.local
|
||||||
deploy/deploy.env
|
deploy/deploy.env
|
||||||
|
deploy/.staging-mysql.pass
|
||||||
|
deploy/_ops-*.log
|
||||||
|
deploy/_ops-*.sh
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
coverage/
|
coverage/
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
server/dukang-api/prisma/migrations/
|
server/dukang-api/prisma/migrations/
|
||||||
debug_v3.xlsx
|
debug_v3.xlsx
|
||||||
~$debug_v3.xlsx
|
~$debug_v3.xlsx
|
||||||
|
server/dukang-api/assets/fonts/*.ttf
|
||||||
|
server/dukang-api/assets/fonts/*.ttc
|
||||||
|
server/dukang-api/assets/fonts/*.otf
|
||||||
|
deploy/auto-release.env
|
||||||
|
deploy/backups/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# 2026-08-12 工作记录
|
||||||
|
|
||||||
|
## 杜康好客 · 三项功能需求落地(dev_jacy 分支)
|
||||||
|
|
||||||
|
### 需求1:管理端用户列表增加好客权益金额列
|
||||||
|
- 后端 `server/dukang-api/src/modules/ops/admin-users.service.ts`:
|
||||||
|
- 新增常量 `BENEFIT_STAT_STATUSES = ['ACTIVE','USED_UP']`、`UserBenefitStat` 类型、`EMPTY_BENEFIT_STAT`、`toAmount()`。
|
||||||
|
- `list()` / `detail()` 均调用新增私有方法 `loadBenefitStats(userIds)`,用 `benefitCoupon.groupBy({ by:['userId'], where:{status:{in:[...BENEFIT_STAT_STATUSES]}}, _sum:{totalAmount,usedAmount,balance} })` 批量聚合,避免 N+1。
|
||||||
|
- `mapAdminUserRow` 增加 `benefit` 参数并展开到返回对象。统计口径与 C 端 `listCoupons` 一致(排除 VOID)。
|
||||||
|
- 前端 `apps/admin-web/src/lib/api.ts`:`AdminUserRow` 增加 `benefitTotalAmount?/benefitUsedAmount?/benefitBalance?: number`。
|
||||||
|
- 前端 `apps/admin-web/src/pages/UsersPage.tsx`:新增「剩余权益/已用权益/累计权益」三列(右对齐、可排序,scroll x 改为 1830),详情抽屉 Descriptions 增加「好客权益」项。
|
||||||
|
|
||||||
|
### 需求2:管理端门店入驻合同支持多图上传
|
||||||
|
- 新建共享工具 `server/dukang-api/src/common/store-media/contract-urls.util.ts`:`MAX_CONTRACT_FILES=20`、`normalizeContractUrls(urls?, legacy?)`、`contractMediaType(url)`(.pdf→FILE 否则 IMAGE)。
|
||||||
|
- 后端 `admin-stores.service.ts`:import 上述工具;create(645 行)/ update(451 行)路径由单条 `contractUrl` 改为遍历建立多条 `bizType:'CONTRACT'` 记录(`sortOrder:i`、`mediaType` 按扩展名判定)。
|
||||||
|
- DTO `admin-mutate.dto.ts`:`contractUrl?:string` 标记 `@deprecated`,新增 `contractUrls?:string[]`(`@IsArray @IsString({each:true}) @ArrayMaxSize(20)`)。
|
||||||
|
- 前端 `apps/admin-web/src/lib/storeCreate.ts`:`StoreCreateForm.contractUrl?:string` → `contractUrls?:string[]`。
|
||||||
|
- `MultiImageUpload.tsx`:新增 `isPdf()`、`FilePdfOutlined`、PDF 缩略图卡片、`buttonText` prop。
|
||||||
|
- `StoresPage.tsx`:编辑抽屉 + 创建 step1 表单均改用 `MultiImageUpload`(name=contractUrls, maxCount=20, accept=image/*,.pdf, buttonText="批量上传合同"),回显/保存 payload 改为 `contractUrls:string[]`。
|
||||||
|
|
||||||
|
### 需求3:合伙人 H5 门店入驻合同支持多图上传
|
||||||
|
|
||||||
|
## 发布流水线(同一会话,22:45 后)
|
||||||
|
|
||||||
|
完整跑通 dukang-release 标准流程:`dev_jacy` → push → merge `dev` → merge `main` → 发生产。
|
||||||
|
- 提交 `8805335` feat(ops,store,partner): 用户权益列 + 门店合同多图上传(13 文件,356+/59-)
|
||||||
|
- `dev_jacy` push → `origin/dev_jacy`(8805335)
|
||||||
|
- merge `dev_jacy`→`dev` → `491f03e`,push `origin/dev`
|
||||||
|
- merge `dev`→`main` → `4a0e184`,push `origin/main`
|
||||||
|
- `deploy/deploy-prod.sh -- --skip-db`(无 Prisma schema 变更,故 --skip-db)→ `DEPLOY_EXIT=0`,`==> 发版完成 (production)`
|
||||||
|
- 生产 PM2 全 online:`dukang-api`(8090) / admin-web / h5-partner / h5-shop / h5-user;健康检查 `api:200` `mini-user(h5):200`;版本记录 commit=4a0e184
|
||||||
|
|
||||||
|
### 踩坑/注意
|
||||||
|
- `git checkout main` 时本机工作树出现 `server/dukang-api` 大量 `D`(误删)假象:实为 sandbox 下 checkout 未刷新工作树,非仓库损坏。用 `git checkout -f main` 重新填充即恢复;`origin/main` 始终正确(ls-remote 核对 4a0e184)。
|
||||||
|
- 提交/合并后 `git status` 报 "ahead of origin by N" 为本地 tracking ref 陈旧,非真实分歧(ls-remote 已核对远程三分支均含本次提交)。
|
||||||
|
- 仅提交 13 个需求文件 + 新建 `server/dukang-api/src/common/store-media/`,已排除 `.workbuddy/` 与 `fix_acl.ps1`。
|
||||||
|
- 发布命令在 sandbox 下直接运行 SSH 部署成功(未强制禁用沙箱)。
|
||||||
|
- `apps/h5-partner/src/lib/storeDraft.ts`:`StoreDraftForm.contractUrl:string` → `contractUrls:string[]`;`defaultStoreForm` 改为 `contractUrls:[]`;新增 `MAX_CONTRACT_COUNT=20`;`normalizeStoreDraftForm` 新增 `contractUrls` 计算(兼容旧 `contractUrl` 迁移为数组,去重限 20);`validateStoreStep2` 参数/校验改为 `contractUrls`,不足 1 张返回「请上传签约合同」。
|
||||||
|
- `MultiOssUploadField.tsx`:新增 `accept?`、`unit?` props,`isPdf()`,PDF 缩略图卡片。
|
||||||
|
- `StoreCreatePage.tsx`:签约合同区块 `OssUploadField` → `MultiOssUploadField`(value=`form.contractUrls ?? []`, maxCount=MAX_CONTRACT_COUNT, unit="个", accept="image/*,.pdf");提交体 `contractUrl` → `contractUrls`,新增 `const contractUrls = Array.from(new Set(...)).slice(0, MAX_CONTRACT_COUNT)`;envPhotoUrls slice 上限由 3 改为 20。修正了渲染块误用未定义变量 `contractUrls`(已改为从 form 读取)。
|
||||||
|
|
||||||
|
### 验证
|
||||||
|
- 后端 `npx tsc --noEmit`(server/dukang-api)通过,exit 0。
|
||||||
|
- admin-web / h5-partner 使用 `git stash` 基线对比法:本次改动**未引入任何新类型错误**(现存错误均为预存在基线问题:css module 声明、`import.meta.env`、过期 config 类型、TabLayout `end`、staff `staffRole` 等)。
|
||||||
|
- 向后兼容:旧 `contractUrl` 字段保留并标记 deprecated,H5 草稿旧数据自动迁移;避免破坏现有 API 客户端。
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 2026-08-16
|
||||||
|
|
||||||
|
## 新增:线上库同步本地 CLI 工具
|
||||||
|
- 文件:`deploy/sync-prod-db-to-local.sh`(新建,未提交)
|
||||||
|
- 用途:将生产库 `dukang_prod`(阿里云 RDS `rm-bp139andp1aa385i9.mysql.rds.aliyuncs.com:3306`,用户 `dukangadmin`)同步到本地 Docker MySQL `dukang_haoke`(localhost:6016 / 容器内 `dukang-v1-mysql`,root/root)。
|
||||||
|
- 原理:SSH 隧道借道 `dukang-server`(`-L 6018:rds:3306`)→ 本地无 mysql 客户端,故在 `dukang-v1-mysql` 容器内跑 mysqldump/mysql,经 `host.docker.internal:TUNNEL_PORT` 连隧道;管道直导,不落临时文件。
|
||||||
|
- 安全:默认先 `docker exec mysqldump` 备份本地到 `deploy/backups/`;需 `--yes` 跳过交互确认;`--dry-run` 只读远端 DATABASE_URL 并打印计划。
|
||||||
|
- 远端凭据从 `/opt/dukang/server/dukang-api/.env.production` 的 DATABASE_URL 运行时解析(密码经 `MYSQL_PWD` 环境变量传入,避免命令行暴露)。
|
||||||
|
- 已知限制:不支持密码含 `@`;prod DB 名与本地不同(dukang_prod→dukang_haoke)靠 mysqldump 不带 `--databases` 实现库名映射。
|
||||||
|
- 状态:仅跑通 `--dry-run`,未执行真实同步(待用户确认,因会覆盖本地库并拉取生产数据)。
|
||||||
|
|
||||||
|
## 修复:首次同步失败的根因
|
||||||
|
- 失败现象:`mysqldump` 退出码 2,提示 `本地库可能处于不一致状态`(工具自身的失败兜底提示)。
|
||||||
|
- 真实根因有两条:
|
||||||
|
1. **密码解析错误**:生产 DB 密码含 `@` 字符(`passHasAt=true`)。原 bash 解析用「第一个 @」切分 userinfo/host,把密码里 `@` 之后的部分截断,导致 Access denied。
|
||||||
|
修复:改为在服务端用 `new URL()` 解析,base64 回传各字段(user/host/port/db/password),本地 `base64 -d` 还原,彻底免疫 `@`/`:`/`$` 等特殊字符。服务端 auth 测试 `authTestStatus=0` 验证密码正确。
|
||||||
|
2. **权限不足**:`dukangadmin`(应用库用户)无 `RELOAD`/`FLUSH_TABLES` 权限。`--single-transaction` 仍会触发全局 `FLUSH TABLES` → Access denied。
|
||||||
|
修复:`mysqldump` 改用 `--lock-tables=0`(不发起 FTWRL)+ `--set-gtid-purged=OFF`(RDS 启用 GTID,否则本地恢复会报 GTID_PURGED 错误),并保留 `--skip-routines --skip-events --column-statistics=0 --no-tablespaces --add-drop-table --skip-triggers --no-create-db`。
|
||||||
|
- 验证:结构导出(--no-data)在 4 组 flag 中仅 `--lock-tables=0` / `--skip-lock-tables`(不含 --single-transaction)退出 0。
|
||||||
|
- 现工具已修正并实际执行同步(--yes,先自动备份本地到 deploy/backups/)。
|
||||||
|
- 注意:`--lock-tables=0` 无事务一致性快照;生产库在线写入期间导出可能有极小不一致,但恢复时 mysqldump 自带 `FOREIGN_KEY_CHECKS=0`,_dev 库可接受。若需完全一致快照,可给 dukangadmin 授 RELOAD 后用 --single-transaction。
|
||||||
|
|
||||||
|
## 修改:套餐审核导航样式恢复一致
|
||||||
|
- 文件:`apps/admin-web/src/layouts/AdminLayout.tsx` 的 `attachPackageAuditBadge`(`/store-package-audits` 项)。
|
||||||
|
- 诉求演变:用户先要求「文字恒为白色」→ 后改为「不要单独处理,保持与子菜单一致,只是多一个待审徽章图标」。
|
||||||
|
- 最终实现:label 改为 `<span style={{display:'inline-flex',gap:6}}>套餐审核{pendingCount>0 && <Badge count size="small"/>}</span>`。文字无强制颜色,随菜单主题继承 normal/hover/selected 颜色;徽章仅 pendingCount>0 时作为图标提示,不参与着色。
|
||||||
|
|
||||||
|
## 发布流水线(本次收尾,含之前未上线的 8+1 文件)
|
||||||
|
- 背景:此前「联系电话脱敏+套餐多行输入+同步工具」等 9 文件 commit `8de4179` 已 push dev_jacy/dev/main,但**生产发布失败**(deploy.env 丢失导致 `未配置 DEPLOY_HOST`)。
|
||||||
|
- 修复发布:`deploy.env` 是 gitignore 的本地密钥文件,已丢失不可找回;改用 SSH 别名 `dukang-server`(~/.ssh/config:HostName 47.110.129.57 / User root / IdentityFile pem)覆盖:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`。
|
||||||
|
- 关键排查:`origin/*` 本地缓存 ref 多次陈旧(误报 ahead/behind),必须以 `git ls-remote` 为准。本次真实远端:dev_jacy=8de4179、dev=c05cd0e、main=bac5c7a,与本地一致,无丢失。
|
||||||
|
- 本次提交:`bafb342` fix(admin) 套餐审核样式 → push dev_jacy → merge dev(`0bfc19b`) → merge main(`029fd69`) → push。
|
||||||
|
- 发布结果:`DEPLOY_EXIT=0`,`==> 发版完成 (production)`,健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `029fd69`。
|
||||||
|
- 提示:以后若想恢复标准 `bash deploy/deploy-prod.sh`(不带 --host),需重建 `deploy/deploy.env`(从 `deploy.env.example` 复制,填入 DEPLOY_HOST=47.110.129.57 等;该文件 gitignore,勿提交)。
|
||||||
|
|
||||||
|
## 排查:h5-partner 本地 /login 调 /partner/me 报 401
|
||||||
|
- 现象:`http://localhost:5176/login` 启动后控制台报 `GET /api/v1/partner/me 401 (Unauthorized)`。
|
||||||
|
- 结论:**不是 bug,是登录页的「过期 token 探测」**。链路:Vite 代理 `/api`→`localhost:3010`(正常);后端返回 `{"code":401,"message":"Missing token"}`(说明后端在线、代理通)。`ensureSession()`(api.ts:244)只在 localStorage 有 token 时才打 `/partner/me`;无 token 直接返回未登录。所以 401 来自之前测试残留的失效 token → 前端静默清除并展示登录表单,不崩、不弹错。刷新页面(token 已清)即不再出现。
|
||||||
|
- 本地登录方式(关键,README 已过期):
|
||||||
|
- `MOCK_SMS=true`(.env),短信走 mock,不连运营商;固定码 `MOCK_SMS_FIXED_CODE` 默认 `999888`(README 写的 123456 已不准)。
|
||||||
|
- README 的测试号 `13700000001` **不在本地同步库**;本地 `partner_account` 共 9 个,可用真实号如 `18049821889`(Jacy) / `13073729990`(queen)(均 ACTIVE)。
|
||||||
|
- 流程:填真实合伙人手机号 → 获取验证码 → **验证码打印在 `pnpm dev:api` 后端终端**(`Mock SMS → 180****8889 code=XXXXXX`)→ 填码登录。
|
||||||
|
- 注意 h5-partner 默认 5175,本次因 5175 被占自动落到 5176,代理是路径匹配不受影响。
|
||||||
|
|
||||||
|
## 发布:v3.4.17 门店套餐审核快捷入口 上生产
|
||||||
|
- 提交 `1c91e6b`(dev_jacy 上,含后端 admin-stores.service.ts + AdminStorePackagesSection/StorePackageAuditsPage/StoresPage 三前端 + 开发计划文档)。
|
||||||
|
- 流水线:直接快进推送(避免 git checkout 工作树不刷新):`git push origin dev_jacy` + `dev_jacy:dev` + `dev_jacy:main`,三分支统一到 `1c91e6b`;本地 `git branch -f dev/main origin/*` 同步。
|
||||||
|
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(后端仅给列表响应加字段,无 schema 变更,故 skip-db)。
|
||||||
|
- 结果:`DEPLOY_EXIT=0`,新 tag `v3.4.17`,全部应用构建通过,PM2 重启 dukang-api/h5-shop/h5-partner/admin-web(另拉起 h5-user),健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `1c91e6b`。
|
||||||
|
|
||||||
|
## 发布:v3.4.18 套餐变更对比逐字段列出变动明细
|
||||||
|
- 需求:套餐变更对比「将变动的地方详细列出来」(之前只给「新增/删除/变更/未变」粗粒度标记,不列具体字段)。
|
||||||
|
- 改动仅在 `apps/admin-web/src/pages/StorePackageAuditsPage.tsx`:
|
||||||
|
- 新增 `fieldChanges(live, proposed)`:逐字段比对 价格/套餐名称/菜品内容/可用时间/其他说明/图片,产出 `label+旧值→新值` 明细。
|
||||||
|
- `diffPackages` 给「变更」行附 `changes`。
|
||||||
|
- `PackageDetailCard` 在「待审核」卡片底部渲染「变更明细」块(旧值删除线 → 新值加粗,橙色块)。
|
||||||
|
- 抽屉顶部加「新增 X · 删除 Y · 变更 Z · 未变 W」汇总条。
|
||||||
|
- 提交 `bbd7e52`(feat(admin) 套餐变更对比逐字段列出变动明细),快进推送 dev_jacy/dev/main。
|
||||||
|
- 部署:`bash deploy/deploy-prod.sh --host dukang-server -- --skip-db`(无 schema 变更)。
|
||||||
|
- 结果:构建通过、PM2 重启 admin-web 等、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `bbd7e52`(无精确 tag,记 tag=-,无害)。
|
||||||
|
- 已知局限:配对按「套餐名称」(空则索引) 位置匹配;若套餐被**改名**,会误判为「删除旧名+新增新名」而非「变更」,故名称变更不会出现在逐字段明细里。
|
||||||
|
|
||||||
|
## 优化:套餐变更对比·逐字差异 + 文档并入 v3.4.17
|
||||||
|
- 需求:变动明细要能**定位到文本中具体哪些字变了(逐字检查)**,且把开发计划文档合并到 v3.4.17 文档里(不再单独留 v3.4.18 文档)。
|
||||||
|
- 代码改动(仍仅 `StorePackageAuditsPage.tsx`):
|
||||||
|
- `diffText(a,b)`:LCS 动态规划 (`dp[i][j]`)+回溯,产出 `equal/delete/insert` 段落并合并相邻同类型;`O(|a|·|b|)`,字段长度可控。
|
||||||
|
- `TextDiff({oldText,newText})`:两行渲染——「原:」红删线标被删字、「新:」绿标新增字。
|
||||||
|
- `FieldChange` 增加 `kind: 'text'|'value'`:价格/图片走 `旧→新`;套餐名称/菜品内容/可用时间/其他说明走 `TextDiff` 逐字高亮。
|
||||||
|
- `杜康好客-v3.4.17-门店套餐审核快捷入口.md` 新增第 F 节「套餐变更对比·逐字段/逐字明细」,并把原先 v3.4.18 的字段级说明并入,删去独立 v3.4.18 文档计划。
|
||||||
|
- 提交 `ecd9973`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`。
|
||||||
|
- 结果:构建通过、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `ecd9973`(tag=-,无害)。
|
||||||
|
- 验证要点:开任一「变更」套餐的待审核卡片 → 底部「变更明细」→ 菜品/说明等文本以红删+绿增逐字呈现。
|
||||||
|
|
||||||
|
## 优化:h5-partner 门店详情·门店套餐「编辑套餐」按钮
|
||||||
|
- 需求:按钮**缩小、靠最右侧、保持美观**。根因:`.partner-btn-outline` 是整行大按钮(`flex:1; height:48px; font-size:16px`),放在卡片标题右侧会被拉伸成大块。
|
||||||
|
- 改动:`apps/h5-partner/src/styles.css` 新增 `.partner-btn-outline--compact`(`flex:none; height:30px; padding:0 14px; font-size:13px; font-weight:500; border-radius:var(--radius-sm); color:var(--color-primary)`,hover 用 `color-mix` 8% 底色);`StoreDetailPage.tsx:288` 的「编辑套餐」按钮加该类(父级 `space-between` 已保证贴右)。
|
||||||
|
- 提交 `69e201f`,快进推送 dev_jacy/dev/main,部署 `--host dukang-server -- --skip-db`。
|
||||||
|
- 结果:构建通过、PM2 重启 h5-partner、健康检查 api:200 / mini-user(h5):200,系统版本记录 commit `69e201f`(tag=-,无害)。
|
||||||
|
- 注意:`StoreDetailPage.tsx:311` 有个历史遗留 TS 错误(`status === 'CLOSED'` 类型无重叠),与本改动无关,未动。
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 2026-08-17 工作日志
|
||||||
|
|
||||||
|
## 杜康好客 v3.4.18 门店体验与登录态优化(含 F 项)
|
||||||
|
|
||||||
|
### 已实现并发布
|
||||||
|
- **A** mini-user 门店座机脱敏:改 `apps/mini-user/src/lib/phone.ts` 的 `maskPhone` 座机分支,中间四位 `****`(保留区号+首2+末2)。
|
||||||
|
- **B** 套餐详情图 1/5 计数 + 自动轮播:`ProductCarousel.tsx` detail 变体加 autoplay(3.5s) + 右下角 `1/N` 计数。
|
||||||
|
- **C** 合伙人暂停禁止登录:`auth.service.ts` 三处非 ACTIVE 抛「该账号已暂停使用,请联系客服人员」;`h5-partner/LoginPage.tsx` 同步文案;复用 `DISABLED` 枚举,无迁移。
|
||||||
|
- **D** 门店端提现/筛选栏留白对齐:`h5-shop/styles.css` 补 `--space-page`。
|
||||||
|
- **E** 休息中核销→是否开启营业:`PhoneRedeemPage`/`RedeemConfirmPage` 弹「是否开启营业?」,确认调 `PUT /shop/store/status` 开张后继续。
|
||||||
|
- **F(本次新增)** 门店套餐图片自适应完整显示:`ProductCarousel` 新增 `imageFit="adaptive"`(`mode="widthFix"` + 按真实比例动态算 Swiper 高度),`product-detail.css` 加 `.detail-carousel-wrap--adaptive` 覆盖固定 1:1 裁剪;仅 `store-package-detail` 使用。根因:原 `.detail-carousel-wrap` 固定 `aspect-ratio:1` + `overflow:hidden` + `aspectFill` 裁掉非正方形图。
|
||||||
|
|
||||||
|
### 发布状态
|
||||||
|
- 文档:`杜康好客-v3.4.18-门店体验与登录态优化.md`(含 A–F 范围、实现位置、验收清单)。
|
||||||
|
- git:本地 `dev_jacy` 提交 v3.4.18 各功能 + F 修复;已 `git push origin dev_jacy:main`(干净快进 `984dec7..18147e1`)。
|
||||||
|
- 生产部署:后台运行 `deploy/deploy-prod.sh --host dukang-server`(/opt/dukang, main)。SSH 配置主机 `dukang-server` 即生产机;`deploy.env` 缺失,用 `--host` 覆盖。
|
||||||
|
|
||||||
|
### 注意
|
||||||
|
- 本次 git 发布前曾遇 origin/main 比本地新、working tree 异常删除 503 文件(已 `reset --hard HEAD` 还原,提交完好);最终 fetch 后确认本地领先可快进,安全发布。
|
||||||
|
- 四端 tsc 仅余项目既有环境类报错(CSS 导入/ImportMeta.env),本次改动文件无新增类型错误。
|
||||||
|
|
||||||
|
### F 项方案 A 改写(22:18)
|
||||||
|
- 放弃手算高度逻辑(SelectorQuery + onLoad 测自然宽高),改用 swiper 原生 `autoHeight`:海报有多高轮播就有多高,无裁切。
|
||||||
|
- `ProductCarousel.tsx`:删 `wrapWidth`/`imgHeights`/`handleImageLoad`/`swiperStyle`,adaptive 模式加 `autoHeight={isAdaptive}`,image 保持 `mode="widthFix"`。
|
||||||
|
- `product-detail.css`:`.detail-carousel-wrap--adaptive` 覆盖(删 `aspect-ratio:1`/`overflow:hidden`),并给 `.detail-carousel`/`.detail-carousel-item`/`.detail-carousel-image` 在 adaptive 下 `height:auto`。
|
||||||
|
- 已提交 `cd1e9ae` 并 `git push origin dev_jacy:main`(干净快进 `18147e1..cd1e9ae`)。
|
||||||
|
- 重要:F 是原生微信小程序端改动,服务器部署只覆盖 H5-user 构建;真机小程序用户需经「微信开发者工具上传 + 微信审核」才能生效。
|
||||||
|
|
||||||
|
### E 项缺陷修复(22:46 测试不通过)
|
||||||
|
- **根因**:门店非 OPEN 时,后端 `redeem.service.ts:loadOpenStoreAccount`(previewRedeem/confirmRedeem 共用)抛「门店未营业」,导致 `RedeemConfirmPage` 的 `preview` 拉取失败 → `preview` 为 null → 「确认核销」按钮 `disabled={!preview}` → 点击不了 → 开张弹窗永远不触发。手机号路径因弹窗在「发送验证码」时触发故未暴露。
|
||||||
|
- **修复**:`RedeemConfirmPage` 把 preview 拉取抽成 `loadPreview()`;确认按钮改为 `disabled={loading || (!preview && !storeClosed)}`(闭店时仍可点→弹框);`openStoreAndContinue` 开张成功后 `await loadPreview()` 再 `doConfirm()`。
|
||||||
|
- 弹窗文案对齐需求:「门店目前休息中无法核销,是否开启营业」(两页 modal title=门店休息中 / desc=该句;内联提示改为「门店目前休息中无法核销,开启营业后可继续」)。
|
||||||
|
- 提交 `3ac008a`,`git push origin dev_jacy:main`(快进 `cd1e9ae..3ac008a`),生产重新部署中(deploy-prod.sh --host dukang-server)。
|
||||||
|
- 注意:后端 `updateShopStatus` 对 CLOSED(永久关闭) 门店拒绝开张(抛「门店已永久关闭...」),属预期;需求针对 闭店/休息中(PAUSED) 场景,OK。
|
||||||
|
|
||||||
|
### E 项方案二次修正(23:00 用户纠正:前置到点击扫码)
|
||||||
|
- **用户新要求**:门店 PAUSED 时,**点击「扫码核销」按钮那一刻就弹窗**,而不是扫完码进确认页才弹。
|
||||||
|
- **改动**:`apps/h5-shop/src/pages/HomePage.tsx`(门店端首页)。
|
||||||
|
- 复用 dashboard 已加载的 `status`(`dash?.store?.status`,L384 `status = String(store?.status||'')`)。
|
||||||
|
- `handleScan()` 开头:`if (status !== 'OPEN') { setShowOpenModal(true); return; }` —— 不进入 isWechatEnv/scanQrCode 流程。
|
||||||
|
- 新增 `openStoreAndContinue()`:`PUT /shop/store/status {OPEN}`,成功后 `loadDashboard()` 刷新状态(开关/扫码按钮同步),用户可再次点击扫码。
|
||||||
|
- 弹窗复用全局 `.shop-redeem-modal` 样式(与确认页一致),文案「门店目前休息中无法核销,是否开启营业?」。
|
||||||
|
- `RedeemConfirmPage` 的弹窗保留为带码直入确认页的兜底。
|
||||||
|
- 提交 `c0d04ee`,`git push origin dev_jacy:main`(快进 `3ac008a..c0d04ee`),生产重新部署中(deploy-prod.sh --host dukang-server)。文档 E 节已同步更新方案描述。
|
||||||
|
|
||||||
|
### C 项缺陷修复(合伙人端 DISABLED 仍登录 - 微信路径)
|
||||||
|
- **现象**:手机号(短信)路径已正确拦截(login 前先 `POST /partner/auth/phone/check` 校验非 ACTIVE 即拒),但**微信 OAuth 路径**报错被吞 → 用户无任何提示。
|
||||||
|
- **根因**:`apps/h5-partner/src/contexts/PartnerSessionContext.tsx` L118 `catch { stripOAuthParamsFromLocation(); }` 把微信回调里 `processPartnerWechatOAuthCallback` 抛出的「该账号已暂停使用」错误吞掉。
|
||||||
|
- **修复**:`catch (e) { stripOAuthParamsFromLocation(); toastError(e); }`(`toastError` 从 `@/lib/toast` 引入),让微信登录被暂停时同样弹「该账号已暂停使用,请联系客服人员」。
|
||||||
|
- 提交 `5ca9aa0`,`git push origin dev_jacy:main`(快进 `c0d04ee..5ca9aa0`)。
|
||||||
|
- 生产部署完成(deploy-prod.sh --host dukang-server, 38s):dukang-api 200、各 h5/partner 服务 online。mini-user(h5) 健康检查 FAIL 与本次无关(非 v3.4.18 改动范围,本次改 native 小程序 + h5-shop + h5-partner)。
|
||||||
|
- **验收状态**:A/B/C/D/E/F 全部提交并发布生产;C 项的短信 + 微信两条路径拦截逻辑均已落地,建议真机/微信环境复测一次确认 toast 文案展示。
|
||||||
|
|
||||||
|
### C 项双拦截增强(23:37 用户要求“两个都要拦”)
|
||||||
|
- **澄清**:city 合伙人有两个独立状态字段,别混:
|
||||||
|
- `partner_account.status`(AccountStatus: ACTIVE/DISABLED)= **登录账号启用态**,feature 原拦截查的就是它(子账号自身)。
|
||||||
|
- `partner_account.bindingStatus`(CityPartnerStatus: ACTIVE/PAUSED)= **城市合伙人绑定态**,主账号为准;子账号取父主账号的此字段。
|
||||||
|
- **需求**:子账号 `status=DISABLED` 与 主账号 `bindingStatus=PAUSED` **都要拦**。
|
||||||
|
- **实现**:`auth.service.ts` 新增统一闸门 `assertPartnerAccountActive(account)`(L313),同时拦上述两种;替换三处旧校验:`assertPartnerAccountByPhone`(L334) / `loginPartner`(L1086) / `loginPartnerWechat`(L1748)。
|
||||||
|
- 自身非 ACTIVE → 「该账号已暂停使用,请联系客服人员」
|
||||||
|
- 主账号 bindingStatus 非 ACTIVE → 「该合伙人合作已暂停,请联系客服人员」
|
||||||
|
- **文档**:`杜康好客-v3.4.18-...md` C 节重写为双拦截;顶部范围表 + 验收清单同步。
|
||||||
|
- 提交 `48d6900`,`git push origin dev_jacy:main`(快进 `5ca9aa0..48d6900`),生产部署完成(task I2mWSU, 37s):commit=48d6900、api 200、各服务 online。
|
||||||
|
- **生产实测双拦截均生效**:
|
||||||
|
- `status=DISABLED`(id=7 临时翻转→测试→已还原 ACTIVE):`phone/check` 返回 400「该账号已暂停使用,请联系客服人员」;还原后 201 ok:true。
|
||||||
|
- `bindingStatus=PAUSED`(18049821889 等 3 个主账号):`phone/check` 返回 400「该合伙人合作已暂停,请联系客服人员」(修复前为 200 ok:true)。
|
||||||
|
- 库内 status!=ACTIVE 数量=0;binding_status!=ACTIVE 数量=3(均为主账号 PAUSED)。
|
||||||
|
- **遗留未提交文件(非本次改动,需用户决策)**:
|
||||||
|
- `apps/h5-shop/src/styles.css`:`shop-records-status-chips` 由 `padding:0` → `padding-bottom:10px`(D 项后续微调,未提交)。
|
||||||
|
- `apps/mini-user/src/styles/product-detail.css`:工作区版本把方案A(`aspect-ratio:auto`/`height:auto`)改回固定 4:3(`aspect-ratio:1.33`/`height:100%`),与已上线方案A矛盾,疑似另一轮实验性改动。已与用户确认前**暂不提交**。
|
||||||
@@ -1,155 +1,50 @@
|
|||||||
# 杜康好客 · AGENTS.md
|
# 杜康好客 · AGENTS.md
|
||||||
|
|
||||||
> AI 编码入口。人类协作者仍读 [`agent.md`](./agent.md);本文件供 Cursor / Codex / Copilot 等自动加载。
|
> AI 编码入口。人类读 [`agent.md`](./agent.md)。启动、端口、测试账号见 [`README.md`](./README.md)。
|
||||||
|
|
||||||
## 项目概览
|
杜康酒业 O2O:**购酒 → 发券 → 门店核销**。Monorepo + NestJS 单体(`/api/v1`,`{ code, message, data }`)。
|
||||||
|
|
||||||
杜康酒业 O2O 平台:**购酒 → 发券 → 门店核销**。Monorepo + 单体 NestJS(方案三:模块 OWNER)。
|
| 路径 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `apps/` | `h5-user` · `h5-shop` · `h5-partner` · `admin-web` · `mini-user` |
|
||||||
|
| `packages/` | `shared-types`(枚举/DTO)· `domain`(纯规则,无 IO)· `shared-ui` |
|
||||||
|
| `server/dukang-api/` | 唯一 API |
|
||||||
|
| `pages/` | UI 原型,只读 |
|
||||||
|
|
||||||
| 阶段 | 手册 | 说明 |
|
前端:[`apps/AGENTS.md`](./apps/AGENTS.md)。后端模块/表 OWNER:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)。
|
||||||
|------|------|------|
|
|
||||||
| **V3.0 唯一需求源** | [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) | 产品需求与三波交付(**只看这个**) |
|
|
||||||
| **现状对照** | [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md) | 已完成 / 冲突 / 缺口审计 |
|
|
||||||
| **V3 实现验收** | [`杜康好客-v3编码手册.md`](./杜康好客-v3编码手册.md) | 核销规则、分工、闭环 |
|
|
||||||
| ~~V2 / preV1~~ | 历史参考 | **不再作为需求依据** |
|
|
||||||
|
|
||||||
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 下过时文档;跨 OWNER 直写他人 Prisma 表。
|
## 文档(冲突时)
|
||||||
|
|
||||||
## 启动命令
|
1. `杜康好客-v3-PRD.md` — **唯一需求事实源**(编码时禁止改业务规则;需求变更只改它)
|
||||||
|
2. `杜康好客-v3-现状对照.md` — 进度
|
||||||
```bash
|
3. `杜康好客-v3编码手册.md` — 验收、核销、分工
|
||||||
# 基础设施
|
4. `conventions.md` — 协作边界
|
||||||
cd deploy && docker compose up -d # dukang-v1:MySQL :6016 Redis :6017
|
5. V2 手册 §四–§六 — 架构/DB/API **参考**(与 V3 冲突则忽略)
|
||||||
|
|
||||||
# 依赖与数据库
|
|
||||||
pnpm install
|
|
||||||
cp server/dukang-api/.env.example server/dukang-api/.env
|
|
||||||
pnpm db:generate && pnpm db:validate
|
|
||||||
cd server/dukang-api && npx prisma db push && pnpm prisma:seed
|
|
||||||
|
|
||||||
# 开发(分终端)
|
|
||||||
pnpm dev:api # http://localhost:3000/api/v1
|
|
||||||
pnpm dev:user # :5173
|
|
||||||
pnpm dev:shop # :5174
|
|
||||||
pnpm dev:partner # :5175
|
|
||||||
pnpm dev:admin # preV1 HQ 替代(内部工具,非 V2 小程序)
|
|
||||||
|
|
||||||
# 验证
|
|
||||||
node scripts/smoke-v3.mjs
|
|
||||||
node scripts/smoke-prev1.mjs
|
|
||||||
pnpm lint && pnpm test
|
|
||||||
```
|
|
||||||
|
|
||||||
Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
|
||||||
|
|
||||||
> **端口冲突**:`h5-partner` 与 `admin-web` 同为 5175,勿同时 `dev:partner` + `dev:admin`。
|
|
||||||
|
|
||||||
## 文档优先级(冲突时)
|
|
||||||
|
|
||||||
1. `杜康好客-v3-PRD.md` — **唯一需求事实源**
|
|
||||||
2. `杜康好客-v3-现状对照.md` — 实现进度审计
|
|
||||||
3. `杜康好客-v3编码手册.md` — 实现验收、核销、分工
|
|
||||||
4. `conventions.md` — 协作与模块边界
|
|
||||||
5. V2 手册 §四 §五 §六 — 仅作架构/DB/API **参考**(与 V3 冲突时忽略)
|
|
||||||
6. `pages/{user,shop,partner,hq}/` — UI 参照
|
6. `pages/{user,shop,partner,hq}/` — UI 参照
|
||||||
|
|
||||||
> **V2 / preV1 手册不再作为需求依据。**
|
**禁止**:臆造 PRD 未定义规则;依赖 `doc/` 过时文档;`apps/*` import `server/*`;跨模块 `prisma` 写他人表。V2 / preV1 手册不再作需求依据。
|
||||||
|
|
||||||
## 团队与 OWNER 边界(2 人)
|
## OWNER(2026-07 起暂由 jacy 代管全部)
|
||||||
|
|
||||||
| 负责人 | Git 账号 | 职责 |
|
`jacy-dukang` 可改 `apps/*`、`packages/*`、全部后端模块。逻辑边界仍按 A/B/C/D,便于子 Agent 隔离。Prisma 迁移由 jacy 主导。
|
||||||
|--------|----------|------|
|
|
||||||
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
|
|
||||||
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管**) |
|
|
||||||
|
|
||||||
> **临时分工(2026-07)**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
|
- 跨模块只 inject 对方 **exported Service**
|
||||||
|
- Mock 只在 `integrations/*`;微信/配送回调只进 `callbacks/`
|
||||||
|
|
||||||
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下:
|
## 不可偏离
|
||||||
|
|
||||||
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|
|
||||||
|------------|--------|----------|-------------|
|
|
||||||
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
|
|
||||||
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
|
|
||||||
|
|
||||||
**Prisma 迁移**:jacy-dukang 主导 Review;涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review)。
|
|
||||||
|
|
||||||
### 跨模块规则(R1–R8 摘要)
|
|
||||||
|
|
||||||
- 只 inject 对方 Module **exports 的 Service**,禁止 `prisma.xxx` 写他人表
|
|
||||||
- `apps/*` 禁止 import `server/*` 源码;只走 HTTP + `packages/shared-types`
|
|
||||||
- 业务纯规则 → `packages/domain`;枚举/DTO → `packages/shared-types`
|
|
||||||
- 微信/配送回调入口 **仅** `callbacks/`;Mock 实现 **仅** `integrations/*`
|
|
||||||
|
|
||||||
## Cursor 规范
|
|
||||||
|
|
||||||
| 类型 | 路径 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Rules | `.cursor/rules/` | 自动加载的编码规范 |
|
|
||||||
| 编码 Skill | `.cursor/skills/dukang-coding/` | @dukang-coding |
|
|
||||||
| V3.0 需求 Skill | `.cursor/skills/dukang-v3/` | @dukang-v3(PRD、Wave、REQ/ACC) |
|
|
||||||
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
|
|
||||||
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
|
|
||||||
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
|
|
||||||
|
|
||||||
## 子 Agent(按职责选用)
|
|
||||||
|
|
||||||
在 Cursor Agent 输入 `/` 选择:
|
|
||||||
|
|
||||||
| 子 Agent | 负责人 | 适用场景 |
|
|
||||||
|----------|--------|----------|
|
|
||||||
| `v3-delivery-lead` | jacy-dukang | V3.0 三波交付统筹、DLV 拆分、跨 Owner 协调 |
|
|
||||||
| `v3-req-reviewer` | — | V3.0 需求只读审查、REQ/ACC 缺口分析 |
|
|
||||||
| `owner-a-user-trade` | jacy-dukang | C 端、订单、支付、权益、埋点 |
|
|
||||||
| `owner-c-catalog-ops` | jacy-dukang | admin-web、开城商品、结算、运营 |
|
|
||||||
| `backend-lead` | jacy-dukang | packages、callbacks、jobs、Prisma 横切 |
|
|
||||||
| `owner-b-partner-store` | 刘景尧 | 合伙人 H5、门店 CRUD/审核 |
|
|
||||||
| `owner-d-shop-redeem` | 刘景尧 | 门店 H5、核销 |
|
|
||||||
| `boundary-reviewer` | — | PR 前只读审查跨模块违规 |
|
|
||||||
|
|
||||||
## Skills(按需 @)
|
|
||||||
|
|
||||||
| Skill | 何时用 |
|
|
||||||
|-------|--------|
|
|
||||||
| `dukang-v3` | V3.0 PRD、现状对照、Wave/REQ/ACC |
|
|
||||||
| `dukang-coding` | 实现功能、修 Bug(通用编码流程) |
|
|
||||||
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
|
|
||||||
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
|
|
||||||
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
|
|
||||||
|
|
||||||
## 快速指令
|
|
||||||
|
|
||||||
- 现状审计:`@dukang-v3` + [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
|
||||||
- 实现 DLV 任务:`@dukang-coding 实现 DLV-W1-M2`
|
|
||||||
- 波次规划:`/v3-delivery-lead 拆分 Wave 2 任务`
|
|
||||||
- 需求缺口审查:`/v3-req-reviewer 审查门店提现`
|
|
||||||
- PR 前审查:`/boundary-reviewer`
|
|
||||||
- 原型参照:`pages/{user,shop,partner}/`(只读)
|
|
||||||
|
|
||||||
## 核心业务常量(不可偏离)
|
|
||||||
|
|
||||||
```
|
```
|
||||||
权益额 = benefit_amount ?? price
|
权益额 = benefit_amount ?? price
|
||||||
同城起购 2 瓶 / 跨城 6 瓶
|
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
||||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
|
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额;Redis 码 3 分钟
|
||||||
C 端门店仅 status=OPEN
|
C 端门店仅 status=OPEN
|
||||||
订单 Tab:待付款 | 已付款 | 已完成
|
订单 Tab:待付款 | 已付款 | 已完成
|
||||||
```
|
```
|
||||||
|
|
||||||
## 提交与 PR
|
微信:iOS H5 登录后禁止仅 SPA 跳转再扫码(`packages/weixin-sdk/GOTCHAS.md`)。小程序 `open-type` 按钮祖先禁止 `stopPropagation`;weapp 标题只用 `navigationBarTitleText`。`h5-partner` 与 `admin-web` 同端口 5175,勿同时开。
|
||||||
|
|
||||||
- Conventional Commits:`feat(trade):`、`fix(redeem):`;scope = 端或模块
|
## 按需加载
|
||||||
- 跨模块 PR → jacy-dukang + 刘景尧 共同 Review(若涉及双方模块)
|
|
||||||
- 改 API/表 → 同步 v3-PRD + v3编码手册 + `shared-types`
|
|
||||||
- 不提交 `.env`、`dist/`、`node_modules/`
|
|
||||||
|
|
||||||
## 完成定义(DoD)
|
- 编码 `/dukang-coding` · V3 需求 `/dukang-v3` · 发版 `/dukang-release`(只说「发版」= 生产)
|
||||||
|
- 子 Agent:`/owner-a-user-trade` 等(见 `.cursor/agents/`)
|
||||||
- [ ] 任务卡验收项全部满足
|
- DoD:验收项满足;不跨模块直写表;类型在 `shared-types`、规则在 `domain`;相关 lint/单测过
|
||||||
- [ ] 未跨模块直写 Prisma 表
|
|
||||||
- [ ] 枚举/DTO 在 `shared-types`;纯规则在 `domain`
|
|
||||||
- [ ] `pnpm lint` 无新增错误;相关 domain 单测通过
|
|
||||||
|
|
||||||
## 嵌套指引
|
|
||||||
|
|
||||||
- 后端细节:[`server/dukang-api/AGENTS.md`](./server/dukang-api/AGENTS.md)
|
|
||||||
- 前端四 App:[`apps/AGENTS.md`](./apps/AGENTS.md)
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ayPJ4CQqbUcec3jX
|
||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
权益发放额 = common_product_item.benefit_amount ?? price
|
权益发放额 = common_product_item.benefit_amount ?? price
|
||||||
同城起购 2 瓶 / 跨城 6 瓶
|
起购:现场提货 2 瓶 / 同城 2 瓶 / 跨城 6 瓶(1 箱);属城市,非承运商
|
||||||
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
|
核销:直接核销 0 < amount ≤ 全部 ACTIVE 权益总余额;带单据核销 0 < amount ≤ 该单据可用金额
|
||||||
C 端门店列表仅 status=OPEN
|
C 端门店列表仅 status=OPEN
|
||||||
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
订单 Tab:all | pending_pay | pending_ship | pending_receive | completed
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ V2 规划中的 `mini-partner` / `mini-hq` 非 V3 主交付。
|
|||||||
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
- 禁止 import `server/` 或另一个 `apps/*` 的源码
|
||||||
- UI 共享组件优先 `@dukang/shared-ui`
|
- UI 共享组件优先 `@dukang/shared-ui`
|
||||||
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
- C 端订单列表 **3 Tab**:`待付款 | 已付款 | 已完成`(Tab key: `pending_pay` / `paid` / `completed`)
|
||||||
|
- **mini-user 微信 open-type**:含 `chooseAvatar` / `getPhoneNumber` 等的 `Button`,祖先禁止 `stopPropagation`(会编成 `catchtap` 导致点击无反应);见知识库 C 端踩坑、`.cursor/rules/mini-user-weapp-opentype.mdc`
|
||||||
|
|
||||||
## 新页面 workflow
|
## 新页面 workflow
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>杜康好客 · HQ 管理后台</title>
|
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet" />
|
||||||
|
<meta name="baiduspider" content="noindex, nofollow, noarchive, nosnippet" />
|
||||||
|
<meta name="googlebot" content="noindex, nofollow, noarchive, nosnippet" />
|
||||||
|
<title>管理后台</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -10,10 +10,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.5.1",
|
"@ant-design/icons": "^5.5.1",
|
||||||
|
"@dukang/domain": "workspace:*",
|
||||||
"@dukang/shared-types": "workspace:*",
|
"@dukang/shared-types": "workspace:*",
|
||||||
|
"@dukang/shared-ui": "workspace:*",
|
||||||
"antd": "^5.22.0",
|
"antd": "^5.22.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
|
"echarts-for-react": "^3.0.6",
|
||||||
"element-china-area-data": "^6.1.0",
|
"element-china-area-data": "^6.1.0",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.26.0"
|
"react-router-dom": "^6.26.0"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
1aacdfe9dcc03f68e4dd855f8c4c8f33
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"cleanUrls": false,
|
||||||
|
"trailingSlash": false,
|
||||||
|
"rewrites": [
|
||||||
|
{ "source": "**", "destination": "/index.html" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import UsersPage from './pages/UsersPage';
|
import UsersPage from './pages/UsersPage';
|
||||||
import OrdersPage from './pages/OrdersPage';
|
import OrdersPage from './pages/OrdersPage';
|
||||||
|
import BigScreenPage from './pages/BigScreenPage';
|
||||||
|
import StorePackageAuditsPage from './pages/StorePackageAuditsPage';
|
||||||
import StoresPage from './pages/StoresPage';
|
import StoresPage from './pages/StoresPage';
|
||||||
|
import StoreRatingsPage from './pages/StoreRatingsPage';
|
||||||
import StoreAccountsPage from './pages/StoreAccountsPage';
|
import StoreAccountsPage from './pages/StoreAccountsPage';
|
||||||
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
||||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||||
@@ -18,8 +21,11 @@ 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 ActivityPostersPage from './pages/ActivityPostersPage';
|
||||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||||
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
||||||
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
|
import PromoCodeUsersPage from './pages/promo/PromoCodeUsersPage';
|
||||||
@@ -29,8 +35,12 @@ 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 DomainEventsPage from './pages/DomainEventsPage';
|
||||||
import HqLogsPage from './pages/HqLogsPage';
|
import HqLogsPage from './pages/HqLogsPage';
|
||||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||||
import StoreLogsPage from './pages/StoreLogsPage';
|
import StoreLogsPage from './pages/StoreLogsPage';
|
||||||
@@ -38,6 +48,17 @@ 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 TestWhitelistPage from './pages/TestWhitelistPage';
|
||||||
|
import WecomBotsPage from './pages/WecomBotsPage';
|
||||||
|
import WecomApiPluginsPage from './pages/WecomApiPluginsPage';
|
||||||
|
import WecomMessagePushesPage from './pages/WecomMessagePushesPage';
|
||||||
|
import WecomReportsPage from './pages/WecomReportsPage';
|
||||||
|
import WecomBotLogsPage from './pages/WecomBotLogsPage';
|
||||||
|
import LlmConfigsPage from './pages/LlmConfigsPage';
|
||||||
|
import KnowledgeBasesPage from './pages/KnowledgeBasesPage';
|
||||||
|
import DevPlanVersionsPage from './pages/DevPlanVersionsPage';
|
||||||
|
import DevPlanTasksPage from './pages/DevPlanTasksPage';
|
||||||
|
import DevPlanSettingsPage from './pages/DevPlanSettingsPage';
|
||||||
|
|
||||||
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 />;
|
||||||
@@ -48,6 +69,14 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/orders/big-screen"
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<BigScreenPage />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
@@ -60,13 +89,28 @@ export default function App() {
|
|||||||
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
<Route path="/wechat-bindings" element={<WechatBindingsPage />} />
|
||||||
<Route path="/orders" element={<OrdersPage />} />
|
<Route path="/orders" element={<OrdersPage />} />
|
||||||
<Route path="/promo-codes" element={<PromoCodesPage />} />
|
<Route path="/promo-codes" element={<PromoCodesPage />} />
|
||||||
|
<Route path="/activity-posters" element={<ActivityPostersPage />} />
|
||||||
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
|
<Route path="/promo-codes/:id" element={<PromoCodeDetailLayout />}>
|
||||||
<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={<Navigate to="/wecom/bots" replace />} />
|
||||||
|
<Route path="/wecom/bots" element={<WecomBotsPage />} />
|
||||||
|
<Route path="/wecom/plugins" element={<WecomApiPluginsPage />} />
|
||||||
|
<Route path="/wecom/pushes" element={<WecomMessagePushesPage />} />
|
||||||
|
<Route path="/wecom/reports" element={<WecomReportsPage />} />
|
||||||
|
<Route path="/logs/wecom-bots" element={<WecomBotLogsPage />} />
|
||||||
|
<Route path="/llm-configs" element={<LlmConfigsPage />} />
|
||||||
|
<Route path="/knowledge-bases" element={<KnowledgeBasesPage />} />
|
||||||
|
<Route path="/dev-plan/versions" element={<DevPlanVersionsPage />} />
|
||||||
|
<Route path="/dev-plan/tasks" element={<DevPlanTasksPage />} />
|
||||||
|
<Route path="/dev-plan/settings" element={<DevPlanSettingsPage />} />
|
||||||
<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-package-audits" element={<StorePackageAuditsPage />} />
|
||||||
|
<Route path="/store-ratings" element={<StoreRatingsPage />} />
|
||||||
|
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
<Route path="/store-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 +118,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 />} />
|
||||||
@@ -81,20 +126,33 @@ export default function App() {
|
|||||||
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
||||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
||||||
|
<Route
|
||||||
|
path="/finance/store-withdrawals"
|
||||||
|
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" replace />}
|
||||||
|
/>
|
||||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
<Route path="/finance/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="/store-withdrawals"
|
||||||
|
element={<Navigate to="/finance/store-bills?kind=WITHDRAW" 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 />} />
|
||||||
<Route path="/logs/hq" element={<HqLogsPage />} />
|
<Route path="/logs/hq" element={<HqLogsPage />} />
|
||||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||||
|
<Route path="/logs/domain-events" element={<DomainEventsPage />} />
|
||||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||||
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
<Route path="/deliveries/xiaofeixia" element={<XiaofeixiaTestPage />} />
|
||||||
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
<Route path="/hq-permissions" element={<HqPermissionsPage />} />
|
||||||
|
<Route path="/test-whitelist" element={<TestWhitelistPage />} />
|
||||||
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
<Route path="/system-settings" element={<SystemSettingsPage />} />
|
||||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
+849
-16
@@ -16,37 +16,163 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 后台内容行:不换行 + 超出省略
|
* 后台表格:不换行、完整展示(v3.5.9 去掉省略号)
|
||||||
* 覆盖主内容区、Drawer、Modal 内的表格与描述列表
|
* 描述列表仍省略,避免详情抽屉被长字段撑破
|
||||||
*/
|
*/
|
||||||
.admin-layout .ant-table-cell,
|
.admin-layout .ant-table-cell,
|
||||||
.ant-drawer .ant-table-cell,
|
.ant-drawer .ant-table-cell,
|
||||||
.ant-modal .ant-table-cell,
|
.ant-modal .ant-table-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-layout .ant-descriptions-item-content,
|
.admin-layout .ant-descriptions-item-content,
|
||||||
.ant-drawer .ant-descriptions-item-content,
|
.ant-drawer .ant-descriptions-item-content,
|
||||||
.ant-modal .ant-descriptions-item-content {
|
.ant-modal .ant-descriptions-item-content {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
max-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-layout .ant-descriptions-item-content,
|
|
||||||
.ant-drawer .ant-descriptions-item-content,
|
|
||||||
.ant-modal .ant-descriptions-item-content {
|
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表头同样不换行 */
|
/* 表头不换行 */
|
||||||
.admin-layout .ant-table-thead > tr > th,
|
.admin-layout .ant-table-thead > tr > th,
|
||||||
.ant-drawer .ant-table-thead > tr > th,
|
.ant-drawer .ant-table-thead > tr > th,
|
||||||
.ant-modal .ant-table-thead > tr > th {
|
.ant-modal .ant-table-thead > tr > th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
text-overflow: ellipsis;
|
text-overflow: clip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 操作列、含交互控件的不截断 */
|
/* 拖表头分割线改列宽 */
|
||||||
|
.admin-layout .ant-table-thead > tr > th.admin-th-resizable {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-col-resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 10px;
|
||||||
|
cursor: col-resize;
|
||||||
|
z-index: 3;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-col-resize-handle:hover,
|
||||||
|
.admin-col-resizing .admin-col-resize-handle {
|
||||||
|
background: rgba(22, 119, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.admin-col-resizing,
|
||||||
|
body.admin-col-resizing * {
|
||||||
|
cursor: col-resize !important;
|
||||||
|
user-select: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 订单页筛选:第一行常驻,第二行起可收起 */
|
||||||
|
.admin-orders-filter-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-row--tools {
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--sm {
|
||||||
|
width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--md {
|
||||||
|
width: 168px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--search {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-users-filter-item--keyword {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--status {
|
||||||
|
width: 220px;
|
||||||
|
min-width: 180px;
|
||||||
|
flex: 1 1 180px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-item--date {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-filter-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-orders-date-picker {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 列表页顶栏:标题左、主操作右、列设置最右 */
|
||||||
|
.admin-list-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-header-left {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-header-desc {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: rgba(0, 0, 0, 0.45);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-settings-slot {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: auto;
|
||||||
|
order: 99;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-settings-btn.ant-btn {
|
||||||
|
color: rgba(0, 0, 0, 0.55);
|
||||||
|
padding-inline: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-list-settings-btn.ant-btn:hover {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作列保持可见 */
|
||||||
.admin-layout .ant-table-cell:has(.ant-btn),
|
.admin-layout .ant-table-cell:has(.ant-btn),
|
||||||
.ant-drawer .ant-table-cell:has(.ant-btn),
|
.ant-drawer .ant-table-cell:has(.ant-btn),
|
||||||
.ant-modal .ant-table-cell:has(.ant-btn),
|
.ant-modal .ant-table-cell:has(.ant-btn),
|
||||||
@@ -71,10 +197,9 @@ body,
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格单元格双行信息合并为单行省略 */
|
/* 表格单元格主/副信息同一行 */
|
||||||
.admin-cell-line {
|
.admin-cell-line {
|
||||||
display: block;
|
display: inline;
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-cell-line-secondary {
|
.admin-cell-line-secondary {
|
||||||
@@ -82,7 +207,715 @@ body,
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-primary-link {
|
||||||
|
display: inline;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a.admin-primary-link {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-primary-link:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-table-nowrap .ant-table-cell,
|
.admin-table-nowrap .ant-table-cell,
|
||||||
.admin-table-nowrap .ant-table-cell-ellipsis {
|
.admin-table-nowrap .ant-table-cell-ellipsis {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-cols {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-col {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-package-audit-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow: visible;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* v3.5.1 #1 订单大屏:发布会现场 */
|
||||||
|
html:has(.big-screen-page),
|
||||||
|
body:has(.big-screen-page),
|
||||||
|
#root:has(.big-screen-page) {
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-page {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: radial-gradient(ellipse at 50% 18%, #163a6b 0%, #0b1e3a 42%, #061224 100%);
|
||||||
|
color: #d6e8ff;
|
||||||
|
padding: 20px 48px 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-stars {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1px 1px at 8% 18%, rgba(255, 255, 255, 0.35), transparent),
|
||||||
|
radial-gradient(1px 1px at 22% 72%, rgba(160, 210, 255, 0.28), transparent),
|
||||||
|
radial-gradient(1.5px 1.5px at 78% 24%, rgba(255, 255, 255, 0.22), transparent),
|
||||||
|
radial-gradient(1px 1px at 91% 68%, rgba(160, 210, 255, 0.3), transparent),
|
||||||
|
radial-gradient(1px 1px at 46% 88%, rgba(255, 255, 255, 0.18), transparent),
|
||||||
|
radial-gradient(1.5px 1.5px at 61% 12%, rgba(160, 210, 255, 0.25), transparent);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-frame {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner {
|
||||||
|
position: absolute;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid rgba(105, 192, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner--tl {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner--tr {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner--bl {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
border-right: none;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-corner--br {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-left: none;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid rgba(105, 192, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-subtitle {
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(200, 220, 245, 0.55);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-live-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-bgm {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 6px 14px 6px 12px;
|
||||||
|
border: 1px solid rgba(105, 192, 255, 0.45);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(8, 28, 56, 0.7);
|
||||||
|
color: rgba(230, 244, 255, 0.92);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-bgm:hover {
|
||||||
|
border-color: rgba(105, 192, 255, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-bgm.is-on {
|
||||||
|
border-color: rgba(64, 169, 255, 0.85);
|
||||||
|
box-shadow: 0 0 12px rgba(64, 169, 255, 0.28);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-bgm-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-live-label {
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgba(230, 244, 255, 0.88);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 12px 4px 10px;
|
||||||
|
border: 1px solid rgba(64, 169, 255, 0.7);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
background: rgba(8, 28, 56, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-live-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ff4d4f;
|
||||||
|
box-shadow: 0 0 8px #ff4d4f;
|
||||||
|
animation: big-screen-pulse 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-hero {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-clock {
|
||||||
|
font-size: 84px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 10px;
|
||||||
|
color: #9fd3ff;
|
||||||
|
text-shadow: 0 0 28px rgba(105, 192, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-date {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 4px;
|
||||||
|
color: rgba(180, 210, 240, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-list {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-list-head,
|
||||||
|
.big-screen-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 18%) minmax(0, 1fr) minmax(140px, 16%) minmax(160px, 18%);
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-list-body {
|
||||||
|
flex: 1 1 0%;
|
||||||
|
min-height: 0;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-track {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-track.is-rolling {
|
||||||
|
animation: big-screen-marquee-up var(--marquee-ms, 20s) linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-track.is-paused {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-marquee-up {
|
||||||
|
from {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
height: 68px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 0 28px 0 40px;
|
||||||
|
background: rgba(18, 48, 88, 0.45);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #e8f4ff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row.is-latest {
|
||||||
|
border-color: rgba(64, 169, 255, 0.85);
|
||||||
|
box-shadow: 0 0 16px rgba(24, 144, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t1 {
|
||||||
|
background: rgba(22, 54, 98, 0.48);
|
||||||
|
border-color: rgba(105, 192, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t1 .big-screen-amount {
|
||||||
|
color: #e6f4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t2.is-latest {
|
||||||
|
border-color: rgba(255, 229, 143, 0.95);
|
||||||
|
box-shadow: 0 0 18px rgba(255, 229, 143, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t3.is-latest {
|
||||||
|
border-color: #ffd666;
|
||||||
|
box-shadow: 0 0 22px rgba(250, 173, 20, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t2 {
|
||||||
|
background: rgba(64, 48, 8, 0.42);
|
||||||
|
border-color: rgba(255, 229, 143, 0.45);
|
||||||
|
color: #fff7d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t2 .big-screen-amount {
|
||||||
|
color: #ffe58f;
|
||||||
|
text-shadow: 0 0 10px rgba(255, 229, 143, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t3 {
|
||||||
|
background: rgba(72, 48, 0, 0.5);
|
||||||
|
border-color: rgba(250, 173, 20, 0.75);
|
||||||
|
color: #ffe7a3;
|
||||||
|
box-shadow: 0 0 18px rgba(250, 173, 20, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t3 .big-screen-amount {
|
||||||
|
color: #ffd666;
|
||||||
|
text-shadow: 0 0 14px rgba(255, 214, 102, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row-mark {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
top: 50%;
|
||||||
|
width: 10px;
|
||||||
|
height: 22px;
|
||||||
|
margin-top: -11px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #40a9ff;
|
||||||
|
box-shadow: 0 0 10px #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t2 .big-screen-row-mark {
|
||||||
|
background: #ffe58f;
|
||||||
|
box-shadow: 0 0 10px #ffe58f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-row--t3 .big-screen-row-mark {
|
||||||
|
background: #ffd666;
|
||||||
|
box-shadow: 0 0 12px #ffd666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-amount--6d {
|
||||||
|
font-size: 0.86em;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-items {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-time,
|
||||||
|
.big-screen-phone {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-empty {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
/* 全屏高度黄金分割点(≈0.382),略偏上 */
|
||||||
|
top: 38.2%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(145, 190, 230, 0.42);
|
||||||
|
padding: 0 40px;
|
||||||
|
font-size: 60px;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(4, 12, 28, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-shock {
|
||||||
|
position: absolute;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(255, 214, 102, 0.85);
|
||||||
|
animation: big-screen-shock 1.1s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-shock {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.2);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(28);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-sweep {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
115deg,
|
||||||
|
transparent 38%,
|
||||||
|
rgba(255, 245, 200, 0.22) 50%,
|
||||||
|
transparent 62%
|
||||||
|
);
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: big-screen-sweep 1.4s ease-out 0.15s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-sweep--alt {
|
||||||
|
animation-delay: 0.55s;
|
||||||
|
background: linear-gradient(
|
||||||
|
65deg,
|
||||||
|
transparent 38%,
|
||||||
|
rgba(255, 214, 102, 0.2) 50%,
|
||||||
|
transparent 62%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-shock--late {
|
||||||
|
animation-delay: 0.35s;
|
||||||
|
border-color: rgba(255, 236, 179, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-sweep {
|
||||||
|
from {
|
||||||
|
background-position: 120% 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: -40% 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
min-width: min(420px, 92vw);
|
||||||
|
max-width: min(920px, 92vw);
|
||||||
|
padding: 28px 40px 32px;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(8, 22, 48, 0.82);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t1 {
|
||||||
|
border: 2px solid rgba(105, 192, 255, 0.85);
|
||||||
|
box-shadow: 0 0 32px rgba(24, 144, 255, 0.45);
|
||||||
|
animation: big-screen-card-in-t1 0.55s cubic-bezier(0.2, 0.9, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t2 {
|
||||||
|
border: 2px solid rgba(255, 229, 143, 0.95);
|
||||||
|
box-shadow: 0 0 40px rgba(255, 214, 102, 0.5);
|
||||||
|
animation: big-screen-card-in-t2 0.6s cubic-bezier(0.16, 1.2, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t3 {
|
||||||
|
min-width: min(560px, 94vw);
|
||||||
|
padding: 36px 48px 40px;
|
||||||
|
border: 3px solid #ffd666;
|
||||||
|
box-shadow:
|
||||||
|
0 0 28px rgba(255, 214, 102, 0.85),
|
||||||
|
0 0 80px rgba(250, 173, 20, 0.45);
|
||||||
|
animation: big-screen-card-in-t3 0.7s cubic-bezier(0.12, 1.4, 0.2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-card-in-t1 {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(80px) scale(0.92);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-card-in-t2 {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.6);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-card-in-t3 {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.4) rotate(-4deg);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
transform: scale(1.12) rotate(1deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) rotate(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-kicker {
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
color: rgba(230, 244, 255, 0.7);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t2 .big-screen-fx-kicker,
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-kicker {
|
||||||
|
color: #ffe58f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-amount {
|
||||||
|
font-size: clamp(44px, 8.5vw, 64px);
|
||||||
|
font-weight: 800;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
line-height: 1.1;
|
||||||
|
color: #e6f7ff;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-amount--6d {
|
||||||
|
font-size: clamp(36px, 7vw, 52px) !important;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t1 .big-screen-fx-amount {
|
||||||
|
color: #91d5ff;
|
||||||
|
text-shadow: 0 0 18px rgba(105, 192, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t2 .big-screen-fx-amount {
|
||||||
|
color: #ffe58f;
|
||||||
|
text-shadow: 0 0 20px rgba(255, 229, 143, 0.7);
|
||||||
|
animation: big-screen-amount-pop 0.8s ease-out 0.15s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
||||||
|
font-size: clamp(52px, 10vw, 84px);
|
||||||
|
color: #ffd666;
|
||||||
|
text-shadow:
|
||||||
|
0 0 12px #ffd666,
|
||||||
|
0 0 36px rgba(250, 173, 20, 0.8);
|
||||||
|
animation: big-screen-amount-flash 0.9s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
|
||||||
|
font-size: clamp(44px, 8.5vw, 68px) !important;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-amount-pop {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: scale(1.12);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes big-screen-amount-flash {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
filter: brightness(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-items {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.big-screen-fx-meta {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 28px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: rgba(210, 228, 250, 0.75);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.big-screen-clock {
|
||||||
|
font-size: 56px;
|
||||||
|
}
|
||||||
|
.big-screen-row {
|
||||||
|
grid-template-columns: minmax(140px, 20%) minmax(0, 1fr) minmax(110px, 16%) minmax(130px, 18%);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.big-screen-amount--6d {
|
||||||
|
font-size: 0.82em;
|
||||||
|
}
|
||||||
|
.big-screen-fx-amount {
|
||||||
|
font-size: clamp(36px, 8vw, 44px);
|
||||||
|
}
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-amount {
|
||||||
|
font-size: clamp(40px, 9vw, 56px);
|
||||||
|
}
|
||||||
|
.big-screen-fx-amount--6d {
|
||||||
|
font-size: clamp(32px, 6.5vw, 40px) !important;
|
||||||
|
}
|
||||||
|
.big-screen-fx-card--t3 .big-screen-fx-amount--6d {
|
||||||
|
font-size: clamp(34px, 7vw, 48px) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,100 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Form, Modal, Select, Typography, message } from 'antd';
|
||||||
|
import type { ActivityPosterItem } from '@dukang/shared-types';
|
||||||
|
import { request, requestDownload, type Paginated } from '../lib/api';
|
||||||
|
import { ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
partnerIds: string[];
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActivityPosterDownloadModal({ open, partnerIds, onClose }: Props) {
|
||||||
|
const [posters, setPosters] = useState<ActivityPosterItem[]>([]);
|
||||||
|
const [posterId, setPosterId] = useState<string>();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const multi = partnerIds.length > 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setPosterId(undefined);
|
||||||
|
setLoading(true);
|
||||||
|
void request<Paginated<ActivityPosterItem>>(
|
||||||
|
`/admin/activity-posters?status=ACTIVE&page=1&pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`,
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
const items = res.items ?? [];
|
||||||
|
setPosters(items);
|
||||||
|
setPosterId(items[0]?.id);
|
||||||
|
})
|
||||||
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载活动图失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!posterId) {
|
||||||
|
message.error('请选择活动图');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!partnerIds.length) {
|
||||||
|
message.error('请选择合伙人');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
if (partnerIds.length === 1) {
|
||||||
|
await requestDownload(
|
||||||
|
`/admin/activity-posters/${posterId}/image?partnerId=${encodeURIComponent(partnerIds[0])}`,
|
||||||
|
{},
|
||||||
|
'activity-poster.png',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await requestDownload(
|
||||||
|
`/admin/activity-posters/${posterId}/partner-pack`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ partnerIds }) },
|
||||||
|
'activity-poster-pack.zip',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
message.success('已开始下载');
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '下载失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={multi ? `导出活动图(${partnerIds.length} 人)` : '下载活动图'}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
okText="下载"
|
||||||
|
okButtonProps={{ disabled: loading || !posterId }}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
{multi
|
||||||
|
? '将把所选主合伙人的关联码贴进同一张活动图,打包为 zip。无关联码或测试账号会跳过。'
|
||||||
|
: '将把该主合伙人的关联码贴进所选活动图后下载 PNG。'}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="活动图" required>
|
||||||
|
<Select
|
||||||
|
loading={loading}
|
||||||
|
placeholder={loading ? '加载中' : posters.length ? '选择已上架活动图' : '暂无上架活动图'}
|
||||||
|
value={posterId}
|
||||||
|
onChange={setPosterId}
|
||||||
|
options={posters.map((row) => ({ value: row.id, label: row.title }))}
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { DEFAULT_ACTIVITY_POSTER_QR_SLOT, type ActivityPosterQrSlot } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
imageUrl?: string;
|
||||||
|
value?: ActivityPosterQrSlot;
|
||||||
|
onChange?: (slot: ActivityPosterQrSlot) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActivityPosterQrSlotEditor({ imageUrl, value, onChange }: Props) {
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragRef = useRef<{
|
||||||
|
mode: 'move' | 'resize';
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
start: ActivityPosterQrSlot;
|
||||||
|
} | null>(null);
|
||||||
|
const slot = value ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT;
|
||||||
|
|
||||||
|
const apply = useCallback((next: ActivityPosterQrSlot) => {
|
||||||
|
const size = Math.min(50, Math.max(5, next.qrSizePct));
|
||||||
|
const box = boxRef.current;
|
||||||
|
const ratio = box && box.clientHeight > 0 ? box.clientWidth / box.clientHeight : 1;
|
||||||
|
const heightPct = size * ratio;
|
||||||
|
const x = Math.min(100 - size, Math.max(0, next.qrXPct));
|
||||||
|
const y = Math.min(Math.max(0, 100 - heightPct), Math.max(0, next.qrYPct));
|
||||||
|
onChange?.({
|
||||||
|
qrXPct: Number(x.toFixed(2)),
|
||||||
|
qrYPct: Number(y.toFixed(2)),
|
||||||
|
qrSizePct: Number(size.toFixed(2)),
|
||||||
|
});
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
function onPointerDown(mode: 'move' | 'resize', e: React.PointerEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
dragRef.current = { mode, startX: e.clientX, startY: e.clientY, start: { ...slot } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: React.PointerEvent) {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
const box = boxRef.current;
|
||||||
|
if (!drag || !box) return;
|
||||||
|
const dx = ((e.clientX - drag.startX) / box.clientWidth) * 100;
|
||||||
|
const dy = ((e.clientY - drag.startY) / box.clientHeight) * 100;
|
||||||
|
if (drag.mode === 'move') {
|
||||||
|
apply({
|
||||||
|
...drag.start,
|
||||||
|
qrXPct: drag.start.qrXPct + dx,
|
||||||
|
qrYPct: drag.start.qrYPct + dy,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
apply({ ...drag.start, qrSizePct: drag.start.qrSizePct + dx });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag() {
|
||||||
|
dragRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!imageUrl) {
|
||||||
|
return <div style={{ color: 'rgba(0,0,0,0.45)' }}>请先上传活动图,再拖拽定位方形码栏</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
ref={boxRef}
|
||||||
|
style={{ position: 'relative', width: '100%', maxWidth: 420, userSelect: 'none', touchAction: 'none' }}
|
||||||
|
onPointerMove={onPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
>
|
||||||
|
<img src={imageUrl} alt="活动图预览" style={{ width: '100%', display: 'block' }} draggable={false} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${slot.qrXPct}%`,
|
||||||
|
top: `${slot.qrYPct}%`,
|
||||||
|
width: `${slot.qrSizePct}%`,
|
||||||
|
aspectRatio: '1',
|
||||||
|
border: '2px dashed #1677ff',
|
||||||
|
background: 'rgba(22,119,255,0.14)',
|
||||||
|
cursor: 'move',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => onPointerDown('move', e)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
right: -6,
|
||||||
|
bottom: -6,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
background: '#1677ff',
|
||||||
|
borderRadius: 2,
|
||||||
|
cursor: 'nwse-resize',
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => onPointerDown('resize', e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.45)', fontSize: 12 }}>
|
||||||
|
拖拽移动码栏,拉右下角调整大小(边长相对图宽 {slot.qrSizePct}%)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Typography } from 'antd';
|
|
||||||
|
|
||||||
type AdminCellLineProps = {
|
type AdminCellLineProps = {
|
||||||
primary?: string | null;
|
primary?: string | null;
|
||||||
secondary?: string | null;
|
secondary?: string | null;
|
||||||
separator?: string;
|
separator?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 表格单元格:主/副信息同一行展示,超出省略,hover 显示全文 */
|
/** 表格单元格:主/副信息同一行完整展示 */
|
||||||
export function AdminCellLine({
|
export function AdminCellLine({
|
||||||
primary,
|
primary,
|
||||||
secondary,
|
secondary,
|
||||||
@@ -14,14 +12,9 @@ export function AdminCellLine({
|
|||||||
}: AdminCellLineProps) {
|
}: AdminCellLineProps) {
|
||||||
const main = primary?.trim() || '—';
|
const main = primary?.trim() || '—';
|
||||||
const sub = secondary?.trim();
|
const sub = secondary?.trim();
|
||||||
const full = sub ? `${main}${separator}${sub}` : main;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Typography.Text
|
<span className="admin-cell-line">
|
||||||
className="admin-cell-line"
|
|
||||||
ellipsis={{ tooltip: full }}
|
|
||||||
style={{ maxWidth: '100%' }}
|
|
||||||
>
|
|
||||||
{main}
|
{main}
|
||||||
{sub ? (
|
{sub ? (
|
||||||
<span className="admin-cell-line-secondary">
|
<span className="admin-cell-line-secondary">
|
||||||
@@ -29,6 +22,6 @@ export function AdminCellLine({
|
|||||||
{sub}
|
{sub}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</Typography.Text>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Checkbox, Modal, Space, Typography } from 'antd';
|
||||||
|
import { HolderOutlined } from '@ant-design/icons';
|
||||||
|
import type { ListColumnSettingItem } from '../lib/list-column-prefs';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
items: ListColumnSettingItem[];
|
||||||
|
saving?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onSave: (items: ListColumnSettingItem[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminListColumnsModal({ open, items, saving, onCancel, onReset, onSave }: Props) {
|
||||||
|
const [draft, setDraft] = useState<ListColumnSettingItem[]>(items);
|
||||||
|
const [dragKey, setDragKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setDraft(items);
|
||||||
|
}, [open, items]);
|
||||||
|
|
||||||
|
function move(from: number, to: number) {
|
||||||
|
if (to < 0 || to >= draft.length) return;
|
||||||
|
const next = [...draft];
|
||||||
|
const [row] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, row);
|
||||||
|
setDraft(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="列设置"
|
||||||
|
open={open}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Button onClick={onReset}>重置默认</Button>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={onCancel}>取消</Button>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => onSave(draft)}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
|
||||||
|
勾选显示列,拖拽或上下箭头调整顺序。序号与操作列固定,不在此列表中。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<div>
|
||||||
|
{draft.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.key}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => setDragKey(item.key)}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={() => {
|
||||||
|
if (!dragKey || dragKey === item.key) return;
|
||||||
|
const from = draft.findIndex((r) => r.key === dragKey);
|
||||||
|
if (from >= 0) move(from, index);
|
||||||
|
setDragKey(null);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '6px 0',
|
||||||
|
borderBottom: '1px solid #f0f0f0',
|
||||||
|
cursor: 'move',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HolderOutlined style={{ color: '#999' }} />
|
||||||
|
<Checkbox
|
||||||
|
checked={item.visible}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...draft];
|
||||||
|
next[index] = { ...item, visible: e.target.checked };
|
||||||
|
setDraft(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Checkbox>
|
||||||
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
|
<Button type="link" size="small" disabled={index === 0} onClick={() => move(index, index - 1)}>
|
||||||
|
上移
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
disabled={index === draft.length - 1}
|
||||||
|
onClick={() => move(index, index + 1)}
|
||||||
|
>
|
||||||
|
下移
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Space, Typography } from 'antd';
|
||||||
|
|
||||||
|
/** 列表页顶栏:标题在左,主操作在右,列设置永远最右 */
|
||||||
|
export function AdminListHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
settings,
|
||||||
|
}: {
|
||||||
|
title?: ReactNode;
|
||||||
|
description?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
settings?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="admin-list-header">
|
||||||
|
<div className="admin-list-header-left">
|
||||||
|
{title == null || title === '' ? null : typeof title === 'string' || typeof title === 'number' ? (
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
{title}
|
||||||
|
</Typography.Title>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
{description ? (
|
||||||
|
<div className="admin-list-header-desc">{description}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="admin-list-header-right">
|
||||||
|
{actions ? <Space wrap>{actions}</Space> : null}
|
||||||
|
{settings}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { MouseEvent, ReactNode } from 'react';
|
||||||
|
|
||||||
|
/** HQ 主列表主展示列:下划线,点击进入编辑或详情 */
|
||||||
|
export function AdminPrimaryLink({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
onClick: (e?: MouseEvent) => void;
|
||||||
|
}) {
|
||||||
|
const empty = children == null || children === '';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-primary-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{empty ? '—' : children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Alert, Button, Form, Input, InputNumber, Modal, Space, Typography, message } from 'antd';
|
||||||
|
import { DownOutlined, UpOutlined } from '@ant-design/icons';
|
||||||
|
import type { StorePackageItemDto, StorePackagesResponse } from '@dukang/shared-types';
|
||||||
|
import { STORE_PACKAGE_IMAGE_MAX_COUNT, STORE_PACKAGE_MAX_COUNT, normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
|
import PackageImagesUpload from './PackageImagesUpload';
|
||||||
|
|
||||||
|
type PackageRow = StorePackageItemDto;
|
||||||
|
|
||||||
|
export type AdminStorePackagesHandle = {
|
||||||
|
/** 仅在用户改过套餐时写入;加载中或未改动则跳过,避免空表单覆盖刚审核通过的线上套餐 */
|
||||||
|
saveIfLoaded: (opts?: { quiet?: boolean }) => Promise<{ skipped: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapLiveRows(live: StorePackagesResponse['live']): PackageRow[] {
|
||||||
|
return (live ?? []).map((p, i) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(p);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
price: String(p.price),
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: i,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyRow(index = 0): PackageRow {
|
||||||
|
return {
|
||||||
|
name: '',
|
||||||
|
price: '0',
|
||||||
|
dishes: '',
|
||||||
|
usableTime: '',
|
||||||
|
otherNotes: '',
|
||||||
|
imageUrl: '',
|
||||||
|
imageUrls: [],
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminStorePackagesSection = forwardRef<AdminStorePackagesHandle, { storeId: string }>(
|
||||||
|
function AdminStorePackagesSection({ storeId }, ref) {
|
||||||
|
const [items, setItems] = useState<PackageRow[]>([emptyRow()]);
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<number, boolean>>({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [pendingRequest, setPendingRequest] = useState<StorePackagesResponse['pendingRequest']>(null);
|
||||||
|
const itemsRef = useRef(items);
|
||||||
|
const loadingRef = useRef(loading);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
itemsRef.current = items;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadingRef.current = loading;
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
function applyServerPackages(data: StorePackagesResponse) {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setPendingRequest(data.pendingRequest ?? null);
|
||||||
|
setItems(mapLiveRows(data.live));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setPendingRequest(null);
|
||||||
|
dirtyRef.current = false;
|
||||||
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
|
.then((data) => applyServerPackages(data))
|
||||||
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载套餐失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
// 审核通过/驳回后刷新提醒;用户未改套餐时同步线上结果,避免抽屉里仍显示空套餐
|
||||||
|
useEffect(() => {
|
||||||
|
const onChanged = () => {
|
||||||
|
request<StorePackagesResponse>(`/admin/stores/${storeId}/packages`)
|
||||||
|
.then((data) => {
|
||||||
|
setPendingRequest(data.pendingRequest ?? null);
|
||||||
|
if (!dirtyRef.current) {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setItems(mapLiveRows(data.live));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
return () => window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
function goAudit() {
|
||||||
|
if (pendingRequest) navigate(`/store-package-audits?requestId=${pendingRequest.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingReminder =
|
||||||
|
pendingRequest && pendingRequest.status === 'PENDING' ? (
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="该门店有待审核套餐"
|
||||||
|
description={
|
||||||
|
<Space>
|
||||||
|
<Button size="small" type="primary" onClick={goAudit}>
|
||||||
|
审核
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={goAudit}>
|
||||||
|
对比
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
function updateAt(index: number, patch: Partial<PackageRow>) {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setItems((prev) => prev.map((item, i) => (i === index ? { ...item, ...patch } : item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (items.length >= STORE_PACKAGE_MAX_COUNT) return;
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setItems((prev) => [...prev, emptyRow(prev.length)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
const run = () => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setItems((prev) => {
|
||||||
|
const next = prev.filter((_, i) => i !== index).map((item, i) => ({ ...item, sortOrder: i }));
|
||||||
|
return next.length ? next : [];
|
||||||
|
});
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next: Record<number, boolean> = {};
|
||||||
|
Object.entries(prev).forEach(([k, v]) => {
|
||||||
|
const i = Number(k);
|
||||||
|
if (i < index) next[i] = v;
|
||||||
|
else if (i > index) next[i - 1] = v;
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (items.length === 1) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '清空门店套餐',
|
||||||
|
content: '删除最后一条套餐后,该门店将无展示套餐,确认继续?',
|
||||||
|
okText: '确认删除',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: run,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCollapse(index: number) {
|
||||||
|
setCollapsed((prev) => ({ ...prev, [index]: !prev[index] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(opts?: { quiet?: boolean }) {
|
||||||
|
const currentItems = itemsRef.current;
|
||||||
|
const filled = currentItems
|
||||||
|
.map((item, index) => {
|
||||||
|
const imageUrls = normalizeStorePackageImageUrls(item);
|
||||||
|
return {
|
||||||
|
name: item.name.trim(),
|
||||||
|
price: item.price.trim(),
|
||||||
|
dishes: item.dishes.trim(),
|
||||||
|
usableTime: item.usableTime?.trim() || null,
|
||||||
|
otherNotes: item.otherNotes?.trim() || null,
|
||||||
|
imageUrl: imageUrls[0] ?? null,
|
||||||
|
imageUrls,
|
||||||
|
sortOrder: index,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
// 允许整店无套餐:忽略空白占位行(默认 price=0 不算已填)
|
||||||
|
.filter((item) => {
|
||||||
|
const hasText = !!(item.name || item.dishes || item.usableTime || item.otherNotes);
|
||||||
|
const hasImages = item.imageUrls.length > 0;
|
||||||
|
const hasNonZeroPrice = item.price !== '' && Number(item.price) !== 0;
|
||||||
|
return hasText || hasImages || hasNonZeroPrice;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < filled.length; i++) {
|
||||||
|
const item = filled[i];
|
||||||
|
if (!item.name) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐名称不能为空`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
if (!item.dishes) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐菜品不能为空`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
const price = Number(item.price);
|
||||||
|
if (!Number.isFinite(price) || price < 0) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐价格须为非负数字`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
if ((item.imageUrls?.length ?? 0) > STORE_PACKAGE_IMAGE_MAX_COUNT) {
|
||||||
|
message.warning(`第 ${i + 1} 条套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张`);
|
||||||
|
throw new Error('套餐校验失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const data = await request<{ live: PackageRow[] }>(`/admin/stores/${storeId}/packages`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
packages: filled.map((p) => ({ ...p, price: Number(p.price).toFixed(2) })),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!opts?.quiet) message.success('套餐已保存并生效');
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setItems(mapLiveRows(data.live));
|
||||||
|
} catch (e) {
|
||||||
|
if (!opts?.quiet) message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
saveIfLoaded: async (opts) => {
|
||||||
|
if (loadingRef.current || !dirtyRef.current) return { skipped: true };
|
||||||
|
await save(opts);
|
||||||
|
return { skipped: false };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Typography.Text type="secondary">加载套餐中…</Typography.Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return (
|
||||||
|
<Form layout="vertical" requiredMark={false}>
|
||||||
|
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="该门店暂无套餐,可添加或保存为空。" />
|
||||||
|
{pendingReminder}
|
||||||
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||||
|
添加套餐
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||||
|
保存套餐
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form layout="vertical" requiredMark={false}>
|
||||||
|
{pendingReminder}
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={`总部直存立即生效,无需审核。同一门店最多 ${STORE_PACKAGE_MAX_COUNT} 条套餐。`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isCollapsed = !!collapsed[index];
|
||||||
|
const displayName = item.name.trim() || `套餐 ${index + 1}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: isCollapsed ? 0 : 12 }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
onClick={() => toggleCollapse(index)}
|
||||||
|
style={{ paddingLeft: 0, height: 'auto' }}
|
||||||
|
>
|
||||||
|
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||||
|
{displayName}
|
||||||
|
</Typography.Title>
|
||||||
|
</Button>
|
||||||
|
{items.length > 0 ? (
|
||||||
|
<Button type="link" danger onClick={() => removeAt(index)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{!isCollapsed ? (
|
||||||
|
<>
|
||||||
|
<Form.Item label="套餐名称" required style={{ marginBottom: 12 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="如:套餐A"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => updateAt(index, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="价格(元)" required style={{ marginBottom: 12 }}>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonAfter="元"
|
||||||
|
placeholder="198"
|
||||||
|
value={item.price === '' ? undefined : Number(item.price)}
|
||||||
|
onChange={(v) => updateAt(index, { price: v != null ? String(v) : '' })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="菜品" required style={{ marginBottom: 12 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="红烧肉、红烧鱼、油焖茄子"
|
||||||
|
value={item.dishes}
|
||||||
|
onChange={(e) => updateAt(index, { dishes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="使用时间" style={{ marginBottom: 12 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="节假日除外"
|
||||||
|
value={item.usableTime || ''}
|
||||||
|
onChange={(e) => updateAt(index, { usableTime: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="套餐图片" style={{ marginBottom: 12 }}>
|
||||||
|
<PackageImagesUpload
|
||||||
|
value={normalizeStorePackageImageUrls(item)}
|
||||||
|
onChange={(imageUrls) =>
|
||||||
|
updateAt(index, {
|
||||||
|
imageUrls,
|
||||||
|
imageUrl: imageUrls[0] ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="其他说明" style={{ marginBottom: 0 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="不可叠加"
|
||||||
|
value={item.otherNotes || ''}
|
||||||
|
onChange={(e) => updateAt(index, { otherNotes: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{items.length < STORE_PACKAGE_MAX_COUNT ? (
|
||||||
|
<Button onClick={addRow} style={{ marginBottom: 16 }}>
|
||||||
|
添加套餐
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||||
|
上传套餐图后请点右上角「保存修改」(会连同套餐一起保存),或点下方「保存套餐」。仅上传不保存,刷新会丢失。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void save().catch(() => undefined)}>
|
||||||
|
保存套餐
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default AdminStorePackagesSection;
|
||||||
@@ -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,13 +1,14 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Cascader,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
@@ -27,9 +28,12 @@ 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 { omitNullFields } from '../lib/omit-null-fields';
|
||||||
|
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';
|
||||||
|
import PartnerAssocPanel from './PartnerAssocPanel';
|
||||||
|
|
||||||
type PartnerRow = {
|
type PartnerRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -37,8 +41,10 @@ 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;
|
||||||
|
storeCount?: number;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
@@ -65,6 +71,7 @@ type PartnerDetail = PartnerRow & {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
cityId: string;
|
cityId: string;
|
||||||
|
cityCode?: string;
|
||||||
maxPartnerCommissionRate?: number;
|
maxPartnerCommissionRate?: number;
|
||||||
onChanged?: () => void;
|
onChanged?: () => void;
|
||||||
};
|
};
|
||||||
@@ -92,14 +99,32 @@ 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 navigate = useNavigate();
|
||||||
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 +178,59 @@ 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) {
|
||||||
|
message.error(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await request(`/admin/partners/${detail.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...omitNullFields(v),
|
||||||
|
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||||
|
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||||
|
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
void loadPartners();
|
||||||
|
onChanged?.();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePartner(row: { id: string; storeCount?: number }) {
|
||||||
|
if ((row.storeCount ?? 0) > 0) {
|
||||||
|
Modal.warning({
|
||||||
|
title: '请先删除门店',
|
||||||
|
content: `该城市合伙人下还有 ${row.storeCount} 家门店,请先删除门店后再删除合伙人。`,
|
||||||
|
okText: '查看门店',
|
||||||
|
onOk: () => navigate(`/stores?partnerId=${row.id}`),
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await request(`/admin/partners/${detail.id}`, {
|
try {
|
||||||
method: 'PUT',
|
await request(`/admin/partners/${row.id}`, { method: 'DELETE' });
|
||||||
body: JSON.stringify({
|
message.success('城市合伙人已删除');
|
||||||
...v,
|
if (detail?.id === row.id) {
|
||||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
setDrawerOpen(false);
|
||||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
setDetail(null);
|
||||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
}
|
||||||
}),
|
void loadPartners();
|
||||||
});
|
onChanged?.();
|
||||||
message.success('已保存');
|
} catch (e) {
|
||||||
setDrawerOpen(false);
|
const msg = formatApiError(e);
|
||||||
void loadPartners();
|
if (msg) message.error(msg);
|
||||||
onChanged?.();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSubAccount(subId: string) {
|
async function deleteSubAccount(subId: string) {
|
||||||
@@ -187,7 +242,14 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<PartnerRow> = [
|
const columns: ColumnsType<PartnerRow> = [
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
{
|
||||||
|
title: '区县',
|
||||||
|
dataIndex: 'districtCodes',
|
||||||
|
width: 160,
|
||||||
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
|
},
|
||||||
|
{ title: '公司名', dataIndex: 'companyName' },
|
||||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
@@ -204,11 +266,24 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
<Space size={0}>
|
||||||
管理
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||||
</Button>
|
管理
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -239,7 +314,22 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
width={640}
|
width={640}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
extra={
|
||||||
|
<Space>
|
||||||
|
{detail ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(detail)}
|
||||||
|
>
|
||||||
|
<Button danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" onClick={() => void savePartner()}>保存</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -249,11 +339,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 +351,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 +386,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) => {
|
||||||
@@ -310,48 +404,62 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'assoc',
|
||||||
|
label: '关联码',
|
||||||
|
children: <PartnerAssocPanel partnerId={detail.id} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button, Form, Space, Typography } from 'antd';
|
import { Typography } from 'antd';
|
||||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
import MultiImageUpload from './MultiImageUpload';
|
||||||
import OssUpload from './OssUpload';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -8,42 +7,35 @@ type Props = {
|
|||||||
bizType?: string;
|
bizType?: string;
|
||||||
/** 最多可添加张数;不传则不限制 */
|
/** 最多可添加张数;不传则不限制 */
|
||||||
maxCount?: number;
|
maxCount?: number;
|
||||||
|
/** Form.Item 注入 */
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 商品详情/轮播等多图列表。
|
||||||
|
* 可直接包在 Form.Item 下(value/onChange),也可用 Form.List 的 name 外层再包 Form.Item。
|
||||||
|
*/
|
||||||
export default function DetailImageUrlList({
|
export default function DetailImageUrlList({
|
||||||
name = 'detailImageUrls',
|
|
||||||
label,
|
label,
|
||||||
bizType = 'DETAIL',
|
bizType = 'DETAIL',
|
||||||
maxCount,
|
maxCount,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{maxCount != null && (
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
{label}:支持批量上传{maxCount != null ? `,最多 ${maxCount} 张` : ''}
|
||||||
最多 {maxCount} 张{label}
|
</Typography.Text>
|
||||||
</Typography.Text>
|
<MultiImageUpload
|
||||||
)}
|
bizType={bizType}
|
||||||
<Form.List name={name}>
|
mediaType="IMAGE"
|
||||||
{(fields, { add, remove }) => (
|
maxCount={maxCount}
|
||||||
<>
|
value={value}
|
||||||
{fields.map((field) => (
|
onChange={onChange}
|
||||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
tip={`支持一次选择多张${label}`}
|
||||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
/>
|
||||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
|
||||||
</Form.Item>
|
|
||||||
{fields.length > 1 && (
|
|
||||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
))}
|
|
||||||
{(!maxCount || fields.length < maxCount) && (
|
|
||||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
|
||||||
添加{label}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Form.List>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Image, Input, Modal, Space, Typography } from 'antd';
|
||||||
|
import { PAYMENT_PROOF_IMAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
import MultiImageUpload from './MultiImageUpload';
|
||||||
|
|
||||||
|
export function parsePaymentProofUrls(raw: unknown): string[] {
|
||||||
|
if (!Array.isArray(raw)) return [];
|
||||||
|
return raw.map((u) => String(u ?? '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentProofGallery({ urls }: { urls?: unknown }) {
|
||||||
|
const list = parsePaymentProofUrls(urls);
|
||||||
|
if (!list.length) return <>—</>;
|
||||||
|
return (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{list.map((url, index) => (
|
||||||
|
<Image
|
||||||
|
key={`${url}-${index}`}
|
||||||
|
src={url}
|
||||||
|
width={72}
|
||||||
|
height={72}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinancePayProofModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
hint: string;
|
||||||
|
okText: string;
|
||||||
|
confirmLoading?: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onOk: (payload: { paymentRef?: string; paymentProofUrls?: string[] }) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FinancePayProofModal({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
hint,
|
||||||
|
okText,
|
||||||
|
confirmLoading,
|
||||||
|
onCancel,
|
||||||
|
onOk,
|
||||||
|
}: FinancePayProofModalProps) {
|
||||||
|
const [paymentRef, setPaymentRef] = useState('');
|
||||||
|
const [proofUrls, setProofUrls] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPaymentRef('');
|
||||||
|
setProofUrls([]);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={title}
|
||||||
|
open={open}
|
||||||
|
okText={okText}
|
||||||
|
cancelText="取消"
|
||||||
|
confirmLoading={confirmLoading}
|
||||||
|
destroyOnClose
|
||||||
|
width={480}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={async () => {
|
||||||
|
await onOk({
|
||||||
|
paymentRef: paymentRef.trim() || undefined,
|
||||||
|
paymentProofUrls: proofUrls.length ? proofUrls : undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 12 }}>{hint}</Typography.Paragraph>
|
||||||
|
<Input
|
||||||
|
placeholder="打款凭证号(可选)"
|
||||||
|
value={paymentRef}
|
||||||
|
onChange={(e) => setPaymentRef(e.target.value)}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">打款凭证照片(可选,银行转账回单等)</Typography.Text>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<MultiImageUpload
|
||||||
|
bizType="PAYMENT_PROOF"
|
||||||
|
value={proofUrls}
|
||||||
|
onChange={setProofUrls}
|
||||||
|
maxCount={PAYMENT_PROOF_IMAGE_MAX_COUNT}
|
||||||
|
buttonText="上传凭证照片"
|
||||||
|
tip={`最多 ${PAYMENT_PROOF_IMAGE_MAX_COUNT} 张,支持一次选择多张`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Form, Input, Modal, message } from 'antd';
|
||||||
|
import type { UpdateMyHqCredentialsRequest } from '@dukang/shared-types';
|
||||||
|
import { request, type HqProfile } from '../lib/api';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
profile: HqProfile | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdated: (profile: HqProfile) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HqAccountSettingsModal({ open, profile, onClose, onUpdated }: Props) {
|
||||||
|
const [form] = Form.useForm<UpdateMyHqCredentialsRequest & { confirmPassword?: string }>();
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
form.setFieldsValue({
|
||||||
|
loginName: profile?.loginName ?? '',
|
||||||
|
oldPassword: '',
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
}, [open, profile, form]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
if (values.newPassword && values.newPassword !== values.confirmPassword) {
|
||||||
|
message.error('两次输入的新密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body: UpdateMyHqCredentialsRequest = {};
|
||||||
|
const nextLogin = values.loginName?.trim();
|
||||||
|
if (nextLogin && nextLogin !== (profile?.loginName ?? '')) {
|
||||||
|
body.loginName = nextLogin;
|
||||||
|
}
|
||||||
|
if (values.newPassword?.trim()) {
|
||||||
|
body.newPassword = values.newPassword.trim();
|
||||||
|
if (values.oldPassword?.trim()) body.oldPassword = values.oldPassword.trim();
|
||||||
|
}
|
||||||
|
if (!body.loginName && !body.newPassword) {
|
||||||
|
message.warning('请填写要修改的内容');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/me/credentials', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const updated = await request<HqProfile>('/admin/auth/me');
|
||||||
|
message.success('账号信息已更新');
|
||||||
|
onUpdated(updated);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="账号设置"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="loginName" label="登录用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||||
|
<Input autoComplete="username" placeholder="用于密码登录" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="oldPassword" label="当前密码" extra="已设置过密码时,修改密码必填">
|
||||||
|
<Input.Password autoComplete="current-password" placeholder="不修改密码请留空" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="newPassword" label="新密码" rules={[{ min: 6, message: '至少 6 位' }]}>
|
||||||
|
<Input.Password autoComplete="new-password" placeholder="不修改请留空" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="confirmPassword" label="确认新密码">
|
||||||
|
<Input.Password autoComplete="new-password" placeholder="不修改请留空" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Image, Space, Typography, Upload, message } from 'antd';
|
||||||
|
import { UploadOutlined, DeleteOutlined, FilePdfOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadProps } from 'antd';
|
||||||
|
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
|
bizType: string;
|
||||||
|
mediaType?: OssMediaType;
|
||||||
|
/** 最多张数;不传则不限制 */
|
||||||
|
maxCount?: number;
|
||||||
|
tip?: string;
|
||||||
|
accept?: string;
|
||||||
|
/** 上传按钮文案,默认「批量上传图片」 */
|
||||||
|
buttonText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUrls(value?: string[]) {
|
||||||
|
return (value ?? []).map((u) => String(u || '').trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(url: string) {
|
||||||
|
return /\.pdf(\?|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 多图批量上传(一次可选多张),用于套餐图 / 环境照 / 商品详情图等。
|
||||||
|
* Form.Item 直接绑定 string[]。
|
||||||
|
*/
|
||||||
|
export default function MultiImageUpload({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
bizType,
|
||||||
|
mediaType = 'IMAGE',
|
||||||
|
maxCount,
|
||||||
|
tip,
|
||||||
|
accept = 'image/*',
|
||||||
|
buttonText = '批量上传图片',
|
||||||
|
}: Props) {
|
||||||
|
const urls = normalizeUrls(value);
|
||||||
|
const urlsRef = useRef(urls);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const batchBuf = useRef<File[]>([]);
|
||||||
|
const batchTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const uploadChain = useRef(Promise.resolve());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
urlsRef.current = urls;
|
||||||
|
}, [urls]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (batchTimer.current) clearTimeout(batchTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const remaining = maxCount != null ? Math.max(0, maxCount - urls.length) : Number.POSITIVE_INFINITY;
|
||||||
|
const canAdd = remaining > 0;
|
||||||
|
|
||||||
|
async function uploadBatch(files: File[]) {
|
||||||
|
const current = urlsRef.current;
|
||||||
|
const room = maxCount != null ? Math.max(0, maxCount - current.length) : files.length;
|
||||||
|
const picked = files.slice(0, room);
|
||||||
|
if (!picked.length) {
|
||||||
|
message.warning(maxCount != null ? `最多 ${maxCount} 张` : '无法上传');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (files.length > picked.length) {
|
||||||
|
message.warning(`已达上限,仅上传前 ${picked.length} 张`);
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
const appended: string[] = [];
|
||||||
|
let fail = 0;
|
||||||
|
try {
|
||||||
|
for (const file of picked) {
|
||||||
|
try {
|
||||||
|
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||||
|
appended.push(result.url);
|
||||||
|
} catch {
|
||||||
|
fail += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appended.length) {
|
||||||
|
// 始终基于最新列表追加,避免并行上传互相覆盖
|
||||||
|
const next = [...urlsRef.current, ...appended];
|
||||||
|
urlsRef.current = next;
|
||||||
|
onChangeRef.current?.(next);
|
||||||
|
message.success(`成功上传 ${appended.length} 张${fail ? `,失败 ${fail} 张` : ''}`);
|
||||||
|
} else if (fail) {
|
||||||
|
message.error('上传失败');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueUploadBatch(files: File[]) {
|
||||||
|
uploadChain.current = uploadChain.current
|
||||||
|
.then(() => uploadBatch(files))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushBatch() {
|
||||||
|
if (batchTimer.current) {
|
||||||
|
clearTimeout(batchTimer.current);
|
||||||
|
batchTimer.current = null;
|
||||||
|
}
|
||||||
|
if (!batchBuf.current.length) return;
|
||||||
|
const files = [...batchBuf.current];
|
||||||
|
batchBuf.current = [];
|
||||||
|
enqueueUploadBatch(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
|
||||||
|
batchBuf.current.push(file as File);
|
||||||
|
// 多选时 beforeUpload 可能逐文件触发;短防抖合并成一次批量
|
||||||
|
if (batchTimer.current) clearTimeout(batchTimer.current);
|
||||||
|
batchTimer.current = setTimeout(() => {
|
||||||
|
flushBatch();
|
||||||
|
}, 80);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
function removeAt(index: number) {
|
||||||
|
const next = urlsRef.current.filter((_, i) => i !== index);
|
||||||
|
urlsRef.current = next;
|
||||||
|
onChangeRef.current?.(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{tip ??
|
||||||
|
(maxCount != null
|
||||||
|
? `最多 ${maxCount} 张,支持一次选择多张批量上传`
|
||||||
|
: '支持一次选择多张批量上传')}
|
||||||
|
{maxCount != null ? `(已选 ${urls.length}/${maxCount})` : urls.length ? `(已选 ${urls.length})` : ''}
|
||||||
|
</Typography.Text>
|
||||||
|
|
||||||
|
{urls.length > 0 ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={12}>
|
||||||
|
{urls.map((url, index) => (
|
||||||
|
<div key={`${url}-${index}`} style={{ position: 'relative', width: 96 }}>
|
||||||
|
{isPdf(url) ? (
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePdfOutlined style={{ fontSize: 24, color: '#cf1322' }} />
|
||||||
|
<span>PDF</span>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Image
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => removeAt(index)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
background: 'rgba(255,255,255,0.85)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
accept={accept}
|
||||||
|
multiple
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
disabled={uploading || !canAdd}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canAdd}>
|
||||||
|
{canAdd ? buttonText : '已达上限'}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Empty,
|
||||||
|
Image,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Timeline,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { OrderTrackDto, OrderTrackNodeDto } from '@dukang/shared-types';
|
||||||
|
import { isXfxProviderCode } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { DELIVERY_PROVIDER_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
orderId: string | null;
|
||||||
|
orderNo?: string | null;
|
||||||
|
orderStatus?: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function providerLabel(provider?: string | null, company?: string | null) {
|
||||||
|
if (company) return company;
|
||||||
|
if (!provider) return '—';
|
||||||
|
if (DELIVERY_PROVIDER_LABELS[provider]) return DELIVERY_PROVIDER_LABELS[provider];
|
||||||
|
if (isXfxProviderCode(provider)) return '小飞侠';
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortOldestFirst(nodes: OrderTrackNodeDto[]) {
|
||||||
|
return [...nodes].sort((a, b) => {
|
||||||
|
const ta = new Date(a.createTime).getTime();
|
||||||
|
const tb = new Date(b.createTime).getTime();
|
||||||
|
if (Number.isNaN(ta) && Number.isNaN(tb)) return 0;
|
||||||
|
if (Number.isNaN(ta)) return 1;
|
||||||
|
if (Number.isNaN(tb)) return -1;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderTrackDrawer({
|
||||||
|
open,
|
||||||
|
orderId,
|
||||||
|
orderNo,
|
||||||
|
orderStatus,
|
||||||
|
onClose,
|
||||||
|
}: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [track, setTrack] = useState<OrderTrackDto | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!orderId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<OrderTrackDto>(`/admin/orders/${orderId}/track`);
|
||||||
|
setTrack(data);
|
||||||
|
} catch (e) {
|
||||||
|
setTrack(null);
|
||||||
|
message.error(e instanceof Error ? e.message : '加载路由失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !orderId) {
|
||||||
|
setTrack(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
}, [open, orderId, load]);
|
||||||
|
|
||||||
|
const nodes = useMemo(
|
||||||
|
() => (track?.nodes?.length ? sortOldestFirst(track.nodes) : []),
|
||||||
|
[track?.nodes],
|
||||||
|
);
|
||||||
|
const timelineComplete = ['PENDING_RECEIVE', 'DELIVERED', 'COMPLETED'].includes(
|
||||||
|
orderStatus || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
title="物流路由"
|
||||||
|
width={520}
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
extra={
|
||||||
|
<Button onClick={() => void load()} loading={loading} disabled={!orderId}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
{(orderNo || track?.trackingNo || track?.provider) && (
|
||||||
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
|
{orderNo ? <Descriptions.Item label="订单号">{orderNo}</Descriptions.Item> : null}
|
||||||
|
<Descriptions.Item label="配送方式">
|
||||||
|
{providerLabel(track?.provider, track?.logisticsCompany)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运单号">{track?.trackingNo || '—'}</Descriptions.Item>
|
||||||
|
{track?.manualQueryUrl ? (
|
||||||
|
<Descriptions.Item label="物流查询">
|
||||||
|
<a href={track.manualQueryUrl} target="_blank" rel="noreferrer">
|
||||||
|
打开物流查询
|
||||||
|
</a>
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{track?.estimatedArrival?.arriveTime ? (
|
||||||
|
<Descriptions.Item label="预计送达">
|
||||||
|
{fmtTime(track.estimatedArrival.arriveTime)}
|
||||||
|
{track.estimatedArrival.siteName
|
||||||
|
? `(${track.estimatedArrival.siteName})`
|
||||||
|
: ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{track?.signPhotoUrls && track.signPhotoUrls.length > 0 ? (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
签收照片
|
||||||
|
</Typography.Title>
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap>
|
||||||
|
{track.signPhotoUrls.map((url) => (
|
||||||
|
<Image
|
||||||
|
key={url}
|
||||||
|
src={url}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
) : nodes.some((n) => n.statusName?.includes('签收') || n.trackInfo?.includes('签收')) ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="暂无签收照片"
|
||||||
|
description="小飞侠 100108 未返回图片(可能是本人签收未拍照,或照片已过期)。可在「小飞侠联调」用运单号复测。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
物流动态
|
||||||
|
</Typography.Title>
|
||||||
|
{nodes.length > 0 ? (
|
||||||
|
<Timeline
|
||||||
|
items={nodes.map((node, index) => {
|
||||||
|
const isLatest = index === nodes.length - 1;
|
||||||
|
return {
|
||||||
|
color: isLatest ? (timelineComplete ? 'green' : 'blue') : 'gray',
|
||||||
|
children: (
|
||||||
|
<div>
|
||||||
|
{node.statusName ? (
|
||||||
|
<Typography.Text strong style={{ display: 'block' }}>
|
||||||
|
{node.statusName}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
<Typography.Text style={{ display: 'block', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{node.trackInfo || '—'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{fmtTime(node.createTime)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={
|
||||||
|
track?.queryError
|
||||||
|
? track.queryError
|
||||||
|
: track?.manualQueryUrl
|
||||||
|
? '暂无实时路由节点,可使用上方物流查询链接'
|
||||||
|
: '暂无路由信息,请稍后刷新'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Spin>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type ReactNode } from 'react';
|
||||||
import { Button, Image, Input, Space, Upload, message } from 'antd';
|
import { Button, Image, Input, Modal, Space, Typography, Upload, message } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { EyeOutlined, FilePdfOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadProps } from 'antd';
|
import type { UploadProps } from 'antd';
|
||||||
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType, type UploadFileResult } from '../lib/upload';
|
||||||
|
|
||||||
@@ -13,9 +13,42 @@ type OssUploadProps = {
|
|||||||
accept?: string;
|
accept?: string;
|
||||||
maxSizeMb?: number;
|
maxSizeMb?: number;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
uploadHint?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MAX_MB = 10;
|
function isImageUrl(url: string) {
|
||||||
|
return /\.(png|jpe?g|gif|webp|bmp|svg)(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdfUrl(url: string) {
|
||||||
|
return /\.pdf(\?|#|$)/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUploadImageMessage(result: UploadFileResult): string {
|
||||||
|
const img = result.image;
|
||||||
|
if (!img) return '上传成功';
|
||||||
|
const sizeLabel = `${img.width}×${img.height}`;
|
||||||
|
if (img.compressed && img.originalWidth && img.originalHeight) {
|
||||||
|
const original = `${img.originalWidth}×${img.originalHeight}`;
|
||||||
|
if (original !== sizeLabel) {
|
||||||
|
return `上传成功(${sizeLabel},已由 ${original} 自动压缩)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `上传成功(${sizeLabel})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUploadImageHint(result: UploadFileResult): string | null {
|
||||||
|
const img = result.image;
|
||||||
|
if (!img) return null;
|
||||||
|
const sizeLabel = `${img.width}×${img.height}`;
|
||||||
|
if (img.compressed && img.originalWidth && img.originalHeight) {
|
||||||
|
const original = `${img.originalWidth}×${img.originalHeight}`;
|
||||||
|
if (original !== sizeLabel) {
|
||||||
|
return `${sizeLabel}(已由 ${original} 自动压缩)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sizeLabel;
|
||||||
|
}
|
||||||
|
|
||||||
export default function OssUpload({
|
export default function OssUpload({
|
||||||
value,
|
value,
|
||||||
@@ -24,28 +57,26 @@ export default function OssUpload({
|
|||||||
bizType,
|
bizType,
|
||||||
mediaType = 'IMAGE',
|
mediaType = 'IMAGE',
|
||||||
accept,
|
accept,
|
||||||
maxSizeMb = DEFAULT_MAX_MB,
|
|
||||||
placeholder = '上传后自动填入,或手动粘贴 URL',
|
placeholder = '上传后自动填入,或手动粘贴 URL',
|
||||||
|
uploadHint,
|
||||||
}: OssUploadProps) {
|
}: OssUploadProps) {
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [pdfPreviewOpen, setPdfPreviewOpen] = useState(false);
|
||||||
|
const [imageMetaHint, setImageMetaHint] = useState<string | null>(null);
|
||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? undefined : 'image/*');
|
||||||
|
|
||||||
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
const customRequest: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||||
const raw = file as File;
|
const raw = file as File;
|
||||||
if (raw.size > maxSizeMb * 1024 * 1024) {
|
|
||||||
const err = new Error(`文件不能超过 ${maxSizeMb}MB`);
|
|
||||||
message.error(err.message);
|
|
||||||
onError?.(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
try {
|
try {
|
||||||
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
const result = await uploadFileToOss(raw, { bizType, mediaType });
|
||||||
onChange?.(result.url);
|
onChange?.(result.url);
|
||||||
onUploaded?.(result);
|
onUploaded?.(result);
|
||||||
message.success('上传成功');
|
const successMessage = formatUploadImageMessage(result);
|
||||||
|
setImageMetaHint(formatUploadImageHint(result));
|
||||||
|
message.success(successMessage);
|
||||||
onSuccess?.(result);
|
onSuccess?.(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error('上传失败');
|
const err = e instanceof Error ? e : new Error('上传失败');
|
||||||
@@ -56,6 +87,57 @@ export default function OssUpload({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filePreview =
|
||||||
|
value && mediaType === 'FILE' ? (
|
||||||
|
isImageUrl(value) ? (
|
||||||
|
<Image src={value} width={120} height={120} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
) : isPdfUrl(value) ? (
|
||||||
|
<Space direction="vertical" size={8}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
background: '#fafafa',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
color: '#cf1322',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilePdfOutlined style={{ fontSize: 36 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>PDF 合同</span>
|
||||||
|
</div>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => setPdfPreviewOpen(true)}>
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" href={value} target="_blank" rel="noreferrer">
|
||||||
|
新窗口打开
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
<Modal
|
||||||
|
title="签约合同预览"
|
||||||
|
open={pdfPreviewOpen}
|
||||||
|
onCancel={() => setPdfPreviewOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width="90vw"
|
||||||
|
styles={{ body: { height: '75vh', padding: 0 } }}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<iframe title="合同 PDF 预览" src={value} style={{ width: '100%', height: '100%', border: 0 }} />
|
||||||
|
</Modal>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Button type="link" href={value} target="_blank" rel="noreferrer" style={{ paddingLeft: 0 }}>
|
||||||
|
打开已上传文件
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
{value && mediaType === 'IMAGE' && (
|
{value && mediaType === 'IMAGE' && (
|
||||||
@@ -64,6 +146,17 @@ export default function OssUpload({
|
|||||||
{value && mediaType === 'VIDEO' && (
|
{value && mediaType === 'VIDEO' && (
|
||||||
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
<video src={value} controls style={{ maxWidth: '100%', maxHeight: 160, borderRadius: 4 }} />
|
||||||
)}
|
)}
|
||||||
|
{filePreview}
|
||||||
|
{uploadHint ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{uploadHint}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
{imageMetaHint ? (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
当前底图:{imageMetaHint}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
<Upload
|
<Upload
|
||||||
accept={resolvedAccept}
|
accept={resolvedAccept}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { STORE_PACKAGE_IMAGE_MAX_COUNT } from '@dukang/shared-types';
|
||||||
|
import MultiImageUpload from './MultiImageUpload';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value?: string[];
|
||||||
|
onChange?: (urls: string[]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 套餐多图:批量上传,最多 STORE_PACKAGE_IMAGE_MAX_COUNT 张 */
|
||||||
|
export default function PackageImagesUpload({ value, onChange }: Props) {
|
||||||
|
return (
|
||||||
|
<MultiImageUpload
|
||||||
|
bizType="STORE_PACKAGE"
|
||||||
|
mediaType="IMAGE"
|
||||||
|
maxCount={STORE_PACKAGE_IMAGE_MAX_COUNT}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
tip={`套餐图片最多 ${STORE_PACKAGE_IMAGE_MAX_COUNT} 张,支持批量选择上传,可逐张删除`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Button, Popconfirm, Space, Table, Typography, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types';
|
||||||
|
import { request, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import ActivityPosterDownloadModal from './ActivityPosterDownloadModal';
|
||||||
|
|
||||||
|
function formatNicknameWithRemark(row: PartnerAssocUserItem) {
|
||||||
|
const name = row.nickname?.trim() || '—';
|
||||||
|
const remark = row.hqRemark?.trim();
|
||||||
|
return remark ? `${name}(${remark})` : name;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PartnerAssocPanelProps = {
|
||||||
|
partnerId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) {
|
||||||
|
const [summary, setSummary] = useState<PartnerAssocSummary | null>(null);
|
||||||
|
const [users, setUsers] = useState<PartnerAssocUserItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [issuing, setIssuing] = useState(false);
|
||||||
|
const [canEditAssoc, setCanEditAssoc] = useState(false);
|
||||||
|
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
|
||||||
|
const [posterDownloadOpen, setPosterDownloadOpen] = useState(false);
|
||||||
|
|
||||||
|
const loadSummary = useCallback(async () => {
|
||||||
|
const data = await request<PartnerAssocSummary>(`/admin/partners/${partnerId}/assoc`);
|
||||||
|
setSummary(data);
|
||||||
|
}, [partnerId]);
|
||||||
|
|
||||||
|
const loadUsers = useCallback(async (p = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<Paginated<PartnerAssocUserItem>>(
|
||||||
|
`/admin/partners/${partnerId}/assoc/users?page=${p}&pageSize=20`,
|
||||||
|
);
|
||||||
|
setUsers(res.items ?? []);
|
||||||
|
setTotal(res.total ?? 0);
|
||||||
|
setPage(p);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [partnerId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<HqProfile>('/admin/auth/me')
|
||||||
|
.then((p) => {
|
||||||
|
const keys = p.permissionKeys ?? [];
|
||||||
|
setCanEditAssoc(keys.includes('users_partner_assoc'));
|
||||||
|
setCanDownloadPosters(keys.includes('activity_posters'));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setCanEditAssoc(false);
|
||||||
|
setCanDownloadPosters(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadSummary().catch((e) => message.error(e instanceof Error ? e.message : '加载关联码失败'));
|
||||||
|
void loadUsers(1).catch(() => undefined);
|
||||||
|
}, [loadSummary, loadUsers]);
|
||||||
|
|
||||||
|
async function reissue() {
|
||||||
|
setIssuing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${partnerId}/assoc/qrcode`, { method: 'POST' });
|
||||||
|
message.success('已重新生成关联码');
|
||||||
|
await loadSummary();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '生成失败');
|
||||||
|
} finally {
|
||||||
|
setIssuing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbind(userId: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' });
|
||||||
|
message.success('已解绑');
|
||||||
|
await Promise.all([loadSummary(), loadUsers(page)]);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '解绑失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<PartnerAssocUserItem> = [
|
||||||
|
{ title: '用户编号', dataIndex: 'userNo', width: 140 },
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: 'nickname',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Link
|
||||||
|
className="admin-primary-link"
|
||||||
|
to={`/users?userId=${encodeURIComponent(row.id)}`}
|
||||||
|
title="在用户列表中查看"
|
||||||
|
>
|
||||||
|
{formatNicknameWithRemark(row)}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '手机', dataIndex: 'phone', render: (v) => v || '—' },
|
||||||
|
{ title: '关联时间', dataIndex: 'boundAt', render: (v) => (v ? fmtTime(v) : '—') },
|
||||||
|
{ title: '已付订单', dataIndex: 'orderCount', width: 90 },
|
||||||
|
...(canEditAssoc
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 90,
|
||||||
|
render: (_: unknown, row: PartnerAssocUserItem) => (
|
||||||
|
<Popconfirm title="解绑后该用户新单不再计订单佣金" onConfirm={() => void unbind(row.id)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
解绑
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
} satisfies ColumnsType<PartnerAssocUserItem>[number],
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space align="start" style={{ marginBottom: 16 }} wrap>
|
||||||
|
{summary?.qrcodeUrl ? (
|
||||||
|
<img src={summary.qrcodeUrl} alt="关联码" style={{ width: 160, height: 160, background: '#fff' }} />
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">尚未生成关联码</Typography.Text>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph style={{ marginBottom: 8 }}>
|
||||||
|
<Link to={`/users?assocPartnerAccountId=${encodeURIComponent(partnerId)}`}>
|
||||||
|
关联用户 {summary?.userCount ?? 0} 人
|
||||||
|
</Link>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Button loading={issuing} onClick={() => void reissue()}>
|
||||||
|
{summary?.qrcodeUrl ? '补发关联码' : '生成关联码'}
|
||||||
|
</Button>
|
||||||
|
{canDownloadPosters ? (
|
||||||
|
<Button onClick={() => setPosterDownloadOpen(true)}>下载活动图</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={users}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize: 20,
|
||||||
|
total,
|
||||||
|
onChange: (p) => void loadUsers(p),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActivityPosterDownloadModal
|
||||||
|
open={posterDownloadOpen}
|
||||||
|
partnerIds={[partnerId]}
|
||||||
|
onClose={() => setPosterDownloadOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -48,7 +48,6 @@ export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }:
|
|||||||
{
|
{
|
||||||
title: '权限',
|
title: '权限',
|
||||||
dataIndex: 'permissions',
|
dataIndex: 'permissions',
|
||||||
ellipsis: true,
|
|
||||||
|
|
||||||
render: (p: string[] | undefined) => formatPermissions(p),
|
render: (p: string[] | undefined) => formatPermissions(p),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button, Image, Input, InputNumber, Modal, Radio, Select, Space, Switch, Table, Typography, message,
|
||||||
|
} from 'antd';
|
||||||
|
import { EditOutlined, MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { PRODUCT_STATUS_LABELS } from '../lib/constants';
|
||||||
|
import OssUpload from './OssUpload';
|
||||||
|
|
||||||
|
type SpecValue = { id?: string; name: string; sortOrder?: number };
|
||||||
|
type SpecAttr = { id?: string; name: string; sortOrder?: number; values: SpecValue[] };
|
||||||
|
type SkuRow = {
|
||||||
|
id?: string;
|
||||||
|
specValueIds: string[];
|
||||||
|
skuCode?: string;
|
||||||
|
barcode69: string;
|
||||||
|
price: number;
|
||||||
|
benefitAmount?: number;
|
||||||
|
status: string;
|
||||||
|
allowOnlinePurchase: boolean;
|
||||||
|
allowCrossCityDelivery: boolean;
|
||||||
|
allowOnSitePickup: boolean;
|
||||||
|
saleUnit: 'BOTTLE' | 'BOX';
|
||||||
|
bottlesPerUnit: number;
|
||||||
|
isDefault: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
specText?: string;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function cartesian(attrs: SpecAttr[]): string[][] {
|
||||||
|
if (!attrs.length) return [[]];
|
||||||
|
return attrs.reduce<string[][]>(
|
||||||
|
(acc, attr) => {
|
||||||
|
const next: string[][] = [];
|
||||||
|
const values = attr.values.filter((v) => v.name?.trim());
|
||||||
|
for (const prev of acc) {
|
||||||
|
for (const v of values) {
|
||||||
|
const vid = v.id || `__new__:${attr.name}:${v.name}`;
|
||||||
|
next.push([...prev, vid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next.length ? next : acc;
|
||||||
|
},
|
||||||
|
[[]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyOf(ids: string[]) {
|
||||||
|
return [...ids].sort().join('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSkuRows(initialSkus: SkuRow[]): SkuRow[] {
|
||||||
|
if (!initialSkus.length) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
specValueIds: [],
|
||||||
|
barcode69: '',
|
||||||
|
price: 0,
|
||||||
|
status: 'ON_SALE',
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: true,
|
||||||
|
imageUrl: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return initialSkus.map((s) => ({
|
||||||
|
...s,
|
||||||
|
specValueIds: s.specValueIds ?? [],
|
||||||
|
status: s.status || 'ON_SALE',
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase !== false,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery !== false,
|
||||||
|
allowOnSitePickup: !!s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit === 'BOX' ? 'BOX' : 'BOTTLE',
|
||||||
|
bottlesPerUnit: s.bottlesPerUnit || (s.saleUnit === 'BOX' ? 6 : 1),
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
imageUrl: s.imageUrl ?? '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
productId: string;
|
||||||
|
initialAttrs: SpecAttr[];
|
||||||
|
initialSkus: SkuRow[];
|
||||||
|
onSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ProductSpecsEditor({ productId, initialAttrs, initialSkus, onSaved }: Props) {
|
||||||
|
const [attrs, setAttrs] = useState<SpecAttr[]>(() => (initialAttrs.length ? initialAttrs : []));
|
||||||
|
const [skus, setSkus] = useState<SkuRow[]>(() => normalizeSkuRows(initialSkus));
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [editIndex, setEditIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAttrs(initialAttrs.length ? initialAttrs : []);
|
||||||
|
setSkus(normalizeSkuRows(initialSkus));
|
||||||
|
setEditIndex(null);
|
||||||
|
// 仅切商品时重载,避免父组件重渲染冲掉编辑中内容
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [productId]);
|
||||||
|
|
||||||
|
const combos = useMemo(() => cartesian(attrs.filter((a) => a.name.trim() && a.values.some((v) => v.name.trim()))), [attrs]);
|
||||||
|
|
||||||
|
function regenerateMatrix() {
|
||||||
|
const byKey = new Map(skus.map((s) => [keyOf(s.specValueIds ?? []), s]));
|
||||||
|
const next: SkuRow[] = combos.map((ids, i) => {
|
||||||
|
const existing = byKey.get(keyOf(ids));
|
||||||
|
if (existing) return { ...existing, specValueIds: ids, sortOrder: i };
|
||||||
|
return {
|
||||||
|
specValueIds: ids,
|
||||||
|
barcode69: '',
|
||||||
|
price: skus[0]?.price ?? 0,
|
||||||
|
benefitAmount: skus[0]?.benefitAmount,
|
||||||
|
status: 'DRAFT',
|
||||||
|
allowOnlinePurchase: true,
|
||||||
|
allowCrossCityDelivery: true,
|
||||||
|
allowOnSitePickup: false,
|
||||||
|
saleUnit: 'BOTTLE',
|
||||||
|
bottlesPerUnit: 1,
|
||||||
|
isDefault: i === 0,
|
||||||
|
sortOrder: i,
|
||||||
|
imageUrl: '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (next.length && !next.some((s) => s.isDefault)) next[0].isDefault = true;
|
||||||
|
setSkus(next.length ? next : skus);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchSku(index: number, patch: Partial<SkuRow>) {
|
||||||
|
setSkus((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[index] = { ...next[index], ...patch };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 1) 保存规格轴(新建值尚无 id,先提交 attrs)
|
||||||
|
const specsRes = await request<{
|
||||||
|
specAttrs: SpecAttr[];
|
||||||
|
skus: SkuRow[];
|
||||||
|
}>(`/admin/products/${productId}/specs`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
attrs: attrs.map((a, i) => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name.trim(),
|
||||||
|
sortOrder: i,
|
||||||
|
values: a.values
|
||||||
|
.filter((v) => v.name.trim())
|
||||||
|
.map((v, j) => ({ id: v.id, name: v.name.trim(), sortOrder: j })),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 映射临时 id → 真实 id
|
||||||
|
const nameToId = new Map<string, string>();
|
||||||
|
for (const a of specsRes.specAttrs ?? []) {
|
||||||
|
for (const v of a.values ?? []) {
|
||||||
|
nameToId.set(`${a.name}:${v.name}`, v.id!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolveIds = (ids: string[]) =>
|
||||||
|
ids.map((id) => {
|
||||||
|
if (!id.startsWith('__new__:')) return id;
|
||||||
|
const [, attrName, valueName] = id.split(':');
|
||||||
|
const real = nameToId.get(`${attrName}:${valueName}`);
|
||||||
|
if (!real) throw new Error(`规格值未创建:${attrName}/${valueName}`);
|
||||||
|
return real;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payloadSkus = skus.map((s, i) => ({
|
||||||
|
id: s.id,
|
||||||
|
specValueIds: resolveIds(s.specValueIds ?? []),
|
||||||
|
barcode69: s.barcode69.trim(),
|
||||||
|
price: s.price,
|
||||||
|
benefitAmount: s.benefitAmount,
|
||||||
|
status: s.status,
|
||||||
|
allowOnlinePurchase: s.allowOnlinePurchase,
|
||||||
|
allowCrossCityDelivery: s.allowCrossCityDelivery,
|
||||||
|
allowOnSitePickup: s.allowOnSitePickup,
|
||||||
|
saleUnit: s.saleUnit,
|
||||||
|
bottlesPerUnit: s.saleUnit === 'BOX' ? s.bottlesPerUnit || 6 : 1,
|
||||||
|
isDefault: !!s.isDefault,
|
||||||
|
sortOrder: i,
|
||||||
|
imageUrl: s.imageUrl?.trim() || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const barcodes = payloadSkus.map((row) => row.barcode69);
|
||||||
|
if (barcodes.some((b) => !b)) throw new Error('每个规格须填写独立 69 码');
|
||||||
|
if (new Set(barcodes).size !== barcodes.length) {
|
||||||
|
throw new Error('同一商品内 69 码不可重复,每个规格请填写不同 69 码');
|
||||||
|
}
|
||||||
|
|
||||||
|
await request(`/admin/products/${productId}/skus`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ skus: payloadSkus }),
|
||||||
|
});
|
||||||
|
message.success('规格与 SKU 已保存');
|
||||||
|
onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editing = editIndex != null ? skus[editIndex] : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
SKU 码由系统自动生成(DK 开头),无需填写。每个规格必须填写<strong>互不相同</strong>的 69 码,并可单独上传主图。
|
||||||
|
点「填写」在弹窗中编辑。先配置销售规格轴(如「包装」),再生成 SKU 矩阵。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Typography.Title level={5}>规格轴</Typography.Title>
|
||||||
|
{attrs.map((attr, ai) => (
|
||||||
|
<div key={ai} style={{ marginBottom: 12, padding: 12, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||||
|
<Space align="start" style={{ width: '100%' }} wrap>
|
||||||
|
<Input
|
||||||
|
style={{ width: 140 }}
|
||||||
|
placeholder="规格名"
|
||||||
|
value={attr.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, name: e.target.value };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<MinusCircleOutlined />}
|
||||||
|
onClick={() => setAttrs(attrs.filter((_, i) => i !== ai))}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{attr.values.map((val, vi) => (
|
||||||
|
<Space key={vi} style={{ display: 'flex', marginBottom: 6 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="规格值"
|
||||||
|
value={val.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...attrs];
|
||||||
|
const values = [...attr.values];
|
||||||
|
values[vi] = { ...val, name: e.target.value };
|
||||||
|
next[ai] = { ...attr, values };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
danger
|
||||||
|
icon={<MinusCircleOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, values: attr.values.filter((_, j) => j !== vi) };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const next = [...attrs];
|
||||||
|
next[ai] = { ...attr, values: [...attr.values, { name: '' }] };
|
||||||
|
setAttrs(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
添加规格值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
disabled={attrs.length >= 3}
|
||||||
|
onClick={() => setAttrs([...attrs, { name: '', values: [{ name: '' }] }])}
|
||||||
|
>
|
||||||
|
添加规格轴
|
||||||
|
</Button>
|
||||||
|
<Button onClick={regenerateMatrix}>按规格生成 SKU 矩阵</Button>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Typography.Title level={5}>SKU 列表</Typography.Title>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey={(_, i) => String(i)}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: 1240 }}
|
||||||
|
dataSource={skus}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '规格',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) => row.specText || (row.specValueIds?.length ? row.specValueIds.join(',') : '默认'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SKU',
|
||||||
|
width: 110,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{row.skuCode || '保存后自动生成'}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <span>69码 <Typography.Text type="danger">*</Typography.Text></span>,
|
||||||
|
width: 180,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Input
|
||||||
|
placeholder="本规格独立 69 码"
|
||||||
|
value={row.barcode69}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, barcode69: e.target.value };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '售价',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.price}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, price: Number(v) || 0 };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '权益',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.benefitAmount}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, benefitAmount: v == null ? undefined : Number(v) };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
width: 110,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={row.status}
|
||||||
|
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
onChange={(status) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, status };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '单位',
|
||||||
|
width: 160,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
style={{ width: 80 }}
|
||||||
|
value={row.saleUnit}
|
||||||
|
options={[
|
||||||
|
{ value: 'BOTTLE', label: '瓶' },
|
||||||
|
{ value: 'BOX', label: '箱' },
|
||||||
|
]}
|
||||||
|
onChange={(saleUnit) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = {
|
||||||
|
...row,
|
||||||
|
saleUnit,
|
||||||
|
bottlesPerUnit: saleUnit === 'BOX' ? row.bottlesPerUnit || 6 : 1,
|
||||||
|
};
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{row.saleUnit === 'BOX' ? (
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
style={{ width: 70 }}
|
||||||
|
value={row.bottlesPerUnit}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, bottlesPerUnit: Number(v) || 6 };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '履约',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="线上"
|
||||||
|
unCheckedChildren="线上"
|
||||||
|
checked={row.allowOnlinePurchase}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = {
|
||||||
|
...row,
|
||||||
|
allowOnlinePurchase: checked,
|
||||||
|
allowCrossCityDelivery: checked ? row.allowCrossCityDelivery : false,
|
||||||
|
};
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="跨城"
|
||||||
|
unCheckedChildren="跨城"
|
||||||
|
disabled={!row.allowOnlinePurchase}
|
||||||
|
checked={row.allowCrossCityDelivery}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, allowCrossCityDelivery: checked };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="现场"
|
||||||
|
unCheckedChildren="现场"
|
||||||
|
checked={row.allowOnSitePickup}
|
||||||
|
onChange={(checked) => {
|
||||||
|
const next = [...skus];
|
||||||
|
next[index] = { ...row, allowOnSitePickup: checked };
|
||||||
|
setSkus(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '默认',
|
||||||
|
width: 70,
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Radio
|
||||||
|
checked={row.isDefault}
|
||||||
|
onChange={() => {
|
||||||
|
setSkus(skus.map((s, i) => ({ ...s, isDefault: i === index })));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主图',
|
||||||
|
width: 72,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.imageUrl ? (
|
||||||
|
<Image src={row.imageUrl} width={40} height={40} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '编辑',
|
||||||
|
width: 80,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row, index) => (
|
||||||
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => setEditIndex(index)}>
|
||||||
|
填写
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type="primary" loading={saving} style={{ marginTop: 16 }} onClick={() => void handleSave()}>
|
||||||
|
保存规格与 SKU
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`编辑规格${editing?.specText ? ` · ${editing.specText}` : editing?.skuCode ? ` · ${editing.skuCode}` : ''}`}
|
||||||
|
open={editIndex != null}
|
||||||
|
onCancel={() => setEditIndex(null)}
|
||||||
|
onOk={() => setEditIndex(null)}
|
||||||
|
okText="完成"
|
||||||
|
width={560}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{editing && editIndex != null ? (
|
||||||
|
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||||
|
<div>
|
||||||
|
<Typography.Text type="secondary">SKU</Typography.Text>
|
||||||
|
<div>{editing.skuCode || '保存后自动生成'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>69 码 <Typography.Text type="danger">*</Typography.Text></Typography.Text>
|
||||||
|
<Input
|
||||||
|
placeholder="本规格独立 69 码"
|
||||||
|
value={editing.barcode69}
|
||||||
|
onChange={(e) => patchSku(editIndex, { barcode69: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>主图</Typography.Text>
|
||||||
|
<OssUpload
|
||||||
|
bizType="COVER"
|
||||||
|
value={editing.imageUrl || ''}
|
||||||
|
onChange={(url) => patchSku(editIndex, { imageUrl: url })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>售价</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={editing.price}
|
||||||
|
onChange={(v) => patchSku(editIndex, { price: Number(v) || 0 })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>权益</Typography.Text>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
value={editing.benefitAmount}
|
||||||
|
onChange={(v) => patchSku(editIndex, { benefitAmount: v == null ? undefined : Number(v) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<div>
|
||||||
|
<Typography.Text>状态</Typography.Text>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={editing.status}
|
||||||
|
options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
onChange={(status) => patchSku(editIndex, { status })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Space>
|
||||||
|
<Select
|
||||||
|
style={{ width: 100 }}
|
||||||
|
value={editing.saleUnit}
|
||||||
|
options={[
|
||||||
|
{ value: 'BOTTLE', label: '瓶' },
|
||||||
|
{ value: 'BOX', label: '箱' },
|
||||||
|
]}
|
||||||
|
onChange={(saleUnit) =>
|
||||||
|
patchSku(editIndex, {
|
||||||
|
saleUnit,
|
||||||
|
bottlesPerUnit: saleUnit === 'BOX' ? editing.bottlesPerUnit || 6 : 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{editing.saleUnit === 'BOX' ? (
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
value={editing.bottlesPerUnit}
|
||||||
|
onChange={(v) => patchSku(editIndex, { bottlesPerUnit: Number(v) || 6 })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
<Space>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="线上"
|
||||||
|
unCheckedChildren="线上"
|
||||||
|
checked={editing.allowOnlinePurchase}
|
||||||
|
onChange={(checked) =>
|
||||||
|
patchSku(editIndex, {
|
||||||
|
allowOnlinePurchase: checked,
|
||||||
|
allowCrossCityDelivery: checked ? editing.allowCrossCityDelivery : false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="跨城"
|
||||||
|
unCheckedChildren="跨城"
|
||||||
|
disabled={!editing.allowOnlinePurchase}
|
||||||
|
checked={editing.allowCrossCityDelivery}
|
||||||
|
onChange={(checked) => patchSku(editIndex, { allowCrossCityDelivery: checked })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="现场"
|
||||||
|
unCheckedChildren="现场"
|
||||||
|
checked={editing.allowOnSitePickup}
|
||||||
|
onChange={(checked) => patchSku(editIndex, { allowOnSitePickup: checked })}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="默认"
|
||||||
|
unCheckedChildren="默认"
|
||||||
|
checked={editing.isDefault}
|
||||||
|
onChange={(checked) => {
|
||||||
|
if (!checked) return;
|
||||||
|
setSkus((prev) => prev.map((s, i) => ({ ...s, isDefault: i === editIndex })));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
QRCode,
|
||||||
|
Radio,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type {
|
||||||
|
HqProxyOrderCreateRequest,
|
||||||
|
PartnerProxyDeliveryMode,
|
||||||
|
PartnerProxyOrderOptions,
|
||||||
|
PartnerProxyOrderPreviewResult,
|
||||||
|
ProxyOrderCreateResponse,
|
||||||
|
ProxyOrderPayResponse,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import ChinaRegionCascader from './ChinaRegionCascader';
|
||||||
|
import { parseRegionCodes } from '../lib/china-region';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type ProxyOrderModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (order: { id: string; orderNo: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PayStatus = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
payStatus: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtMoney(n: number) {
|
||||||
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProxyOrderModal({ open, onClose, onSuccess }: ProxyOrderModalProps) {
|
||||||
|
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
|
||||||
|
const [loadingOptions, setLoadingOptions] = useState(false);
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [receiverName, setReceiverName] = useState('');
|
||||||
|
const [regionCodes, setRegionCodes] = useState<string[]>([]);
|
||||||
|
const [addressDetail, setAddressDetail] = useState('');
|
||||||
|
const [productId, setProductId] = useState<string>();
|
||||||
|
const [skuId, setSkuId] = useState<string>();
|
||||||
|
const [quantity, setQuantity] = useState(2);
|
||||||
|
const [promoCodeId, setPromoCodeId] = useState<string>();
|
||||||
|
const [assocPartnerAccountId, setAssocPartnerAccountId] = useState<string>();
|
||||||
|
const [deliveryMode, setDeliveryMode] = useState<PartnerProxyDeliveryMode>('ADDRESS');
|
||||||
|
const [autoReceive, setAutoReceive] = useState(false);
|
||||||
|
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [step, setStep] = useState<'form' | 'pay'>('form');
|
||||||
|
const [created, setCreated] = useState<ProxyOrderCreateResponse | null>(null);
|
||||||
|
const [codeUrl, setCodeUrl] = useState<string | null>(null);
|
||||||
|
const [payLoading, setPayLoading] = useState(false);
|
||||||
|
const pollRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
|
||||||
|
|
||||||
|
function stopPoll() {
|
||||||
|
if (pollRef.current != null) {
|
||||||
|
window.clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setLoadingOptions(true);
|
||||||
|
request<PartnerProxyOrderOptions>('/admin/proxy-orders/options')
|
||||||
|
.then((data) => {
|
||||||
|
setOptions(data);
|
||||||
|
if (data.products[0]) setProductId(data.products[0].id);
|
||||||
|
})
|
||||||
|
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
.finally(() => setLoadingOptions(false));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const selectedProduct = options?.products.find((p) => p.id === productId);
|
||||||
|
const skuOptions = (selectedProduct?.skus ?? []).filter((s) => s.status === 'ON_SALE');
|
||||||
|
const selectedSku =
|
||||||
|
skuOptions.find((s) => s.id === skuId) ||
|
||||||
|
skuOptions.find((s) => s.id === selectedProduct?.defaultSkuId) ||
|
||||||
|
skuOptions[0];
|
||||||
|
const allowOnline = selectedSku
|
||||||
|
? selectedSku.allowOnlinePurchase !== false
|
||||||
|
: selectedProduct
|
||||||
|
? selectedProduct.allowOnlinePurchase !== false
|
||||||
|
: true;
|
||||||
|
const allowOnSite = selectedSku
|
||||||
|
? !!selectedSku.allowOnSitePickup
|
||||||
|
: !!selectedProduct?.allowOnSitePickup;
|
||||||
|
const allowCrossCity = selectedSku
|
||||||
|
? selectedSku.allowCrossCityDelivery !== false
|
||||||
|
: selectedProduct
|
||||||
|
? selectedProduct.allowCrossCityDelivery !== false
|
||||||
|
: true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !selectedProduct) return;
|
||||||
|
const def =
|
||||||
|
skuOptions.find((s) => s.id === selectedProduct.defaultSkuId) ||
|
||||||
|
skuOptions.find((s) => s.isDefault) ||
|
||||||
|
skuOptions[0];
|
||||||
|
if (def && skuId !== def.id && (!skuId || !skuOptions.some((s) => s.id === skuId))) {
|
||||||
|
setSkuId(def.id);
|
||||||
|
setQuantity(def.saleUnit === 'BOX' ? 1 : Math.max(quantity, 2));
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, productId, selectedProduct?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !selectedProduct) return;
|
||||||
|
if (!allowOnline && allowOnSite && deliveryMode !== 'ON_SITE_PICKUP') {
|
||||||
|
setDeliveryMode('ON_SITE_PICKUP');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (allowOnline && !allowOnSite && deliveryMode !== 'ADDRESS') {
|
||||||
|
setDeliveryMode('ADDRESS');
|
||||||
|
}
|
||||||
|
}, [open, selectedProduct, allowOnline, allowOnSite, deliveryMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !productId || quantity < 1 || step !== 'form') {
|
||||||
|
if (step === 'form') setPreview(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setPreviewLoading(true);
|
||||||
|
request<PartnerProxyOrderPreviewResult>('/admin/proxy-orders/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
productId,
|
||||||
|
quantity,
|
||||||
|
deliveryMode,
|
||||||
|
skuId: skuId || undefined,
|
||||||
|
receiverCity: deliveryMode === 'ADDRESS' ? region?.city || undefined : undefined,
|
||||||
|
receiverDistrict: deliveryMode === 'ADDRESS' ? region?.district || undefined : undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(setPreview)
|
||||||
|
.catch(() => setPreview(null))
|
||||||
|
.finally(() => setPreviewLoading(false));
|
||||||
|
}, 300);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [open, productId, skuId, quantity, deliveryMode, region?.city, region?.district, step]);
|
||||||
|
|
||||||
|
useEffect(() => () => stopPoll(), []);
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
stopPoll();
|
||||||
|
setPhone('');
|
||||||
|
setReceiverName('');
|
||||||
|
setRegionCodes([]);
|
||||||
|
setAddressDetail('');
|
||||||
|
setQuantity(2);
|
||||||
|
setProductId(undefined);
|
||||||
|
setSkuId(undefined);
|
||||||
|
setPromoCodeId(undefined);
|
||||||
|
setAssocPartnerAccountId(undefined);
|
||||||
|
setDeliveryMode('ADDRESS');
|
||||||
|
setAutoReceive(false);
|
||||||
|
setPreview(null);
|
||||||
|
setProductId(options?.products[0]?.id);
|
||||||
|
setStep('form');
|
||||||
|
setCreated(null);
|
||||||
|
setCodeUrl(null);
|
||||||
|
setPayLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
resetForm();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm(): string | null {
|
||||||
|
if (!/^1\d{10}$/.test(phone.trim())) return '请输入有效手机号';
|
||||||
|
if (!productId) return '请选择商品';
|
||||||
|
if (deliveryMode === 'ADDRESS') {
|
||||||
|
if (!allowOnline) return '该商品不支持线上购买';
|
||||||
|
if (!region?.province || !region?.city || !region?.district) return '请选择省市区';
|
||||||
|
if (!addressDetail.trim()) return '请填写详细地址';
|
||||||
|
if (!autoReceive) return '配送到址须勾选同意自动收货';
|
||||||
|
} else if (!allowOnSite) {
|
||||||
|
return '该商品不支持现场提货';
|
||||||
|
}
|
||||||
|
if (!preview) return '请等待费用计算完成';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPoll(orderId: string) {
|
||||||
|
stopPoll();
|
||||||
|
pollRef.current = window.setInterval(() => {
|
||||||
|
void request<PayStatus>(`/admin/proxy-orders/${orderId}/pay-status`)
|
||||||
|
.then((st) => {
|
||||||
|
if (st.payStatus === 'PAID') {
|
||||||
|
stopPoll();
|
||||||
|
message.success(`支付成功:${st.orderNo}`);
|
||||||
|
const payload = { id: st.id, orderNo: st.orderNo };
|
||||||
|
resetForm();
|
||||||
|
onSuccess(payload);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const err = validateForm();
|
||||||
|
if (err) {
|
||||||
|
message.warning(err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: HqProxyOrderCreateRequest = {
|
||||||
|
phone: phone.trim(),
|
||||||
|
deliveryMode,
|
||||||
|
autoReceive: deliveryMode === 'ADDRESS' ? true : undefined,
|
||||||
|
receiverName: receiverName.trim() || undefined,
|
||||||
|
province: deliveryMode === 'ADDRESS' ? region?.province : undefined,
|
||||||
|
city: deliveryMode === 'ADDRESS' ? region?.city : undefined,
|
||||||
|
district: deliveryMode === 'ADDRESS' ? region?.district : undefined,
|
||||||
|
addressDetail: deliveryMode === 'ADDRESS' ? addressDetail.trim() : undefined,
|
||||||
|
productId: productId!,
|
||||||
|
quantity,
|
||||||
|
promoCodeId: promoCodeId || undefined,
|
||||||
|
skuId: skuId || undefined,
|
||||||
|
assocPartnerAccountId: assocPartnerAccountId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const order = await request<ProxyOrderCreateResponse>('/admin/proxy-orders', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
setCreated(order);
|
||||||
|
setStep('pay');
|
||||||
|
setPayLoading(true);
|
||||||
|
const pay = await request<ProxyOrderPayResponse>(`/admin/proxy-orders/${order.id}/pay`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ payMethod: 'NATIVE' }),
|
||||||
|
});
|
||||||
|
if (pay.mode === 'mock') {
|
||||||
|
message.success(`支付成功:${order.orderNo}`);
|
||||||
|
resetForm();
|
||||||
|
onSuccess({ id: order.id, orderNo: order.orderNo });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCodeUrl(pay.codeUrl ?? null);
|
||||||
|
startPoll(order.id);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '下单失败');
|
||||||
|
setStep('form');
|
||||||
|
setCreated(null);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
setPayLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveryLabel =
|
||||||
|
preview?.deliveryType === 'ON_SITE_PICKUP'
|
||||||
|
? '现场提货'
|
||||||
|
: preview?.deliveryType === 'CROSS_CITY'
|
||||||
|
? '跨城配送'
|
||||||
|
: '同城配送';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={step === 'pay' ? '代下单 · 待支付' : '代下单'}
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
width={640}
|
||||||
|
destroyOnClose
|
||||||
|
footer={
|
||||||
|
step === 'pay' ? (
|
||||||
|
<Space>
|
||||||
|
<Button onClick={handleClose}>关闭</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space>
|
||||||
|
<Button onClick={handleClose}>取消</Button>
|
||||||
|
<Button type="primary" loading={submitting || loadingOptions} onClick={() => void submit()}>
|
||||||
|
提交并收款
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{step === 'pay' && created ? (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16, textAlign: 'left' }}
|
||||||
|
message={`订单 ${created.orderNo} 待支付 ¥${fmtMoney(created.payAmount)},请扫码支付`}
|
||||||
|
description={
|
||||||
|
created.deliveryType === 'ON_SITE_PICKUP'
|
||||||
|
? '支付完成后订单即为已完成,并发放好客权益'
|
||||||
|
: '支付完成后将进入待发货,由总部履约发货'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{payLoading && !codeUrl ? (
|
||||||
|
<Typography.Text type="secondary">正在生成收款码…</Typography.Text>
|
||||||
|
) : codeUrl ? (
|
||||||
|
<Space direction="vertical" size={12} align="center">
|
||||||
|
<QRCode value={codeUrl} size={200} />
|
||||||
|
<Typography.Text type="secondary">请使用微信扫一扫完成支付,成功后自动关闭</Typography.Text>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all' }}>
|
||||||
|
{codeUrl}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="danger">收款码生成失败,请关闭后重试</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="提交后生成微信收款码,支付成功后发放好客权益;配送单进入待发货"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form layout="vertical">
|
||||||
|
<Form.Item label="用户手机号" required>
|
||||||
|
<Input
|
||||||
|
placeholder="11 位手机号"
|
||||||
|
value={phone}
|
||||||
|
maxLength={11}
|
||||||
|
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="酒品" required>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
loading={loadingOptions}
|
||||||
|
placeholder="选择商品"
|
||||||
|
value={productId}
|
||||||
|
onChange={(id) => {
|
||||||
|
setProductId(id);
|
||||||
|
setSkuId(undefined);
|
||||||
|
}}
|
||||||
|
options={(options?.products ?? []).map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.name} · ${p.spec} · ¥${fmtMoney(p.price)}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{skuOptions.length > 1 || selectedProduct?.specEnabled ? (
|
||||||
|
<Form.Item label="规格" required>
|
||||||
|
<Select
|
||||||
|
placeholder="选择规格"
|
||||||
|
value={skuId}
|
||||||
|
onChange={(id) => {
|
||||||
|
setSkuId(id);
|
||||||
|
const sku = skuOptions.find((s) => s.id === id);
|
||||||
|
if (sku?.saleUnit === 'BOX') setQuantity(1);
|
||||||
|
}}
|
||||||
|
options={skuOptions.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.specText || '默认'} · ¥${fmtMoney(s.price)} · ${s.saleUnit === 'BOX' ? `${s.bottlesPerUnit}瓶/箱` : '瓶'}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Form.Item label={selectedSku?.saleUnit === 'BOX' ? '数量(箱)' : '数量(瓶)'} required>
|
||||||
|
<InputNumber
|
||||||
|
min={1}
|
||||||
|
value={quantity}
|
||||||
|
onChange={(v) => setQuantity(Math.max(1, Number(v) || 1))}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="关联合伙人(选填)">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="不选则本单不计订单佣金"
|
||||||
|
value={assocPartnerAccountId}
|
||||||
|
onChange={setAssocPartnerAccountId}
|
||||||
|
options={(options?.partners ?? []).map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.companyName || p.name}${p.phone ? ` · ${p.phone}` : ''}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{(options?.promoCodes.length ?? 0) > 0 ? (
|
||||||
|
<Form.Item label="绑定推广码(选填)">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="不绑定"
|
||||||
|
value={promoCodeId}
|
||||||
|
onChange={setPromoCodeId}
|
||||||
|
options={(options?.promoCodes ?? []).map((p) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: `${p.code} · ${p.name}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Form.Item label="履约方式" required>
|
||||||
|
{allowOnline && allowOnSite ? (
|
||||||
|
<Radio.Group
|
||||||
|
value={deliveryMode}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDeliveryMode(e.target.value);
|
||||||
|
if (e.target.value !== 'ADDRESS') setAutoReceive(false);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'ADDRESS', label: '配送到址' },
|
||||||
|
{ value: 'ON_SITE_PICKUP', label: '现场提货' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{!allowOnline && allowOnSite
|
||||||
|
? '该商品仅支持现场提货'
|
||||||
|
: allowOnline && !allowOnSite
|
||||||
|
? allowCrossCity
|
||||||
|
? '该商品仅支持配送到址(含跨城)'
|
||||||
|
: '该商品仅支持配送到址(不可跨城)'
|
||||||
|
: '该商品暂无可选履约方式'}
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{deliveryMode === 'ADDRESS' && allowOnline ? (
|
||||||
|
<>
|
||||||
|
<Form.Item label="收货人(选填)">
|
||||||
|
<Input
|
||||||
|
placeholder="默认:用户+手机尾号"
|
||||||
|
value={receiverName}
|
||||||
|
maxLength={32}
|
||||||
|
onChange={(e) => setReceiverName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="省市区" required>
|
||||||
|
<ChinaRegionCascader value={regionCodes} onChange={setRegionCodes} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="详细地址" required>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={2}
|
||||||
|
placeholder="街道门牌等"
|
||||||
|
value={addressDetail}
|
||||||
|
maxLength={256}
|
||||||
|
onChange={(e) => setAddressDetail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Checkbox checked={autoReceive} onChange={(e) => setAutoReceive(e.target.checked)}>
|
||||||
|
同意自动收货(配送到址必选)
|
||||||
|
</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={{ background: '#fafafa', borderRadius: 8, padding: 12 }}>
|
||||||
|
<Typography.Text type="secondary">费用预览</Typography.Text>
|
||||||
|
{previewLoading ? (
|
||||||
|
<div>计算中…</div>
|
||||||
|
) : preview ? (
|
||||||
|
<Space direction="vertical" size={2} style={{ width: '100%', marginTop: 8 }}>
|
||||||
|
<div>履约:{deliveryLabel}</div>
|
||||||
|
<div>商品金额:¥{fmtMoney(preview.productAmount)}</div>
|
||||||
|
<div>实付:¥{fmtMoney(preview.payAmount)}</div>
|
||||||
|
<div>好客权益:¥{fmtMoney(preview.benefitAmount)}</div>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Typography.Text type="secondary">请完善商品与数量</Typography.Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { Descriptions } from 'antd';
|
||||||
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
function maskPhone(phone: string | null | undefined) {
|
||||||
|
if (!phone || phone.length < 7) return phone ?? '—';
|
||||||
|
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
/** 列表页已脱敏展示时可设为 false,详情抽屉展示完整手机号 */
|
||||||
|
maskUserPhone?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RedeemRecordDetailDescriptions({ detail, maskUserPhone = false }: Props) {
|
||||||
|
const user = detail.user as
|
||||||
|
| {
|
||||||
|
id?: string;
|
||||||
|
userNo?: string;
|
||||||
|
nickname?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
hqRemark?: string | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const store = detail.store as
|
||||||
|
| {
|
||||||
|
name?: string;
|
||||||
|
cityName?: string;
|
||||||
|
address?: string;
|
||||||
|
partnerAccount?: { companyName?: string };
|
||||||
|
primaryAccount?: {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankAccountNo?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
const coupon = detail.coupon as { couponNo?: string; order?: { orderNo?: string } } | undefined;
|
||||||
|
const channel = detail.channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="核销号">{String(detail.redeemNo ?? '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="方式">{REDEEM_CHANNEL_LABELS[channel as RedeemChannel]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销额">¥{Number(detail.amount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结算额">¥{Number(detail.settleAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt ?? ''))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户编号">{user?.userNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户昵称">
|
||||||
|
{(() => {
|
||||||
|
const name = user?.nickname?.trim() || '—';
|
||||||
|
const remark = user?.hqRemark?.trim();
|
||||||
|
return remark ? `${name}(${remark})` : name;
|
||||||
|
})()}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="用户手机">
|
||||||
|
{maskUserPhone ? maskPhone(user?.phone) : user?.phone || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店">
|
||||||
|
{store?.name ?? '—'}
|
||||||
|
{store?.cityName ? `(${store.cityName})` : ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店地址">{store?.address ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="合伙人">{store?.partnerAccount?.companyName ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收款户名">
|
||||||
|
{store?.primaryAccount?.bankAccountName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收款账号">
|
||||||
|
{store?.primaryAccount?.bankAccountNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户行">
|
||||||
|
{store?.primaryAccount?.bankBranch || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="权益券号">{coupon?.couponNo ?? '—'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联订单">{coupon?.order?.orderNo ?? '—'}</Descriptions.Item>
|
||||||
|
{Array.isArray(detail.allocations) && (detail.allocations as unknown[]).length > 0 ? (
|
||||||
|
<Descriptions.Item label="券分摊">
|
||||||
|
{(
|
||||||
|
detail.allocations as Array<{
|
||||||
|
couponNo?: string;
|
||||||
|
orderNo?: string | null;
|
||||||
|
amount?: 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}
|
||||||
|
{detail.payout ? (
|
||||||
|
<Descriptions.Item label="门店结算单">
|
||||||
|
¥{Number((detail.payout as { settleAmount?: number }).settleAmount ?? 0).toFixed(2)}
|
||||||
|
{' / '}
|
||||||
|
{String((detail.payout as { status?: string }).status ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
{detail.rating ? (
|
||||||
|
<Descriptions.Item label="评价">
|
||||||
|
服务 {(detail.rating as { serviceScore?: number }).serviceScore ?? '—'} 分 / 环境{' '}
|
||||||
|
{(detail.rating as { envScore?: number }).envScore ?? '—'} 分
|
||||||
|
{(detail.rating as { comment?: string | null }).comment
|
||||||
|
? ` · ${(detail.rating as { comment?: string }).comment}`
|
||||||
|
: ''}
|
||||||
|
</Descriptions.Item>
|
||||||
|
) : null}
|
||||||
|
</Descriptions>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { StoreBankAccountDto } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
type Props = { storeId: string };
|
||||||
|
|
||||||
|
type FormValues = { bankAccountName: string; bankAccountNo: string; bankBranch?: string };
|
||||||
|
|
||||||
|
export default function StoreBankAccountsPanel({ storeId }: Props) {
|
||||||
|
const [form] = Form.useForm<FormValues>();
|
||||||
|
const [items, setItems] = useState<StoreBankAccountDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<StoreBankAccountDto[]>(`/admin/stores/${storeId}/bank-accounts`);
|
||||||
|
setItems(res ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [storeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditingId(null);
|
||||||
|
form.resetFields();
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(item: StoreBankAccountDto) {
|
||||||
|
setEditingId(item.id);
|
||||||
|
form.setFieldsValue({
|
||||||
|
bankAccountName: item.bankAccountName,
|
||||||
|
bankAccountNo: item.bankAccountNo,
|
||||||
|
bankBranch: item.bankBranch ?? '',
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const v = await form.validateFields();
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const body = JSON.stringify({
|
||||||
|
bankAccountName: v.bankAccountName.trim(),
|
||||||
|
bankAccountNo: v.bankAccountNo.replace(/\s+/g, ''),
|
||||||
|
bankBranch: v.bankBranch?.trim() || undefined,
|
||||||
|
});
|
||||||
|
if (editingId) {
|
||||||
|
await request(`/admin/stores/${storeId}/bank-accounts/${editingId}`, { method: 'PUT', body });
|
||||||
|
} else {
|
||||||
|
await request(`/admin/stores/${storeId}/bank-accounts`, { method: 'POST', body });
|
||||||
|
}
|
||||||
|
message.success('已保存');
|
||||||
|
setModalOpen(false);
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setDefault(id: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/stores/${storeId}/bank-accounts/${id}/default`, { method: 'POST' });
|
||||||
|
message.success('已设为默认打款账户');
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '设置失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/stores/${storeId}/bank-accounts/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<StoreBankAccountDto> = [
|
||||||
|
{ title: '收款人', dataIndex: 'bankAccountName', width: 140 },
|
||||||
|
{ title: '银行账号', dataIndex: 'bankAccountNo', width: 180 },
|
||||||
|
{ title: '开户行', dataIndex: 'bankBranch', render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '默认',
|
||||||
|
dataIndex: 'isDefault',
|
||||||
|
width: 80,
|
||||||
|
render: (v) => (v ? <Tag color="green">默认</Tag> : null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 220,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0} wrap>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||||
|
{!row.isDefault ? (
|
||||||
|
<Button type="link" size="small" onClick={() => void setDefault(row.id)}>设为默认</Button>
|
||||||
|
) : null}
|
||||||
|
{!row.isDefault ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该收款账户?"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void remove(row.id)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#888' }}>
|
||||||
|
每门店可维护多个收款账户,标记「默认」的账户用于核销结算与提现打款;切换即时生效、无需重启服务。
|
||||||
|
</span>
|
||||||
|
<Button type="primary" size="small" onClick={openCreate}>新增账户</Button>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={editingId ? '编辑收款账户' : '新增收款账户'}
|
||||||
|
open={modalOpen}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => void submit()}
|
||||||
|
okText="保存"
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="bankAccountName"
|
||||||
|
label="收款人"
|
||||||
|
rules={[{ required: true, message: '请填写收款人' }]}
|
||||||
|
>
|
||||||
|
<Input maxLength={64} placeholder="户名 / 收款人姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="bankAccountNo"
|
||||||
|
label="银行账号"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写银行账号' },
|
||||||
|
{ pattern: /^\d{8,32}$/, message: '请填写 8~32 位数字账号' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input maxLength={32} placeholder="请输入银行账号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="bankBranch" label="开户行名称">
|
||||||
|
<Input maxLength={128} placeholder="如 中国工商银行郑州分行" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Image, Input, Modal, Space, Tag, Typography, message } from 'antd';
|
||||||
|
import type {
|
||||||
|
StorePackageAuditDetailDto,
|
||||||
|
StorePackageItemDto,
|
||||||
|
StorePackageViewDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { normalizeStorePackageImageUrls } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { notifyPackageAuditChanged } from '../lib/admin-events';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
|
const HQ_PACKAGE_STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: '待审核',
|
||||||
|
APPROVED: '已通过',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
};
|
||||||
|
|
||||||
|
function packageKey(pkg: StorePackageItemDto | StorePackageViewDto, index: number) {
|
||||||
|
const name = String(pkg.name ?? '').trim();
|
||||||
|
return name ? `name:${name}` : `idx:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function imageSignature(pkg: StorePackageItemDto | StorePackageViewDto) {
|
||||||
|
return normalizeStorePackageImageUrls(pkg).join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldChange = { label: string; old: string; now: string; kind: 'text' | 'value' };
|
||||||
|
|
||||||
|
function diffText(a: string, b: string): Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> {
|
||||||
|
const m = a.length;
|
||||||
|
const n = b.length;
|
||||||
|
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
||||||
|
for (let i = m - 1; i >= 0; i--) {
|
||||||
|
for (let j = n - 1; j >= 0; j--) {
|
||||||
|
dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const raw: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < m && j < n) {
|
||||||
|
if (a[i] === b[j]) {
|
||||||
|
raw.push({ type: 'equal', text: a[i] });
|
||||||
|
i++;
|
||||||
|
j++;
|
||||||
|
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
|
||||||
|
raw.push({ type: 'delete', text: a[i] });
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
raw.push({ type: 'insert', text: b[j] });
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while (i < m) raw.push({ type: 'delete', text: a[i++] });
|
||||||
|
while (j < n) raw.push({ type: 'insert', text: b[j++] });
|
||||||
|
const merged: Array<{ type: 'equal' | 'insert' | 'delete'; text: string }> = [];
|
||||||
|
for (const s of raw) {
|
||||||
|
const last = merged[merged.length - 1];
|
||||||
|
if (last && last.type === s.type) last.text += s.text;
|
||||||
|
else merged.push({ ...s });
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldChanges(
|
||||||
|
live: StorePackageItemDto | StorePackageViewDto,
|
||||||
|
proposed: StorePackageItemDto | StorePackageViewDto,
|
||||||
|
): FieldChange[] {
|
||||||
|
const changes: FieldChange[] = [];
|
||||||
|
const text = (v: string | number | null | undefined) => (v ?? '').toString().trim();
|
||||||
|
const pushText = (label: string, oldV: string, newV: string) => {
|
||||||
|
if (oldV !== newV) changes.push({ label, old: oldV, now: newV, kind: 'text' });
|
||||||
|
};
|
||||||
|
const pushValue = (label: string, oldV: string, newV: string) => {
|
||||||
|
if (oldV !== newV) changes.push({ label, old: oldV || '(空)', now: newV || '(空)', kind: 'value' });
|
||||||
|
};
|
||||||
|
pushValue('价格', `¥${text(live.price)}`, `¥${text(proposed.price)}`);
|
||||||
|
pushText('套餐名称', text(live.name), text(proposed.name));
|
||||||
|
pushText('菜品内容', text(live.dishes), text(proposed.dishes));
|
||||||
|
pushText('可用时间', text(live.usableTime), text(proposed.usableTime));
|
||||||
|
pushText('其他说明', text(live.otherNotes), text(proposed.otherNotes));
|
||||||
|
const liveImgs = normalizeStorePackageImageUrls(live);
|
||||||
|
const proposedImgs = normalizeStorePackageImageUrls(proposed);
|
||||||
|
if (imageSignature(live) !== imageSignature(proposed)) {
|
||||||
|
changes.push({ label: '图片', old: `${liveImgs.length} 张`, now: `${proposedImgs.length} 张`, kind: 'value' });
|
||||||
|
}
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffPackages(live: StorePackageViewDto[], proposed: StorePackageItemDto[]) {
|
||||||
|
const liveMap = new Map(live.map((p, i) => [packageKey(p, i), p]));
|
||||||
|
const proposedMap = new Map(proposed.map((p, i) => [packageKey(p, i), p]));
|
||||||
|
const keys = new Set([...liveMap.keys(), ...proposedMap.keys()]);
|
||||||
|
const rows: Array<{
|
||||||
|
key: string;
|
||||||
|
change: 'added' | 'removed' | 'changed' | 'unchanged';
|
||||||
|
live?: StorePackageViewDto;
|
||||||
|
proposed?: StorePackageItemDto;
|
||||||
|
changes?: FieldChange[];
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const l = liveMap.get(key);
|
||||||
|
const p = proposedMap.get(key);
|
||||||
|
if (l && !p) {
|
||||||
|
rows.push({ key, change: 'removed', live: l });
|
||||||
|
} else if (!l && p) {
|
||||||
|
rows.push({ key, change: 'added', proposed: p });
|
||||||
|
} else if (l && p) {
|
||||||
|
const changed =
|
||||||
|
l.price !== p.price ||
|
||||||
|
l.dishes !== p.dishes ||
|
||||||
|
(l.usableTime ?? '') !== (p.usableTime ?? '') ||
|
||||||
|
(l.otherNotes ?? '') !== (p.otherNotes ?? '') ||
|
||||||
|
imageSignature(l) !== imageSignature(p);
|
||||||
|
rows.push({
|
||||||
|
key,
|
||||||
|
change: changed ? 'changed' : 'unchanged',
|
||||||
|
live: l,
|
||||||
|
proposed: p,
|
||||||
|
changes: changed ? fieldChanges(l, p) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHANGE_LABELS = {
|
||||||
|
added: { text: '新增', color: 'green' },
|
||||||
|
removed: { text: '删除', color: 'red' },
|
||||||
|
changed: { text: '变更', color: 'orange' },
|
||||||
|
unchanged: { text: '未变', color: 'default' },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function TextDiff({ oldText, newText }: { oldText: string; newText: string }) {
|
||||||
|
const segs = diffText(oldText, newText);
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 2 }}>
|
||||||
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
|
<Typography.Text type="secondary">原:</Typography.Text>
|
||||||
|
{segs
|
||||||
|
.filter((s) => s.type !== 'insert')
|
||||||
|
.map((s, idx) =>
|
||||||
|
s.type === 'delete' ? (
|
||||||
|
<Typography.Text key={idx} delete style={{ color: '#cf1322' }}>
|
||||||
|
{s.text || '(空)'}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
|
<Typography.Text type="secondary">新:</Typography.Text>
|
||||||
|
{segs
|
||||||
|
.filter((s) => s.type !== 'delete')
|
||||||
|
.map((s, idx) =>
|
||||||
|
s.type === 'insert' ? (
|
||||||
|
<Typography.Text key={idx} style={{ color: '#389e0d' }}>
|
||||||
|
{s.text || '(空)'}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Typography.Text key={idx}>{s.text}</Typography.Text>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PackageDetailCard({
|
||||||
|
title,
|
||||||
|
pkg,
|
||||||
|
change,
|
||||||
|
changes,
|
||||||
|
imageSize = 72,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
pkg: StorePackageItemDto | StorePackageViewDto;
|
||||||
|
change?: keyof typeof CHANGE_LABELS;
|
||||||
|
changes?: FieldChange[];
|
||||||
|
imageSize?: number;
|
||||||
|
}) {
|
||||||
|
const images = normalizeStorePackageImageUrls(pkg);
|
||||||
|
const meta = change ? CHANGE_LABELS[change] : null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 12,
|
||||||
|
padding: 12,
|
||||||
|
border: '1px solid #f0f0f0',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: '#fafafa',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space style={{ marginBottom: 8 }} wrap>
|
||||||
|
{title ? <Typography.Text type="secondary">{title}</Typography.Text> : null}
|
||||||
|
{meta ? <Tag color={meta.color}>{meta.text}</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<strong>{pkg.name}</strong>
|
||||||
|
<span style={{ marginLeft: 8 }}>¥{pkg.price}</span>
|
||||||
|
</div>
|
||||||
|
<Typography.Paragraph className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||||
|
{pkg.dishes || '—'}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
{pkg.usableTime ? (
|
||||||
|
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 4 }}>
|
||||||
|
可用时间:{pkg.usableTime}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
{pkg.otherNotes ? (
|
||||||
|
<Typography.Paragraph type="secondary" className="admin-package-audit-text" style={{ marginBottom: 8 }}>
|
||||||
|
其他说明:{pkg.otherNotes}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
{images.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space wrap size={8}>
|
||||||
|
{images.map((url) => (
|
||||||
|
<Image key={url} src={url} width={imageSize} height={imageSize} style={{ objectFit: 'cover', borderRadius: 4 }} />
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">无套餐图片</Typography.Text>
|
||||||
|
)}
|
||||||
|
{changes && changes.length ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
padding: 8,
|
||||||
|
background: '#fff7e6',
|
||||||
|
border: '1px solid #ffe7ba',
|
||||||
|
borderRadius: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text strong style={{ fontSize: 12 }}>
|
||||||
|
变更明细
|
||||||
|
</Typography.Text>
|
||||||
|
<ul style={{ margin: '6px 0 0', paddingLeft: 18 }}>
|
||||||
|
{changes.map((c) => (
|
||||||
|
<li key={c.label} style={{ marginBottom: 6 }}>
|
||||||
|
<Typography.Text type="secondary">{c.label}:</Typography.Text>
|
||||||
|
{c.kind === 'text' ? (
|
||||||
|
<TextDiff oldText={c.old} newText={c.now} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Typography.Text delete type="secondary">
|
||||||
|
{c.old}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary"> → </Typography.Text>
|
||||||
|
<Typography.Text strong>{c.now}</Typography.Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StorePackageAuditPanelProps = {
|
||||||
|
requestId: string;
|
||||||
|
/** 是否在面板顶部显示通过/驳回(抽屉 extra 另有按钮时可关) */
|
||||||
|
showActions?: boolean;
|
||||||
|
fullscreen?: boolean;
|
||||||
|
onAudited?: () => void;
|
||||||
|
onDetailLoaded?: (detail: StorePackageAuditDetailDto | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 套餐变更对比 + 通过/驳回(审核通知页与门店详情抽屉共用) */
|
||||||
|
export default function StorePackageAuditPanel({
|
||||||
|
requestId,
|
||||||
|
showActions = true,
|
||||||
|
fullscreen = false,
|
||||||
|
onAudited,
|
||||||
|
onDetailLoaded,
|
||||||
|
}: StorePackageAuditPanelProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<StorePackageAuditDetailDto | null>(null);
|
||||||
|
const [rejectOpen, setRejectOpen] = useState(false);
|
||||||
|
const [rejectReason, setRejectReason] = useState('');
|
||||||
|
const [auditing, setAuditing] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<StorePackageAuditDetailDto>(`/admin/store-package-audits/${requestId}`);
|
||||||
|
setDetail(data);
|
||||||
|
onDetailLoaded?.(data);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载套餐审核详情失败');
|
||||||
|
setDetail(null);
|
||||||
|
onDetailLoaded?.(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [requestId]);
|
||||||
|
|
||||||
|
async function audit(action: 'APPROVE' | 'REJECT', reason?: string) {
|
||||||
|
if (!detail) return;
|
||||||
|
setAuditing(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/store-package-audits/${detail.id}/audit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason: reason } : { action }),
|
||||||
|
});
|
||||||
|
message.success(action === 'APPROVE' ? '套餐已通过' : '套餐已驳回');
|
||||||
|
notifyPackageAuditChanged();
|
||||||
|
onAudited?.();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setAuditing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading && !detail) {
|
||||||
|
return <Typography.Text type="secondary">加载套餐变更…</Typography.Text>;
|
||||||
|
}
|
||||||
|
if (!detail) {
|
||||||
|
return <Typography.Text type="secondary">暂无套餐审核详情</Typography.Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffRows = diffPackages(detail.livePackages ?? [], detail.packages ?? []);
|
||||||
|
const changeByKey = new Map(diffRows.map((row) => [row.key, row.change]));
|
||||||
|
const changesByKey = new Map(diffRows.map((row) => [row.key, row.changes]));
|
||||||
|
const addedCount = diffRows.filter((r) => r.change === 'added').length;
|
||||||
|
const removedCount = diffRows.filter((r) => r.change === 'removed').length;
|
||||||
|
const changedCount = diffRows.filter((r) => r.change === 'changed').length;
|
||||||
|
const unchangedCount = diffRows.filter((r) => r.change === 'unchanged').length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }} wrap>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag>{HQ_PACKAGE_STATUS_LABELS[detail.status] ?? detail.status}</Tag>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
提交方:{detail.submitterType === 'PARTNER' ? '合伙人' : '门店'} · {fmtTime(detail.createdAt)}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{showActions && detail.status === 'PENDING' ? (
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" loading={auditing} onClick={() => void audit('APPROVE')}>
|
||||||
|
通过套餐
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
loading={auditing}
|
||||||
|
onClick={() => {
|
||||||
|
setRejectReason('');
|
||||||
|
setRejectOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
驳回套餐
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
{detail.rejectReason ? (
|
||||||
|
<Typography.Paragraph type="danger">驳回原因:{detail.rejectReason}</Typography.Paragraph>
|
||||||
|
) : null}
|
||||||
|
<Space direction="vertical" size={4} style={{ marginBottom: 12 }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
线上已审核 {detail.livePackages?.length ?? 0} 条 · 待审核 {detail.packages?.length ?? 0} 条
|
||||||
|
</Typography.Text>
|
||||||
|
<Space wrap>
|
||||||
|
<Tag color="green">新增 {addedCount}</Tag>
|
||||||
|
<Tag color="red">删除 {removedCount}</Tag>
|
||||||
|
<Tag color="orange">变更 {changedCount}</Tag>
|
||||||
|
{unchangedCount ? <Tag>未变 {unchangedCount}</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
<div className="admin-package-audit-cols">
|
||||||
|
<div className="admin-package-audit-col">
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
线上已审核套餐
|
||||||
|
</Typography.Title>
|
||||||
|
{(detail.livePackages ?? []).length ? (
|
||||||
|
(detail.livePackages ?? []).map((pkg, index) => (
|
||||||
|
<PackageDetailCard
|
||||||
|
key={`live-${packageKey(pkg, index)}`}
|
||||||
|
title={`套餐 ${index + 1}`}
|
||||||
|
pkg={pkg}
|
||||||
|
change={changeByKey.get(packageKey(pkg, index))}
|
||||||
|
imageSize={fullscreen ? 160 : 72}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无线上套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-package-audit-col">
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||||
|
待审核套餐
|
||||||
|
</Typography.Title>
|
||||||
|
{(detail.packages ?? []).length ? (
|
||||||
|
(detail.packages ?? []).map((pkg, index) => (
|
||||||
|
<PackageDetailCard
|
||||||
|
key={`pending-${packageKey(pkg, index)}`}
|
||||||
|
title={`套餐 ${index + 1}`}
|
||||||
|
pkg={pkg}
|
||||||
|
change={changeByKey.get(packageKey(pkg, index))}
|
||||||
|
changes={changesByKey.get(packageKey(pkg, index))}
|
||||||
|
imageSize={fullscreen ? 160 : 72}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无待审核套餐</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="驳回套餐变更"
|
||||||
|
open={rejectOpen}
|
||||||
|
confirmLoading={auditing}
|
||||||
|
onCancel={() => setRejectOpen(false)}
|
||||||
|
onOk={() => {
|
||||||
|
if (!rejectReason.trim()) {
|
||||||
|
message.warning('请填写驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void audit('REJECT', rejectReason.trim()).then(() => setRejectOpen(false));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={rejectReason}
|
||||||
|
placeholder="驳回原因"
|
||||||
|
onChange={(e) => setRejectReason(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供门店详情抽屉 extra 调用 */
|
||||||
|
export async function auditStorePackageRequest(
|
||||||
|
requestId: string,
|
||||||
|
action: 'APPROVE' | 'REJECT',
|
||||||
|
rejectReason?: string,
|
||||||
|
) {
|
||||||
|
await request(`/admin/store-package-audits/${requestId}/audit`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(action === 'REJECT' ? { action, rejectReason } : { action }),
|
||||||
|
});
|
||||||
|
notifyPackageAuditChanged();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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, Badge } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
|
RobotOutlined,
|
||||||
DashboardOutlined,
|
DashboardOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
ShoppingOutlined,
|
ShoppingOutlined,
|
||||||
@@ -17,16 +18,31 @@ import {
|
|||||||
LockOutlined,
|
LockOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
|
ProjectOutlined,
|
||||||
|
PictureOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
hasAnySystemSettingsPermission,
|
||||||
|
hasAnyStoreMenuPermission,
|
||||||
|
HQ_ADMIN_ROLES,
|
||||||
|
} 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';
|
||||||
|
import { AUDIT_NOTICE_CHANGED_EVENT, PACKAGE_AUDIT_CHANGED_EVENT } from '../lib/admin-events';
|
||||||
|
import { ListColumnPrefsProvider } from '../lib/ListColumnPrefsContext';
|
||||||
|
import { HqAccountSettingsModal } from '../components/HqAccountSettingsModal';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
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: '用户' },
|
||||||
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
|
||||||
{
|
{
|
||||||
key: 'products-group',
|
key: 'products-group',
|
||||||
icon: <ShoppingOutlined />,
|
icon: <ShoppingOutlined />,
|
||||||
@@ -37,17 +53,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
{ key: '/orders', icon: <ShoppingOutlined />, label: '订单' },
|
||||||
{ key: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
|
||||||
{
|
|
||||||
key: 'stores-group',
|
|
||||||
icon: <ShopOutlined />,
|
|
||||||
label: '门店',
|
|
||||||
children: [
|
|
||||||
{ key: '/stores', label: '门店列表' },
|
|
||||||
{ key: '/store-accounts', label: '门店账户' },
|
|
||||||
{ key: '/store-media', label: '门店资源' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'partners-group',
|
key: 'partners-group',
|
||||||
icon: <TeamOutlined />,
|
icon: <TeamOutlined />,
|
||||||
@@ -56,6 +61,20 @@ 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: '仓配管理' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stores-group',
|
||||||
|
icon: <ShopOutlined />,
|
||||||
|
label: '门店',
|
||||||
|
children: [
|
||||||
|
{ key: '/stores', label: '门店列表' },
|
||||||
|
{ key: '/store-package-audits', label: '审核通知' },
|
||||||
|
{ key: '/store-ratings', label: '门店评价' },
|
||||||
|
{ key: '/store-categories', label: '门店分类' },
|
||||||
|
{ key: '/store-accounts', label: '门店账户' },
|
||||||
|
{ key: '/store-media', label: '门店资源' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -66,6 +85,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 +109,42 @@ 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: '/promo-codes', icon: <GiftOutlined />, label: '推广码' },
|
||||||
|
{ key: '/activity-posters', icon: <PictureOutlined />, label: '活动图' },
|
||||||
|
{ key: '/wechat-bindings', icon: <UserOutlined />, label: '微信绑定' },
|
||||||
|
{
|
||||||
|
key: 'wecom-group',
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
label: '企微机器人',
|
||||||
|
children: [
|
||||||
|
{ key: '/wecom/bots', label: '智能机器人' },
|
||||||
|
{ key: '/wecom/plugins', label: 'API 插件' },
|
||||||
|
{ key: '/wecom/pushes', label: '消息推送' },
|
||||||
|
{ key: '/wecom/reports', label: '报告' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ key: '/llm-configs', icon: <RobotOutlined />, label: '语言模型' },
|
||||||
|
{ key: '/knowledge-bases', icon: <FileTextOutlined />, label: '知识库' },
|
||||||
|
{
|
||||||
|
key: 'dev-plan-group',
|
||||||
|
icon: <ProjectOutlined />,
|
||||||
|
label: '开发计划',
|
||||||
|
children: [
|
||||||
|
{ key: '/dev-plan/versions', label: '版本列表' },
|
||||||
|
{ key: '/dev-plan/tasks', label: '任务列表' },
|
||||||
|
{ key: '/dev-plan/settings', label: '开发设置' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||||
{
|
{
|
||||||
key: 'logs-group',
|
key: 'logs-group',
|
||||||
@@ -101,23 +156,176 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/logs/partners', label: '合伙人日志' },
|
{ key: '/logs/partners', label: '合伙人日志' },
|
||||||
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
||||||
{ key: '/logs/third-party', label: '第三方日志' },
|
{ key: '/logs/third-party', label: '第三方日志' },
|
||||||
|
{ key: '/logs/domain-events', label: '领域事件' },
|
||||||
|
{ key: '/logs/wecom-bots', label: '智能机器人日志' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
{ key: '/hq-permissions', icon: <LockOutlined />, label: '权限分配' },
|
||||||
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
|
||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
|
{ key: '/test-whitelist', icon: <SafetyOutlined />, label: '白名单管理' },
|
||||||
|
{ key: '/system-settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||||
|
if (key === 'tickets-group') {
|
||||||
|
return permissionKeys.includes('tickets') || permissionKeys.includes('tech_support');
|
||||||
|
}
|
||||||
|
if (key === 'stores-group') {
|
||||||
|
return hasAnyStoreMenuPermission(permissionKeys);
|
||||||
|
}
|
||||||
|
if (key === 'dev-plan-group') {
|
||||||
|
return permissionKeys.includes('dev_plan');
|
||||||
|
}
|
||||||
|
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',
|
||||||
|
'/activity-posters': 'activity_posters',
|
||||||
|
'/wecom/bots': 'wecom_bots',
|
||||||
|
'/wecom/plugins': 'wecom_bots',
|
||||||
|
'/wecom/pushes': 'wecom_bots',
|
||||||
|
'/wecom/reports': 'wecom_bots',
|
||||||
|
'wecom-group': 'wecom_bots',
|
||||||
|
'/llm-configs': 'llm_configs',
|
||||||
|
'/knowledge-bases': 'knowledge_bases',
|
||||||
|
'stores-group': 'stores',
|
||||||
|
'/stores': 'stores',
|
||||||
|
'/store-package-audits': 'store_audits',
|
||||||
|
'/store-ratings': 'store_ratings',
|
||||||
|
'/store-categories': 'store_categories',
|
||||||
|
'/store-accounts': 'store_accounts',
|
||||||
|
'/store-media': 'store_media',
|
||||||
|
'partners-group': 'partners',
|
||||||
|
'/cities': 'partners',
|
||||||
|
'/city-partners': 'partners',
|
||||||
|
'/city-warehouses': 'partners',
|
||||||
|
'/fulfillment-providers': 'partners',
|
||||||
|
'finance-group': 'finance',
|
||||||
|
'/finance/store-bills': 'finance',
|
||||||
|
'/finance/store-withdrawals': 'finance',
|
||||||
|
'/finance/partner-bills': 'finance',
|
||||||
|
'/finance/winery-bills': 'finance',
|
||||||
|
'/finance/logistics-bills': 'finance',
|
||||||
|
'benefit-group': 'benefit',
|
||||||
|
'/benefit/coupons': 'benefit',
|
||||||
|
'/benefit/ledgers': 'benefit',
|
||||||
|
'/redeem-records': 'benefit',
|
||||||
|
'/redeem-pending': 'benefit',
|
||||||
|
'/redeem/debug': 'benefit_debug',
|
||||||
|
'deliveries-group': 'deliveries',
|
||||||
|
'/deliveries': 'deliveries',
|
||||||
|
'/deliveries/xiaofeixia': 'deliveries_debug',
|
||||||
|
'/tickets': 'tickets',
|
||||||
|
'/tickets/support': 'tech_support',
|
||||||
|
'dev-plan-group': 'dev_plan',
|
||||||
|
'/dev-plan/versions': 'dev_plan',
|
||||||
|
'/dev-plan/tasks': 'dev_plan',
|
||||||
|
'/dev-plan/settings': 'dev_plan',
|
||||||
|
'/invoices': 'invoices',
|
||||||
|
'/resources': 'resources',
|
||||||
|
'logs-group': 'logs',
|
||||||
|
'/logs/users': 'logs',
|
||||||
|
'/logs/wecom-bots': 'logs',
|
||||||
|
'/logs/stores': 'logs',
|
||||||
|
'/logs/partners': 'logs',
|
||||||
|
'/logs/hq': 'logs',
|
||||||
|
'/logs/third-party': 'logs',
|
||||||
|
'/logs/domain-events': 'logs',
|
||||||
|
'/hq-permissions': 'hq_permissions',
|
||||||
|
'/test-whitelist': 'test_whitelist',
|
||||||
|
'/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'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachAuditBadge(items: MenuProps['items'], pendingCount: number): MenuProps['items'] {
|
||||||
|
if (!items) return items;
|
||||||
|
return items.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||||
|
if ('children' in item && Array.isArray(item.children)) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: attachAuditBadge(item.children as MenuProps['items'], pendingCount),
|
||||||
|
} as MenuItem;
|
||||||
|
}
|
||||||
|
if (String(item.key) === '/store-package-audits') {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
label: (
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
审核通知
|
||||||
|
{pendingCount > 0 && <Badge count={pendingCount} size="small" />}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
} as MenuItem;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging';
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
|
const [auditPendingCount, setAuditPendingCount] = useState(0);
|
||||||
|
const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
|
||||||
|
|
||||||
|
function refreshAuditPendingCount() {
|
||||||
|
Promise.all([
|
||||||
|
request<{ pendingCount: number }>('/admin/store-package-audits/summary').catch(() => ({ pendingCount: 0 })),
|
||||||
|
request<{ pendingCount: number; packagePendingCount?: number }>('/admin/store-info-change-requests/summary').catch(() => ({ pendingCount: 0 })),
|
||||||
|
])
|
||||||
|
.then(([pkg, info]) => setAuditPendingCount((pkg.pendingCount ?? 0) + (info.pendingCount ?? 0)))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshAuditPendingCount();
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onChanged = () => refreshAuditPendingCount();
|
||||||
|
window.addEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
window.addEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener(PACKAGE_AUDIT_CHANGED_EVENT, onChanged);
|
||||||
|
window.removeEventListener(AUDIT_NOTICE_CHANGED_EVENT, onChanged);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
contentRef.current?.scrollTo({ top: 0, left: 0 });
|
||||||
}, [location.pathname]);
|
}, [location.pathname]);
|
||||||
@@ -135,26 +343,53 @@ export default function AdminLayout() {
|
|||||||
? '/promo-codes'
|
? '/promo-codes'
|
||||||
: location.pathname;
|
: location.pathname;
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
const base =
|
||||||
|
!profile || profile.adminRole === 'SUPER_ADMIN'
|
||||||
|
? MENU_ITEMS
|
||||||
|
: filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||||
|
return attachAuditBadge(base, auditPendingCount);
|
||||||
|
}, [profile, auditPendingCount]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
|
{IS_STAGING ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 1100,
|
||||||
|
background: '#d48806',
|
||||||
|
color: '#fff',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: '28px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
测试环境 · staging · 数据与生产隔离 · Mock 开启
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
<Sider
|
<Sider
|
||||||
breakpoint="lg"
|
breakpoint="lg"
|
||||||
collapsedWidth={64}
|
collapsedWidth={64}
|
||||||
theme="dark"
|
theme="dark"
|
||||||
width={220}
|
width={220}
|
||||||
style={{ height: '100vh', overflow: 'hidden' }}
|
style={{ height: '100vh', overflow: 'hidden', marginTop: IS_STAGING ? 28 : 0 }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: IS_STAGING ? 'calc(100vh - 28px)' : '100vh' }}>
|
||||||
<div style={{ flexShrink: 0, padding: '16px', color: '#fff', fontWeight: 600 }}>
|
<div style={{ flexShrink: 0, padding: '16px', color: '#fff', fontWeight: 600 }}>
|
||||||
杜康 HQ
|
杜康 HQ{IS_STAGING ? ' · 测试' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-sider-menu-scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
<div className="admin-sider-menu-scroll" style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
<Menu
|
<Menu
|
||||||
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);
|
||||||
}}
|
}}
|
||||||
@@ -162,7 +397,15 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Sider>
|
</Sider>
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
<Layout
|
||||||
|
style={{
|
||||||
|
height: IS_STAGING ? 'calc(100vh - 28px)' : '100vh',
|
||||||
|
marginTop: IS_STAGING ? 28 : 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Header
|
<Header
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
@@ -179,19 +422,32 @@ export default function AdminLayout() {
|
|||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Space>
|
<Space>
|
||||||
<span>{profile?.name || '—'}</span>
|
<span>{profile?.name || '—'}</span>
|
||||||
<span style={{ color: '#999' }}>{profile?.adminRole}</span>
|
<span style={{ color: '#999' }}>
|
||||||
|
{HQ_ROLE_LABELS[profile?.adminRole ?? ''] || profile?.adminRole || ''}
|
||||||
|
</span>
|
||||||
|
<Button type="text" icon={<UserOutlined />} onClick={() => setAccountSettingsOpen(true)}>
|
||||||
|
账号设置
|
||||||
|
</Button>
|
||||||
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
<Button type="text" icon={<LogoutOutlined />} onClick={logout}>
|
||||||
退出
|
退出
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Header>
|
</Header>
|
||||||
|
<HqAccountSettingsModal
|
||||||
|
open={accountSettingsOpen}
|
||||||
|
profile={profile}
|
||||||
|
onClose={() => setAccountSettingsOpen(false)}
|
||||||
|
onUpdated={setProfile}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="admin-layout"
|
className="admin-layout"
|
||||||
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
style={{ flex: 1, minHeight: 0, overflow: 'auto' }}
|
||||||
>
|
>
|
||||||
<Content style={{ margin: 24 }}>
|
<Content style={{ margin: 24 }}>
|
||||||
<Outlet />
|
<ListColumnPrefsProvider initial={profile?.listColumnPrefs}>
|
||||||
|
<Outlet />
|
||||||
|
</ListColumnPrefsProvider>
|
||||||
</Content>
|
</Content>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||||
|
import type { HqListColumnKey, HqListColumnPref, HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||||
|
import { request } from './api';
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
prefs: HqListColumnPrefsMap;
|
||||||
|
ready: boolean;
|
||||||
|
setPrefs: (next: HqListColumnPrefsMap) => void;
|
||||||
|
save: (listKey: HqListColumnKey, next: HqListColumnPref | null) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ListColumnPrefsContext = createContext<Ctx>({
|
||||||
|
prefs: {},
|
||||||
|
ready: false,
|
||||||
|
setPrefs: () => {},
|
||||||
|
save: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ListColumnPrefsProvider({
|
||||||
|
initial,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initial?: HqListColumnPrefsMap;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const [prefs, setPrefs] = useState<HqListColumnPrefsMap>(initial ?? {});
|
||||||
|
const [ready] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) setPrefs(initial);
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
const save = useCallback(async (listKey: HqListColumnKey, next: HqListColumnPref | null) => {
|
||||||
|
const res = await request<{ listColumnPrefs: HqListColumnPrefsMap }>(
|
||||||
|
`/admin/me/list-columns/${listKey}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(
|
||||||
|
next
|
||||||
|
? { order: next.order, hidden: next.hidden, widths: next.widths }
|
||||||
|
: { reset: true },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setPrefs(res.listColumnPrefs ?? {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ prefs, ready, setPrefs, save }), [prefs, ready, save]);
|
||||||
|
return <ListColumnPrefsContext.Provider value={value}>{children}</ListColumnPrefsContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListColumnPrefs() {
|
||||||
|
return useContext(ListColumnPrefsContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const PACKAGE_AUDIT_CHANGED_EVENT = 'admin:package-audit-changed';
|
||||||
|
export const AUDIT_NOTICE_CHANGED_EVENT = 'dukang:audit-notice-changed';
|
||||||
|
|
||||||
|
export function notifyPackageAuditChanged() {
|
||||||
|
window.dispatchEvent(new Event(PACKAGE_AUDIT_CHANGED_EVENT));
|
||||||
|
window.dispatchEvent(new Event(AUDIT_NOTICE_CHANGED_EVENT));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyAuditNoticeChanged() {
|
||||||
|
notifyPackageAuditChanged();
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
|
import type { HqListColumnPrefsMap } from '@dukang/shared-types';
|
||||||
|
|
||||||
export const apiBase = '/api/v1';
|
export const apiBase = '/api/v1';
|
||||||
export const CLIENT_APP = 'HQ_WEB';
|
export const CLIENT_APP = 'HQ_WEB';
|
||||||
|
|
||||||
export type HqProfile = {
|
export type HqProfile = {
|
||||||
id: string;
|
id: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
loginName?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
adminRole: string;
|
adminRole: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
permissionKeys?: string[];
|
||||||
|
cityIds?: string[];
|
||||||
|
cityScoped?: boolean;
|
||||||
|
listColumnPrefs?: HqListColumnPrefsMap;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getToken() {
|
export function getToken() {
|
||||||
@@ -52,6 +59,54 @@ export async function request<T>(path: string, options: RequestInit = {}): Promi
|
|||||||
return json.data as T;
|
return json.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDownloadFileName(header: string | null, fallback: string): string {
|
||||||
|
if (!header) return fallback;
|
||||||
|
const star = /filename\*=(?:UTF-8''|utf-8'')([^;]+)/i.exec(header);
|
||||||
|
if (star?.[1]) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(star[1].trim());
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const quoted = /filename="([^"]+)"/i.exec(header);
|
||||||
|
if (quoted?.[1]) return quoted[1];
|
||||||
|
const plain = /filename=([^;]+)/i.exec(header);
|
||||||
|
return plain?.[1]?.trim() || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binary download (PNG / zip). JSON error bodies are surfaced as Error. */
|
||||||
|
export async function requestDownload(path: string, options: RequestInit = {}, fallbackName: string) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'X-Client-App': CLIENT_APP,
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
|
||||||
|
const token = getToken();
|
||||||
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||||
|
const contentType = res.headers.get('content-type') || '';
|
||||||
|
if (contentType.includes('application/json')) {
|
||||||
|
const json = (await res.json()) as { code?: number; message?: string };
|
||||||
|
if (json.code === 401) {
|
||||||
|
clearAuth();
|
||||||
|
window.location.href = '/login';
|
||||||
|
throw new Error('未登录');
|
||||||
|
}
|
||||||
|
throw new Error(json.message || '下载失败');
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`下载失败(HTTP ${res.status})`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const fileName = parseDownloadFileName(res.headers.get('content-disposition'), fallbackName);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export type Paginated<T> = {
|
export type Paginated<T> = {
|
||||||
items: T[];
|
items: T[];
|
||||||
total: number;
|
total: number;
|
||||||
@@ -79,9 +134,26 @@ export type DashboardStats = {
|
|||||||
partnersTotal: number;
|
partnersTotal: number;
|
||||||
redeemToday: number;
|
redeemToday: number;
|
||||||
deliveriesTotal: number;
|
deliveriesTotal: number;
|
||||||
|
pendingPayouts?: number;
|
||||||
|
/** 合伙人已确认、待总部审核打款 */
|
||||||
|
pendingBills?: number;
|
||||||
|
pendingPartnerDraftBills?: number;
|
||||||
|
openTickets?: number;
|
||||||
|
pendingStoreWithdrawals?: number;
|
||||||
|
overdueStoreWithdrawals?: number;
|
||||||
|
pendingStoreOnboard?: number;
|
||||||
|
pendingStorePackageAudits?: number;
|
||||||
|
pendingStoreInfoChanges?: number;
|
||||||
ordersByStatus: Array<{ status: string; count: number }>;
|
ordersByStatus: Array<{ status: string; count: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type {
|
||||||
|
DashboardAnalytics,
|
||||||
|
DashboardGranularity,
|
||||||
|
DashboardLineChart,
|
||||||
|
DashboardLineHref,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
export type SystemVersion = {
|
export type SystemVersion = {
|
||||||
id: string;
|
id: string;
|
||||||
gitTag: string | null;
|
gitTag: string | null;
|
||||||
@@ -108,12 +180,34 @@ export type AdminUserRow = {
|
|||||||
wxOpenId: string | null;
|
wxOpenId: string | null;
|
||||||
wechatVerified: boolean;
|
wechatVerified: boolean;
|
||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
|
hqRemark: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
sourceType: string;
|
sourceType: string;
|
||||||
sourceRefId: string | null;
|
sourceRefId: string | null;
|
||||||
sourceLabel: string | null;
|
sourceLabel: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
|
isTest?: boolean;
|
||||||
|
/** 好客权益·累计获得(含已使用,不含退款作废) */
|
||||||
|
benefitTotalAmount?: number;
|
||||||
|
/** 好客权益·已使用(已核销) */
|
||||||
|
benefitUsedAmount?: number;
|
||||||
|
/** 好客权益·剩余未使用 */
|
||||||
|
benefitBalance?: number;
|
||||||
|
assocPartner?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminOrderItem = {
|
||||||
|
productName: string;
|
||||||
|
productSpec: string;
|
||||||
|
productImage: string;
|
||||||
|
unitPrice: number;
|
||||||
|
quantity: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminOrderRow = {
|
export type AdminOrderRow = {
|
||||||
@@ -122,9 +216,44 @@ export type AdminOrderRow = {
|
|||||||
status: string;
|
status: string;
|
||||||
deliveryType: string;
|
deliveryType: string;
|
||||||
payAmount: number;
|
payAmount: number;
|
||||||
|
productName?: string;
|
||||||
|
productSpec?: string;
|
||||||
|
quantity?: number;
|
||||||
|
saleUnit?: string;
|
||||||
receiverName: string;
|
receiverName: string;
|
||||||
receiverPhone: string;
|
receiverPhone: string;
|
||||||
|
receiverProvince?: string;
|
||||||
|
receiverCity?: string;
|
||||||
|
receiverDistrict?: string;
|
||||||
|
receiverAddress?: string;
|
||||||
|
benefitAmount?: number;
|
||||||
|
benefitCoupon?: {
|
||||||
|
couponNo?: string;
|
||||||
|
totalAmount?: number;
|
||||||
|
usedAmount?: number;
|
||||||
|
balance?: number;
|
||||||
|
status?: string;
|
||||||
|
} | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
cityId?: string;
|
||||||
delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null };
|
city?: { id: string; name: string; code: string };
|
||||||
|
fulfillmentWarehouseId?: string | null;
|
||||||
|
fulfillmentWarehouse?: { id: string; name: string } | null;
|
||||||
|
fulfillmentHold?: boolean;
|
||||||
|
fulfillmentHoldReason?: string | null;
|
||||||
|
orderType?: string;
|
||||||
|
isProxyOrder?: boolean;
|
||||||
|
proxyPartnerName?: string | null;
|
||||||
|
proxyPartnerPhone?: string | null;
|
||||||
|
isTest?: boolean;
|
||||||
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null; hqRemark?: string | null };
|
||||||
|
delivery?: {
|
||||||
|
provider: string;
|
||||||
|
trackingNo: string | null;
|
||||||
|
providerOrderNo: string | null;
|
||||||
|
logisticsCompany?: string | null;
|
||||||
|
manualQueryUrl?: string | null;
|
||||||
|
/** 当次应付物流费(按瓶当量 × 承运商计价) */
|
||||||
|
logisticsFee?: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { MouseEvent, ReactNode } from 'react';
|
||||||
|
import type { ColumnType } from 'antd/es/table';
|
||||||
|
|
||||||
|
export const MIN_COL_WIDTH = 48;
|
||||||
|
export const MAX_COL_WIDTH = 960;
|
||||||
|
|
||||||
|
export function clampColWidth(n: number): number {
|
||||||
|
return Math.min(MAX_COL_WIDTH, Math.max(MIN_COL_WIDTH, Math.round(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function beginColumnResize(
|
||||||
|
e: MouseEvent,
|
||||||
|
startWidth: number,
|
||||||
|
onMove: (width: number) => void,
|
||||||
|
onEnd: (width: number) => void,
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const startX = e.clientX;
|
||||||
|
let latest = clampColWidth(startWidth);
|
||||||
|
let moved = false;
|
||||||
|
document.body.classList.add('admin-col-resizing');
|
||||||
|
|
||||||
|
const onMouseMove = (ev: globalThis.MouseEvent) => {
|
||||||
|
moved = true;
|
||||||
|
latest = clampColWidth(startWidth + ev.clientX - startX);
|
||||||
|
onMove(latest);
|
||||||
|
};
|
||||||
|
const onMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onMouseUp);
|
||||||
|
document.body.classList.remove('admin-col-resizing');
|
||||||
|
if (moved) onEnd(latest);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', onMouseMove);
|
||||||
|
document.addEventListener('mouseup', onMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withResizeTitle<T>(
|
||||||
|
title: ColumnType<T>['title'],
|
||||||
|
onMouseDown: (e: MouseEvent) => void,
|
||||||
|
): ColumnType<T>['title'] {
|
||||||
|
const handle: ReactNode = (
|
||||||
|
<span
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label="拖动调整列宽"
|
||||||
|
className="admin-col-resize-handle"
|
||||||
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (typeof title === 'function') {
|
||||||
|
return (props) => (
|
||||||
|
<>
|
||||||
|
{title(props)}
|
||||||
|
{handle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{title}
|
||||||
|
{handle}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ export const DELIVERY_PROVIDER_LABELS: Record<string, string> = {
|
|||||||
MANUAL: '手动',
|
MANUAL: '手动',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DELIVERY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
LOCAL: '同城',
|
||||||
|
CROSS_CITY: '跨城',
|
||||||
|
ON_SITE_PICKUP: '现场提货',
|
||||||
|
};
|
||||||
|
|
||||||
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
export const ORDER_STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
PENDING_SHIP: '待发货',
|
PENDING_SHIP: '待发货',
|
||||||
@@ -19,6 +25,26 @@ export const ORDER_STATUS_LABELS: Record<string, string> = {
|
|||||||
REFUNDED: '已退款',
|
REFUNDED: '已退款',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 订单状态流转操作人(common_event.param3) */
|
||||||
|
export const ORDER_STATUS_OPERATOR_LABELS: Record<string, string> = {
|
||||||
|
USER: '用户',
|
||||||
|
USER_ON_SITE: '用户·现场取货',
|
||||||
|
USER_WECHAT_CONFIRM: '用户·微信确认收货',
|
||||||
|
WECHAT_TRADE_MANAGE: '微信结算同步',
|
||||||
|
WECHAT_PAY: '微信支付',
|
||||||
|
MOCK: 'Mock',
|
||||||
|
MOCK_PAY: 'Mock 支付',
|
||||||
|
SYSTEM: '系统',
|
||||||
|
SYSTEM_AUTO_EXPIRE: '系统·超时取消',
|
||||||
|
PARTNER_PROXY: '合伙人代下单',
|
||||||
|
HQ_PROXY: 'HQ 代操作',
|
||||||
|
HQ_LOGISTICS: 'HQ 物流',
|
||||||
|
WAREHOUSE_MANUAL: '仓库手工',
|
||||||
|
HQ: '总部',
|
||||||
|
STORE: '门店',
|
||||||
|
PARTNER: '合伙人',
|
||||||
|
};
|
||||||
|
|
||||||
/** 订单状态 Tag 颜色(Ant Design preset) */
|
/** 订单状态 Tag 颜色(Ant Design preset) */
|
||||||
export const ORDER_STATUS_COLORS: Record<string, string> = {
|
export const ORDER_STATUS_COLORS: Record<string, string> = {
|
||||||
PENDING_PAY: 'red',
|
PENDING_PAY: 'red',
|
||||||
@@ -34,8 +60,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 +88,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> = {
|
||||||
@@ -92,6 +120,7 @@ export const RESOURCE_BIZ_TYPE_LABELS: Record<string, string> = {
|
|||||||
QRCODE: '二维码',
|
QRCODE: '二维码',
|
||||||
SIGN_PHOTO: '签收照',
|
SIGN_PHOTO: '签收照',
|
||||||
VIDEO: '视频',
|
VIDEO: '视频',
|
||||||
|
ACTIVITY_POSTER: '活动图',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
|
export const RESOURCE_STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -126,3 +155,37 @@ 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;座机 0379-****888;空值返回 — */
|
||||||
|
export function maskPhone(phone?: string | null): string {
|
||||||
|
const raw = String(phone ?? '').trim().replace(/\s+/g, '');
|
||||||
|
if (!raw) return '—';
|
||||||
|
if (/^1[3-9]\d{9}$/.test(raw)) {
|
||||||
|
const digits = raw.replace(/\D/g, '');
|
||||||
|
return `${digits.slice(0, 3)}****${digits.slice(-4)}`;
|
||||||
|
}
|
||||||
|
if (/^0\d{2,3}-?\d{7,8}(-\d{1,6})?$/.test(raw)) {
|
||||||
|
const extMatch = raw.match(/-(\d{1,6})$/);
|
||||||
|
const hasExt = !!extMatch && raw.indexOf('-') !== raw.lastIndexOf('-');
|
||||||
|
const ext = hasExt ? extMatch![1] : '';
|
||||||
|
const main = hasExt ? raw.slice(0, -(ext.length + 1)) : raw;
|
||||||
|
const digits = main.replace(/\D/g, '');
|
||||||
|
const areaLen = digits.startsWith('01') || digits.startsWith('02') ? 3 : 4;
|
||||||
|
const area = digits.slice(0, areaLen);
|
||||||
|
const local = digits.slice(areaLen);
|
||||||
|
const keepTail = Math.min(4, Math.max(2, local.length - 4));
|
||||||
|
const maskedLocal =
|
||||||
|
local.length <= 4 ? '*'.repeat(local.length) : `${'*'.repeat(local.length - keepTail)}${local.slice(-keepTail)}`;
|
||||||
|
const joiner = main.includes('-') ? '-' : '';
|
||||||
|
return ext ? `${area}${joiner}${maskedLocal}-${ext}` : `${area}${joiner}${maskedLocal}`;
|
||||||
|
}
|
||||||
|
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 '****';
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { ECharts } from 'echarts';
|
||||||
|
|
||||||
|
const TITLE_FONT = 'bold 32px "Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif';
|
||||||
|
const SUB_FONT = '22px "Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif';
|
||||||
|
|
||||||
|
type ChartShot = {
|
||||||
|
title: string;
|
||||||
|
instance: ECharts | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error('图表截图失败'));
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function composePage(params: {
|
||||||
|
chartPng: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
}): Promise<{ dataUrl: string; width: number; height: number }> {
|
||||||
|
const img = await loadImage(params.chartPng);
|
||||||
|
const header = 96;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height + header;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) throw new Error('无法生成 PDF 画布');
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillStyle = '#1f1f1f';
|
||||||
|
ctx.font = TITLE_FONT;
|
||||||
|
ctx.fillText(params.title, 24, 16, canvas.width - 48);
|
||||||
|
ctx.fillStyle = '#8c8c8c';
|
||||||
|
ctx.font = SUB_FONT;
|
||||||
|
ctx.fillText(params.subtitle, 24, 56, canvas.width - 48);
|
||||||
|
ctx.drawImage(img, 0, header);
|
||||||
|
return {
|
||||||
|
dataUrl: canvas.toDataURL('image/jpeg', 0.92),
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把当前已渲染折线图打成横向 A4 PDF(不含 KPI / 待办)。 */
|
||||||
|
export async function downloadDashboardChartsPdf(params: {
|
||||||
|
filename: string;
|
||||||
|
subtitle: string;
|
||||||
|
charts: ChartShot[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const ready = params.charts.filter((c): c is { title: string; instance: ECharts } => !!c.instance);
|
||||||
|
if (ready.length === 0) {
|
||||||
|
throw new Error('暂无可下载的图');
|
||||||
|
}
|
||||||
|
if (ready.length !== params.charts.length) {
|
||||||
|
throw new Error('图表尚未渲染完成,请稍后再试');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { jsPDF } = await import('jspdf');
|
||||||
|
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' });
|
||||||
|
const pageW = pdf.internal.pageSize.getWidth();
|
||||||
|
const pageH = pdf.internal.pageSize.getHeight();
|
||||||
|
const margin = 10;
|
||||||
|
|
||||||
|
for (let i = 0; i < ready.length; i += 1) {
|
||||||
|
const chart = ready[i];
|
||||||
|
const png = chart.instance.getDataURL({
|
||||||
|
type: 'png',
|
||||||
|
pixelRatio: 2,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
});
|
||||||
|
const page = await composePage({
|
||||||
|
chartPng: png,
|
||||||
|
title: chart.title,
|
||||||
|
subtitle: params.subtitle,
|
||||||
|
});
|
||||||
|
const maxW = pageW - margin * 2;
|
||||||
|
const maxH = pageH - margin * 2;
|
||||||
|
const ratio = Math.min(maxW / page.width, maxH / page.height);
|
||||||
|
const w = page.width * ratio;
|
||||||
|
const h = page.height * ratio;
|
||||||
|
const x = (pageW - w) / 2;
|
||||||
|
const y = margin + (maxH - h) / 2;
|
||||||
|
if (i > 0) pdf.addPage();
|
||||||
|
pdf.addImage(page.dataUrl, 'JPEG', x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = params.filename.endsWith('.pdf') ? params.filename : `${params.filename}.pdf`;
|
||||||
|
pdf.save(filename);
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import {
|
||||||
|
LEDGER_TYPE_LABELS,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
ORDER_STATUS_OPERATOR_LABELS,
|
||||||
|
} from './constants';
|
||||||
|
import { HQ_OPERATION_ACTION_LABELS } from './hq-log';
|
||||||
|
import { USER_LOG_CATEGORY_LABELS } from './user-log';
|
||||||
|
import {
|
||||||
|
PARTNER_LOG_CATEGORY_LABELS,
|
||||||
|
HQ_ADMIN_ROLES,
|
||||||
|
resolvePartnerLogCategory,
|
||||||
|
resolveStoreLogCategory,
|
||||||
|
resolveUserLogCategory,
|
||||||
|
STORE_LOG_CATEGORY_LABELS,
|
||||||
|
WECOM_BOT_PERMISSION_LABELS,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export function zhLabel(dict: Record<string, string>, value?: string | null, empty = '—'): string {
|
||||||
|
if (value == null || value === '') return empty;
|
||||||
|
return dict[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CLIENT_APP_LABELS: Record<string, string> = {
|
||||||
|
USER_MINI: 'C 端小程序',
|
||||||
|
USER_H5: 'C 端 H5',
|
||||||
|
PARTNER_MINI: '合伙人小程序',
|
||||||
|
PARTNER_H5: '合伙人 H5',
|
||||||
|
HQ_MINI: 'HQ 小程序',
|
||||||
|
HQ_WEB: 'HQ 后台',
|
||||||
|
SHOP_H5: '门店 H5',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const REF_TYPE_LABELS: Record<string, string> = {
|
||||||
|
ORDER: '订单',
|
||||||
|
USER: '用户',
|
||||||
|
STORE: '门店',
|
||||||
|
PARTNER: '合伙人',
|
||||||
|
CITY: '城市',
|
||||||
|
PRODUCT: '商品',
|
||||||
|
COUPON: '权益券',
|
||||||
|
TICKET: '工单',
|
||||||
|
SUPPORT_TICKET: '技术支持工单',
|
||||||
|
INVOICE: '发票',
|
||||||
|
PROMO: '推广码',
|
||||||
|
PROMO_CODE: '推广码',
|
||||||
|
ACTIVITY_POSTER: '活动图',
|
||||||
|
HQ: '总部',
|
||||||
|
HQ_ACCOUNT: 'HQ 账号',
|
||||||
|
STORE_ACCOUNT: '门店账号',
|
||||||
|
PARTNER_ACCOUNT: '合伙人账号',
|
||||||
|
PAYOUT: '打款',
|
||||||
|
WITHDRAWAL: '提现',
|
||||||
|
BILL: '账单',
|
||||||
|
DELIVERY: '配送单',
|
||||||
|
REDEEM: '核销',
|
||||||
|
WAREHOUSE: '仓库',
|
||||||
|
PACKAGE: '套餐',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DOMAIN_EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
ORDER_STATUS: '订单状态',
|
||||||
|
BENEFIT_LEDGER: '权益流水',
|
||||||
|
STORE_AUDIT: '门店审核',
|
||||||
|
TICKET_COLLAB: '工单协作',
|
||||||
|
PROMO_TOUCH: '推广触达',
|
||||||
|
HQ_OPERATION: 'HQ 操作',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PARAM_DESC_LABELS: Record<string, string> = {
|
||||||
|
from_status: '原状态',
|
||||||
|
to_status: '目标状态',
|
||||||
|
operator: '操作人',
|
||||||
|
ledger_type: '流水类型',
|
||||||
|
coupon_id: '权益券 ID',
|
||||||
|
action: '行为',
|
||||||
|
target_type: '对象类型',
|
||||||
|
target_id: '对象 ID',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const USER_LOG_EVENT_LABELS: Record<string, string> = {
|
||||||
|
login_success: '登录成功',
|
||||||
|
sms_login: '短信登录',
|
||||||
|
sms_send: '发送验证码',
|
||||||
|
sms_verify_fail: '验证码校验失败',
|
||||||
|
session_start: '会话开始',
|
||||||
|
home_view: '浏览首页',
|
||||||
|
product_list_view: '浏览商品列表',
|
||||||
|
product_click: '点击商品',
|
||||||
|
product_detail_view: '浏览商品详情',
|
||||||
|
order_confirm_view: '浏览确认订单',
|
||||||
|
order_submit: '提交订单',
|
||||||
|
order_list_view: '浏览订单列表',
|
||||||
|
order_detail_view: '浏览订单详情',
|
||||||
|
order_cancel: '取消订单',
|
||||||
|
pay_page_view: '进入支付页',
|
||||||
|
pay_success: '支付成功',
|
||||||
|
pay_fail: '支付失败',
|
||||||
|
wechat_login: '微信登录',
|
||||||
|
wechat_phone: '微信授权手机号',
|
||||||
|
wechat_phone_login: '微信手机号登录',
|
||||||
|
wechat_location: '微信定位',
|
||||||
|
wechat_album: '微信选图',
|
||||||
|
wechat_bind: '绑定微信',
|
||||||
|
store_list_view: '浏览门店列表',
|
||||||
|
store_detail_view: '浏览门店详情',
|
||||||
|
benefit_page_view: '浏览权益页',
|
||||||
|
benefit_detail_view: '浏览权益详情',
|
||||||
|
benefit_redeem_start: '开始核销',
|
||||||
|
benefit_redeem_success: '核销成功',
|
||||||
|
redeem_code_view: '查看核销码',
|
||||||
|
redeem_success_view: '核销成功页',
|
||||||
|
profile_update: '修改资料',
|
||||||
|
bind_phone: '绑定手机号',
|
||||||
|
address_list_view: '浏览地址列表',
|
||||||
|
address_edit: '编辑地址',
|
||||||
|
promo_touch: '推广触达',
|
||||||
|
promo_share: '分享推广',
|
||||||
|
cs_contact: '联系客服',
|
||||||
|
after_sale_list_view: '浏览售后列表',
|
||||||
|
after_sale_apply: '申请售后',
|
||||||
|
client_error: '前端异常',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STORE_LOG_EVENT_LABELS: Record<string, string> = {
|
||||||
|
store_sms_send: '发送验证码',
|
||||||
|
store_sms_login: '短信登录',
|
||||||
|
store_sms_verify_fail: '验证码校验失败',
|
||||||
|
store_login_success: '登录成功',
|
||||||
|
store_select: '选择门店',
|
||||||
|
store_wechat_login: '微信登录',
|
||||||
|
store_wechat_bind: '绑定微信',
|
||||||
|
store_home_view: '浏览首页',
|
||||||
|
store_redeem_entry_view: '进入核销页',
|
||||||
|
store_redeem_confirm_view: '核销确认页',
|
||||||
|
store_phone_redeem_view: '手机号核销页',
|
||||||
|
store_records_view: '浏览核销记录',
|
||||||
|
store_withdraw_view: '浏览提现页',
|
||||||
|
store_packages_view: '浏览套餐页',
|
||||||
|
store_mine_view: '浏览我的',
|
||||||
|
store_staff_view: '浏览子账号',
|
||||||
|
store_redeem_preview: '核销预览',
|
||||||
|
store_redeem_confirm: '确认核销',
|
||||||
|
store_redeem_confirm_fail: '核销失败',
|
||||||
|
store_redeem_weaknet_threshold: '弱网阈值',
|
||||||
|
store_redeem_pending_submit: '提交挂起核销',
|
||||||
|
store_redeem_pending_complete: '完成挂起核销',
|
||||||
|
store_redeem_pending_reject: '驳回挂起核销',
|
||||||
|
store_redeem_phone_lookup_sms: '手机号核销发码',
|
||||||
|
store_redeem_phone_balance: '查询用户权益余额',
|
||||||
|
store_redeem_phone_prepare: '手机号核销准备',
|
||||||
|
store_redeem_scan_start: '扫码核销开始',
|
||||||
|
store_payout_created: '生成打款单',
|
||||||
|
store_payout_paid: '打款完成',
|
||||||
|
store_withdraw_applied: '申请提现',
|
||||||
|
store_withdraw_paid: '提现已打款',
|
||||||
|
store_status_change: '变更营业状态',
|
||||||
|
store_staff_create: '新增子账号',
|
||||||
|
store_staff_update: '编辑子账号',
|
||||||
|
store_staff_delete: '删除子账号',
|
||||||
|
store_staff_permission_update: '更新子账号权限',
|
||||||
|
store_package_apply: '申请套餐',
|
||||||
|
store_package_audit_view: '查看套餐审核',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PARTNER_LOG_EVENT_LABELS: Record<string, string> = {
|
||||||
|
partner_sms_send: '发送验证码',
|
||||||
|
partner_sms_login: '短信登录',
|
||||||
|
partner_sms_verify_fail: '验证码校验失败',
|
||||||
|
partner_login_success: '登录成功',
|
||||||
|
partner_wechat_login: '微信登录',
|
||||||
|
partner_wechat_bind: '绑定微信',
|
||||||
|
partner_home_view: '浏览首页',
|
||||||
|
partner_store_list_view: '浏览门店列表',
|
||||||
|
partner_store_detail_view: '浏览门店详情',
|
||||||
|
partner_store_create_view: '进入录店页',
|
||||||
|
partner_order_list_view: '浏览订单列表',
|
||||||
|
partner_order_detail_view: '浏览订单详情',
|
||||||
|
partner_proxy_order_view: '进入代下单页',
|
||||||
|
partner_bills_view: '浏览账单列表',
|
||||||
|
partner_bill_detail_view: '浏览账单详情',
|
||||||
|
partner_staff_list_view: '浏览子账号',
|
||||||
|
partner_leaderboard_view: '浏览排行榜',
|
||||||
|
partner_weekly_report_view: '浏览周报',
|
||||||
|
partner_staff_create: '新增子账号',
|
||||||
|
partner_staff_sms_send: '子账号发验证码',
|
||||||
|
partner_staff_sms_verify_fail: '子账号验证码失败',
|
||||||
|
partner_staff_update: '编辑子账号',
|
||||||
|
partner_staff_delete: '删除子账号',
|
||||||
|
partner_staff_permission_update: '更新子账号权限',
|
||||||
|
partner_store_create: '录入门店',
|
||||||
|
partner_store_status_change: '变更门店状态',
|
||||||
|
partner_store_audit_approved: '门店审核通过',
|
||||||
|
partner_store_audit_rejected: '门店审核驳回',
|
||||||
|
partner_order_ship: '订单发货',
|
||||||
|
partner_delivery_advance: '推进配送',
|
||||||
|
partner_proxy_order_create: '代下单',
|
||||||
|
partner_bill_view: '查看账单',
|
||||||
|
partner_bill_confirm: '确认账单',
|
||||||
|
partner_warehouse_view: '浏览仓库',
|
||||||
|
partner_warehouse_update: '更新仓库',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EVENT_NAME_LABELS: Record<string, string> = {
|
||||||
|
...USER_LOG_EVENT_LABELS,
|
||||||
|
...STORE_LOG_EVENT_LABELS,
|
||||||
|
...PARTNER_LOG_EVENT_LABELS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THIRD_PARTY_PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
WECHAT_AUTH: '微信授权',
|
||||||
|
WECHAT_PAY: '微信支付',
|
||||||
|
WECHAT_REFUND: '微信退款',
|
||||||
|
WECHAT_MAP: '微信/腾讯地图',
|
||||||
|
ALIYUN_OSS: '阿里云 OSS',
|
||||||
|
ALIYUN_SMS: '阿里云短信',
|
||||||
|
MOCK_SMS: 'Mock 短信',
|
||||||
|
SMS: '短信',
|
||||||
|
XFX: '小飞侠',
|
||||||
|
LOGISTICS: '物流快递',
|
||||||
|
MANUAL: '手动',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THIRD_PARTY_STATUS_LABELS: Record<string, string> = {
|
||||||
|
SUCCESS: '成功',
|
||||||
|
FAILED: '失败',
|
||||||
|
PENDING: '处理中',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const THIRD_PARTY_SCENE_LABELS: Record<string, string> = {
|
||||||
|
LOGIN: '登录',
|
||||||
|
USERINFO: '用户信息',
|
||||||
|
JSSDK_CONFIG: 'JSSDK 配置',
|
||||||
|
BIND_PHONE: '绑定手机号',
|
||||||
|
GET_LOCATION: '获取定位',
|
||||||
|
CHOOSE_IMAGE: '选择图片',
|
||||||
|
ORDER_PAY: '订单支付',
|
||||||
|
ORDER_REFUND: '订单退款',
|
||||||
|
UPLOAD_SHIPPING: '上传发货信息',
|
||||||
|
TRADE_MANAGE_PUSH: '交易结算推送',
|
||||||
|
ORDER_DISPATCH: '配送下单',
|
||||||
|
ORDER_DISPATCH_HOLD: '配送暂不下发',
|
||||||
|
TRACK_CALLBACK: '物流回调',
|
||||||
|
UPLOAD_TOKEN: '上传凭证',
|
||||||
|
UPLOAD_PUT_OBJECT: '上传文件',
|
||||||
|
GEOCODE: '地理编码',
|
||||||
|
REVERSE_GEOCODE: '逆地理编码',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WECOM_BOT_ACTION_LABELS: Record<string, string> = {
|
||||||
|
...WECOM_BOT_PERMISSION_LABELS,
|
||||||
|
'user.read_sms.start': '查询用户·发送验证码',
|
||||||
|
'user.read_sms.verify': '查询用户·校验验证码',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GENERIC_STATUS_LABELS: Record<string, string> = {
|
||||||
|
...ORDER_STATUS_LABELS,
|
||||||
|
...LEDGER_TYPE_LABELS,
|
||||||
|
...THIRD_PARTY_STATUS_LABELS,
|
||||||
|
PENDING: '待处理',
|
||||||
|
APPROVED: '已通过',
|
||||||
|
REJECTED: '已驳回',
|
||||||
|
ACTIVE: '正常',
|
||||||
|
DISABLED: '停用',
|
||||||
|
SUCCESS: '成功',
|
||||||
|
FAILED: '失败',
|
||||||
|
OPEN: '营业中',
|
||||||
|
PAUSED: '已暂停',
|
||||||
|
CLOSED: '已关闭',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HQ_ROLE_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
HQ_ADMIN_ROLES.map((r) => [r.value, r.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const CODE_ZH: Record<string, string> = {
|
||||||
|
...CLIENT_APP_LABELS,
|
||||||
|
...REF_TYPE_LABELS,
|
||||||
|
...DOMAIN_EVENT_TYPE_LABELS,
|
||||||
|
...EVENT_NAME_LABELS,
|
||||||
|
...ORDER_STATUS_LABELS,
|
||||||
|
...ORDER_STATUS_OPERATOR_LABELS,
|
||||||
|
...LEDGER_TYPE_LABELS,
|
||||||
|
...HQ_OPERATION_ACTION_LABELS,
|
||||||
|
...HQ_ROLE_LABELS,
|
||||||
|
...THIRD_PARTY_PROVIDER_LABELS,
|
||||||
|
...THIRD_PARTY_STATUS_LABELS,
|
||||||
|
...THIRD_PARTY_SCENE_LABELS,
|
||||||
|
...WECOM_BOT_ACTION_LABELS,
|
||||||
|
...GENERIC_STATUS_LABELS,
|
||||||
|
...PARAM_DESC_LABELS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function zhCode(value?: string | null, empty = '—'): string {
|
||||||
|
if (value == null || value === '') return empty;
|
||||||
|
return CODE_ZH[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function eventNameLabel(value?: string | null): string {
|
||||||
|
return zhLabel(EVENT_NAME_LABELS, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clientAppLabel(value?: string | null): string {
|
||||||
|
return zhLabel(CLIENT_APP_LABELS, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRef(refType?: string | null, refId?: string | null): string {
|
||||||
|
if (!refType && !refId) return '—';
|
||||||
|
const type = zhLabel(REF_TYPE_LABELS, refType, refType || '');
|
||||||
|
return refId ? `${type} #${refId}` : type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userEventCategoryLabel(eventName: string, category?: string | null): string {
|
||||||
|
const key = category || resolveUserLogCategory(eventName);
|
||||||
|
if (key && USER_LOG_CATEGORY_LABELS[key]) return USER_LOG_CATEGORY_LABELS[key];
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeEventCategoryLabel(eventName: string, category?: string | null): string {
|
||||||
|
const key = category || resolveStoreLogCategory(eventName);
|
||||||
|
const labels = STORE_LOG_CATEGORY_LABELS as Record<string, string>;
|
||||||
|
if (key && labels[key]) return labels[key];
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function partnerEventCategoryLabel(eventName: string, category?: string | null): string {
|
||||||
|
const key = category || resolvePartnerLogCategory(eventName);
|
||||||
|
const labels = PARTNER_LOG_CATEGORY_LABELS as Record<string, string>;
|
||||||
|
if (key && labels[key]) return labels[key];
|
||||||
|
return '其他';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function domainParamFieldLabel(desc?: string | null, fallbackIndex?: 1 | 2 | 3): string {
|
||||||
|
if (desc && PARAM_DESC_LABELS[desc]) return PARAM_DESC_LABELS[desc];
|
||||||
|
if (fallbackIndex) return `参数${fallbackIndex}`;
|
||||||
|
return '参数';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zhRemark(value?: string | null): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
return value
|
||||||
|
.replace(/partnerAccountId=/g, '合伙人账号=')
|
||||||
|
.replace(/hqAccountId=/g, 'HQ账号=')
|
||||||
|
.replace(/deliveryMode=/g, '履约方式=')
|
||||||
|
.replace(/\bmode=/g, '履约方式=')
|
||||||
|
.replace(/customer=/g, '客户=')
|
||||||
|
.replace(/\bON_SITE_PICKUP\b/g, '现场提货')
|
||||||
|
.replace(/\bCROSS_CITY\b/g, '跨城')
|
||||||
|
.replace(/\bLOCAL\b/g, '同城');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function domainParamValue(desc?: string | null, value?: string | null): string {
|
||||||
|
if (value == null || value === '') return '—';
|
||||||
|
if (desc === 'from_status' || desc === 'to_status') return zhLabel(ORDER_STATUS_LABELS, value);
|
||||||
|
if (desc === 'operator') return zhLabel(ORDER_STATUS_OPERATOR_LABELS, value);
|
||||||
|
if (desc === 'ledger_type') return zhLabel(LEDGER_TYPE_LABELS, value);
|
||||||
|
if (desc === 'action') return zhLabel(HQ_OPERATION_ACTION_LABELS, value);
|
||||||
|
if (desc === 'target_type') return zhLabel(REF_TYPE_LABELS, value);
|
||||||
|
return zhCode(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** 将列表可见列 key 追加到导出 query */
|
||||||
|
export function appendExportColumns(qs: URLSearchParams, keys: string[]) {
|
||||||
|
if (keys.length) qs.set('columns', keys.join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportColumnsBody(keys: string[]): { columns?: string[] } {
|
||||||
|
return keys.length ? { columns: keys } : {};
|
||||||
|
}
|
||||||
@@ -8,3 +8,19 @@ export function downloadExcelCsv(csv: string, filename: string) {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Download binary file from base64 payload returned by HQ export APIs. */
|
||||||
|
export function downloadBase64File(contentBase64: string, filename: string, mimeType: string) {
|
||||||
|
const binary = atob(contentBase64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
||||||
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
|
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
|
||||||
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
|
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
|
||||||
|
{ value: 'USER_ASSOC_UPDATE', label: '修改用户关联合伙人' },
|
||||||
{ value: 'USER_DELETE', label: '删除用户' },
|
{ value: 'USER_DELETE', label: '删除用户' },
|
||||||
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
|
{ value: 'USER_BATCH_DELETE', label: '批量删除用户' },
|
||||||
{ value: 'ORDER_SHIP', label: '订单发货' },
|
{ value: 'ORDER_SHIP', label: '订单发货' },
|
||||||
@@ -27,6 +28,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 +40,31 @@ 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: '批量酒厂对账单打款' },
|
||||||
|
{ value: 'ACTIVITY_POSTER_CREATE', label: '新增活动图' },
|
||||||
|
{ value: 'ACTIVITY_POSTER_UPDATE', label: '编辑活动图' },
|
||||||
|
{ value: 'ACTIVITY_POSTER_UPDATE_STATUS', label: '活动图上下架' },
|
||||||
|
{ value: 'ACTIVITY_POSTER_DELETE', 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(
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { ColumnType } from 'antd/es/table';
|
||||||
|
import type { HqListColumnKey, HqListColumnPref } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
export const SERIAL_COLUMN_KEY = '__serial';
|
||||||
|
export const ACTIONS_COLUMN_KEY = '__actions';
|
||||||
|
|
||||||
|
export function columnKey<T>(col: ColumnType<T>, index: number): string {
|
||||||
|
if (col.key != null && String(col.key).length) return String(col.key);
|
||||||
|
const dataIndex = col.dataIndex;
|
||||||
|
if (typeof dataIndex === 'string' && dataIndex) return dataIndex;
|
||||||
|
if (Array.isArray(dataIndex) && dataIndex.length) return dataIndex.map(String).join('.');
|
||||||
|
if (typeof col.title === 'string' && col.title) return col.title;
|
||||||
|
return `col-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActionsColumn<T>(col: ColumnType<T>, key: string): boolean {
|
||||||
|
if (key === ACTIONS_COLUMN_KEY || key === 'actions' || key === 'action') return true;
|
||||||
|
return col.title === '操作';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeColumnOrder(defaultKeys: string[], pref?: HqListColumnPref): string[] {
|
||||||
|
const known = new Set(defaultKeys);
|
||||||
|
const ordered = (pref?.order ?? []).filter((k) => known.has(k));
|
||||||
|
for (const key of defaultKeys) {
|
||||||
|
if (ordered.includes(key)) continue;
|
||||||
|
const defaultIdx = defaultKeys.indexOf(key);
|
||||||
|
let insertAt = ordered.length;
|
||||||
|
for (let i = 0; i < ordered.length; i += 1) {
|
||||||
|
if (defaultKeys.indexOf(ordered[i]) > defaultIdx) {
|
||||||
|
insertAt = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ordered.splice(insertAt, 0, key);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function visibleColumnKeys(defaultKeys: string[], pref?: HqListColumnPref): string[] {
|
||||||
|
const hidden = new Set(pref?.hidden ?? []);
|
||||||
|
return mergeColumnOrder(defaultKeys, pref).filter((k) => !hidden.has(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyColumnPrefs<T>(
|
||||||
|
columns: ColumnType<T>[],
|
||||||
|
pref: HqListColumnPref | undefined,
|
||||||
|
): ColumnType<T>[] {
|
||||||
|
const keyed = columns.map((col, i) => {
|
||||||
|
const key = columnKey(col, i);
|
||||||
|
return { col: { ...col, key }, key, actions: isActionsColumn(col, key) };
|
||||||
|
});
|
||||||
|
const configurable = keyed.filter((c) => !c.actions);
|
||||||
|
const actions = keyed.filter((c) => c.actions);
|
||||||
|
const defaultKeys = configurable.map((c) => c.key);
|
||||||
|
const visible = new Set(visibleColumnKeys(defaultKeys, pref));
|
||||||
|
const orderedKeys = mergeColumnOrder(defaultKeys, pref).filter((k) => visible.has(k));
|
||||||
|
const byKey = new Map(configurable.map((c) => [c.key, c.col]));
|
||||||
|
return [
|
||||||
|
...orderedKeys
|
||||||
|
.map((k) => {
|
||||||
|
const col = byKey.get(k);
|
||||||
|
if (!col) return null;
|
||||||
|
const w = pref?.widths?.[k];
|
||||||
|
return w != null ? { ...col, width: w } : col;
|
||||||
|
})
|
||||||
|
.filter((c): c is ColumnType<T> => !!c),
|
||||||
|
...actions.map((c) => {
|
||||||
|
const w = pref?.widths?.[c.key];
|
||||||
|
return w != null ? { ...c.col, width: w } : c.col;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ListColumnSettingItem = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
visible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function settingItems<T>(
|
||||||
|
columns: ColumnType<T>[],
|
||||||
|
pref?: HqListColumnPref,
|
||||||
|
): ListColumnSettingItem[] {
|
||||||
|
const keyed = columns
|
||||||
|
.map((col, i) => ({ col, key: columnKey(col, i) }))
|
||||||
|
.filter(({ col, key }) => !isActionsColumn(col, key));
|
||||||
|
const hidden = new Set(pref?.hidden ?? []);
|
||||||
|
const order = mergeColumnOrder(keyed.map((c) => c.key), pref);
|
||||||
|
const byKey = new Map(keyed.map((c) => [c.key, c.col]));
|
||||||
|
return order.map((key) => {
|
||||||
|
const col = byKey.get(key);
|
||||||
|
const title = typeof col?.title === 'string' ? col.title : key;
|
||||||
|
return { key, title, visible: !hidden.has(key) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { HqListColumnKey };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** 表单选填项常为 null,PUT 时去掉以免后端对 null 调 trim */
|
||||||
|
export function omitNullFields<T extends Record<string, unknown>>(input: T): Partial<T> {
|
||||||
|
const out: Partial<T> = {};
|
||||||
|
for (const [key, value] of Object.entries(input)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
(out as Record<string, unknown>)[key] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { isStoreContactPhone, STORE_CONTACT_PHONE_HINT } from '@dukang/domain';
|
||||||
|
|
||||||
export type StoreCreateForm = {
|
export type StoreCreateForm = {
|
||||||
partnerAccountId: string;
|
partnerAccountId: string;
|
||||||
cityId: string;
|
cityId: string;
|
||||||
@@ -6,35 +8,104 @@ export type StoreCreateForm = {
|
|||||||
city?: string;
|
city?: string;
|
||||||
district: string;
|
district: string;
|
||||||
districtCode?: string;
|
districtCode?: string;
|
||||||
|
/** @deprecated 使用 categoryIds */
|
||||||
|
categoryParentId?: string;
|
||||||
|
/** @deprecated 使用 categoryIds */
|
||||||
|
categoryId?: string;
|
||||||
|
categoryIds: 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;
|
||||||
|
/** 对外联系电话(店长);可与登录号不同,支持座机 */
|
||||||
|
contactPhone?: string;
|
||||||
|
openTime?: string;
|
||||||
|
closeTime?: string;
|
||||||
|
openTime2?: string;
|
||||||
|
closeTime2?: string;
|
||||||
|
avgPrice?: number | null;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
envPhotoUrls?: string[];
|
envPhotoUrls?: string[];
|
||||||
contractUrl?: string;
|
/** 签约合同,支持多张照片 / PDF */
|
||||||
|
contractUrls?: string[];
|
||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
settlementRate?: number;
|
settlementRate?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
visibilityWhitelistEnabled?: boolean;
|
||||||
|
visibilityPhones?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
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'
|
||||||
|
| 'categoryIds'
|
||||||
|
| 'name'
|
||||||
|
| 'phone'
|
||||||
|
| 'address'
|
||||||
|
| 'intro'
|
||||||
|
| 'benefitUsageRule'
|
||||||
|
| 'openTime'
|
||||||
|
| 'closeTime'
|
||||||
|
| 'openTime2'
|
||||||
|
| 'closeTime2'
|
||||||
|
| 'avgPrice'
|
||||||
|
| 'contactPhone'
|
||||||
|
>,
|
||||||
): 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.categoryIds?.length) 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位手机号';
|
||||||
|
const contactPhone = form.contactPhone?.trim();
|
||||||
|
if (contactPhone && !isStoreContactPhone(contactPhone)) return STORE_CONTACT_PHONE_HINT;
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
|
import { apiBase, CLIENT_APP, getToken, clearAuth } from './api';
|
||||||
|
import {
|
||||||
|
compressImageFileIfNeeded,
|
||||||
|
DEFAULT_OSS_MAX_UPLOAD_BYTES,
|
||||||
|
formatOssMaxSizeMb,
|
||||||
|
} from '@dukang/shared-ui/compressImage';
|
||||||
|
import type { OssUploadImageMeta } from '@dukang/shared-types';
|
||||||
|
|
||||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
@@ -7,16 +13,27 @@ export type UploadFileResult = {
|
|||||||
ossKey: string;
|
ossKey: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
mock: boolean;
|
mock: boolean;
|
||||||
|
image?: OssUploadImageMeta;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function prepareUploadFile(file: File, mediaType: OssMediaType): Promise<File> {
|
||||||
|
if (mediaType !== 'IMAGE') return file;
|
||||||
|
const compressed = await compressImageFileIfNeeded(file);
|
||||||
|
if (compressed.size > DEFAULT_OSS_MAX_UPLOAD_BYTES) {
|
||||||
|
throw new Error(`图片压缩后仍超过 ${formatOssMaxSizeMb()}MB,请换一张较小的图片`);
|
||||||
|
}
|
||||||
|
return compressed;
|
||||||
|
}
|
||||||
|
|
||||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||||
export async function uploadFileToOss(
|
export async function uploadFileToOss(
|
||||||
file: File,
|
file: File,
|
||||||
options: { bizType: string; mediaType?: OssMediaType },
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
): Promise<UploadFileResult> {
|
): Promise<UploadFileResult> {
|
||||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||||
|
const prepared = await prepareUploadFile(file, mediaType);
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', prepared);
|
||||||
formData.append('bizType', options.bizType);
|
formData.append('bizType', options.bizType);
|
||||||
formData.append('mediaType', mediaType);
|
formData.append('mediaType', mediaType);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Button, message } from 'antd';
|
||||||
|
import { SettingOutlined } from '@ant-design/icons';
|
||||||
|
import type { ColumnsType, ColumnType } from 'antd/es/table';
|
||||||
|
import type { HqListColumnKey } from '@dukang/shared-types';
|
||||||
|
import { AdminListColumnsModal } from '../components/AdminListColumnsModal';
|
||||||
|
import { useListColumnPrefs } from './ListColumnPrefsContext';
|
||||||
|
import {
|
||||||
|
ACTIONS_COLUMN_KEY,
|
||||||
|
SERIAL_COLUMN_KEY,
|
||||||
|
applyColumnPrefs,
|
||||||
|
columnKey,
|
||||||
|
settingItems,
|
||||||
|
visibleColumnKeys,
|
||||||
|
type ListColumnSettingItem,
|
||||||
|
} from './list-column-prefs';
|
||||||
|
import { beginColumnResize, withResizeTitle } from './column-resize';
|
||||||
|
|
||||||
|
type Options = {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAdminListColumns<T>(
|
||||||
|
listKey: HqListColumnKey,
|
||||||
|
allColumns: ColumnsType<T>,
|
||||||
|
options: Options = {},
|
||||||
|
) {
|
||||||
|
const { page = 1, pageSize = 20 } = options;
|
||||||
|
const { prefs, save } = useListColumnPrefs();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [localWidths, setLocalWidths] = useState<Record<string, number>>({});
|
||||||
|
const pref = prefs[listKey];
|
||||||
|
const prefRef = useRef(pref);
|
||||||
|
prefRef.current = pref;
|
||||||
|
const localWidthsRef = useRef(localWidths);
|
||||||
|
localWidthsRef.current = localWidths;
|
||||||
|
const itemsRef = useRef<ListColumnSettingItem[]>([]);
|
||||||
|
|
||||||
|
const configured = useMemo(
|
||||||
|
() => applyColumnPrefs(allColumns as ColumnType<T>[], pref),
|
||||||
|
[allColumns, pref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = useMemo(
|
||||||
|
() => settingItems(allColumns as ColumnType<T>[], pref),
|
||||||
|
[allColumns, pref],
|
||||||
|
);
|
||||||
|
itemsRef.current = items;
|
||||||
|
|
||||||
|
const exportColumnKeys = useMemo(() => {
|
||||||
|
const keyed = (allColumns as ColumnType<T>[]).map((col, i) => ({
|
||||||
|
col,
|
||||||
|
key: columnKey(col, i),
|
||||||
|
actions: col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions',
|
||||||
|
}));
|
||||||
|
const defaultKeys = keyed.filter((c) => !c.actions).map((c) => c.key);
|
||||||
|
return visibleColumnKeys(defaultKeys, pref);
|
||||||
|
}, [allColumns, pref]);
|
||||||
|
|
||||||
|
const serialCol: ColumnType<T> = useMemo(
|
||||||
|
() => ({
|
||||||
|
key: SERIAL_COLUMN_KEY,
|
||||||
|
title: '序号',
|
||||||
|
width: 64,
|
||||||
|
fixed: 'left' as const,
|
||||||
|
render: (_: unknown, __: T, index: number) => (page - 1) * pageSize + index + 1,
|
||||||
|
}),
|
||||||
|
[page, pageSize],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistPref = useCallback(
|
||||||
|
async (next: { order: string[]; hidden: string[]; widths?: Record<string, number> } | null) => {
|
||||||
|
if (!next) {
|
||||||
|
setLocalWidths({});
|
||||||
|
await save(listKey, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await save(listKey, next);
|
||||||
|
},
|
||||||
|
[listKey, save],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persist = useCallback(
|
||||||
|
async (next: ListColumnSettingItem[] | null) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (!next) {
|
||||||
|
await persistPref(null);
|
||||||
|
} else {
|
||||||
|
const widths = { ...(prefRef.current?.widths ?? {}), ...localWidthsRef.current };
|
||||||
|
await persistPref({
|
||||||
|
order: next.map((i) => i.key),
|
||||||
|
hidden: next.filter((i) => !i.visible).map((i) => i.key),
|
||||||
|
...(Object.keys(widths).length ? { widths } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
message.success(next ? '列设置已保存' : '已恢复默认列');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[persistPref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistWidth = useCallback(
|
||||||
|
async (key: string, width: number) => {
|
||||||
|
const current = prefRef.current;
|
||||||
|
const prevW = current?.widths?.[key];
|
||||||
|
if (prevW === width) return;
|
||||||
|
const order = current?.order?.length ? current.order : itemsRef.current.map((i) => i.key);
|
||||||
|
const hidden = current?.hidden ?? [];
|
||||||
|
const widths = { ...(current?.widths ?? {}), ...localWidthsRef.current, [key]: width };
|
||||||
|
try {
|
||||||
|
await persistPref({ order, hidden, widths });
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '列宽保存失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[persistPref],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnsType<T>>(() => {
|
||||||
|
const merged: ColumnType<T>[] = [
|
||||||
|
serialCol,
|
||||||
|
...configured.map((col) =>
|
||||||
|
col.title === '操作' || col.key === ACTIONS_COLUMN_KEY || col.key === 'actions'
|
||||||
|
? { ...col, key: col.key ?? ACTIONS_COLUMN_KEY }
|
||||||
|
: col,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const widths = { ...(pref?.widths ?? {}), ...localWidths };
|
||||||
|
return merged.map((col, i) => {
|
||||||
|
const key = columnKey(col, i);
|
||||||
|
const width = widths[key] ?? (typeof col.width === 'number' ? col.width : undefined);
|
||||||
|
const prevHeader = col.onHeaderCell;
|
||||||
|
return {
|
||||||
|
...col,
|
||||||
|
key,
|
||||||
|
width,
|
||||||
|
title: withResizeTitle(col.title, (e) => {
|
||||||
|
const th = (e.currentTarget as HTMLElement).closest('th');
|
||||||
|
const startWidth = width ?? th?.getBoundingClientRect().width ?? 120;
|
||||||
|
beginColumnResize(
|
||||||
|
e,
|
||||||
|
startWidth,
|
||||||
|
(next) => setLocalWidths((prev) => ({ ...prev, [key]: next })),
|
||||||
|
(next) => void persistWidth(key, next),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
onHeaderCell: (column) => {
|
||||||
|
const extra = typeof prevHeader === 'function' ? prevHeader(column) : {};
|
||||||
|
return {
|
||||||
|
...extra,
|
||||||
|
className: [extra.className, 'admin-th-resizable'].filter(Boolean).join(' '),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [configured, serialCol, pref?.widths, localWidths, persistWidth]);
|
||||||
|
|
||||||
|
const settingsButton = (
|
||||||
|
<span className="admin-list-settings-slot">
|
||||||
|
<Button type="text" icon={<SettingOutlined />} className="admin-list-settings-btn" onClick={() => setOpen(true)}>
|
||||||
|
列设置
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
const settingsModal = (
|
||||||
|
<AdminListColumnsModal
|
||||||
|
open={open}
|
||||||
|
items={items}
|
||||||
|
saving={saving}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
onReset={() => void persist(null)}
|
||||||
|
onSave={(draft) => void persist(draft)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { columns, settingsButton, settingsModal, exportColumnKeys };
|
||||||
|
}
|
||||||
@@ -1,45 +1,12 @@
|
|||||||
export type UserLogCategory =
|
export {
|
||||||
| 'login'
|
USER_LOG_CATEGORY_OPTIONS,
|
||||||
| 'browse_product'
|
resolveUserLogCategory,
|
||||||
| 'order'
|
eventNamesForUserLogCategory,
|
||||||
| 'pay'
|
type UserLogCategory,
|
||||||
| 'wechat_auth'
|
} from '@dukang/shared-types';
|
||||||
| 'browse_store'
|
|
||||||
| 'redeem'
|
|
||||||
| 'profile';
|
|
||||||
|
|
||||||
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
import { USER_LOG_CATEGORY_OPTIONS } from '@dukang/shared-types';
|
||||||
login: ['login_success', 'sms_login', 'sms_send', 'sms_verify_fail'],
|
|
||||||
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
|
||||||
order: ['order_confirm_view', 'order_submit'],
|
|
||||||
pay: ['pay_success', 'pay_fail'],
|
|
||||||
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
|
||||||
browse_store: ['store_list_view', 'store_detail_view'],
|
|
||||||
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
|
||||||
profile: ['profile_update', 'bind_phone'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
export const USER_LOG_CATEGORY_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
{ value: '', label: '全部' },
|
|
||||||
{ value: 'login', label: '登录' },
|
|
||||||
{ value: 'browse_product', label: '浏览商品' },
|
|
||||||
{ value: 'order', label: '下单' },
|
|
||||||
{ value: 'pay', label: '支付' },
|
|
||||||
{ value: 'wechat_auth', label: '微信授权' },
|
|
||||||
{ value: 'browse_store', label: '浏览门店' },
|
|
||||||
{ value: 'redeem', label: '核销' },
|
|
||||||
{ value: 'profile', label: '信息修改' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
|
|
||||||
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
|
|
||||||
[UserLogCategory, readonly string[]]
|
|
||||||
>) {
|
|
||||||
if (events.includes(eventName)) return category;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
|
|
||||||
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
|
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
|
||||||
) as Record<string, string>;
|
);
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert, Button, Form, Image, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
ACTIVITY_POSTER_STATUS_LABELS,
|
||||||
|
ACTIVITY_POSTER_UPLOAD_HINT,
|
||||||
|
DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||||
|
type ActivityPosterItem,
|
||||||
|
type ActivityPosterQrSlot,
|
||||||
|
type ActivityPosterStatus,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request, requestDownload } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import ActivityPosterQrSlotEditor from '../components/ActivityPosterQrSlotEditor';
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
title: string;
|
||||||
|
copyText?: string;
|
||||||
|
imageUrl: string;
|
||||||
|
qrXPct: number;
|
||||||
|
qrYPct: number;
|
||||||
|
qrSizePct: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
status?: ActivityPosterStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PartnerHint = {
|
||||||
|
id: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActivityPostersPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const partnerId = searchParams.get('partnerId')?.trim() || '';
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [editForm] = Form.useForm<FormValues>();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<ActivityPosterItem>(
|
||||||
|
'/admin/activity-posters',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ActivityPosterItem | null>(null);
|
||||||
|
const [partnerHint, setPartnerHint] = useState<PartnerHint | null>(null);
|
||||||
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const imageUrl = Form.useWatch('imageUrl', editForm);
|
||||||
|
const qrXPct = Form.useWatch('qrXPct', editForm);
|
||||||
|
const qrYPct = Form.useWatch('qrYPct', editForm);
|
||||||
|
const qrSizePct = Form.useWatch('qrSizePct', editForm);
|
||||||
|
const slot: ActivityPosterQrSlot = {
|
||||||
|
qrXPct: qrXPct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrXPct,
|
||||||
|
qrYPct: qrYPct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrYPct,
|
||||||
|
qrSizePct: qrSizePct ?? DEFAULT_ACTIVITY_POSTER_QR_SLOT.qrSizePct,
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!partnerId) {
|
||||||
|
setPartnerHint(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void request<PartnerHint>(`/admin/partners/${partnerId}`)
|
||||||
|
.then(setPartnerHint)
|
||||||
|
.catch(() => setPartnerHint({ id: partnerId }));
|
||||||
|
}, [partnerId]);
|
||||||
|
|
||||||
|
const partnerLabel = partnerHint
|
||||||
|
? (partnerHint.companyName || partnerHint.name || partnerHint.phone || partnerHint.id)
|
||||||
|
: partnerId;
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
title: '',
|
||||||
|
copyText: '',
|
||||||
|
imageUrl: '',
|
||||||
|
...DEFAULT_ACTIVITY_POSTER_QR_SLOT,
|
||||||
|
sortOrder: 0,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: ActivityPosterItem) {
|
||||||
|
setEditing(row);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
title: row.title,
|
||||||
|
copyText: row.copyText,
|
||||||
|
imageUrl: row.imageUrl,
|
||||||
|
qrXPct: row.qrXPct,
|
||||||
|
qrYPct: row.qrYPct,
|
||||||
|
qrSizePct: row.qrSizePct,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: row.status,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const v = await editForm.validateFields();
|
||||||
|
const body = JSON.stringify(v);
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/activity-posters/${editing.id}`, { method: 'PUT', body });
|
||||||
|
message.success('已保存');
|
||||||
|
} else {
|
||||||
|
await request('/admin/activity-posters', { method: 'POST', body });
|
||||||
|
message.success('已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadForPartner(posterId: string) {
|
||||||
|
if (!partnerId) return;
|
||||||
|
setDownloadingId(posterId);
|
||||||
|
try {
|
||||||
|
await requestDownload(
|
||||||
|
`/admin/activity-posters/${posterId}/image?partnerId=${encodeURIComponent(partnerId)}`,
|
||||||
|
{},
|
||||||
|
'activity-poster.png',
|
||||||
|
);
|
||||||
|
message.success('已开始下载');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '下载失败');
|
||||||
|
} finally {
|
||||||
|
setDownloadingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<ActivityPosterItem> = [
|
||||||
|
{
|
||||||
|
title: '标题',
|
||||||
|
dataIndex: 'title',
|
||||||
|
width: 180,
|
||||||
|
render: (v, row) => <AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '封面',
|
||||||
|
dataIndex: 'imageUrl',
|
||||||
|
width: 90,
|
||||||
|
render: (url: string) => <Image src={url} width={56} height={56} style={{ objectFit: 'cover' }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '文案',
|
||||||
|
dataIndex: 'copyText',
|
||||||
|
width: 240,
|
||||||
|
render: (v: string) => v || '—',
|
||||||
|
},
|
||||||
|
{ title: '排序', dataIndex: 'sortOrder', width: 70 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (s: ActivityPosterStatus) => (
|
||||||
|
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{ACTIVITY_POSTER_STATUS_LABELS[s] || s}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: partnerId ? 280 : 200,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||||
|
{partnerId && row.status === 'ACTIVE' ? (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
loading={downloadingId === row.id}
|
||||||
|
onClick={() => void downloadForPartner(row.id)}
|
||||||
|
>
|
||||||
|
为该合伙人下载
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
|
||||||
|
await request(`/admin/activity-posters/${row.id}/status`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ status: next }),
|
||||||
|
});
|
||||||
|
message.success(next === 'ACTIVE' ? '已上架' : '已下架');
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.status === 'ACTIVE' ? '下架' : '上架'}
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该活动图?"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await request(`/admin/activity-posters/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('activity-posters', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="活动图"
|
||||||
|
settings={settingsButton}
|
||||||
|
actions={<Button type="primary" onClick={openCreate}>新建活动图</Button>}
|
||||||
|
/>
|
||||||
|
{partnerId ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
正在为合伙人「{partnerLabel}」下载活动图。点行内「为该合伙人下载」,或返回{' '}
|
||||||
|
<Link to="/city-partners">城市合伙人</Link>。
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
options={Object.entries(ACTIVITY_POSTER_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
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 save()}
|
||||||
|
width={720}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={editForm} layout="vertical">
|
||||||
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请填写标题' }]}>
|
||||||
|
<Input maxLength={128} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="imageUrl" label="活动图" rules={[{ required: true, message: '请上传活动图' }]}>
|
||||||
|
<OssUpload bizType="ACTIVITY_POSTER" uploadHint={ACTIVITY_POSTER_UPLOAD_HINT} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="qrXPct" hidden><InputNumber /></Form.Item>
|
||||||
|
<Form.Item name="qrYPct" hidden><InputNumber /></Form.Item>
|
||||||
|
<Form.Item name="qrSizePct" hidden><InputNumber /></Form.Item>
|
||||||
|
<Form.Item label="二维码栏">
|
||||||
|
<ActivityPosterQrSlotEditor
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
value={slot}
|
||||||
|
onChange={(next) => editForm.setFieldsValue(next)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="copyText" label="推广文案">
|
||||||
|
<Input.TextArea rows={4} maxLength={4000} placeholder="合伙人可一键复制" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序" initialValue={0}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||||
|
<Select options={Object.entries(ACTIVITY_POSTER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -17,8 +19,23 @@ import {
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
import type { AdminBenefitGrantRequest } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { COUPON_STATUS_LABELS, DELIVERY_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
import RedeemRecordDetailDescriptions from '../components/RedeemRecordDetailDescriptions';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
type CouponOrder = {
|
||||||
|
id?: string;
|
||||||
|
orderNo?: string;
|
||||||
|
productName?: string | null;
|
||||||
|
productSpec?: string | null;
|
||||||
|
quantity?: number | null;
|
||||||
|
saleUnit?: string | null;
|
||||||
|
deliveryType?: string | null;
|
||||||
|
payAmount?: number | string | null;
|
||||||
|
};
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,14 +46,81 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
sourceProduct: string;
|
sourceProduct: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
user?: { userNo: string; phone: string | null };
|
user?: { id?: string; userNo: string; phone: string | null };
|
||||||
order?: { orderNo: string } | null;
|
order?: CouponOrder | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function saleUnitLabel(unit?: string | null) {
|
||||||
|
if (unit === 'BOX') return '箱';
|
||||||
|
if (unit === 'BOTTLE') return '瓶';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPayAmount(v?: number | string | null) {
|
||||||
|
if (v == null || v === '') return '';
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? `¥${n.toFixed(2)}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 来源:有订单时展示商品名、规格、数量、配送方式、金额;手动发放沿用 sourceProduct */
|
||||||
|
function formatCouponSource(row: { sourceProduct?: string; order?: CouponOrder | null }) {
|
||||||
|
const order = row.order;
|
||||||
|
if (!order?.orderNo && !order?.productName) {
|
||||||
|
return row.sourceProduct || '—';
|
||||||
|
}
|
||||||
|
const unit = saleUnitLabel(order.saleUnit);
|
||||||
|
const qty =
|
||||||
|
order.quantity != null ? `${order.quantity}${unit}` : '';
|
||||||
|
const delivery = DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '';
|
||||||
|
const amount = formatPayAmount(order.payAmount);
|
||||||
|
const parts = [
|
||||||
|
order.productName || row.sourceProduct,
|
||||||
|
order.productSpec,
|
||||||
|
qty,
|
||||||
|
delivery,
|
||||||
|
amount,
|
||||||
|
].filter((p) => p != null && String(p).trim() !== '');
|
||||||
|
return parts.join(' / ') || row.sourceProduct || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
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[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function BenefitCouponsPage() {
|
export default function BenefitCouponsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialCouponNo = searchParams.get('couponNo')?.trim() || '';
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
const [grantForm] = Form.useForm<AdminBenefitGrantRequest>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
if (initialCouponNo) init.couponNo = initialCouponNo;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
const [grantOpen, setGrantOpen] = useState(false);
|
const [grantOpen, setGrantOpen] = useState(false);
|
||||||
const [granting, setGranting] = useState(false);
|
const [granting, setGranting] = useState(false);
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
@@ -50,24 +134,106 @@ 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);
|
||||||
|
const deepLinkOpenedRef = useRef(false);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
async function openCouponDetail(id: string) {
|
||||||
{ title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false },
|
setDetail(await request<CouponDetail>(`/admin/benefit/coupons/${id}`));
|
||||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialCouponNo) form.setFieldsValue({ couponNo: initialCouponNo });
|
||||||
|
}, [form, initialCouponNo]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const openCouponId = (location.state as { openCouponId?: string } | null)?.openCouponId;
|
||||||
|
if (openCouponId && !deepLinkOpenedRef.current) {
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
void openCouponDetail(openCouponId).catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载权益券失败');
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!initialCouponNo || deepLinkOpenedRef.current || loading) return;
|
||||||
|
const first = data?.items?.[0];
|
||||||
|
if (first && String(first.couponNo) === initialCouponNo) {
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
void openCouponDetail(first.id).catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载权益券失败');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [data, initialCouponNo, loading, location.state]);
|
||||||
|
|
||||||
|
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 baseColumns: ColumnsType<Row> = [
|
||||||
|
{
|
||||||
|
title: '券号',
|
||||||
|
dataIndex: 'couponNo',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: false,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openCouponDetail(row.id)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
dataIndex: ['user', 'userNo'],
|
||||||
|
width: 120,
|
||||||
|
ellipsis: false,
|
||||||
|
render: (v: string | undefined, row) =>
|
||||||
|
row.user?.id ? (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate('/users', { state: { openUserId: String(row.user!.id) } })}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
v || '—'
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
{ title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '订单',
|
title: '订单',
|
||||||
dataIndex: ['order', 'orderNo'],
|
dataIndex: ['order', 'orderNo'],
|
||||||
width: 180,
|
width: 180,
|
||||||
ellipsis: false,
|
ellipsis: false,
|
||||||
render: (v) => v || '—',
|
render: (v: string | undefined) =>
|
||||||
|
v ? (
|
||||||
|
<AdminPrimaryLink onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
{ title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
{ title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` },
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{COUPON_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '来源', dataIndex: 'sourceProduct', ellipsis: true },
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'sourceProduct',
|
||||||
|
width: 360,
|
||||||
|
ellipsis: false,
|
||||||
|
render: (_: string, row) => formatCouponSource(row),
|
||||||
|
},
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -76,10 +242,7 @@ export default function BenefitCouponsPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={async () => {
|
onClick={() => void openCouponDetail(row.id)}
|
||||||
setDetail(await request(`/admin/benefit/coupons/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</Button>
|
</Button>
|
||||||
@@ -109,16 +272,20 @@ export default function BenefitCouponsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-coupons', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
好客权益券
|
title="好客权益券"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
actions={
|
||||||
手动发放
|
<Button type="primary" onClick={() => setGrantOpen(true)}>
|
||||||
</Button>
|
手动发放
|
||||||
</div>
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="couponNo" label="券号">
|
<Form.Item name="couponNo" label="券号">
|
||||||
@@ -144,7 +311,7 @@ export default function BenefitCouponsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -197,7 +364,8 @@ export default function BenefitCouponsPage() {
|
|||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
title="权益券详情"
|
title="权益券详情"
|
||||||
width={560}
|
width={980}
|
||||||
|
styles={{ body: { paddingBottom: 24 } }}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
@@ -218,26 +386,154 @@ export default function BenefitCouponsPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<>
|
||||||
<Descriptions.Item label="券号">{String(detail.couponNo)}</Descriptions.Item>
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="用户">
|
<Descriptions.Item label="券号">{detail.couponNo}</Descriptions.Item>
|
||||||
{String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')}
|
<Descriptions.Item label="用户">
|
||||||
</Descriptions.Item>
|
{detail.user?.id ? (
|
||||||
<Descriptions.Item label="手机号">
|
<AdminPrimaryLink
|
||||||
{String((detail.user as { phone?: string } | undefined)?.phone ?? '—')}
|
onClick={() => navigate('/users', { state: { openUserId: String(detail.user!.id) } })}
|
||||||
</Descriptions.Item>
|
>
|
||||||
<Descriptions.Item label="关联订单">
|
{detail.user?.userNo}
|
||||||
{String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')}
|
</AdminPrimaryLink>
|
||||||
</Descriptions.Item>
|
) : (
|
||||||
<Descriptions.Item label="总额">¥{String(detail.totalAmount)}</Descriptions.Item>
|
(detail.user?.userNo ?? '—')
|
||||||
<Descriptions.Item label="余额">¥{String(detail.balance)}</Descriptions.Item>
|
)}
|
||||||
<Descriptions.Item label="状态">
|
</Descriptions.Item>
|
||||||
{COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
<Descriptions.Item label="手机号">{detail.user?.phone ?? '—'}</Descriptions.Item>
|
||||||
</Descriptions.Item>
|
<Descriptions.Item label="关联订单">
|
||||||
<Descriptions.Item label="来源">{String(detail.sourceProduct)}</Descriptions.Item>
|
{detail.order?.orderNo ? (
|
||||||
</Descriptions>
|
<AdminPrimaryLink
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/orders?orderNo=${encodeURIComponent(detail.order!.orderNo!)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{detail.order.orderNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</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="来源">{formatCouponSource(detail)}</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}
|
||||||
|
tableLayout="fixed"
|
||||||
|
locale={{ emptyText: '暂无关联核销单' }}
|
||||||
|
dataSource={detail.redeemRecords ?? []}
|
||||||
|
columns={[
|
||||||
|
{ title: '核销号', dataIndex: 'redeemNo' },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
width: 72,
|
||||||
|
render: (v: string | undefined) =>
|
||||||
|
v === 'SECONDARY' ? <Tag color="orange">次券</Tag> : <Tag color="blue">主券</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '门店',
|
||||||
|
dataIndex: ['store', 'name'],
|
||||||
|
|
||||||
|
render: (v: string | undefined, row: CouponRedeemRecord) =>
|
||||||
|
v ? `${v}${row.store?.cityName ? `(${row.store.cityName})` : ''}` : '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本券分摊',
|
||||||
|
dataIndex: 'couponAmount',
|
||||||
|
width: 96,
|
||||||
|
render: (v: number | undefined, row: CouponRedeemRecord) =>
|
||||||
|
`¥${Number(v ?? row.amount).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '核销总额',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
width: 96,
|
||||||
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算额',
|
||||||
|
dataIndex: 'settleAmount',
|
||||||
|
width: 96,
|
||||||
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 148,
|
||||||
|
render: (v: string) => fmtTime(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 64,
|
||||||
|
render: (_: unknown, row: CouponRedeemRecord) => (
|
||||||
|
<Button type="link" size="small" onClick={() => void openRedeemDetail(row.id)}>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="secondary">暂无核销摘要</Typography.Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="核销单详情"
|
||||||
|
width={640}
|
||||||
|
open={redeemDrawerOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setRedeemDrawerOpen(false);
|
||||||
|
setRedeemDetail(null);
|
||||||
|
}}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
{redeemDetailLoading ? (
|
||||||
|
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||||||
|
) : redeemDetail ? (
|
||||||
|
<RedeemRecordDetailDescriptions detail={redeemDetail} />
|
||||||
|
) : null}
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { Form, Input, Select, Button, Table, Tag, Typography } from 'antd';
|
|||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
import { LEDGER_TYPE_LABELS, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
|
id: string; type: string; amount: number; balanceAfter: number; remark: string | null; createdAt: string;
|
||||||
@@ -24,19 +27,22 @@ export default function BenefitLedgersPage() {
|
|||||||
[filters],
|
[filters],
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
|
{ title: '类型', dataIndex: 'type', width: 90, render: (t) => <Tag>{LEDGER_TYPE_LABELS[t] || t}</Tag> },
|
||||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
{ title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false },
|
||||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
|
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 200, ellipsis: false },
|
||||||
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
|
{ title: '变动', dataIndex: 'amount', width: 90, render: (v) => `${v >= 0 ? '+' : ''}${v}` },
|
||||||
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
|
{ title: '余额后', dataIndex: 'balanceAfter', width: 90 },
|
||||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
{ title: '备注', dataIndex: 'remark' },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('benefit-ledgers', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>权益流水</Typography.Title>
|
{settingsModal}
|
||||||
|
<AdminListHeader title="权益流水" settings={settingsButton} />
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
<Form.Item name="userId" label="用户ID"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
<Form.Item name="couponId" label="券ID"><Input allowClear /></Form.Item>
|
||||||
@@ -45,7 +51,7 @@ export default function BenefitLedgersPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,621 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import bgmSrc from '../assets/big-screen/dukang-bgm.mp3';
|
||||||
|
import sfxTier1 from '../assets/big-screen/celebrate-t1.mp3';
|
||||||
|
import sfxTier2 from '../assets/big-screen/celebrate-t2.mp3';
|
||||||
|
import sfxTier3 from '../assets/big-screen/celebrate-t3.mp3';
|
||||||
|
|
||||||
|
export type BigScreenOrder = {
|
||||||
|
id: string;
|
||||||
|
orderNo: string;
|
||||||
|
payAmount: number;
|
||||||
|
items: string;
|
||||||
|
/** 展示用时间(付款成功时间) */
|
||||||
|
createdAt: string;
|
||||||
|
paidAt?: string;
|
||||||
|
userPhoneMasked: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AmountTier = 1 | 2 | 3;
|
||||||
|
|
||||||
|
const POLL_MS = 3000;
|
||||||
|
const ROW_MS = 2500;
|
||||||
|
/** 不足此条数时重复填充,保证滚动连贯 */
|
||||||
|
const MIN_SCROLL_ROWS = 10;
|
||||||
|
const FX_DURATION_MS = 10_000;
|
||||||
|
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
|
||||||
|
|
||||||
|
const CELEBRATE_SFX: Record<AmountTier, string> = {
|
||||||
|
1: sfxTier1,
|
||||||
|
2: sfxTier2,
|
||||||
|
3: sfxTier3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 浏览器自动播放策略:需用户手势后才能出声 */
|
||||||
|
let audioUnlocked = false;
|
||||||
|
const sharedAudio = typeof Audio !== 'undefined' ? new Audio() : null;
|
||||||
|
const BGM_VOLUME = 0.32;
|
||||||
|
const BGM_DUCK_VOLUME = 0.08;
|
||||||
|
let bgmAudio: HTMLAudioElement | null = null;
|
||||||
|
let bgmDucked = false;
|
||||||
|
|
||||||
|
function getBgmAudio(): HTMLAudioElement | null {
|
||||||
|
if (typeof Audio === 'undefined') return null;
|
||||||
|
if (!bgmAudio) {
|
||||||
|
bgmAudio = new Audio(bgmSrc);
|
||||||
|
bgmAudio.loop = true;
|
||||||
|
bgmAudio.preload = 'auto';
|
||||||
|
bgmAudio.volume = BGM_VOLUME;
|
||||||
|
}
|
||||||
|
return bgmAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBgmVolume() {
|
||||||
|
if (!bgmAudio) return;
|
||||||
|
bgmAudio.volume = bgmDucked ? BGM_DUCK_VOLUME : BGM_VOLUME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function duckBgm() {
|
||||||
|
bgmDucked = true;
|
||||||
|
applyBgmVolume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unduckBgm() {
|
||||||
|
bgmDucked = false;
|
||||||
|
applyBgmVolume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBgmPlaying(on: boolean): Promise<boolean> {
|
||||||
|
const audio = getBgmAudio();
|
||||||
|
if (!audio) return Promise.resolve(false);
|
||||||
|
applyBgmVolume();
|
||||||
|
if (!on) {
|
||||||
|
audio.pause();
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
return audio
|
||||||
|
.play()
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockCelebrateAudio() {
|
||||||
|
if (audioUnlocked || !sharedAudio) return;
|
||||||
|
sharedAudio.muted = true;
|
||||||
|
sharedAudio.src = CELEBRATE_SFX[1];
|
||||||
|
void sharedAudio
|
||||||
|
.play()
|
||||||
|
.then(() => {
|
||||||
|
sharedAudio.pause();
|
||||||
|
sharedAudio.currentTime = 0;
|
||||||
|
sharedAudio.muted = false;
|
||||||
|
audioUnlocked = true;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* 等待下次手势 */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playCelebrateSfx(tier: AmountTier): () => void {
|
||||||
|
if (!sharedAudio) return () => undefined;
|
||||||
|
duckBgm();
|
||||||
|
try {
|
||||||
|
sharedAudio.pause();
|
||||||
|
sharedAudio.currentTime = 0;
|
||||||
|
sharedAudio.src = CELEBRATE_SFX[tier];
|
||||||
|
sharedAudio.volume = tier === 3 ? 0.85 : tier === 2 ? 0.75 : 0.65;
|
||||||
|
sharedAudio.muted = false;
|
||||||
|
void sharedAudio.play().catch(() => {
|
||||||
|
/* 未解锁时静默失败 */
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
sharedAudio.pause();
|
||||||
|
sharedAudio.currentTime = 0;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
unduckBgm();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad(n: number) {
|
||||||
|
return String(n).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(d: Date) {
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateLine(d: Date) {
|
||||||
|
return `${d.getFullYear()} / ${pad(d.getMonth() + 1)} / ${pad(d.getDate())} ${WEEKDAYS[d.getDay()]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOrderTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
return formatClock(new Date(iso));
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// function formatAmount(n: number): string {
|
||||||
|
// return Number(n || 0).toLocaleString('zh-CN', {
|
||||||
|
// minimumFractionDigits: 0,
|
||||||
|
// maximumFractionDigits: 0,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
function formatAmount(n: number): string {
|
||||||
|
const num = Number(n || 0);
|
||||||
|
// 先保留两位小数,再分割处理
|
||||||
|
const fixed = num.toFixed(2);
|
||||||
|
const [intStr, decStr] = fixed.split('.');
|
||||||
|
const intFormatted = Number(intStr).toLocaleString('zh-CN');
|
||||||
|
return decStr === '00' ? intFormatted : `${intFormatted}.${decStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function amountCssClass(payAmount: number, base: string): string {
|
||||||
|
const wide = Math.floor(Math.abs(Number(payAmount || 0))) >= 100_000;
|
||||||
|
return wide ? `${base} ${base}--6d` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function amountTier(payAmount: number): AmountTier {
|
||||||
|
if (payAmount >= 1000) return 3;
|
||||||
|
if (payAmount >= 500) return 2;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LiveClock() {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div className="big-screen-hero">
|
||||||
|
<div className="big-screen-clock">{formatClock(now)}</div>
|
||||||
|
<div className="big-screen-date">{formatDateLine(now)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderRow({
|
||||||
|
order,
|
||||||
|
latest,
|
||||||
|
}: {
|
||||||
|
order: BigScreenOrder;
|
||||||
|
latest?: boolean;
|
||||||
|
}) {
|
||||||
|
const tier = amountTier(order.payAmount);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`big-screen-row big-screen-row--t${tier}${latest ? ' is-latest' : ''}`}
|
||||||
|
>
|
||||||
|
{latest ? <span className="big-screen-row-mark" /> : null}
|
||||||
|
<span className={amountCssClass(order.payAmount, 'big-screen-amount')}>
|
||||||
|
¥ {formatAmount(order.payAmount)}
|
||||||
|
</span>
|
||||||
|
<span className="big-screen-items">{order.items || '—'}</span>
|
||||||
|
<span className="big-screen-time">{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
|
||||||
|
<span className="big-screen-phone">{order.userPhoneMasked || '—'}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Particle = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
vx: number;
|
||||||
|
vy: number;
|
||||||
|
rot: number;
|
||||||
|
vr: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
color: string;
|
||||||
|
life: number;
|
||||||
|
kind: 'rect' | 'ribbon' | 'spark';
|
||||||
|
rain?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIER_COLORS: Record<AmountTier, string[]> = {
|
||||||
|
1: ['#e6f7ff', '#91d5ff', '#40a9ff', '#ffffff', '#69c0ff'],
|
||||||
|
2: ['#fff1b8', '#ffe58f', '#ffd666', '#fffbe6', '#ffe7ba'],
|
||||||
|
3: ['#ffd666', '#faad14', '#ffec3d', '#fff1b8', '#ff4d4f', '#ff7a45', '#ffffff'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function spawnParticles(tier: AmountTier, w: number, h: number): Particle[] {
|
||||||
|
const colors = TIER_COLORS[tier];
|
||||||
|
const count = tier === 3 ? 180 : tier === 2 ? 110 : 70;
|
||||||
|
const out: Particle[] = [];
|
||||||
|
const cx = w / 2;
|
||||||
|
const cy = h * 0.42;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const angle = Math.random() * Math.PI * 2;
|
||||||
|
const speed = (tier === 3 ? 8 : tier === 2 ? 6 : 4) * (0.4 + Math.random());
|
||||||
|
const kind: Particle['kind'] =
|
||||||
|
tier === 3 && Math.random() < 0.25 ? 'ribbon' : Math.random() < 0.2 ? 'spark' : 'rect';
|
||||||
|
out.push({
|
||||||
|
x: cx + (Math.random() - 0.5) * 80,
|
||||||
|
y: cy + (Math.random() - 0.5) * 40,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed - (tier === 3 ? 6 : 3),
|
||||||
|
rot: Math.random() * 360,
|
||||||
|
vr: (Math.random() - 0.5) * 18,
|
||||||
|
w: kind === 'ribbon' ? 10 + Math.random() * 16 : kind === 'spark' ? 2 : 6 + Math.random() * 8,
|
||||||
|
h: kind === 'ribbon' ? 28 + Math.random() * 24 : kind === 'spark' ? 10 + Math.random() * 8 : 4 + Math.random() * 6,
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
life: 1,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (tier >= 2) {
|
||||||
|
for (let i = 0; i < (tier === 3 ? 80 : 40); i++) {
|
||||||
|
out.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: -20 - Math.random() * 80,
|
||||||
|
vx: (Math.random() - 0.5) * 1.4,
|
||||||
|
vy: 3 + Math.random() * 5,
|
||||||
|
rot: Math.random() * 360,
|
||||||
|
vr: (Math.random() - 0.5) * 10,
|
||||||
|
w: 5 + Math.random() * 8,
|
||||||
|
h: 10 + Math.random() * 14,
|
||||||
|
color: colors[Math.floor(Math.random() * colors.length)],
|
||||||
|
life: 1,
|
||||||
|
kind: 'rect',
|
||||||
|
rain: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CelebrateFx({ order, onDone }: { order: BigScreenOrder; onDone: () => void }) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const onDoneRef = useRef(onDone);
|
||||||
|
onDoneRef.current = onDone;
|
||||||
|
const tier = amountTier(order.payAmount);
|
||||||
|
const [displayAmount, setDisplayAmount] = useState(tier === 3 ? 0 : order.payAmount);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => onDoneRef.current(), FX_DURATION_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [order.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stop = playCelebrateSfx(tier);
|
||||||
|
return stop;
|
||||||
|
}, [order.id, tier]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tier !== 3) return;
|
||||||
|
const start = performance.now();
|
||||||
|
const dur = 900;
|
||||||
|
let raf = 0;
|
||||||
|
const tick = (now: number) => {
|
||||||
|
const p = Math.min(1, (now - start) / dur);
|
||||||
|
const eased = 1 - (1 - p) ** 3;
|
||||||
|
setDisplayAmount(Math.round(order.payAmount * eased));
|
||||||
|
if (p < 1) raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, [order.payAmount, order.id, tier]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
const resize = () => {
|
||||||
|
canvas.width = canvas.clientWidth * devicePixelRatio;
|
||||||
|
canvas.height = canvas.clientHeight * devicePixelRatio;
|
||||||
|
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
const particles = spawnParticles(tier, canvas.clientWidth, canvas.clientHeight);
|
||||||
|
let raf = 0;
|
||||||
|
let last = performance.now();
|
||||||
|
const started = performance.now();
|
||||||
|
const gravity = tier === 3 ? 0.18 : 0.14;
|
||||||
|
const loop = (now: number) => {
|
||||||
|
const dt = Math.min(32, now - last) / 16.6;
|
||||||
|
last = now;
|
||||||
|
ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
|
||||||
|
for (const p of particles) {
|
||||||
|
p.vy += gravity * dt;
|
||||||
|
p.x += p.vx * dt;
|
||||||
|
p.y += p.vy * dt;
|
||||||
|
p.rot += p.vr * dt;
|
||||||
|
p.life -= (tier === 3 ? 0.002 : 0.003) * dt;
|
||||||
|
if (
|
||||||
|
p.rain &&
|
||||||
|
(p.y > canvas.clientHeight + 30 || p.life <= 0) &&
|
||||||
|
now - started < FX_DURATION_MS - 400
|
||||||
|
) {
|
||||||
|
p.x = Math.random() * canvas.clientWidth;
|
||||||
|
p.y = -16 - Math.random() * 60;
|
||||||
|
p.vx = (Math.random() - 0.5) * 1.4;
|
||||||
|
p.vy = 3 + Math.random() * 5;
|
||||||
|
p.life = 1;
|
||||||
|
}
|
||||||
|
if (p.life <= 0) continue;
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(p.x, p.y);
|
||||||
|
ctx.rotate((p.rot * Math.PI) / 180);
|
||||||
|
ctx.globalAlpha = Math.max(0, p.life);
|
||||||
|
ctx.fillStyle = p.color;
|
||||||
|
if (p.kind === 'spark') {
|
||||||
|
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||||
|
} else {
|
||||||
|
ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
const onResize = () => resize();
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
};
|
||||||
|
}, [order.id, tier]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`big-screen-fx big-screen-fx--t${tier}`} role="presentation">
|
||||||
|
<canvas ref={canvasRef} className="big-screen-fx-canvas" />
|
||||||
|
{tier === 3 ? <div className="big-screen-fx-shock" /> : null}
|
||||||
|
{tier === 3 ? <div className="big-screen-fx-shock big-screen-fx-shock--late" /> : null}
|
||||||
|
{tier >= 2 ? <div className="big-screen-fx-sweep" /> : null}
|
||||||
|
{tier >= 2 ? <div className="big-screen-fx-sweep big-screen-fx-sweep--alt" /> : null}
|
||||||
|
<div className={`big-screen-fx-card big-screen-fx-card--t${tier}`}>
|
||||||
|
<div className="big-screen-fx-kicker">{tier === 3 ? '高额成交' : tier === 2 ? '大额成交' : '新成交'}</div>
|
||||||
|
<div className={amountCssClass(displayAmount || order.payAmount, 'big-screen-fx-amount')}>
|
||||||
|
¥ {formatAmount(displayAmount)}
|
||||||
|
</div>
|
||||||
|
<div className="big-screen-fx-items">{order.items || '—'}</div>
|
||||||
|
<div className="big-screen-fx-meta">
|
||||||
|
<span>{order.userPhoneMasked || '—'}</span>
|
||||||
|
<span>{formatOrderTime(order.paidAt ?? order.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BgmToggleButton({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`big-screen-bgm${on ? ' is-on' : ''}`}
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-pressed={on}
|
||||||
|
aria-label={on ? '暂停背景音乐' : '播放背景音乐'}
|
||||||
|
>
|
||||||
|
<span className="big-screen-bgm-icon" aria-hidden>
|
||||||
|
{on ? (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M9 18V6h2v12H9zm4 0V6h2v12h-2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16">
|
||||||
|
<path fill="currentColor" d="M8 5.14v13.72L19.06 12 8 5.14z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{on ? 'BGM 播放中' : '播放 BGM'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BigScreenPage() {
|
||||||
|
const [items, setItems] = useState<BigScreenOrder[]>([]);
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
|
const [paused, setPaused] = useState(false);
|
||||||
|
const [bgmOn, setBgmOn] = useState(false);
|
||||||
|
const bgmWantedRef = useRef(false);
|
||||||
|
const [celebrate, setCelebrate] = useState<BigScreenOrder | null>(null);
|
||||||
|
const seenIdsRef = useRef<Set<string> | null>(null);
|
||||||
|
const celebratedIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const queueRef = useRef<BigScreenOrder[]>([]);
|
||||||
|
const celebratingRef = useRef(false);
|
||||||
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const dayKeyRef = useRef('');
|
||||||
|
const [viewportH, setViewportH] = useState(0);
|
||||||
|
|
||||||
|
const playNext = useCallback(() => {
|
||||||
|
const next = queueRef.current.shift() ?? null;
|
||||||
|
celebratingRef.current = !!next;
|
||||||
|
setCelebrate(next);
|
||||||
|
setPaused(!!next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enqueueNew = useCallback(
|
||||||
|
(fresh: BigScreenOrder[]) => {
|
||||||
|
const toCelebrate = fresh.filter((o) => !celebratedIdsRef.current.has(o.id));
|
||||||
|
if (!toCelebrate.length) return;
|
||||||
|
for (const o of toCelebrate) celebratedIdsRef.current.add(o.id);
|
||||||
|
const ranked = [...toCelebrate].sort((a, b) => {
|
||||||
|
const td = amountTier(b.payAmount) - amountTier(a.payAmount);
|
||||||
|
if (td !== 0) return td;
|
||||||
|
return +new Date(b.paidAt ?? b.createdAt) - +new Date(a.paidAt ?? a.createdAt);
|
||||||
|
});
|
||||||
|
queueRef.current.push(...ranked);
|
||||||
|
if (!celebratingRef.current) playNext();
|
||||||
|
},
|
||||||
|
[playNext],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchData = useCallback(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const todayKey = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||||
|
if (dayKeyRef.current && dayKeyRef.current !== todayKey) {
|
||||||
|
seenIdsRef.current = null;
|
||||||
|
celebratedIdsRef.current = new Set();
|
||||||
|
queueRef.current = [];
|
||||||
|
celebratingRef.current = false;
|
||||||
|
setCelebrate(null);
|
||||||
|
setPaused(false);
|
||||||
|
}
|
||||||
|
dayKeyRef.current = todayKey;
|
||||||
|
|
||||||
|
return request<{ items: BigScreenOrder[] }>('/admin/orders/big-screen?limit=2000')
|
||||||
|
.then((d) => {
|
||||||
|
const list = d.items ?? [];
|
||||||
|
setItems(list);
|
||||||
|
setLoadError('');
|
||||||
|
const seen = seenIdsRef.current;
|
||||||
|
if (!seen) {
|
||||||
|
seenIdsRef.current = new Set(list.map((o) => o.id));
|
||||||
|
for (const o of list) celebratedIdsRef.current.add(o.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fresh = list.filter((o) => !seen.has(o.id));
|
||||||
|
for (const o of fresh) seen.add(o.id);
|
||||||
|
enqueueNew(fresh);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setLoadError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
});
|
||||||
|
}, [enqueueNew]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchData();
|
||||||
|
const id = setInterval(() => void fetchData(), POLL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [fetchData]);
|
||||||
|
|
||||||
|
const toggleBgm = useCallback(() => {
|
||||||
|
unlockCelebrateAudio();
|
||||||
|
const next = !bgmWantedRef.current;
|
||||||
|
bgmWantedRef.current = next;
|
||||||
|
if (!next) {
|
||||||
|
void setBgmPlaying(false);
|
||||||
|
setBgmOn(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBgmOn(true);
|
||||||
|
void setBgmPlaying(true).then((playing) => {
|
||||||
|
if (!bgmWantedRef.current) return;
|
||||||
|
setBgmOn(playing);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
unlockCelebrateAudio();
|
||||||
|
const unlock = () => unlockCelebrateAudio();
|
||||||
|
window.addEventListener('pointerdown', unlock, { once: true });
|
||||||
|
window.addEventListener('keydown', unlock, { once: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', unlock);
|
||||||
|
window.removeEventListener('keydown', unlock);
|
||||||
|
bgmWantedRef.current = false;
|
||||||
|
void setBgmPlaying(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const prevHtml = html.style.overflow;
|
||||||
|
const prevBody = document.body.style.overflow;
|
||||||
|
html.style.overflow = 'hidden';
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
html.style.overflow = prevHtml;
|
||||||
|
document.body.style.overflow = prevBody;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = viewportRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const measure = () => setViewportH(el.clientHeight);
|
||||||
|
measure();
|
||||||
|
const ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const unitItems = useMemo(() => {
|
||||||
|
if (!items.length) return [];
|
||||||
|
const rowH = 80;
|
||||||
|
const visible = Math.max(1, Math.ceil((viewportH || 480) / rowH));
|
||||||
|
const minCount = Math.max(MIN_SCROLL_ROWS, visible + 1);
|
||||||
|
const unit: BigScreenOrder[] = [];
|
||||||
|
while (unit.length < minCount) unit.push(...items);
|
||||||
|
return unit;
|
||||||
|
}, [items, viewportH]);
|
||||||
|
|
||||||
|
const trackItems = useMemo(() => unitItems.concat(unitItems), [unitItems]);
|
||||||
|
const marqueeMs = Math.max(unitItems.length, 1) * ROW_MS;
|
||||||
|
const rolling = items.length > 0 && unitItems.length > 0;
|
||||||
|
const latestId = items[0]?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="big-screen-page">
|
||||||
|
<div className="big-screen-stars" aria-hidden />
|
||||||
|
<div className="big-screen-frame" aria-hidden>
|
||||||
|
<span className="big-screen-corner big-screen-corner--tl" />
|
||||||
|
<span className="big-screen-corner big-screen-corner--tr" />
|
||||||
|
<span className="big-screen-corner big-screen-corner--bl" />
|
||||||
|
<span className="big-screen-corner big-screen-corner--br" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header className="big-screen-header">
|
||||||
|
<div className="big-screen-brand">
|
||||||
|
<h1 className="big-screen-title">杜康好客</h1>
|
||||||
|
<span className="big-screen-subtitle">· 发布会现场</span>
|
||||||
|
</div>
|
||||||
|
<div className="big-screen-live-wrap">
|
||||||
|
<BgmToggleButton on={bgmOn} onToggle={toggleBgm} />
|
||||||
|
<span className="big-screen-live-label">实时成交</span>
|
||||||
|
<span className="big-screen-live">
|
||||||
|
<span className="big-screen-live-dot" />
|
||||||
|
LIVE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<LiveClock />
|
||||||
|
|
||||||
|
<div className="big-screen-list">
|
||||||
|
<div className="big-screen-list-body" ref={viewportRef}>
|
||||||
|
{items.length === 0 ? null : (
|
||||||
|
<div
|
||||||
|
className={`big-screen-track${rolling ? ' is-rolling' : ''}${paused ? ' is-paused' : ''}`}
|
||||||
|
style={rolling ? ({ ['--marquee-ms']: `${marqueeMs}ms` } as CSSProperties) : undefined}
|
||||||
|
>
|
||||||
|
{trackItems.map((o, idx) => (
|
||||||
|
<OrderRow
|
||||||
|
key={`${o.id}-${idx}`}
|
||||||
|
order={o}
|
||||||
|
latest={idx % items.length === 0 && o.id === latestId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="big-screen-empty">{loadError || '当日暂无订单'}</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{celebrate ? (
|
||||||
|
<CelebrateFx
|
||||||
|
key={celebrate.id}
|
||||||
|
order={celebrate}
|
||||||
|
onDone={playNext}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
|
Divider,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
@@ -17,12 +19,16 @@ 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';
|
||||||
import CityPartnersPanel from '../components/CityPartnersPanel';
|
import CityPartnersPanel from '../components/CityPartnersPanel';
|
||||||
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type WarehouseRow = {
|
type WarehouseRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,6 +56,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 +122,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,29 +181,99 @@ export default function CitiesPage() {
|
|||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
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 baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
{
|
||||||
|
title: '城市',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 100,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ 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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const warehouseColumns: ColumnsType<WarehouseRow> = [
|
const warehouseColumns: ColumnsType<WarehouseRow> = [
|
||||||
{ title: '仓库名', dataIndex: 'name' },
|
{ title: '仓库名', dataIndex: 'name' },
|
||||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
{ title: '地址', dataIndex: 'address' },
|
||||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||||
{
|
{
|
||||||
@@ -160,7 +282,7 @@ export default function CitiesPage() {
|
|||||||
width: 100,
|
width: 100,
|
||||||
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
|
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
|
||||||
},
|
},
|
||||||
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
|
{ title: '合伙人', dataIndex: 'partnerCompanyName', render: (v) => v || '—' },
|
||||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -201,12 +323,16 @@ export default function CitiesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('cities', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>城市</Typography.Title>
|
<AdminListHeader
|
||||||
<Button type="primary" onClick={openCreateModal}>新建城市</Button>
|
title="城市"
|
||||||
</Space>
|
settings={settingsButton}
|
||||||
|
actions={<Button type="primary" onClick={openCreateModal}>新建城市</Button>}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
<Form.Item name="code" label="编码"><Input allowClear /></Form.Item>
|
||||||
@@ -215,7 +341,7 @@ export default function CitiesPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
<Table scroll={{ x: 'max-content' }} rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
|
|
||||||
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
@@ -237,8 +363,28 @@ export default function CitiesPage() {
|
|||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
<Divider orientation="left">履约起购</Divider>
|
||||||
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
<Form.Item
|
||||||
|
name="pickupMinQty"
|
||||||
|
label="现场提货"
|
||||||
|
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="localMinQty"
|
||||||
|
label="同城配送"
|
||||||
|
extra="按瓶当量;箱装 1 箱即可过。默认 2 瓶。"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="crossMinQty"
|
||||||
|
label="跨城配送"
|
||||||
|
extra="按瓶当量。当前箱规 6 瓶/箱时,填 6 = 最少 1 箱。"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="maxPartnerCommissionPercent"
|
name="maxPartnerCommissionPercent"
|
||||||
label="合伙人佣金合计上限 %"
|
label="合伙人佣金合计上限 %"
|
||||||
@@ -264,6 +410,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 +440,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 +573,139 @@ 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' },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||||
|
{ title: '归属', dataIndex: 'parent' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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' },
|
||||||
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||||
|
{
|
||||||
|
title: '合伙人',
|
||||||
|
width: 140,
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Cascader,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
@@ -31,11 +30,19 @@ import {
|
|||||||
PARTNER_STAFF_ROLE_LABELS,
|
PARTNER_STAFF_ROLE_LABELS,
|
||||||
type PartnerPermissionKey,
|
type PartnerPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type Paginated } from '../lib/api';
|
import { omitNullFields } from '../lib/omit-null-fields';
|
||||||
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';
|
||||||
|
import PartnerAssocPanel from '../components/PartnerAssocPanel';
|
||||||
|
import ActivityPosterDownloadModal from '../components/ActivityPosterDownloadModal';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
import { request, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
|
||||||
|
|
||||||
type SubRow = PartnerSubAccountRow;
|
type SubRow = PartnerSubAccountRow;
|
||||||
|
|
||||||
@@ -48,6 +55,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;
|
||||||
@@ -55,9 +63,11 @@ type Row = {
|
|||||||
managedWarehouseName?: string | null;
|
managedWarehouseName?: string | null;
|
||||||
maxPartnerCommissionRate?: number | null;
|
maxPartnerCommissionRate?: number | null;
|
||||||
storeCount: number;
|
storeCount: number;
|
||||||
|
assocUserCount?: number;
|
||||||
accountCount: number;
|
accountCount: number;
|
||||||
subAccounts?: SubRow[];
|
subAccounts?: SubRow[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isTest?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerDetail = Row & {
|
type PartnerDetail = Row & {
|
||||||
@@ -98,27 +108,47 @@ 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 navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialCityId = searchParams.get('cityId')?.trim() || '';
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [subForm] = Form.useForm();
|
const [subForm] = Form.useForm();
|
||||||
const [subEditForm] = Form.useForm();
|
const [subEditForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string | boolean>>(() => {
|
||||||
|
const init: Record<string, string | boolean> = {};
|
||||||
|
if (initialCityId) init.cityId = initialCityId;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/partners',
|
'/admin/partners',
|
||||||
() => {
|
() => {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
if (filters.companyName) qs.set('companyName', String(filters.companyName));
|
||||||
if (filters.phone) qs.set('phone', filters.phone);
|
if (filters.phone) qs.set('phone', String(filters.phone));
|
||||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
if (filters.cityId) qs.set('cityId', String(filters.cityId));
|
||||||
|
if (filters.excludeTest) qs.set('excludeTest', 'true');
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -135,6 +165,10 @@ 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 [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
const [posterDownloadIds, setPosterDownloadIds] = useState<string[] | null>(null);
|
||||||
|
const [canDownloadPosters, setCanDownloadPosters] = useState(false);
|
||||||
|
|
||||||
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 +179,21 @@ export default function CityPartnersPage() {
|
|||||||
void loadCities();
|
void loadCities();
|
||||||
}, [loadCities]);
|
}, [loadCities]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<HqProfile>('/admin/auth/me')
|
||||||
|
.then((p) => setCanDownloadPosters((p.permissionKeys ?? []).includes('activity_posters')))
|
||||||
|
.catch(() => setCanDownloadPosters(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 +215,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({
|
||||||
|
...omitNullFields(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) {
|
||||||
@@ -202,6 +251,30 @@ export default function CityPartnersPage() {
|
|||||||
void reload();
|
void reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deletePartner(row: { id: string; storeCount?: number }) {
|
||||||
|
if ((row.storeCount ?? 0) > 0) {
|
||||||
|
Modal.warning({
|
||||||
|
title: '请先删除门店',
|
||||||
|
content: `该城市合伙人下还有 ${row.storeCount} 家门店,请先删除门店后再删除合伙人。`,
|
||||||
|
okText: '查看门店',
|
||||||
|
onOk: () => navigate(`/stores?partnerId=${row.id}`),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await request(`/admin/partners/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('城市合伙人已删除');
|
||||||
|
if (detail?.id === row.id) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setDetail(null);
|
||||||
|
}
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
const msg = formatApiError(e);
|
||||||
|
if (msg) message.error(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSubAccount(subId: string, parentId: string) {
|
async function deleteSubAccount(subId: string, parentId: string) {
|
||||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||||
message.success('子账号已删除');
|
message.success('子账号已删除');
|
||||||
@@ -224,19 +297,46 @@ 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 baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
{
|
||||||
|
title: '区县',
|
||||||
|
dataIndex: 'districtCodes',
|
||||||
|
width: 160,
|
||||||
|
render: (codes: string[] | null | undefined, row) =>
|
||||||
|
row.scopeType === CityPartnerScopeType.CITY_WIDE ? '全城' : formatDistrictLabels(codes),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '公司名',
|
||||||
|
dataIndex: 'companyName',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openPartner(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '主账号姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 120,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<span>{v}</span>
|
||||||
|
{row.isTest ? <Tag color="orange">测试</Tag> : null}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
title: '管辖',
|
title: '管辖',
|
||||||
@@ -264,47 +364,115 @@ export default function CityPartnersPage() {
|
|||||||
title: '管仓仓库',
|
title: '管仓仓库',
|
||||||
dataIndex: 'managedWarehouseName',
|
dataIndex: 'managedWarehouseName',
|
||||||
width: 110,
|
width: 110,
|
||||||
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: 'assocUserCount',
|
||||||
|
width: 80,
|
||||||
|
render: (n: number, row) => (
|
||||||
|
<Link
|
||||||
|
to={`/users?assocPartnerAccountId=${encodeURIComponent(row.id)}`}
|
||||||
|
title={`查看「${row.companyName || row.phone}」关联用户`}
|
||||||
|
>
|
||||||
|
{n ?? 0}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '活动图',
|
||||||
|
width: 70,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Link
|
||||||
|
to={`/activity-posters?partnerId=${encodeURIComponent(row.id)}`}
|
||||||
|
title={`为「${row.companyName || row.phone}」下载活动图`}
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</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 },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
width: 80,
|
width: 220,
|
||||||
fixed: 'right',
|
fixed: 'right',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
<Space size={0}>
|
||||||
管理
|
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||||
</Button>
|
管理
|
||||||
|
</Button>
|
||||||
|
{canDownloadPosters ? (
|
||||||
|
<Button type="link" size="small" onClick={() => setPosterDownloadIds([row.id])}>
|
||||||
|
下载活动图
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-partners', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
城市合伙人
|
title="城市合伙人"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Button
|
actions={
|
||||||
type="primary"
|
<>
|
||||||
onClick={() => {
|
<Link to="/activity-posters">活动图</Link>
|
||||||
createForm.resetFields();
|
<Link to="/users?assocPartnerAccountId=any">全部关联用户</Link>
|
||||||
createForm.setFieldsValue({
|
{canDownloadPosters ? (
|
||||||
orderCommissionRate: 0,
|
<Button
|
||||||
redeemCommissionRate: 3,
|
disabled={!selectedRowKeys.length}
|
||||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
onClick={() => setPosterDownloadIds(selectedRowKeys)}
|
||||||
});
|
>
|
||||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
导出活动图{selectedRowKeys.length ? `(${selectedRowKeys.length})` : ''}
|
||||||
setCreateMaxRate(0.05);
|
</Button>
|
||||||
setCreateOpen(true);
|
) : null}
|
||||||
}}
|
<Button
|
||||||
>
|
type="primary"
|
||||||
新建合伙人
|
onClick={() => {
|
||||||
</Button>
|
createForm.resetFields();
|
||||||
</Space>
|
createForm.setFieldsValue({
|
||||||
|
orderCommissionRate: 0,
|
||||||
|
redeemCommissionRate: 3,
|
||||||
|
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||||
|
});
|
||||||
|
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||||
|
setCreateMaxRate(0.05);
|
||||||
|
setCreateCityCode(undefined);
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建合伙人
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
@@ -322,17 +490,12 @@ export default function CityPartnersPage() {
|
|||||||
form={filterForm}
|
form={filterForm}
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
|
initialValues={initialCityId ? { cityId: initialCityId } : undefined}
|
||||||
onFinish={(v) => {
|
onFinish={(v) => {
|
||||||
setFilters(v);
|
setFilters(v);
|
||||||
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 +506,15 @@ 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 name="excludeTest" valuePropName="checked">
|
||||||
|
<Checkbox>过滤测试账号</Checkbox>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
@@ -367,8 +539,16 @@ export default function CityPartnersPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={toTableRows(data?.items ?? [])}
|
dataSource={toTableRows(data?.items ?? [])}
|
||||||
scroll={{ x: 1200 }}
|
scroll={{ x: 'max-content' }}
|
||||||
childrenColumnName="__noTreeChildren__"
|
childrenColumnName="__noTreeChildren__"
|
||||||
|
rowSelection={
|
||||||
|
canDownloadPosters
|
||||||
|
? {
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys.map(String)),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowRender: (record) => (
|
expandedRowRender: (record) => (
|
||||||
<PartnerSubAccountList
|
<PartnerSubAccountList
|
||||||
@@ -400,16 +580,56 @@ export default function CityPartnersPage() {
|
|||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={() => void savePartner()}>
|
<Space>
|
||||||
保存
|
{detail ? (
|
||||||
</Button>
|
<Popconfirm
|
||||||
|
title="确认删除该城市合伙人?"
|
||||||
|
description="若有门店需先删除门店;账号下有订单或核销单将禁止删除。"
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void deletePartner(detail)}
|
||||||
|
>
|
||||||
|
<Button danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
<Button type="primary" onClick={() => void savePartner()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<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="关联用户">
|
||||||
|
<Link
|
||||||
|
to={`/users?assocPartnerAccountId=${encodeURIComponent(detail.id)}`}
|
||||||
|
title="查看该城市合伙人关联用户"
|
||||||
|
>
|
||||||
|
{detail.assocUserCount ?? 0}
|
||||||
|
</Link>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="活动图">
|
||||||
|
<Space>
|
||||||
|
<Link
|
||||||
|
to={`/activity-posters?partnerId=${encodeURIComponent(detail.id)}`}
|
||||||
|
title="查看活动图并下载该合伙人合成图"
|
||||||
|
>
|
||||||
|
查看
|
||||||
|
</Link>
|
||||||
|
{canDownloadPosters ? (
|
||||||
|
<Button type="link" size="small" onClick={() => setPosterDownloadIds([detail.id])}>
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
</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 +645,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 +657,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 +667,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 +706,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)}
|
||||||
@@ -490,6 +714,11 @@ export default function CityPartnersPage() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'assoc',
|
||||||
|
label: '关联码',
|
||||||
|
children: <PartnerAssocPanel partnerId={detail.id} />,
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -502,29 +731,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 +770,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 +779,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">
|
||||||
@@ -640,6 +878,12 @@ export default function CityPartnersPage() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<ActivityPosterDownloadModal
|
||||||
|
open={posterDownloadIds != null && posterDownloadIds.length > 0}
|
||||||
|
partnerIds={posterDownloadIds ?? []}
|
||||||
|
onClose={() => setPosterDownloadIds(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,25 +6,33 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
|
||||||
message,
|
message,
|
||||||
} 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';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -39,6 +47,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 +63,80 @@ 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 +162,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 +184,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 +201,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 baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
|
|
||||||
{
|
{
|
||||||
title: '城市',
|
title: '城市',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -128,20 +233,26 @@ export default function CityWarehousesPage() {
|
|||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
{
|
||||||
|
title: '仓库',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '地址', dataIndex: 'address', width: 180 },
|
||||||
{ 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,
|
|
||||||
render: (v, row) =>
|
render: (v, row) =>
|
||||||
v && row.partnerAccountId ? (
|
v && row.partnerAccountId ? (
|
||||||
<Link to="/city-partners">{v}</Link>
|
<Link to="/city-partners">{v}</Link>
|
||||||
@@ -149,6 +260,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',
|
||||||
@@ -187,32 +308,43 @@ export default function CityWarehousesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('city-warehouses', baseColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
<AdminListHeader
|
||||||
仓库管理
|
title="仓库"
|
||||||
</Typography.Title>
|
settings={settingsButton}
|
||||||
<Button
|
actions={
|
||||||
type="primary"
|
<Button
|
||||||
onClick={() => {
|
type="primary"
|
||||||
createForm.resetFields();
|
onClick={() => {
|
||||||
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
|
createForm.resetFields();
|
||||||
setCreateManagerType(WarehouseManagerType.HQ);
|
setCreateManagerType(WarehouseManagerType.HQ);
|
||||||
setCreateCityId(undefined);
|
setCreateCityId(undefined);
|
||||||
setPartners([]);
|
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
|
||||||
setCreateOpen(true);
|
createForm.setFieldsValue({
|
||||||
}}
|
managerType: WarehouseManagerType.HQ,
|
||||||
>
|
status: WarehouseStatus.ACTIVE,
|
||||||
新建仓库
|
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
|
||||||
</Button>
|
});
|
||||||
</Space>
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新增仓库
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Alert
|
<Alert
|
||||||
type="info"
|
type="info"
|
||||||
showIcon
|
showIcon
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
|
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
@@ -225,7 +357,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 +365,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: 'max-content' }}
|
||||||
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 +400,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 +459,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 +522,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>
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, 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, DatePicker, Descriptions, Form, Modal, Row, Segmented, Select, Space, Statistic, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { CloudUploadOutlined, ReloadOutlined } from '@ant-design/icons';
|
import { CloudUploadOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
|
import dayjs, { type Dayjs } from 'dayjs';
|
||||||
|
import ReactECharts from 'echarts-for-react';
|
||||||
|
import { getInstanceByDom, type EChartsOption } from 'echarts';
|
||||||
|
import {
|
||||||
|
addShanghaiDays,
|
||||||
|
defaultShanghaiRangeYmds,
|
||||||
|
shanghaiMonthRange,
|
||||||
|
shanghaiQuarterIndex,
|
||||||
|
shanghaiQuarterRange,
|
||||||
|
shanghaiWeekRange,
|
||||||
|
shanghaiYearMonth,
|
||||||
|
shanghaiYmd,
|
||||||
|
} from '@dukang/domain';
|
||||||
|
import type { DashboardGranularity, DashboardLineChart, DashboardLineHref } from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
request,
|
request,
|
||||||
|
type DashboardAnalytics,
|
||||||
type DashboardStats,
|
type DashboardStats,
|
||||||
type DeployTriggerResult,
|
type DeployTriggerResult,
|
||||||
type HqProfile,
|
type HqProfile,
|
||||||
|
type Paginated,
|
||||||
type SystemVersion,
|
type SystemVersion,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
|
import { downloadDashboardChartsPdf } from '../lib/dashboard-charts-pdf';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
PENDING_PAY: '待付款',
|
PENDING_PAY: '待付款',
|
||||||
@@ -30,6 +48,121 @@ const DEPLOYED_BY_LABELS: Record<string, string> = {
|
|||||||
admin: 'Admin 发布',
|
admin: 'Admin 发布',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const GRAIN_OPTIONS: Array<{ label: string; value: DashboardGranularity }> = [
|
||||||
|
{ label: '日', value: 'day' },
|
||||||
|
{ label: '周', value: 'week' },
|
||||||
|
{ label: '月', value: 'month' },
|
||||||
|
{ label: '季', value: 'quarter' },
|
||||||
|
{ label: '年', value: 'year' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ChartMetric = 'total' | 'increment';
|
||||||
|
|
||||||
|
const METRIC_OPTIONS: Array<{ label: string; value: ChartMetric }> = [
|
||||||
|
{ label: '总量', value: 'total' },
|
||||||
|
{ label: '增量', value: 'increment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function toDayjsRange(from: Date, toInclusive: Date): [Dayjs, Dayjs] {
|
||||||
|
return [dayjs(shanghaiYmd(from)), dayjs(shanghaiYmd(toInclusive))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完整上一自然周 / 上月 / 上季(北京日历) */
|
||||||
|
function datePresetRange(kind: 'lastWeek' | 'lastMonth' | 'lastQuarter'): [Dayjs, Dayjs] {
|
||||||
|
const today = new Date();
|
||||||
|
if (kind === 'lastWeek') {
|
||||||
|
const thisMonday = shanghaiWeekRange(today).start;
|
||||||
|
return toDayjsRange(addShanghaiDays(thisMonday, -7), addShanghaiDays(thisMonday, -1));
|
||||||
|
}
|
||||||
|
if (kind === 'lastMonth') {
|
||||||
|
const { year, month } = shanghaiYearMonth(today);
|
||||||
|
const prev = month === 1 ? { year: year - 1, month: 12 } : { year, month: month - 1 };
|
||||||
|
const r = shanghaiMonthRange(prev.year, prev.month);
|
||||||
|
return toDayjsRange(r.start, addShanghaiDays(r.endExclusive, -1));
|
||||||
|
}
|
||||||
|
const { year, quarter } = shanghaiQuarterIndex(today);
|
||||||
|
const prev = quarter === 1 ? { year: year - 1, quarter: 4 } : { year, quarter: quarter - 1 };
|
||||||
|
const r = shanghaiQuarterRange(prev.year, prev.quarter);
|
||||||
|
return toDayjsRange(r.start, addShanghaiDays(r.endExclusive, -1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_PRESETS: Array<{ key: 'lastWeek' | 'lastMonth' | 'lastQuarter'; label: string }> = [
|
||||||
|
{ key: 'lastWeek', label: '上周' },
|
||||||
|
{ key: 'lastMonth', label: '上月' },
|
||||||
|
{ key: 'lastQuarter', label: '上季度' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function chartIsMetric(key: string, metric: ChartMetric): boolean {
|
||||||
|
const isInc = key.includes('.increment');
|
||||||
|
return metric === 'increment' ? isInc : !isInc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HREF_PATH: Record<DashboardLineHref, string> = {
|
||||||
|
users: '/users',
|
||||||
|
partners: '/city-partners',
|
||||||
|
stores: '/stores',
|
||||||
|
orders: '/orders',
|
||||||
|
redeems: '/redeem-records',
|
||||||
|
};
|
||||||
|
|
||||||
|
type CityOption = { id: string; name: string; code: string };
|
||||||
|
|
||||||
|
type AnalyticsFilters = {
|
||||||
|
granularity: DashboardGranularity;
|
||||||
|
range: [Dayjs, Dayjs];
|
||||||
|
cityId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function defaultRange(grain: DashboardGranularity): [Dayjs, Dayjs] {
|
||||||
|
const y = defaultShanghaiRangeYmds(grain);
|
||||||
|
return [dayjs(y.from), dayjs(y.to)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAnalyticsQs(f: AnalyticsFilters) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('granularity', f.granularity);
|
||||||
|
qs.set('dateFrom', f.range[0].format('YYYY-MM-DD'));
|
||||||
|
qs.set('dateTo', f.range[1].format('YYYY-MM-DD'));
|
||||||
|
if (f.cityId) qs.set('cityId', f.cityId);
|
||||||
|
return qs.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listHref(kind: DashboardLineHref, f: AnalyticsFilters): string {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set('createdFrom', f.range[0].format('YYYY-MM-DD'));
|
||||||
|
qs.set('createdTo', f.range[1].format('YYYY-MM-DD'));
|
||||||
|
if (f.cityId && f.cityId !== 'none') qs.set('cityId', f.cityId);
|
||||||
|
const s = qs.toString();
|
||||||
|
return s ? `${HREF_PATH[kind]}?${s}` : HREF_PATH[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineOption(
|
||||||
|
periods: Array<{ key: string; label: string }>,
|
||||||
|
chart: DashboardLineChart,
|
||||||
|
): EChartsOption {
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { type: 'scroll', top: 0 },
|
||||||
|
grid: { left: 56, right: 28, top: 48, bottom: periods.length > 14 ? 64 : 36 },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: periods.map((p) => p.label),
|
||||||
|
axisLabel: { rotate: periods.length > 14 ? 45 : 0 },
|
||||||
|
},
|
||||||
|
yAxis: {
|
||||||
|
type: 'value',
|
||||||
|
minInterval: chart.unit === 'count' ? 1 : undefined,
|
||||||
|
},
|
||||||
|
series: chart.series.map((s) => ({
|
||||||
|
name: s.name,
|
||||||
|
type: 'line' as const,
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: periods.length <= 24,
|
||||||
|
data: s.values,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [version, setVersion] = useState<SystemVersion | null>(null);
|
const [version, setVersion] = useState<SystemVersion | null>(null);
|
||||||
@@ -38,6 +171,35 @@ export default function DashboardPage() {
|
|||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
const keys = profile?.permissionKeys ?? [];
|
||||||
|
const has = (k: string) => keys.includes(k);
|
||||||
|
const cityScoped = !!profile?.cityScoped;
|
||||||
|
const canUsers = has('users');
|
||||||
|
const canOrders = has('orders');
|
||||||
|
const canStores = has('stores');
|
||||||
|
const canStoreAudits = has('store_audits');
|
||||||
|
const canPartners = has('partners');
|
||||||
|
const canFinance = has('finance');
|
||||||
|
const canBenefit = has('benefit');
|
||||||
|
const canDeliveries = has('deliveries');
|
||||||
|
const canTickets = has('tickets');
|
||||||
|
const permsReady = profile !== null;
|
||||||
|
const hasAnalytics = canUsers || canOrders || canPartners || canStores || canBenefit;
|
||||||
|
|
||||||
|
const [filterForm] = Form.useForm<{
|
||||||
|
range: [Dayjs, Dayjs];
|
||||||
|
cityId?: string;
|
||||||
|
}>();
|
||||||
|
const [filters, setFilters] = useState<AnalyticsFilters>({
|
||||||
|
granularity: 'day',
|
||||||
|
range: defaultRange('day'),
|
||||||
|
});
|
||||||
|
const [analytics, setAnalytics] = useState<DashboardAnalytics | null>(null);
|
||||||
|
const [analyticsLoading, setAnalyticsLoading] = useState(true);
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [metric, setMetric] = useState<ChartMetric>('total');
|
||||||
|
const [pdfLoading, setPdfLoading] = useState(false);
|
||||||
|
const chartsWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const loadVersion = useCallback(() => {
|
const loadVersion = useCallback(() => {
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
@@ -47,14 +209,67 @@ export default function DashboardPage() {
|
|||||||
.finally(() => setVersionLoading(false));
|
.finally(() => setVersionLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadAnalytics = useCallback((f: AnalyticsFilters) => {
|
||||||
|
setAnalyticsLoading(true);
|
||||||
|
return request<DashboardAnalytics>(`/admin/dashboard/analytics?${buildAnalyticsQs(f)}`)
|
||||||
|
.then(setAnalytics)
|
||||||
|
.catch((e) => {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载统计失败');
|
||||||
|
setAnalytics(null);
|
||||||
|
})
|
||||||
|
.finally(() => setAnalyticsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
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]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!profile) return;
|
||||||
|
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items ?? []))
|
||||||
|
.catch(() => setCities([]));
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAnalytics(filters);
|
||||||
|
}, [filters, loadAnalytics]);
|
||||||
|
|
||||||
|
function applyFilters(values: {
|
||||||
|
range?: [Dayjs, Dayjs];
|
||||||
|
cityId?: string;
|
||||||
|
}) {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
range: values.range ?? prev.range,
|
||||||
|
cityId: values.cityId || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGranularity(granularity: DashboardGranularity) {
|
||||||
|
setFilters((prev) => ({ ...prev, granularity }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDatePreset(kind: 'lastWeek' | 'lastMonth' | 'lastQuarter') {
|
||||||
|
const range = datePresetRange(kind);
|
||||||
|
filterForm.setFieldsValue({ range });
|
||||||
|
setFilters((prev) => ({ ...prev, range }));
|
||||||
|
}
|
||||||
|
|
||||||
function handleDeploy() {
|
function handleDeploy() {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认发布更新?',
|
title: '确认发布更新?',
|
||||||
@@ -77,11 +292,59 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
|
const shortSha = version?.commitId ? version.commitId.slice(0, 7) : '—';
|
||||||
|
const periods = analytics?.periods ?? [];
|
||||||
|
const chartOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
(analytics?.charts ?? [])
|
||||||
|
.filter((chart) => chartIsMetric(chart.key, metric))
|
||||||
|
.map((chart) => ({
|
||||||
|
chart,
|
||||||
|
option: lineOption(periods, chart),
|
||||||
|
})),
|
||||||
|
[analytics, periods, metric],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleDownloadPdf() {
|
||||||
|
if (analyticsLoading || chartOptions.length === 0) {
|
||||||
|
message.warning('暂无可下载的图');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const grainLabel = GRAIN_OPTIONS.find((o) => o.value === filters.granularity)?.label ?? filters.granularity;
|
||||||
|
const metricLabel = metric === 'increment' ? '增量' : '总量';
|
||||||
|
const cityLabel = !filters.cityId
|
||||||
|
? (cityScoped ? '全部负责城市' : '全部开城')
|
||||||
|
: filters.cityId === 'none'
|
||||||
|
? '未选城'
|
||||||
|
: (cities.find((c) => c.id === filters.cityId)?.name ?? '城市');
|
||||||
|
const from = filters.range[0].format('YYYY-MM-DD');
|
||||||
|
const to = filters.range[1].format('YYYY-MM-DD');
|
||||||
|
setPdfLoading(true);
|
||||||
|
try {
|
||||||
|
const doms = [
|
||||||
|
...(chartsWrapRef.current?.querySelectorAll<HTMLElement>('[_echarts_instance_]') ?? []),
|
||||||
|
];
|
||||||
|
await downloadDashboardChartsPdf({
|
||||||
|
filename: `数据概览-${metricLabel}-${grainLabel}-${from}_${to}.pdf`,
|
||||||
|
subtitle: `${metricLabel} · ${grainLabel} · ${from} ~ ${to} · ${cityLabel}`,
|
||||||
|
charts: chartOptions.map(({ chart }, index) => ({
|
||||||
|
title: chart.title,
|
||||||
|
instance: doms[index] ? getInstanceByDom(doms[index]) : undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '下载 PDF 失败');
|
||||||
|
} finally {
|
||||||
|
setPdfLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>数据概览</Typography.Title>
|
<Typography.Title level={4}>
|
||||||
|
{cityScoped ? '数据概览(负责城市)' : '数据概览'}
|
||||||
|
</Typography.Title>
|
||||||
|
|
||||||
|
{isSuperAdmin ? (
|
||||||
<Card
|
<Card
|
||||||
title="系统版本"
|
title="系统版本"
|
||||||
loading={versionLoading}
|
loading={versionLoading}
|
||||||
@@ -91,16 +354,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,56 +382,274 @@ 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}>
|
{!permsReady ? (
|
||||||
<Card loading={loading}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
<Card loading />
|
||||||
</Card>
|
</Col>
|
||||||
</Col>
|
) : null}
|
||||||
<Col xs={24} sm={12} lg={6}>
|
{permsReady && canUsers ? (
|
||||||
<Card loading={loading}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
<Card loading={loading}>
|
||||||
</Card>
|
<Statistic title="有效用户" value={stats?.usersTotal ?? 0} />
|
||||||
</Col>
|
</Card>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
</Col>
|
||||||
<Card loading={loading}>
|
) : null}
|
||||||
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
{permsReady && canUsers ? (
|
||||||
</Card>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
</Col>
|
<Card loading={loading}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Statistic title="访客(未验手机)" value={stats?.guestUsers ?? 0} valueStyle={{ color: '#faad14' }} />
|
||||||
<Card loading={loading}>
|
</Card>
|
||||||
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
</Col>
|
||||||
</Card>
|
) : null}
|
||||||
</Col>
|
{permsReady && canUsers ? (
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Card loading={loading}>
|
<Card loading={loading}>
|
||||||
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
<Statistic title="已验手机" value={stats?.verifiedUsers ?? 0} valueStyle={{ color: '#52c41a' }} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
) : null}
|
||||||
<Card loading={loading}>
|
{permsReady && canOrders ? (
|
||||||
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
<Col xs={24} sm={12} lg={6}>
|
||||||
</Card>
|
<Card loading={loading}>
|
||||||
</Col>
|
<Statistic title="今日下单" value={stats?.ordersToday ?? 0} />
|
||||||
<Col xs={24} sm={12} lg={6}>
|
</Card>
|
||||||
<Card loading={loading}>
|
</Col>
|
||||||
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
) : null}
|
||||||
</Card>
|
{permsReady && canStores ? (
|
||||||
</Col>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Card loading={loading}>
|
||||||
<Card loading={loading}>
|
<Statistic title="门店" value={stats?.storesTotal ?? 0} />
|
||||||
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
</Card>
|
||||||
</Card>
|
</Col>
|
||||||
</Col>
|
) : null}
|
||||||
|
{permsReady && canStores ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading} extra={<Link to="/stores?auditStatus=PENDING">去处理</Link>}>
|
||||||
|
<Statistic
|
||||||
|
title="待入驻审核"
|
||||||
|
value={stats?.pendingStoreOnboard ?? 0}
|
||||||
|
valueStyle={{ color: (stats?.pendingStoreOnboard ?? 0) > 0 ? '#fa8c16' : undefined }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{permsReady && canStoreAudits ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading} extra={<Link to="/store-package-audits">去处理</Link>}>
|
||||||
|
<Statistic
|
||||||
|
title="待套餐/信息审核"
|
||||||
|
value={(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0)}
|
||||||
|
valueStyle={{
|
||||||
|
color:
|
||||||
|
(stats?.pendingStorePackageAudits ?? 0) + (stats?.pendingStoreInfoChanges ?? 0) > 0
|
||||||
|
? '#fa8c16'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{permsReady && canPartners ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Statistic title="合伙人" value={stats?.partnersTotal ?? 0} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{permsReady && canBenefit ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Statistic title="今日核销" value={stats?.redeemToday ?? 0} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{permsReady && canDeliveries ? (
|
||||||
|
<Col xs={24} sm={12} lg={6}>
|
||||||
|
<Card loading={loading}>
|
||||||
|
<Statistic title="配送单" value={stats?.deliveriesTotal ?? 0} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title="全局筛选"
|
||||||
|
style={{ marginBottom: 24 }}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => void loadAnalytics(filters)}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
layout="inline"
|
||||||
|
initialValues={{ range: filters.range }}
|
||||||
|
onFinish={applyFilters}
|
||||||
|
>
|
||||||
|
<Form.Item label="粒度">
|
||||||
|
<Segmented
|
||||||
|
options={GRAIN_OPTIONS}
|
||||||
|
value={filters.granularity}
|
||||||
|
onChange={(v) => setGranularity(v as DashboardGranularity)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="日期" required>
|
||||||
|
<Space size={8} wrap>
|
||||||
|
<Form.Item name="range" noStyle rules={[{ required: true, message: '请选择日期' }]}>
|
||||||
|
<DatePicker.RangePicker allowClear={false} />
|
||||||
|
</Form.Item>
|
||||||
|
{DATE_PRESETS.map((preset) => (
|
||||||
|
<Button key={preset.key} autoInsertSpace={false} onClick={() => applyDatePreset(preset.key)}>
|
||||||
|
{preset.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="cityId" label="城市">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder={cityScoped ? '全部负责城市' : '全部开城'}
|
||||||
|
style={{ width: 160 }}
|
||||||
|
options={[
|
||||||
|
...(cityScoped ? [] : [{ value: 'none', label: '未选城' }]),
|
||||||
|
...cities.map((c) => ({ value: c.id, label: c.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="指标">
|
||||||
|
<Segmented
|
||||||
|
options={METRIC_OPTIONS}
|
||||||
|
value={metric}
|
||||||
|
onChange={(v) => setMetric(v as ChartMetric)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
loading={pdfLoading}
|
||||||
|
disabled={!hasAnalytics || analyticsLoading || chartOptions.length === 0}
|
||||||
|
onClick={() => void handleDownloadPdf()}
|
||||||
|
>
|
||||||
|
下载PDF
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{permsReady && !hasAnalytics ? (
|
||||||
|
<Card style={{ marginBottom: 24 }}>
|
||||||
|
<Typography.Text type="secondary">当前账号无可视指标</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasAnalytics ? (
|
||||||
|
<div ref={chartsWrapRef} style={{ marginBottom: 24 }}>
|
||||||
|
{chartOptions.map(({ chart, option }) => (
|
||||||
|
<Card
|
||||||
|
key={chart.key}
|
||||||
|
title={chart.title}
|
||||||
|
extra={<Link to={listHref(chart.href, filters)}>查看</Link>}
|
||||||
|
loading={analyticsLoading}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<ReactECharts option={option} style={{ height: 320, width: '100%' }} notMerge />
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canFinance || canTickets ? (
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
{canFinance ? (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
{canFinance ? (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
|
{canFinance ? (
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card
|
||||||
|
loading={loading}
|
||||||
|
title="待审门店提现"
|
||||||
|
extra={<Link to="/finance/store-bills?kind=WITHDRAW">去处理</Link>}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
value={stats?.pendingStoreWithdrawals ?? 0}
|
||||||
|
suffix="笔"
|
||||||
|
valueStyle={{
|
||||||
|
color: (stats?.overdueStoreWithdrawals ?? 0) > 0 ? '#cf1322' : (stats?.pendingStoreWithdrawals ?? 0) > 0 ? '#fa8c16' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{(stats?.overdueStoreWithdrawals ?? 0) > 0
|
||||||
|
? `超时未审 ${stats?.overdueStoreWithdrawals} 笔(FIN-003)`
|
||||||
|
: '工作日 T+0 审完'}
|
||||||
|
</Typography.Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
{canTickets ? (
|
||||||
|
<Col xs={24} sm={12} lg={8}>
|
||||||
|
<Card loading={loading} title="待处理工单">
|
||||||
|
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canUsers || canOrders ? (
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} lg={12}>
|
{canUsers ? (
|
||||||
|
<Col xs={24} lg={canOrders ? 12 : 24}>
|
||||||
<Card title="已合并访客账号" loading={loading}>
|
<Card title="已合并访客账号" loading={loading}>
|
||||||
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
<Statistic value={stats?.mergedUsers ?? 0} suffix="个" />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={12}>
|
) : null}
|
||||||
|
{canOrders ? (
|
||||||
|
<Col xs={24} lg={canUsers ? 12 : 24}>
|
||||||
<Card title="订单状态分布" loading={loading}>
|
<Card title="订单状态分布" loading={loading}>
|
||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
@@ -188,7 +667,9 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,79 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
Button, Descriptions, Drawer, Form, Input, Space, Table, Typography, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import OrderTrackDrawer from '../components/OrderTrackDrawer';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import {
|
||||||
|
DELIVERY_PROVIDER_LABELS,
|
||||||
|
DELIVERY_TYPE_LABELS,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
fmtTime,
|
||||||
|
} from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
type Row = {
|
type DeliveryOrder = {
|
||||||
id: string; provider: string; trackingNo: string | null; providerOrderNo: string | null; updatedAt: string;
|
id: string;
|
||||||
order?: { orderNo: string; status: string; receiverName: string; receiverPhone: string; deliveryType: string };
|
orderNo: string;
|
||||||
|
status: string;
|
||||||
|
deliveryType: string;
|
||||||
|
productName: string;
|
||||||
|
productSpec?: string | null;
|
||||||
|
barcode69?: string | null;
|
||||||
|
quantity: number;
|
||||||
|
saleUnit?: string;
|
||||||
|
bottlesPerUnit?: number;
|
||||||
|
payAmount?: number;
|
||||||
|
receiverName: string;
|
||||||
|
receiverPhone: string;
|
||||||
|
receiverAddress?: string;
|
||||||
|
receiverProvince?: string;
|
||||||
|
receiverCity?: string;
|
||||||
|
receiverDistrict?: string;
|
||||||
|
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
|
||||||
|
imageResource?: { url: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
orderId: string;
|
||||||
|
provider: string;
|
||||||
|
trackingNo: string | null;
|
||||||
|
providerOrderNo: string | null;
|
||||||
|
updatedAt: string;
|
||||||
|
logisticsFee?: number | null;
|
||||||
|
order?: DeliveryOrder;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatQty(order?: DeliveryOrder | null) {
|
||||||
|
if (!order || order.quantity == null) return '—';
|
||||||
|
const unit = order.saleUnit === 'BOX' ? '箱' : '瓶';
|
||||||
|
const bottles =
|
||||||
|
order.saleUnit === 'BOX' && order.bottlesPerUnit && order.bottlesPerUnit > 1
|
||||||
|
? `(${order.quantity * order.bottlesPerUnit}瓶)`
|
||||||
|
: '';
|
||||||
|
return `${order.quantity}${unit}${bottles}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAddress(order?: DeliveryOrder | null) {
|
||||||
|
if (!order) return '—';
|
||||||
|
if (order.deliveryType === 'ON_SITE_PICKUP') return '现场取货';
|
||||||
|
const region = [order.receiverProvince, order.receiverCity, order.receiverDistrict]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
const detail = (order.receiverAddress || '').trim();
|
||||||
|
if (!region) return detail || '—';
|
||||||
|
if (!detail || detail.startsWith(region)) return detail || region;
|
||||||
|
return `${region}${detail}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DeliveriesPage() {
|
export default function DeliveriesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
@@ -29,40 +90,165 @@ export default function DeliveriesPage() {
|
|||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [trackOpen, setTrackOpen] = useState(false);
|
||||||
|
const [trackOrderId, setTrackOrderId] = useState<string | null>(null);
|
||||||
|
const [trackOrderNo, setTrackOrderNo] = useState<string | null>(null);
|
||||||
|
const [trackOrderStatus, setTrackOrderStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
function openTrack(row: Row) {
|
||||||
{ title: '订单号', dataIndex: ['order', 'orderNo'], width: 170 },
|
const orderId = row.orderId || row.order?.id;
|
||||||
{ title: 'provider', dataIndex: 'provider', width: 90 },
|
if (!orderId) {
|
||||||
|
message.warning('缺少关联订单,无法查询路由');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTrackOrderId(orderId);
|
||||||
|
setTrackOrderNo(row.order?.orderNo ?? null);
|
||||||
|
setTrackOrderStatus(row.order?.status ?? null);
|
||||||
|
setTrackOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(row: Row) {
|
||||||
|
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
||||||
|
setDetail(d);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
provider: d.provider,
|
||||||
|
trackingNo: d.trackingNo,
|
||||||
|
providerOrderNo: d.providerOrderNo,
|
||||||
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<Row> = [
|
||||||
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: ['order', 'orderNo'],
|
||||||
|
width: 170,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '用户',
|
||||||
|
key: 'user',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) => {
|
||||||
|
const user = row.order?.user;
|
||||||
|
if (!user) return '—';
|
||||||
|
const label = user.userNo || user.phone || '—';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{user.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(user.id) } })}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
{user.phone && user.userNo ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{user.phone}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '商品',
|
||||||
|
key: 'product',
|
||||||
|
width: 200,
|
||||||
|
render: (_, row) => {
|
||||||
|
const order = row.order;
|
||||||
|
if (!order?.productName) return '—';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>{order.productName}</span>
|
||||||
|
{order.productSpec ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{order.productSpec}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '数量',
|
||||||
|
key: 'quantity',
|
||||||
|
width: 90,
|
||||||
|
render: (_, row) => formatQty(row.order),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配送方式',
|
||||||
|
dataIndex: ['order', 'deliveryType'],
|
||||||
|
width: 90,
|
||||||
|
render: (v: string | undefined) => DELIVERY_TYPE_LABELS[v ?? ''] || v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '承运商',
|
||||||
|
dataIndex: 'provider',
|
||||||
|
width: 90,
|
||||||
|
render: (v: string) => DELIVERY_PROVIDER_LABELS[v] || v || '—',
|
||||||
|
},
|
||||||
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
{ title: '运单号', dataIndex: 'trackingNo', width: 140, render: (v) => v || '—' },
|
||||||
|
{
|
||||||
|
title: '运费',
|
||||||
|
dataIndex: 'logisticsFee',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number | null | undefined) => (v == null ? '—' : `¥${Number(v).toFixed(2)}`),
|
||||||
|
},
|
||||||
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
{ title: '第三方单号', dataIndex: 'providerOrderNo', width: 140, render: (v) => v || '—' },
|
||||||
{ title: '订单状态', dataIndex: ['order', 'status'], width: 100, render: (s) => ORDER_STATUS_LABELS[s] || s },
|
{
|
||||||
|
title: '订单状态',
|
||||||
|
dataIndex: ['order', 'status'],
|
||||||
|
width: 100,
|
||||||
|
render: (s) => ORDER_STATUS_LABELS[s] || s,
|
||||||
|
},
|
||||||
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
{ title: '收货人', dataIndex: ['order', 'receiverName'], width: 90 },
|
||||||
|
{
|
||||||
|
title: '收货电话',
|
||||||
|
dataIndex: ['order', 'receiverPhone'],
|
||||||
|
width: 120,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '配送地址',
|
||||||
|
key: 'address',
|
||||||
|
width: 280,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, row) => formatAddress(row.order),
|
||||||
|
},
|
||||||
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
{ title: '更新', dataIndex: 'updatedAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 80,
|
title: '操作', width: 140,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Space size={0}>
|
||||||
const d = await request<Row>(`/admin/deliveries/${row.id}`);
|
<Button type="link" size="small" onClick={() => openTrack(row)}>路由</Button>
|
||||||
setDetail(d);
|
<Button type="link" size="small" onClick={() => void openDetail(row)}>编辑</Button>
|
||||||
editForm.setFieldsValue({ provider: d.provider, trackingNo: d.trackingNo, providerOrderNo: d.providerOrderNo });
|
</Space>
|
||||||
setDrawerOpen(true);
|
|
||||||
}}>编辑</Button>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('deliveries', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
const order = detail?.order;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>快递/配送单</Typography.Title>
|
{settingsModal}
|
||||||
|
<AdminListHeader title="快递/配送单" settings={settingsButton} />
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
<Form.Item name="orderNo" label="订单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="provider" label="provider"><Input allowClear placeholder="MOCK" /></Form.Item>
|
<Form.Item name="provider" label="承运商"><Input allowClear placeholder="XFX" /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input allowClear /></Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 'max-content' }}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="配送单编辑" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="配送单编辑" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
<Button onClick={() => setDrawerOpen(false)}>取消</Button>
|
||||||
@@ -79,17 +265,82 @@ export default function DeliveriesPage() {
|
|||||||
{detail && (
|
{detail && (
|
||||||
<>
|
<>
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="订单">{detail.order?.orderNo}</Descriptions.Item>
|
<Descriptions.Item label="订单">{order?.orderNo || '—'}</Descriptions.Item>
|
||||||
<Descriptions.Item label="收货">{detail.order?.receiverName} {detail.order?.receiverPhone}</Descriptions.Item>
|
<Descriptions.Item label="用户">
|
||||||
|
{order?.user ? (
|
||||||
|
<Space size={8} wrap>
|
||||||
|
{order.user.id ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate('/users', { state: { openUserId: String(order.user!.id) } })}
|
||||||
|
>
|
||||||
|
{order.user.userNo}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
order.user.userNo || '—'
|
||||||
|
)}
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
{[order.user.nickname, order.user.phone].filter(Boolean).join(' / ') || ''}
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="商品">
|
||||||
|
<Space align="start">
|
||||||
|
{order?.imageResource?.url ? (
|
||||||
|
<img
|
||||||
|
src={order.imageResource.url}
|
||||||
|
alt=""
|
||||||
|
style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4 }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<span>
|
||||||
|
{order?.productName || '—'}
|
||||||
|
{order?.productSpec ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{order.productSpec}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
{order?.barcode69 ? (
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||||
|
{order.barcode69}
|
||||||
|
</Typography.Text>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="数量">{formatQty(order)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="配送方式">
|
||||||
|
{DELIVERY_TYPE_LABELS[order?.deliveryType ?? ''] || order?.deliveryType || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="收货人">
|
||||||
|
{[order?.receiverName, order?.receiverPhone].filter(Boolean).join(' ') || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="配送地址">{formatAddress(order)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运费">
|
||||||
|
{detail.logisticsFee == null ? '—' : `¥${Number(detail.logisticsFee).toFixed(2)}`}
|
||||||
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
<Form.Item name="provider" label="provider" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="provider" label="承运商" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
<Form.Item name="providerOrderNo" label="第三方单号"><Input /></Form.Item>
|
||||||
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
<Form.Item name="trackingNo" label="运单号"><Input /></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
<Button
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={() => openTrack(detail)}
|
||||||
|
>
|
||||||
|
查看路由
|
||||||
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
<OrderTrackDrawer
|
||||||
|
open={trackOpen}
|
||||||
|
orderId={trackOrderId}
|
||||||
|
orderNo={trackOrderNo}
|
||||||
|
orderStatus={trackOrderStatus}
|
||||||
|
onClose={() => setTrackOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Collapse, Form, Input, Select, Typography, message } from 'antd';
|
||||||
|
import type {
|
||||||
|
DevPlanSettingsDto,
|
||||||
|
KnowledgeBaseOptionDto,
|
||||||
|
LlmApiConfigOptionDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
|
export default function DevPlanSettingsPage() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [llmOptions, setLlmOptions] = useState<LlmApiConfigOptionDto[]>([]);
|
||||||
|
const [kbOptions, setKbOptions] = useState<KnowledgeBaseOptionDto[]>([]);
|
||||||
|
const [settingsForm] = Form.useForm();
|
||||||
|
|
||||||
|
async function loadAll() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [s, llm, kb] = await Promise.all([
|
||||||
|
request<DevPlanSettingsDto>('/admin/dev-plan/settings'),
|
||||||
|
request<LlmApiConfigOptionDto[]>('/admin/llm-configs/options'),
|
||||||
|
request<KnowledgeBaseOptionDto[]>('/admin/knowledge-bases/options'),
|
||||||
|
]);
|
||||||
|
setLlmOptions(llm);
|
||||||
|
setKbOptions(kb);
|
||||||
|
settingsForm.setFieldsValue({
|
||||||
|
reviewAssistantLlmConfigId: s.reviewAssistantLlmConfigId,
|
||||||
|
reviewAssistantKnowledgeBaseId: s.reviewAssistantKnowledgeBaseId,
|
||||||
|
reviewAssistantPrompt: s.reviewAssistantPrompt,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadAll();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function saveSettings() {
|
||||||
|
const values = await settingsForm.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await request<DevPlanSettingsDto>('/admin/dev-plan/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
});
|
||||||
|
message.success('设置已保存');
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Typography.Title level={4}>开发计划 · 开发设置</Typography.Title>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
任务评审派发 Webhook 已迁移至「企微机器人 → 消息推送」,勾选「开发任务评审派发」条件即可。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
defaultActiveKey={['review-ai']}
|
||||||
|
style={{ marginTop: 16 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'review-ai',
|
||||||
|
label: '本地审核 AI 助手',
|
||||||
|
children: (
|
||||||
|
<Form form={settingsForm} layout="vertical" disabled={loading}>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
仅用于技术支持批量预审(只读知识库 + LLM),preview 阶段不写库。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form.Item name="reviewAssistantLlmConfigId" label="语言模型">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
options={llmOptions.map((o) => ({ value: o.id, label: o.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="reviewAssistantKnowledgeBaseId" label="知识库">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
options={kbOptions.map((o) => ({ value: o.id, label: o.name }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="reviewAssistantPrompt" label="审核提示词">
|
||||||
|
<Input.TextArea rows={5} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" loading={saving} onClick={() => void saveSettings()}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
DEV_PLAN_TASK_STATUS_LABELS,
|
||||||
|
DEV_PLAN_TASK_TYPE_LABELS,
|
||||||
|
SUPPORT_TICKET_STATUS_LABELS,
|
||||||
|
type DevPlanTaskDto,
|
||||||
|
type DevPlanTaskExportFormat,
|
||||||
|
type DevPlanTaskStatusDto,
|
||||||
|
type DevPlanTaskTypeDto,
|
||||||
|
type DevPlanVersionDto,
|
||||||
|
type SupportTicketDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { downloadBase64File } from '../lib/exportExcel';
|
||||||
|
import { exportColumnsBody } from '../lib/export-columns';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import OssUpload from '../components/OssUpload';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
const TYPE_OPTIONS = (Object.keys(DEV_PLAN_TASK_TYPE_LABELS) as DevPlanTaskTypeDto[]).map((v) => ({
|
||||||
|
value: v,
|
||||||
|
label: DEV_PLAN_TASK_TYPE_LABELS[v],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_TASK_STATUS_LABELS) as DevPlanTaskStatusDto[]).map(
|
||||||
|
(v) => ({ value: v, label: DEV_PLAN_TASK_STATUS_LABELS[v] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<DevPlanTaskStatusDto, string> = {
|
||||||
|
TODO: 'default',
|
||||||
|
IN_PROGRESS: 'orange',
|
||||||
|
DEVELOPED: 'blue',
|
||||||
|
RELEASED: 'green',
|
||||||
|
STOPPED: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DISPATCH_SUPPLEMENT = '请按以下任务进入开发流程';
|
||||||
|
|
||||||
|
export default function DevPlanTasksPage() {
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [dispatchOpen, setDispatchOpen] = useState(false);
|
||||||
|
const [batchEditOpen, setBatchEditOpen] = useState(false);
|
||||||
|
const [batchSaving, setBatchSaving] = useState(false);
|
||||||
|
const [creatingVersion, setCreatingVersion] = useState(false);
|
||||||
|
const [newVersionNo, setNewVersionNo] = useState('');
|
||||||
|
const [versions, setVersions] = useState<DevPlanVersionDto[]>([]);
|
||||||
|
const [supportTickets, setSupportTickets] = useState<SupportTicketDto[]>([]);
|
||||||
|
const [editing, setEditing] = useState<DevPlanTaskDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [dispatching, setDispatching] = useState(false);
|
||||||
|
const [exportFormat, setExportFormat] = useState<DevPlanTaskExportFormat>('xlsx');
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [dispatchForm] = Form.useForm<{ supplement?: string }>();
|
||||||
|
const [batchForm] = Form.useForm<{ status?: DevPlanTaskStatusDto; versionId?: string }>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen && !batchEditOpen) return;
|
||||||
|
request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100')
|
||||||
|
.then((res) => setVersions(res.items ?? []))
|
||||||
|
.catch(() => setVersions([]));
|
||||||
|
}, [modalOpen, batchEditOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!modalOpen || editing) return;
|
||||||
|
request<{ items: SupportTicketDto[] }>('/admin/support-tickets?pageSize=100')
|
||||||
|
.then((res) => setSupportTickets(res.items ?? []))
|
||||||
|
.catch(() => setSupportTickets([]));
|
||||||
|
}, [modalOpen, editing]);
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<DevPlanTaskDto>(
|
||||||
|
'/admin/dev-plan/tasks',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.type) qs.set('type', filters.type);
|
||||||
|
if (filters.keyword) qs.set('keyword', filters.keyword);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
form.setFieldsValue({ content: '', type: 'BUG', status: 'TODO', supportTicketId: undefined, attachmentUrls: [''] });
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: DevPlanTaskDto) {
|
||||||
|
setEditing(row);
|
||||||
|
form.setFieldsValue({
|
||||||
|
content: row.content,
|
||||||
|
type: row.type,
|
||||||
|
status: row.status,
|
||||||
|
versionIds: row.versionIds?.length ? row.versionIds : row.versions?.map((v) => v.id) ?? [],
|
||||||
|
attachmentUrls: row.attachmentUrls?.length ? row.attachmentUrls : [''],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const attachmentUrls = (values.attachmentUrls ?? []).map((u: string) => u?.trim()).filter(Boolean);
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/dev-plan/tasks/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
...values,
|
||||||
|
attachmentUrls,
|
||||||
|
versionIds: values.versionIds ?? [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已更新');
|
||||||
|
} else {
|
||||||
|
await request('/admin/dev-plan/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: values.content,
|
||||||
|
type: values.type,
|
||||||
|
supportTicketId: values.supportTicketId || undefined,
|
||||||
|
attachmentUrls,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
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/dev-plan/tasks/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDispatch() {
|
||||||
|
dispatchForm.setFieldsValue({
|
||||||
|
supplement: DEFAULT_DISPATCH_SUPPLEMENT,
|
||||||
|
});
|
||||||
|
setDispatchOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchEdit() {
|
||||||
|
batchForm.setFieldsValue({ status: undefined, versionId: undefined });
|
||||||
|
setNewVersionNo('');
|
||||||
|
setBatchEditOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVersionInBatch() {
|
||||||
|
const versionNo = newVersionNo.trim();
|
||||||
|
if (!versionNo) {
|
||||||
|
message.warning('请输入版本号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreatingVersion(true);
|
||||||
|
try {
|
||||||
|
const created = await request<DevPlanVersionDto>('/admin/dev-plan/versions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ versionNo }),
|
||||||
|
});
|
||||||
|
const res = await request<{ items: DevPlanVersionDto[] }>('/admin/dev-plan/versions?pageSize=100');
|
||||||
|
setVersions(res.items ?? []);
|
||||||
|
batchForm.setFieldsValue({ versionId: created.id });
|
||||||
|
setNewVersionNo('');
|
||||||
|
message.success(`已创建版本 ${versionNo}`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setCreatingVersion(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBatchEdit() {
|
||||||
|
const values = await batchForm.validateFields();
|
||||||
|
if (!values.status && !values.versionId) {
|
||||||
|
message.warning('请至少选择状态或关联版本');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBatchSaving(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/dev-plan/tasks/batch-update', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
taskIds: selectedRowKeys,
|
||||||
|
status: values.status || undefined,
|
||||||
|
versionId: values.versionId || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('批量更新成功');
|
||||||
|
setBatchEditOpen(false);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '批量更新失败');
|
||||||
|
} finally {
|
||||||
|
setBatchSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitDispatch() {
|
||||||
|
const values = await dispatchForm.validateFields();
|
||||||
|
setDispatching(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/dev-plan/tasks/dispatch', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
taskIds: selectedRowKeys,
|
||||||
|
supplement: values.supplement?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已派发到企微群');
|
||||||
|
setDispatchOpen(false);
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '派发失败');
|
||||||
|
} finally {
|
||||||
|
setDispatching(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<DevPlanTaskDto> = [
|
||||||
|
{
|
||||||
|
title: '任务号',
|
||||||
|
dataIndex: 'taskNo',
|
||||||
|
width: 160,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'type',
|
||||||
|
width: 80,
|
||||||
|
render: (t: DevPlanTaskTypeDto) => DEV_PLAN_TASK_TYPE_LABELS[t],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (s: DevPlanTaskStatusDto) => (
|
||||||
|
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_TASK_STATUS_LABELS[s]}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '内容',
|
||||||
|
dataIndex: 'content',
|
||||||
|
width: 360,
|
||||||
|
render: (v: string) => (
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxWidth: 480 }}>{v}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '附件',
|
||||||
|
key: 'attachmentUrls',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.attachmentUrls?.length ? (
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Space size={4}>
|
||||||
|
{row.attachmentUrls.slice(0, 3).map((url) => (
|
||||||
|
<Image key={url} src={url} width={32} height={32} style={{ objectFit: 'cover' }} />
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '来源工单',
|
||||||
|
dataIndex: 'supportTicketNo',
|
||||||
|
width: 140,
|
||||||
|
render: (v, row) =>
|
||||||
|
row.supportTicketId && v ? (
|
||||||
|
<Link className="admin-primary-link" to={`/tickets/support?id=${row.supportTicketId}`}>
|
||||||
|
{v}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
v || '—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关联版本',
|
||||||
|
key: 'versions',
|
||||||
|
dataIndex: 'versions',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) =>
|
||||||
|
row.versions?.length ? (
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{row.versions.map((v) => (
|
||||||
|
<Tag key={v.id}>{v.versionNo}</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '创建人', dataIndex: 'creatorName', width: 90 },
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确认删除?" onConfirm={() => void remove(row.id)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('dev-plan-tasks', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
async function exportTasks(scope: 'filter' | 'selected') {
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
const result = await request<{
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
contentBase64: string;
|
||||||
|
count: number;
|
||||||
|
}>('/admin/dev-plan/tasks/export', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
scope,
|
||||||
|
format: exportFormat,
|
||||||
|
ids: scope === 'selected' ? selectedRowKeys : undefined,
|
||||||
|
status: filters.status || undefined,
|
||||||
|
type: filters.type || undefined,
|
||||||
|
keyword: filters.keyword || undefined,
|
||||||
|
...exportColumnsBody(exportColumnKeys),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
|
||||||
|
message.success(`已导出 ${result.count} 条任务`);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '导出失败');
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="开发计划 · 任务列表"
|
||||||
|
settings={settingsButton}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
value={exportFormat}
|
||||||
|
style={{ width: 120 }}
|
||||||
|
onChange={setExportFormat}
|
||||||
|
options={[
|
||||||
|
{ value: 'markdown', label: 'Markdown' },
|
||||||
|
{ value: 'docx', label: 'Word' },
|
||||||
|
{ value: 'xlsx', label: 'Excel' },
|
||||||
|
{ value: 'pdf', label: 'PDF' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button loading={exporting} disabled={!selectedRowKeys.length} onClick={() => void exportTasks('selected')}>
|
||||||
|
导出已勾选
|
||||||
|
</Button>
|
||||||
|
<Button loading={exporting} onClick={() => void exportTasks('filter')}>
|
||||||
|
导出全部筛选
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!selectedRowKeys.length} onClick={openBatchEdit}>
|
||||||
|
批量编辑
|
||||||
|
</Button>
|
||||||
|
<Button disabled={!selectedRowKeys.length} onClick={openDispatch}>
|
||||||
|
评审
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建任务
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="type" label="类型">
|
||||||
|
<Select allowClear style={{ width: 100 }} options={TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select allowClear style={{ width: 110 }} options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="keyword" label="关键词">
|
||||||
|
<Input allowClear placeholder="任务号/内容" style={{ width: 160 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: (keys) => setSelectedRowKeys(keys as string[]),
|
||||||
|
}}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
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 save()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="content" label="任务内容" rules={[{ required: true }]}>
|
||||||
|
<Input.TextArea rows={4} maxLength={4000} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
|
||||||
|
<Select options={TYPE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
{editing ? (
|
||||||
|
<>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
|
<Select options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="versionIds" label="关联版本">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="可选:关联版本"
|
||||||
|
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Form.Item name="supportTicketId" label="绑定技术支持工单">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="可选:关联工单"
|
||||||
|
options={supportTickets.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.ticketNo} · ${SUPPORT_TICKET_STATUS_LABELS[t.status]} · ${t.title}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Form.Item label="附件图片">
|
||||||
|
<Form.List name="attachmentUrls">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
||||||
|
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
||||||
|
<OssUpload bizType="SUPPORT_TICKET_ATTACHMENT" mediaType="IMAGE" />
|
||||||
|
</Form.Item>
|
||||||
|
{fields.length > 1 ? (
|
||||||
|
<Button type="link" danger onClick={() => remove(field.name)}>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button type="dashed" onClick={() => add('')} block>
|
||||||
|
添加图片
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="评审派发"
|
||||||
|
open={dispatchOpen}
|
||||||
|
onCancel={() => setDispatchOpen(false)}
|
||||||
|
onOk={() => void submitDispatch()}
|
||||||
|
confirmLoading={dispatching}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary">
|
||||||
|
已选 {selectedRowKeys.length} 条任务,将通过「企微机器人 → 消息推送」中勾选「开发任务评审派发」的实例推送到开发群(@
|
||||||
|
userid 由各推送配置)。
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Form form={dispatchForm} layout="vertical">
|
||||||
|
<Form.Item name="supplement" label="补充说明">
|
||||||
|
<Input.TextArea rows={3} placeholder="默认任务说明;@ 成员由消息推送配置决定" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="批量编辑任务"
|
||||||
|
open={batchEditOpen}
|
||||||
|
onCancel={() => setBatchEditOpen(false)}
|
||||||
|
onOk={() => void submitBatchEdit()}
|
||||||
|
confirmLoading={batchSaving}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Typography.Paragraph type="secondary">已选 {selectedRowKeys.length} 条任务</Typography.Paragraph>
|
||||||
|
<Form form={batchForm} layout="vertical">
|
||||||
|
<Form.Item name="status" label="状态(不修改请留空)">
|
||||||
|
<Select allowClear placeholder="不修改" options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="versionId" label="关联版本(追加关联,不修改请留空)">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
placeholder="不修改"
|
||||||
|
options={versions.map((v) => ({ value: v.id, label: v.versionNo }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="创建新版本">
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input
|
||||||
|
placeholder="版本号,如 v4.0.11"
|
||||||
|
value={newVersionNo}
|
||||||
|
onChange={(e) => setNewVersionNo(e.target.value)}
|
||||||
|
onPressEnter={() => void createVersionInBatch()}
|
||||||
|
/>
|
||||||
|
<Button loading={creatingVersion} onClick={() => void createVersionInBatch()}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
DEV_PLAN_VERSION_STATUS_LABELS,
|
||||||
|
type DevPlanTaskDto,
|
||||||
|
type DevPlanVersionDto,
|
||||||
|
type DevPlanVersionStatusDto,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = (Object.keys(DEV_PLAN_VERSION_STATUS_LABELS) as DevPlanVersionStatusDto[]).map(
|
||||||
|
(v) => ({ value: v, label: DEV_PLAN_VERSION_STATUS_LABELS[v] }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const STATUS_COLOR: Record<DevPlanVersionStatusDto, string> = {
|
||||||
|
PENDING: 'default',
|
||||||
|
IN_PROGRESS: 'processing',
|
||||||
|
TESTING: 'purple',
|
||||||
|
RELEASED: 'green',
|
||||||
|
STOPPED: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDuration(minutes?: number | null) {
|
||||||
|
if (minutes == null) return '—';
|
||||||
|
if (minutes < 60) return `${minutes} 分钟`;
|
||||||
|
return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DevPlanVersionsPage() {
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<DevPlanVersionDto | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [allTasks, setAllTasks] = useState<DevPlanTaskDto[]>([]);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } =
|
||||||
|
useAdminList<DevPlanVersionDto>('/admin/dev-plan/versions', () => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.keyword) qs.set('keyword', filters.keyword);
|
||||||
|
return qs;
|
||||||
|
}, [filters]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<{ items: DevPlanTaskDto[] }>('/admin/dev-plan/tasks?page=1&pageSize=500')
|
||||||
|
.then((res) => setAllTasks(res.items ?? []))
|
||||||
|
.catch(() => setAllTasks([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function openEdit(row: DevPlanVersionDto) {
|
||||||
|
const detail = await request<DevPlanVersionDto>(`/admin/dev-plan/versions/${row.id}`);
|
||||||
|
setEditing(detail);
|
||||||
|
form.setFieldsValue({
|
||||||
|
versionNo: detail.versionNo,
|
||||||
|
content: detail.content ?? '',
|
||||||
|
status: detail.status,
|
||||||
|
taskIds: detail.taskIds ?? [],
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditing(null);
|
||||||
|
form.setFieldsValue({ versionNo: '', content: '', status: 'PENDING', taskIds: [] });
|
||||||
|
setModalOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
versionNo: values.versionNo.trim(),
|
||||||
|
content: values.content?.trim() || undefined,
|
||||||
|
status: values.status,
|
||||||
|
taskIds: values.taskIds ?? [],
|
||||||
|
};
|
||||||
|
if (editing) {
|
||||||
|
await request(`/admin/dev-plan/versions/${editing.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
message.success('已更新');
|
||||||
|
} else {
|
||||||
|
await request('/admin/dev-plan/versions', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
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/dev-plan/versions/${id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<DevPlanVersionDto> = [
|
||||||
|
{ title: '版本号', dataIndex: 'versionNo', width: 120, render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
) },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s: DevPlanVersionStatusDto) => (
|
||||||
|
<Tag color={STATUS_COLOR[s]}>{DEV_PLAN_VERSION_STATUS_LABELS[s]}</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '内容', dataIndex: 'content', render: (v) => v || '—' },
|
||||||
|
{ title: '开发起始', dataIndex: 'devStartedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||||
|
{ title: '开发完成', dataIndex: 'devCompletedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||||
|
{ title: '上线时间', dataIndex: 'releasedAt', width: 160, render: (v) => (v ? fmtTime(v) : '—') },
|
||||||
|
{
|
||||||
|
title: '用时',
|
||||||
|
dataIndex: 'durationMinutes',
|
||||||
|
width: 100,
|
||||||
|
render: (v) => formatDuration(v),
|
||||||
|
},
|
||||||
|
{ title: '创建时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 120,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space>
|
||||||
|
<Button type="link" size="small" onClick={() => void openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确认删除?(不删除关联任务)" onConfirm={() => void remove(row.id)}>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('dev-plan-versions', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="开发计划 · 版本列表"
|
||||||
|
settings={settingsButton}
|
||||||
|
actions={
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
新建版本
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Form
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select allowClear style={{ width: 120 }} options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="keyword" label="查找">
|
||||||
|
<Input allowClear placeholder="版本号或内容" style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
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 save()}
|
||||||
|
confirmLoading={saving}
|
||||||
|
destroyOnClose
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="versionNo" label="版本号" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 v3.4.11" maxLength={32} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||||
|
<Select options={STATUS_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="content" label="内容">
|
||||||
|
<Input.TextArea placeholder="版本描述 / 更新日志" rows={3} maxLength={500} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="taskIds" label="关联任务">
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={allTasks.map((t) => ({
|
||||||
|
value: t.id,
|
||||||
|
label: `${t.taskNo} · ${t.content.slice(0, 40)}`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Card, Descriptions, Drawer, Select, Space, Table, Tag } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { fmtTime } from '../lib/constants';
|
||||||
|
import {
|
||||||
|
DOMAIN_EVENT_TYPE_LABELS,
|
||||||
|
domainParamFieldLabel,
|
||||||
|
domainParamValue,
|
||||||
|
formatRef,
|
||||||
|
zhCode,
|
||||||
|
zhLabel,
|
||||||
|
} from '../lib/display-labels';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
eventType: string;
|
||||||
|
refType: string;
|
||||||
|
refId: string;
|
||||||
|
status?: string | null;
|
||||||
|
param1?: string | null;
|
||||||
|
param1Desc?: string | null;
|
||||||
|
param2?: string | null;
|
||||||
|
param2Desc?: string | null;
|
||||||
|
param3?: string | null;
|
||||||
|
param3Desc?: string | null;
|
||||||
|
remark?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_OPTIONS = [
|
||||||
|
{ value: '', label: '全部领域事件' },
|
||||||
|
{ value: 'ORDER_STATUS', label: '订单状态' },
|
||||||
|
{ value: 'BENEFIT_LEDGER', label: '权益流水' },
|
||||||
|
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||||
|
{ value: 'TICKET_COLLAB', label: '工单协作' },
|
||||||
|
{ value: 'PROMO_TOUCH', label: '推广触达' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DomainEventsPage() {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const eventType = params.get('eventType') ?? '';
|
||||||
|
const refType = params.get('refType') ?? '';
|
||||||
|
const refId = params.get('refId') ?? '';
|
||||||
|
const [data, setData] = useState<{ items: Row[]; total: number; page: number; pageSize: number } | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (eventType) q.set('eventType', eventType);
|
||||||
|
if (refType) q.set('refType', refType);
|
||||||
|
if (refId) q.set('refId', refId);
|
||||||
|
q.set('page', params.get('page') ?? '1');
|
||||||
|
q.set('pageSize', '20');
|
||||||
|
request<{ items: Row[]; total: number; page: number; pageSize: number }>(
|
||||||
|
`/admin/logs/domain-events?${q.toString()}`,
|
||||||
|
)
|
||||||
|
.then(setData)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [eventType, refType, refId, params]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<Row> = [
|
||||||
|
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v) => fmtTime(v) },
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
dataIndex: 'eventType',
|
||||||
|
width: 130,
|
||||||
|
render: (v, r) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag>{zhLabel(DOMAIN_EVENT_TYPE_LABELS, v)}</Tag>
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '关联', render: (_, r) => formatRef(r.refType, r.refId) },
|
||||||
|
{ title: '状态', dataIndex: 'status', width: 100, render: (v) => zhCode(v) },
|
||||||
|
{
|
||||||
|
title: '摘要',
|
||||||
|
render: (_, r) => domainParamValue(r.param1Desc, r.param1) !== '—'
|
||||||
|
? domainParamValue(r.param1Desc, r.param1)
|
||||||
|
: (r.remark || '—'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => (
|
||||||
|
<a
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request<Row>(`/admin/logs/domain-events/${r.id}`));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const page = Number(params.get('page') ?? 1);
|
||||||
|
const pageSize = data?.pageSize ?? 20;
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-domain-events', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="领域事件" extra={settingsButton}>
|
||||||
|
{settingsModal}
|
||||||
|
<Space wrap style={{ marginBottom: 16 }}>
|
||||||
|
<Select
|
||||||
|
style={{ width: 180 }}
|
||||||
|
value={eventType}
|
||||||
|
options={EVENT_OPTIONS}
|
||||||
|
onChange={(v) => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
if (v) next.set('eventType', v);
|
||||||
|
else next.delete('eventType');
|
||||||
|
next.set('page', '1');
|
||||||
|
setParams(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
pagination={{
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
pageSize: data?.pageSize ?? 20,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
onChange: (p) => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
next.set('page', String(p));
|
||||||
|
setParams(next);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer open={!!detail} title="事件详情" width={520} onClose={() => setDetail(null)}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} size="small">
|
||||||
|
<Descriptions.Item label="ID">{detail.id}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="类型">{zhLabel(DOMAIN_EVENT_TYPE_LABELS, detail.eventType)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={domainParamFieldLabel(detail.param1Desc, 1)}>
|
||||||
|
{domainParamValue(detail.param1Desc, detail.param1)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={domainParamFieldLabel(detail.param2Desc, 2)}>
|
||||||
|
{domainParamValue(detail.param2Desc, detail.param2)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label={domainParamFieldLabel(detail.param3Desc, 3)}>
|
||||||
|
{domainParamValue(detail.param3Desc, detail.param3)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="备注">{detail.remark}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{fmtTime(detail.createdAt)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
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';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
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(Array.isArray(res) ? res : []);
|
||||||
|
} catch (e) {
|
||||||
|
setRows([]);
|
||||||
|
message.error(e instanceof Error ? e.message : '加载承运商失败');
|
||||||
|
} 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,
|
||||||
|
deliveryHintHtml: '',
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
deliveryHintHtml: row.deliveryHintHtml || '',
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
deliveryHintHtml: v.deliveryHintHtml?.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(row: FulfillmentProviderDto) {
|
||||||
|
try {
|
||||||
|
await request(`/admin/fulfillment-providers/${row.id}`, { method: 'DELETE' });
|
||||||
|
message.success('已删除');
|
||||||
|
void load();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<FulfillmentProviderDto> = [
|
||||||
|
{ title: '编码', dataIndex: 'code', width: 100 },
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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: 140,
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" onClick={() => openEdit(row)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={`确认删除承运商「${row.name}」?`}
|
||||||
|
okText="删除"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => void remove(row)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('fulfillment-providers', baseColumns, {
|
||||||
|
page: 1,
|
||||||
|
pageSize: rows.length || 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="仓配管理"
|
||||||
|
description="注册承运商接口凭证,并配置物流对账用的银行账户、结算方式与计价标准"
|
||||||
|
settings={settingsButton}
|
||||||
|
actions={
|
||||||
|
<Button type="primary" onClick={openCreate}>
|
||||||
|
注册承运商
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
message="小飞侠默认计价:2瓶6元,加一瓶+2元,6瓶一箱14元。财务对账见「财务 → 物流对账」。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Table scroll={{ x: 'max-content' }} 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>
|
||||||
|
<Form.Item
|
||||||
|
name="deliveryHintHtml"
|
||||||
|
label="配送信息提示"
|
||||||
|
extra="C 端同城送展示。支持 HTML:span/p/br/b/strong/i/em/font,style 可用 color、font-weight、font-size、font-style。回车换行会原样显示。空则回退「同城配送,预计24小时内送到」。"
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder='<span style="color:#A61D24;font-weight:700;font-size:13px">同城配送,预计24小时内送到</span>'
|
||||||
|
/>
|
||||||
|
</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="起送瓶数"
|
||||||
|
extra="物流计价起步瓶数,不影响用户起购(起购在城市管理)"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,14 @@ import {
|
|||||||
Button, Drawer, Form, Input, Modal, Radio, Select, Space, Table, Tag, Typography, message,
|
Button, Drawer, Form, Input, Modal, Radio, 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 HqProfile } from '../lib/api';
|
import { HQ_ADMIN_ROLES } from '@dukang/shared-types';
|
||||||
import { ACCOUNT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
import { request, type HqProfile, type Paginated } from '../lib/api';
|
||||||
|
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,14 +22,13 @@ type Row = {
|
|||||||
status: string;
|
status: string;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
cityIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const ROLE_LABELS: Record<string, string> = {
|
type CityOption = { id: string; name: string };
|
||||||
SUPER_ADMIN: '超级管理员',
|
|
||||||
OPS: '运营',
|
const ROLE_OPTIONS = HQ_ADMIN_ROLES.map((r) => ({ value: r.value, label: r.label }));
|
||||||
FINANCE: '财务',
|
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||||||
CUSTOMER_SERVICE: '客服',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HqAccountsPage() {
|
export default function HqAccountsPage() {
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
@@ -46,15 +50,52 @@ export default function HqAccountsPage() {
|
|||||||
const [detail, setDetail] = useState<Row | 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 [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
|
const credentialType = Form.useWatch('credentialType', createForm) ?? 'phone';
|
||||||
|
const editRole = Form.useWatch('adminRole', editForm);
|
||||||
|
const createRole = Form.useWatch('adminRole', createForm);
|
||||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
useEffect(() => {
|
||||||
{ title: '姓名', dataIndex: 'name' },
|
if (!isSuperAdmin) return;
|
||||||
|
request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setCities(res.items))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [isSuperAdmin]);
|
||||||
|
|
||||||
|
const cityOptions = cities.map((c) => ({ value: c.id, label: c.name }));
|
||||||
|
|
||||||
|
const baseColumns: ColumnsType<Row> = [
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) =>
|
||||||
|
isSuperAdmin ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => {
|
||||||
|
setDetail(row);
|
||||||
|
editForm.setFieldsValue({
|
||||||
|
name: row.name,
|
||||||
|
phone: row.phone,
|
||||||
|
loginName: row.loginName,
|
||||||
|
adminRole: row.adminRole,
|
||||||
|
status: row.status,
|
||||||
|
cityIds: row.cityIds ?? [],
|
||||||
|
});
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
v
|
||||||
|
),
|
||||||
|
},
|
||||||
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
{ title: '用户名', dataIndex: 'loginName', width: 120, render: (v) => v || '—' },
|
||||||
{ title: '手机', dataIndex: 'phone', width: 130 },
|
{ title: '手机', dataIndex: 'phone', width: 130 },
|
||||||
{
|
{
|
||||||
@@ -67,7 +108,18 @@ export default function HqAccountsPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ title: '角色', dataIndex: 'adminRole', width: 110, render: (r) => ROLE_LABELS[r] || r },
|
{ title: '角色', dataIndex: 'adminRole', width: 130, render: (r) => ROLE_LABELS[r] || r },
|
||||||
|
{
|
||||||
|
title: '城市',
|
||||||
|
width: 160,
|
||||||
|
render: (_, r) => {
|
||||||
|
if (!r.cityIds?.length) return <Typography.Text type="secondary">全国</Typography.Text>;
|
||||||
|
const names = r.cityIds
|
||||||
|
.map((id) => cities.find((c) => c.id === id)?.name || id)
|
||||||
|
.join('、');
|
||||||
|
return names;
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
{ title: '最后登录', dataIndex: 'lastLoginAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -81,6 +133,7 @@ export default function HqAccountsPage() {
|
|||||||
loginName: row.loginName,
|
loginName: row.loginName,
|
||||||
adminRole: row.adminRole,
|
adminRole: row.adminRole,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
cityIds: row.cityIds ?? [],
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}>编辑</Button>
|
}}>编辑</Button>
|
||||||
@@ -88,43 +141,57 @@ export default function HqAccountsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('hq-accounts', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
{settingsModal}
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>HQ 账户</Typography.Title>
|
<AdminListHeader
|
||||||
{isSuperAdmin && (
|
title="HQ 账户"
|
||||||
<Button
|
settings={settingsButton}
|
||||||
type="primary"
|
actions={
|
||||||
onClick={() => {
|
isSuperAdmin ? (
|
||||||
createForm.resetFields();
|
<Button
|
||||||
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS' });
|
type="primary"
|
||||||
setCreateOpen(true);
|
onClick={() => {
|
||||||
}}
|
createForm.resetFields();
|
||||||
>
|
createForm.setFieldsValue({ credentialType: 'phone', adminRole: 'OPS', cityIds: [] });
|
||||||
新建账户
|
setCreateOpen(true);
|
||||||
</Button>
|
}}
|
||||||
)}
|
>
|
||||||
</Space>
|
新建账户
|
||||||
|
</Button>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
<Select allowClear style={{ width: 120 }} options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select allowClear style={{ width: 160 }} options={ROLE_OPTIONS} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
<Table scroll={{ x: 'max-content' }} rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||||
<Drawer title="编辑 HQ 账户" width={420} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
<Drawer title="编辑 HQ 账户" width={460} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
<Button type="primary" onClick={async () => {
|
<Button type="primary" loading={saving} onClick={async () => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
const v = await editForm.validateFields();
|
try {
|
||||||
const payload = { ...v };
|
const v = await editForm.validateFields();
|
||||||
if (!payload.password) delete payload.password;
|
const payload: Record<string, unknown> = { ...v };
|
||||||
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
if (!payload.password) delete payload.password;
|
||||||
message.success('已保存');
|
if (!String(payload.loginName ?? '').trim()) delete payload.loginName;
|
||||||
setDrawerOpen(false);
|
setSaving(true);
|
||||||
void reload();
|
await request(`/admin/hq-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||||
|
message.success('已保存');
|
||||||
|
setDrawerOpen(false);
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message) message.error(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
}}>保存</Button>
|
}}>保存</Button>
|
||||||
}>
|
}>
|
||||||
<Form form={editForm} layout="vertical">
|
<Form form={editForm} layout="vertical">
|
||||||
@@ -144,7 +211,15 @@ export default function HqAccountsPage() {
|
|||||||
<Input.Password placeholder="至少 6 位" />
|
<Input.Password placeholder="至少 6 位" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="cityIds"
|
||||||
|
label="负责城市"
|
||||||
|
extra={editRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
|
||||||
|
rules={editRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
|
||||||
|
>
|
||||||
|
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="status" label="状态">
|
<Form.Item name="status" label="状态">
|
||||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||||
@@ -156,15 +231,19 @@ export default function HqAccountsPage() {
|
|||||||
open={createOpen}
|
open={createOpen}
|
||||||
onCancel={() => setCreateOpen(false)}
|
onCancel={() => setCreateOpen(false)}
|
||||||
onOk={async () => {
|
onOk={async () => {
|
||||||
const v = await createForm.validateFields();
|
try {
|
||||||
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
const v = await createForm.validateFields();
|
||||||
message.success('已创建');
|
await request('/admin/hq-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||||
setCreateOpen(false);
|
message.success('已创建');
|
||||||
createForm.resetFields();
|
setCreateOpen(false);
|
||||||
void reload();
|
createForm.resetFields();
|
||||||
|
void reload();
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message) message.error(e.message);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS' }}>
|
<Form form={createForm} layout="vertical" initialValues={{ credentialType: 'phone', adminRole: 'OPS', cityIds: [] }}>
|
||||||
<Form.Item name="credentialType" label="创建方式">
|
<Form.Item name="credentialType" label="创建方式">
|
||||||
<Radio.Group>
|
<Radio.Group>
|
||||||
<Radio value="phone">手机号账户(短信登录)</Radio>
|
<Radio value="phone">手机号账户(短信登录)</Radio>
|
||||||
@@ -173,7 +252,15 @@ export default function HqAccountsPage() {
|
|||||||
</Form.Item>
|
</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="adminRole" label="角色">
|
<Form.Item name="adminRole" label="角色">
|
||||||
<Select options={Object.entries(ROLE_LABELS).map(([value, label]) => ({ value, label }))} />
|
<Select options={ROLE_OPTIONS} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="cityIds"
|
||||||
|
label="负责城市"
|
||||||
|
extra={createRole === 'CITY_STORE_SERVICE' ? '城市门店服务必须至少选一个城市' : '不选表示全国可见'}
|
||||||
|
rules={createRole === 'CITY_STORE_SERVICE' ? [{ required: true, type: 'array', min: 1, message: '请至少勾选一个城市' }] : []}
|
||||||
|
>
|
||||||
|
<Select mode="multiple" allowClear showSearch optionFilterProp="label" placeholder="不选=全国" options={cityOptions} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{credentialType === 'phone' ? (
|
{credentialType === 'phone' ? (
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
|
|||||||
@@ -7,8 +7,13 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
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 } from '../lib/constants';
|
||||||
|
import { formatRef, zhCode } from '../lib/display-labels';
|
||||||
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
import { HQ_OPERATION_ACTION_OPTIONS, resolveHqOperationLabel } from '../lib/hq-log';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -52,12 +57,11 @@ export default function HqLogsPage() {
|
|||||||
form.setFieldsValue(filters);
|
form.setFieldsValue(filters);
|
||||||
}, [form, filters]);
|
}, [form, filters]);
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作人',
|
title: '操作人',
|
||||||
width: 200,
|
width: 200,
|
||||||
ellipsis: true,
|
|
||||||
render: (_, r) => (
|
render: (_, r) => (
|
||||||
<AdminCellLine
|
<AdminCellLine
|
||||||
primary={r.hqName}
|
primary={r.hqName}
|
||||||
@@ -69,9 +73,19 @@ export default function HqLogsPage() {
|
|||||||
title: '行为',
|
title: '行为',
|
||||||
dataIndex: 'actionLabel',
|
dataIndex: 'actionLabel',
|
||||||
width: 160,
|
width: 160,
|
||||||
render: (v, r) => <Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>,
|
render: (v, r) => (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={async () => {
|
||||||
|
const res = await request<Row>(`/admin/logs/hq/${r.id}`);
|
||||||
|
setDetail(res);
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tag color="blue">{v || resolveHqOperationLabel(r.action)}</Tag>
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => v || '—' },
|
{ title: '对象类型', dataIndex: 'refType', width: 120, render: (v) => zhCode(v) },
|
||||||
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
{ title: '对象 ID', dataIndex: 'refId', width: 100, render: (v) => v || '—' },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
@@ -92,12 +106,16 @@ export default function HqLogsPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('logs-hq', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>HQ 操作日志</Typography.Title>
|
{settingsModal}
|
||||||
<Typography.Paragraph type="secondary">
|
<AdminListHeader
|
||||||
记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。
|
title="HQ 操作日志"
|
||||||
</Typography.Paragraph>
|
settings={settingsButton}
|
||||||
|
description="记录总部后台写操作(开城、订单、用户、权限、合伙人等),仅追加不删除。"
|
||||||
|
/>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
@@ -126,7 +144,7 @@ export default function HqLogsPage() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="refType" label="对象类型">
|
<Form.Item name="refType" label="对象类型">
|
||||||
<Input allowClear placeholder="ORDER / USER / CITY..." style={{ width: 140 }} />
|
<Input allowClear placeholder="对象编码" style={{ width: 140 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item>
|
<Form.Item>
|
||||||
<Space>
|
<Space>
|
||||||
@@ -150,7 +168,7 @@ export default function HqLogsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data?.items ?? []}
|
dataSource={data?.items ?? []}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -175,12 +193,12 @@ export default function HqLogsPage() {
|
|||||||
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
secondary={[detail.hqPhone, detail.hqAccountId ? `ID:${detail.hqAccountId}` : ''].filter(Boolean).join(' ') || null}
|
||||||
/>
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="角色">{detail.hqRole || '—'}</Descriptions.Item>
|
<Descriptions.Item label="角色">{zhCode(detail.hqRole)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="行为">
|
<Descriptions.Item label="行为">
|
||||||
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
{detail.actionLabel || resolveHqOperationLabel(detail.action)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="对象">{detail.refType} / {detail.refId}</Descriptions.Item>
|
<Descriptions.Item label="对象">{formatRef(detail.refType, detail.refId)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">{detail.status || '—'}</Descriptions.Item>
|
<Descriptions.Item label="状态">{zhCode(detail.status)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
<Typography.Title level={5} style={{ marginTop: 16 }}>请求/响应快照</Typography.Title>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
HQ_ADMIN_ROLES,
|
HQ_ADMIN_ROLES,
|
||||||
HQ_PERMISSION_CATALOG,
|
HQ_PERMISSION_CATALOG,
|
||||||
|
computeHqEffectivePermissionKeys,
|
||||||
type HqPermissionKey,
|
type HqPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request, type HqProfile } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
@@ -27,21 +28,38 @@ type AccountPermRes = {
|
|||||||
account: AccountOption;
|
account: AccountOption;
|
||||||
permissionKeys: string[];
|
permissionKeys: string[];
|
||||||
rolePermissionKeys: string[];
|
rolePermissionKeys: string[];
|
||||||
|
grantKeys?: string[];
|
||||||
|
denyKeys?: string[];
|
||||||
userPermissionKeys: string[];
|
userPermissionKeys: string[];
|
||||||
effectivePermissionKeys: string[];
|
effectivePermissionKeys: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
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,
|
||||||
disabled,
|
disabled,
|
||||||
|
roleKeys,
|
||||||
|
grantKeys,
|
||||||
}: {
|
}: {
|
||||||
value: string[];
|
value: string[];
|
||||||
onChange: (keys: string[]) => void;
|
onChange: (keys: string[]) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
roleKeys?: string[];
|
||||||
|
grantKeys?: string[];
|
||||||
}) {
|
}) {
|
||||||
|
const roleSet = new Set(roleKeys ?? []);
|
||||||
|
const grantSet = new Set(grantKeys ?? []);
|
||||||
return (
|
return (
|
||||||
<Checkbox.Group
|
<Checkbox.Group
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
@@ -49,17 +67,72 @@ 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}
|
||||||
|
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||||||
|
) : grantSet.has(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||||||
|
) : roleSet.has(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||||||
|
) : null}
|
||||||
|
</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}
|
||||||
|
{roleSet.has(item.key) && !value.includes(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="red">已撤销</Tag>
|
||||||
|
) : grantSet.has(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="orange">追加</Tag>
|
||||||
|
) : roleSet.has(item.key) ? (
|
||||||
|
<Tag style={{ marginLeft: 6 }} color="blue">角色</Tag>
|
||||||
|
) : null}
|
||||||
|
</Checkbox>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Checkbox.Group>
|
</Checkbox.Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function splitAccountOverrides(roleKeys: string[], effectiveKeys: string[]) {
|
||||||
|
const roleSet = new Set(roleKeys);
|
||||||
|
const effectiveSet = new Set(effectiveKeys);
|
||||||
|
const grantKeys = effectiveKeys.filter((k) => !roleSet.has(k));
|
||||||
|
const denyKeys = roleKeys.filter((k) => !effectiveSet.has(k));
|
||||||
|
return { grantKeys, denyKeys };
|
||||||
|
}
|
||||||
|
|
||||||
export default function HqPermissionsPage() {
|
export default function HqPermissionsPage() {
|
||||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [role, setRole] = useState<string>('OPS');
|
const [role, setRole] = useState<string>('OPS');
|
||||||
@@ -71,12 +144,20 @@ export default function HqPermissionsPage() {
|
|||||||
const [accountId, setAccountId] = useState<string>();
|
const [accountId, setAccountId] = useState<string>();
|
||||||
const [accountKeys, setAccountKeys] = useState<string[]>([]);
|
const [accountKeys, setAccountKeys] = useState<string[]>([]);
|
||||||
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
|
const [roleInheritedKeys, setRoleInheritedKeys] = useState<string[]>([]);
|
||||||
|
const [grantKeys, setGrantKeys] = useState<string[]>([]);
|
||||||
|
const [denyKeys, setDenyKeys] = useState<string[]>([]);
|
||||||
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])],
|
() => computeHqEffectivePermissionKeys(roleInheritedKeys, grantKeys, denyKeys),
|
||||||
[roleInheritedKeys, accountKeys],
|
[roleInheritedKeys, grantKeys, denyKeys],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
const isSuperAdmin = profile?.adminRole === 'SUPER_ADMIN';
|
||||||
@@ -105,8 +186,10 @@ export default function HqPermissionsPage() {
|
|||||||
setAccountLoading(true);
|
setAccountLoading(true);
|
||||||
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setAccountKeys(res.userPermissionKeys);
|
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||||||
|
setDenyKeys(res.denyKeys ?? []);
|
||||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||||
|
setAccountKeys(res.effectivePermissionKeys);
|
||||||
})
|
})
|
||||||
.finally(() => setAccountLoading(false));
|
.finally(() => setAccountLoading(false));
|
||||||
}, [isSuperAdmin, accountId]);
|
}, [isSuperAdmin, accountId]);
|
||||||
@@ -133,10 +216,12 @@ export default function HqPermissionsPage() {
|
|||||||
try {
|
try {
|
||||||
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
const res = await request<AccountPermRes>(`/admin/hq-permissions/accounts/${accountId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ permissionKeys: accountKeys }),
|
body: JSON.stringify({ grantKeys, denyKeys }),
|
||||||
});
|
});
|
||||||
setAccountKeys(res.userPermissionKeys);
|
setGrantKeys(res.grantKeys ?? res.userPermissionKeys ?? []);
|
||||||
|
setDenyKeys(res.denyKeys ?? []);
|
||||||
setRoleInheritedKeys(res.rolePermissionKeys);
|
setRoleInheritedKeys(res.rolePermissionKeys);
|
||||||
|
setAccountKeys(res.effectivePermissionKeys);
|
||||||
message.success('用户权限已保存');
|
message.success('用户权限已保存');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '保存失败');
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
@@ -158,7 +243,10 @@ 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 +271,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 +304,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,30 +315,51 @@ export default function HqPermissionsPage() {
|
|||||||
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
<Alert type="info" showIcon message="请先选择要配置的 HQ 账户" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Space wrap style={{ marginBottom: 12 }}>
|
|
||||||
<span>角色继承:</span>
|
|
||||||
{roleInheritedKeys.map((key) => {
|
|
||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
|
||||||
return (
|
|
||||||
<Tag key={key} color="blue">
|
|
||||||
{item?.label || key}
|
|
||||||
</Tag>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
下方勾选为用户专属追加权限(保存后与角色权限合并生效)。
|
勾选表示该账号最终拥有该权限。取消角色已有项即为撤销;勾选角色没有的项即为追加。
|
||||||
|
切换角色会清空账号级追加/撤销。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
<PermissionChecklist value={accountKeys} onChange={setAccountKeys} />
|
<PermissionChecklist
|
||||||
<Space wrap style={{ marginTop: 12 }}>
|
value={accountKeys}
|
||||||
<span>合并生效:</span>
|
roleKeys={roleInheritedKeys}
|
||||||
{previewEffectiveKeys.map((key) => {
|
grantKeys={grantKeys}
|
||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
disabled={selectedIsSuperAdmin}
|
||||||
return <Tag key={key}>{item?.label || key}</Tag>;
|
onChange={(nextEffective) => {
|
||||||
})}
|
if (selectedIsSuperAdmin) return;
|
||||||
</Space>
|
const next = splitAccountOverrides(roleInheritedKeys, nextEffective);
|
||||||
|
setGrantKeys(next.grantKeys);
|
||||||
|
setDenyKeys(next.denyKeys);
|
||||||
|
setAccountKeys(nextEffective);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedIsSuperAdmin ? (
|
||||||
|
<Alert
|
||||||
|
type="info"
|
||||||
|
showIcon
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
message="超级管理员基础权限不可撤销。危险操作请到下方以外的方式:当前勾选仅展示生效权限。"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<span style={{ marginRight: 8 }}>合并生效:</span>
|
||||||
|
<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}
|
||||||
|
disabled={selectedIsSuperAdmin}
|
||||||
|
onClick={() => void saveAccountPermissions()}
|
||||||
|
>
|
||||||
保存用户权限
|
保存用户权限
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Upload,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
INVOICE_CATEGORY_LABELS,
|
||||||
|
INVOICE_KIND_LABELS,
|
||||||
|
INVOICE_STATUS_LABELS,
|
||||||
|
INVOICE_TITLE_TYPE_LABELS,
|
||||||
|
type InvoiceCategory,
|
||||||
|
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';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
invoiceNo: string;
|
||||||
|
orderNo?: string;
|
||||||
|
titleType: InvoiceTitleType;
|
||||||
|
invoiceKind: InvoiceKind;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
|
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;
|
||||||
|
invoiceCategory?: InvoiceCategory;
|
||||||
|
titleName: string;
|
||||||
|
taxNo?: string;
|
||||||
|
addressPhone?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InvoicesPage() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialStatus = searchParams.get('status')?.trim() || '';
|
||||||
|
const initialInvoiceNo = searchParams.get('invoiceNo')?.trim() || '';
|
||||||
|
const [filterForm] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>(() => {
|
||||||
|
const init: Record<string, string> = {};
|
||||||
|
if (initialStatus) init.status = initialStatus;
|
||||||
|
if (initialInvoiceNo) init.invoiceNo = initialInvoiceNo;
|
||||||
|
return init;
|
||||||
|
});
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/invoices',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.invoiceNo) qs.set('invoiceNo', filters.invoiceNo);
|
||||||
|
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);
|
||||||
|
const deepLinkOpenedRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
filterForm.setFieldsValue({
|
||||||
|
status: filters.status || undefined,
|
||||||
|
invoiceNo: filters.invoiceNo || undefined,
|
||||||
|
});
|
||||||
|
}, [filterForm, filters.invoiceNo, filters.status]);
|
||||||
|
|
||||||
|
async function openDetail(id: string) {
|
||||||
|
setDetail(await request(`/admin/invoices/${id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialInvoiceNo || deepLinkOpenedRef.current || loading) return;
|
||||||
|
const first = data?.items?.[0];
|
||||||
|
if (first && String(first.invoiceNo) === initialInvoiceNo) {
|
||||||
|
deepLinkOpenedRef.current = true;
|
||||||
|
void openDetail(first.id);
|
||||||
|
}
|
||||||
|
}, [data, initialInvoiceNo, loading]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
invoiceCategory: values.invoiceCategory,
|
||||||
|
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 baseColumns: 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: 140,
|
||||||
|
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '类型',
|
||||||
|
width: 88,
|
||||||
|
render: (_, r) =>
|
||||||
|
r.invoiceCategory
|
||||||
|
? INVOICE_CATEGORY_LABELS[r.invoiceCategory] ?? r.invoiceCategory
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'titleName',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('invoices', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="发票管理"
|
||||||
|
settings={settingsButton}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
createForm.setFieldsValue({
|
||||||
|
titleType: 'PERSONAL',
|
||||||
|
invoiceKind: 'NORMAL',
|
||||||
|
invoiceCategory: 'LIQUOR',
|
||||||
|
});
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
创建发票申请
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form
|
||||||
|
form={filterForm}
|
||||||
|
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>
|
||||||
|
<Form.Item name="invoiceNo" label="申请单号">
|
||||||
|
<Input allowClear placeholder="发票申请单号" />
|
||||||
|
</Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
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.invoiceCategory
|
||||||
|
? INVOICE_CATEGORY_LABELS[detail.invoiceCategory]
|
||||||
|
: '—'}
|
||||||
|
</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="invoiceCategory"
|
||||||
|
label="类型"
|
||||||
|
rules={[{ required: true, message: '请选择类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={(Object.keys(INVOICE_CATEGORY_LABELS) as InvoiceCategory[]).map((k) => ({
|
||||||
|
value: k,
|
||||||
|
label: INVOICE_CATEGORY_LABELS[k],
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</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,486 @@
|
|||||||
|
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,
|
||||||
|
KnowledgeDocumentDetailDto,
|
||||||
|
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';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
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 [editingDoc, setEditingDoc] = useState<KnowledgeDocumentDto | null>(null);
|
||||||
|
const [editDocModalOpen, setEditDocModalOpen] = useState(false);
|
||||||
|
const [editDocSaving, setEditDocSaving] = useState(false);
|
||||||
|
const [editDocForm] = Form.useForm<DocForm>();
|
||||||
|
|
||||||
|
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 baseColumns: ColumnsType<KnowledgeBaseDto> = [
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '说明', dataIndex: 'description' },
|
||||||
|
{ 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' },
|
||||||
|
{
|
||||||
|
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: 140,
|
||||||
|
render: (_, row) =>
|
||||||
|
drawerKb?.canEditFull ? (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!drawerKb) return;
|
||||||
|
try {
|
||||||
|
const detail = await request<KnowledgeDocumentDetailDto>(
|
||||||
|
`/admin/knowledge-bases/${drawerKb.id}/documents/${row.id}`,
|
||||||
|
);
|
||||||
|
setEditingDoc(row);
|
||||||
|
editDocForm.setFieldsValue({
|
||||||
|
title: detail.title,
|
||||||
|
contentText: detail.contentText || '',
|
||||||
|
});
|
||||||
|
setEditDocModalOpen(true);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<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>
|
||||||
|
</Space>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('knowledge-bases', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="知识库"
|
||||||
|
settings={settingsButton}
|
||||||
|
description="支持粘贴文本或上传 .txt/.md;可绑定到企微机器人供 AI 检索"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="编辑知识文档"
|
||||||
|
open={editDocModalOpen}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditDocModalOpen(false);
|
||||||
|
setEditingDoc(null);
|
||||||
|
}}
|
||||||
|
confirmLoading={editDocSaving}
|
||||||
|
onOk={async () => {
|
||||||
|
if (!drawerKb || !editingDoc) return;
|
||||||
|
const values = await editDocForm.validateFields();
|
||||||
|
setEditDocSaving(true);
|
||||||
|
try {
|
||||||
|
await request(`/admin/knowledge-bases/${drawerKb.id}/documents/${editingDoc.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: values.title,
|
||||||
|
contentText: values.contentText || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('已保存');
|
||||||
|
setEditDocModalOpen(false);
|
||||||
|
setEditingDoc(null);
|
||||||
|
void loadDocs(drawerKb);
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setEditDocSaving(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
destroyOnClose
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={editDocForm} 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>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
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';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
|
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 baseColumns: ColumnsType<LlmApiConfigDto> = [
|
||||||
|
{
|
||||||
|
title: '名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => openEdit(row)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '提供商',
|
||||||
|
dataIndex: 'provider',
|
||||||
|
width: 120,
|
||||||
|
render: (p: LlmProvider) => LLM_PROVIDER_LABELS[p] || p,
|
||||||
|
},
|
||||||
|
{ title: '模型', dataIndex: 'modelName' },
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal } = useAdminListColumns('llm-configs', baseColumns, { page, pageSize });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="语言模型配置"
|
||||||
|
settings={settingsButton}
|
||||||
|
description="非超管仅可见自己创建的配置,且创建后只能改是否生效"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
|
import { Button, Card, Form, Input, Tabs, message, Typography } from 'antd';
|
||||||
|
import { MOCK_SMS_FIXED_CODE } from '@dukang/shared-types';
|
||||||
import { saveAuth, request } from '../lib/api';
|
import { saveAuth, request } from '../lib/api';
|
||||||
|
|
||||||
type LoginResult = { accessToken: string; refreshToken: string };
|
type LoginResult = { accessToken: string; refreshToken: string };
|
||||||
@@ -117,14 +118,14 @@ export default function LoginPage() {
|
|||||||
form={smsForm}
|
form={smsForm}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
onFinish={onSmsFinish}
|
onFinish={onSmsFinish}
|
||||||
initialValues={{ phone: '13600000001', code: '123456' }}
|
initialValues={{ phone: '13600000001', code: MOCK_SMS_FIXED_CODE }}
|
||||||
>
|
>
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }]}>
|
||||||
<Input placeholder="13600000001" maxLength={11} />
|
<Input placeholder="13600000001" maxLength={11} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
<Form.Item name="code" label="验证码" rules={[{ required: true, message: '请输入验证码' }]}>
|
||||||
<Input
|
<Input
|
||||||
placeholder="123456"
|
placeholder={MOCK_SMS_FIXED_CODE}
|
||||||
addonAfter={
|
addonAfter={
|
||||||
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
|
<Button type="link" size="small" disabled={codeCooldown > 0} onClick={() => void sendCode()}>
|
||||||
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
{codeCooldown > 0 ? `${codeCooldown}s` : '获取验证码'}
|
||||||
|
|||||||
@@ -0,0 +1,720 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
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 { appendExportColumns } from '../lib/export-columns';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
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;
|
||||||
|
paymentRef?: string | null;
|
||||||
|
fulfillmentProvider?: {
|
||||||
|
bankAccountName?: string | null;
|
||||||
|
bankName?: string | null;
|
||||||
|
bankBranch?: string | null;
|
||||||
|
bankAccountNo?: 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 navigate = useNavigate();
|
||||||
|
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) {
|
||||||
|
let paymentRef = '';
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认结算?',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
将确认 {ids.length} 笔物流对账单
|
||||||
|
{amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。充值模式将扣减余额,挂账模式标记已打款。
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
placeholder="打款凭证号(可选)"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onChange={(e) => {
|
||||||
|
paymentRef = e.target.value;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
okText: '确认结算',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
const body = JSON.stringify({ paymentRef: paymentRef.trim() || undefined });
|
||||||
|
if (ids.length === 1) {
|
||||||
|
await request(`/admin/logistics-bills/${ids[0]}/confirm`, { method: 'POST', body });
|
||||||
|
} 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);
|
||||||
|
appendExportColumns(qs, exportColumnKeys);
|
||||||
|
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 providerBank = (r: BillRow) => r.fulfillmentProvider;
|
||||||
|
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: '账单号',
|
||||||
|
key: '账单号',
|
||||||
|
dataIndex: 'billNo',
|
||||||
|
width: 170,
|
||||||
|
render: (v, row) => (
|
||||||
|
<AdminPrimaryLink onClick={() => void openDetail(row.id)}>{v}</AdminPrimaryLink>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '承运商',
|
||||||
|
key: '承运商',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => `${r.providerName || ''} (${r.providerCode || ''})`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账期',
|
||||||
|
key: '账期',
|
||||||
|
width: 200,
|
||||||
|
render: (_, r) =>
|
||||||
|
`${String(r.periodStart || '').slice(0, 10)} ~ ${String(r.periodEnd || '').slice(0, 10)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结算方式',
|
||||||
|
key: '结算方式',
|
||||||
|
dataIndex: 'settlementMethod',
|
||||||
|
width: 100,
|
||||||
|
render: (v) =>
|
||||||
|
LOGISTICS_SETTLEMENT_METHOD_LABELS[v as LogisticsSettlementMethod] || v,
|
||||||
|
},
|
||||||
|
{ title: '订单数', key: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||||
|
{ title: '瓶数', key: '瓶数', dataIndex: 'bottleCount', width: 80 },
|
||||||
|
{
|
||||||
|
title: '物流费',
|
||||||
|
key: '物流费',
|
||||||
|
dataIndex: 'logisticsAmount',
|
||||||
|
width: 110,
|
||||||
|
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收款户名',
|
||||||
|
key: '收款户名',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => providerBank(r)?.bankAccountName || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '收款账号',
|
||||||
|
key: '收款账号',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => providerBank(r)?.bankAccountNo || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: '状态',
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const { columns, settingsButton, settingsModal, exportColumnKeys } = useAdminListColumns('finance-logistics-bills', billColumns, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{settingsModal}
|
||||||
|
<AdminListHeader
|
||||||
|
title="物流对账"
|
||||||
|
settings={settingsButton}
|
||||||
|
description="按快递承运商汇总月度物流费;计价与银行账户在「仓配管理」配置。前期充值扣款,后期可切挂账月结。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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={columns}
|
||||||
|
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.Item label="打款凭证">
|
||||||
|
{detail.paymentRef ? String(detail.paymentRef) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
{providerBank(detail) ? (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||||
|
收款账户
|
||||||
|
</Typography.Title>
|
||||||
|
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||||
|
<Descriptions.Item label="户名">
|
||||||
|
{providerBank(detail)?.bankAccountName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户银行">
|
||||||
|
{providerBank(detail)?.bankName || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="开户支行">
|
||||||
|
{providerBank(detail)?.bankBranch || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="账号">
|
||||||
|
{providerBank(detail)?.bankAccountNo || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
dataSource={detail.items ?? []}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '订单号',
|
||||||
|
dataIndex: 'orderNo',
|
||||||
|
render: (v: string) =>
|
||||||
|
v ? (
|
||||||
|
<AdminPrimaryLink
|
||||||
|
onClick={() => navigate(`/orders?orderNo=${encodeURIComponent(v)}`)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</AdminPrimaryLink>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1456
-149
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user