316 Commits

Author SHA1 Message Date
卡若
ab3cd25c5c chore: auto-sync 2025-12-21 12:11:19 +08:00
卡若
c8abd58d9b chore: auto-sync 2025-12-21 12:10:52 +08:00
卡若
368ab7b97d chore: auto-sync 2025-12-21 12:09:43 +08:00
卡若
6cf818217f chore: auto-sync 2025-12-21 12:06:10 +08:00
卡若
f9af782912 chore: auto-sync 2025-12-21 12:05:47 +08:00
卡若
eaa697a9dc chore: auto-sync 2025-12-21 12:05:08 +08:00
卡若
549445b7f4 chore: auto-sync 2025-12-21 12:04:51 +08:00
卡若
8c8c7b0d58 chore: auto-sync 2025-12-21 12:04:28 +08:00
卡若
23939a109e chore: auto-sync 2025-12-21 12:04:16 +08:00
卡若
d4844d94db chore: auto-sync 2025-12-21 12:04:00 +08:00
卡若
2f18dbf4f1 chore: auto-sync 2025-12-21 12:03:36 +08:00
卡若
7ea7c7b3cc chore: auto-sync 2025-12-21 09:24:33 +08:00
卡若
10f0f9e002 执行项目监控脚本,生成最新更新报告和日志 2025-08-04 18:32:55 +08:00
卡若
2f465a91f1 更新项目监控脚本和清理文档文件 2025-08-04 18:21:52 +08:00
卡若
468fdd34fc feat: 添加MongoDB配置、Docker Compose和系统优化脚本
- 新增MongoDB Docker配置和初始化脚本
- 添加完整的Docker Compose配置文件
- 创建系统优化和监控脚本
- 更新项目文档和配置文件
- 修复ESLint配置问题
2025-08-04 18:21:51 +08:00
54ae3aff5d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-04 08:28:29 +00:00
a6635e2b83 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-04 06:35:52 +00:00
28be8b05da Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-04 04:56:18 +00:00
7cefb146f8 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-04 03:54:33 +00:00
1c2997a3ab Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-04 01:48:32 +00:00
a5ac5af5bd Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 22:18:26 +00:00
466a607238 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 20:20:39 +00:00
e905d10c0a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 18:26:50 +00:00
e2a47744cd Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 16:22:22 +00:00
bfd7e4b2db Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 14:16:24 +00:00
a7561e91b3 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 12:37:57 +00:00
9007d9f712 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 10:18:55 +00:00
e50ef26921 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 08:23:20 +00:00
3c9975f006 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 06:26:39 +00:00
483736d8db Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 04:47:10 +00:00
bdd78ac6c9 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 03:49:52 +00:00
33f0afa17b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-03 01:48:25 +00:00
ac2c5196aa Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 22:18:03 +00:00
e33ceaf3d4 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 20:19:52 +00:00
0c667e43fd Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 18:26:18 +00:00
340d6c6b32 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 16:22:00 +00:00
97add23b0c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 14:53:12 +00:00
29ba387794 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 14:17:13 +00:00
f910933f75 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 12:36:32 +00:00
bd090c9100 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 10:19:10 +00:00
f42cd74720 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 08:23:16 +00:00
39ab944d64 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 06:26:28 +00:00
50de80e6b0 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 04:33:43 +00:00
e47c3252c3 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 03:24:31 +00:00
cf5c6e1160 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-02 01:27:36 +00:00
256076b2aa Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 22:18:40 +00:00
e44835a32f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 20:20:57 +00:00
d1f2c04d5b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 18:28:13 +00:00
b9564f5ece Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 16:25:28 +00:00
786eb4243f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 14:20:23 +00:00
d1dacb6318 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 12:39:36 +00:00
029577ad8b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 10:21:08 +00:00
7bf1dca780 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 08:27:01 +00:00
f43936b5c8 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 06:30:27 +00:00
8d27beaddd Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 04:53:00 +00:00
b1c374f3a1 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 03:54:26 +00:00
1fe6d6e11e Merge remote-tracking branch 'coding-origin/develop' into develop 2025-08-01 01:50:23 +00:00
8edde161df Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 22:19:05 +00:00
2115601f89 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 20:21:24 +00:00
3d32b06f6c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 18:29:01 +00:00
178c760a98 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 16:24:31 +00:00
ca45b974dc Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 14:20:53 +00:00
9578aca12d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 12:38:44 +00:00
ca1101b46b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 10:20:56 +00:00
02e0676e9d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 08:27:15 +00:00
eacff5b2de Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 06:29:35 +00:00
4ab60e3e27 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 04:40:47 +00:00
1e19fb247e Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 03:40:29 +00:00
ff3941b553 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-31 01:40:46 +00:00
9edacd0fca Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 22:18:56 +00:00
42f45e6a59 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 20:20:02 +00:00
570d9a2b1a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 18:28:25 +00:00
fbef14f3eb Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 16:24:31 +00:00
4124715da6 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 14:19:58 +00:00
82383e7ae0 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 12:40:53 +00:00
e4453f5c3b Auto-merged coding-origin/develop with theirs resolution 2025-07-30 10:21:11 +00:00
d156fded52 Auto-merged coding-origin/develop with theirs resolution 2025-07-30 08:26:53 +00:00
4495ae9837 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 06:30:59 +00:00
2a5b83af99 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 04:41:51 +00:00
7273efbabc Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 03:41:58 +00:00
2106f52808 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-30 01:41:26 +00:00
e3540a3ccb Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 22:19:13 +00:00
d24931bd42 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 20:22:00 +00:00
d16281fc72 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 18:29:36 +00:00
875b177ddb Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 16:23:32 +00:00
9354e29e6f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 14:23:02 +00:00
1dd206655d Auto-merged coding-origin/develop with theirs resolution 2025-07-29 12:41:06 +00:00
卡若
2846fd69c0 chore: 更新依赖包锁定文件和开发文档 2025-07-29 18:25:53 +08:00
卡若
916a85a075 feat: 禁用ESLint检查以解决编译错误 2025-07-29 18:25:45 +08:00
9c471be489 Auto-merged coding-origin/develop with theirs resolution 2025-07-29 10:20:43 +00:00
60b2fa504b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 08:27:00 +00:00
942758070e Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 07:18:39 +00:00
5d8de291ae Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 06:29:35 +00:00
8933ba27ca Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-29 04:46:06 +00:00
61c3d2d22b Auto-merged coding-origin/develop with theirs resolution 2025-07-29 03:48:30 +00:00
cc2c22476e Auto-merged coding-origin/develop with theirs resolution 2025-07-29 01:50:28 +00:00
3441e4dec9 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 22:19:26 +00:00
0cd21be9ff Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 20:21:44 +00:00
6ad6b77437 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 18:29:20 +00:00
87845c605d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 16:25:41 +00:00
f3132e5c52 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 14:21:16 +00:00
2fad478883 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 12:41:01 +00:00
71b2b92d07 Auto-merged coding-origin/develop with theirs resolution 2025-07-28 10:21:27 +00:00
49f4cb9903 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 08:28:43 +00:00
6a884929bf Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 06:31:00 +00:00
5556814a46 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 04:43:53 +00:00
cadadd1abc Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 03:44:49 +00:00
ce2daa730d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-28 01:45:08 +00:00
ce1ae453ed Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 22:18:05 +00:00
3fe479e483 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 20:20:13 +00:00
580c74086c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 18:25:38 +00:00
e2cfa2d507 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 16:21:47 +00:00
dac2607e3f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 14:16:27 +00:00
0dfc5361a5 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 12:36:43 +00:00
2d1f82faf3 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 10:19:16 +00:00
39df396518 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 08:22:48 +00:00
cb92d81023 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 06:27:37 +00:00
b8bc1fd8f4 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 04:41:55 +00:00
09b4a53aa6 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 03:45:52 +00:00
99c167bad6 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-27 01:46:48 +00:00
3663153758 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 22:17:54 +00:00
53c697f537 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 20:19:46 +00:00
08684440f8 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 18:25:28 +00:00
c10f1b765b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 16:21:27 +00:00
45aa736e46 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 14:17:27 +00:00
d6c6769cae Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 12:35:55 +00:00
dc5fa4ed54 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 10:18:46 +00:00
f09f388604 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 08:23:10 +00:00
698be9a87f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 06:27:06 +00:00
5b493e13a0 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 04:34:52 +00:00
cdc2b55090 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 03:24:52 +00:00
bba1f4ca79 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-26 01:27:29 +00:00
e7298378d2 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 22:18:19 +00:00
7bdddf9219 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 20:20:55 +00:00
356a4445f7 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 18:27:26 +00:00
bcfbadae7a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 16:24:01 +00:00
175c84ce28 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 14:17:31 +00:00
1cd229cde4 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 12:38:58 +00:00
e70205ff1c Auto-merged coding-origin/develop with theirs resolution 2025-07-25 10:21:14 +00:00
30e9aa0896 Auto-merged coding-origin/develop with theirs resolution 2025-07-25 08:25:48 +00:00
b6c3e7eb3d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 06:29:31 +00:00
b1d08d8f10 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 04:37:51 +00:00
0781979649 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 03:28:04 +00:00
c0aaa8e425 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-25 01:38:56 +00:00
53f2d3a1e7 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 22:18:50 +00:00
704beadf72 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 20:19:53 +00:00
453bd1aa2b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 18:27:56 +00:00
d14e20ef6e Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 16:21:17 +00:00
8fc0e10dd2 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 14:20:23 +00:00
44de95b3c4 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 12:40:06 +00:00
b4e2a94cea Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 10:21:24 +00:00
04c7e5ee7e Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 08:27:01 +00:00
6061472d18 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 06:29:26 +00:00
79a56e6f55 Auto-merged coding-origin/develop with theirs resolution 2025-07-24 04:38:09 +00:00
a487a4dabc Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 03:28:02 +00:00
2055f8f546 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-24 01:39:12 +00:00
a62f3ef1df Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-23 22:19:03 +00:00
8deeacf849 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-23 20:21:11 +00:00
527471303a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-23 18:28:06 +00:00
1f690c88ee Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-23 16:24:57 +00:00
6d9b359c7a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-23 14:20:34 +00:00
e3afcaf52b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-23 12:39:55 +00:00
3b15c1cc4f Auto-merged coding-origin/develop with theirs resolution 2025-07-23 10:20:35 +00:00
0ef08abfd6 Auto-merged coding-origin/develop with theirs resolution 2025-07-23 08:27:13 +00:00
5959951f15 Auto-merged coding-origin/develop with theirs resolution 2025-07-23 06:29:08 +00:00
8bd365c394 Auto-merged coding-origin/develop with theirs resolution 2025-07-23 04:39:41 +00:00
132d6d3000 Auto-merged coding-origin/develop with theirs resolution 2025-07-23 03:38:10 +00:00
1f6f90b3c7 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-23 01:39:59 +00:00
b4aea217aa Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 22:18:16 +00:00
973e4d1964 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 20:21:44 +00:00
0695296607 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 18:28:10 +00:00
dfa3b951a9 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 16:24:18 +00:00
503d81c26c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 14:19:53 +00:00
496cd7b204 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 12:39:29 +00:00
471996d502 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 10:20:53 +00:00
3de18fafa0 Auto-merged coding-origin/develop with theirs resolution 2025-07-22 08:27:11 +00:00
90e01259be Auto-merged coding-origin/develop with theirs resolution 2025-07-22 06:29:03 +00:00
c385508ab1 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 04:37:38 +00:00
b123e19bf0 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 03:27:51 +00:00
4c6f2be089 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-22 01:39:08 +00:00
b475b32390 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 22:19:08 +00:00
63bb9d1f0d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 20:21:09 +00:00
dd5517f450 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 18:28:36 +00:00
ec17241aa7 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 16:23:40 +00:00
e2ad545b73 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 14:20:44 +00:00
5f9bfc89fa Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 12:40:18 +00:00
2d1cec6095 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 10:21:19 +00:00
7c39215e24 Auto-merged coding-origin/develop with theirs resolution 2025-07-21 08:29:08 +00:00
fdc0f61a3c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 06:29:06 +00:00
d47fc095b0 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 04:41:44 +00:00
012b5bef1c Auto-merged coding-origin/develop with theirs resolution 2025-07-21 03:43:17 +00:00
af656a42af Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-21 01:43:51 +00:00
adae3cfccf Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 22:17:33 +00:00
97755042df Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 20:20:19 +00:00
ff5b7e89d1 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 18:25:03 +00:00
a71ee286ce Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 16:21:29 +00:00
b7e929f89f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 14:16:34 +00:00
da08c97217 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 12:35:46 +00:00
3b23393036 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 10:18:49 +00:00
5b6dd21668 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 08:23:04 +00:00
a88bb74512 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 06:26:20 +00:00
5a094c7be4 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 04:40:26 +00:00
f95432dad6 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 03:44:40 +00:00
d96fcba91c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-20 01:46:04 +00:00
30fb101c23 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 22:17:29 +00:00
14f179ba4c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 20:19:38 +00:00
1d1fdb230d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 18:24:45 +00:00
67bac870c0 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 16:21:32 +00:00
f27809a909 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 14:16:25 +00:00
1609eddb32 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 12:35:28 +00:00
094782d56f Auto-merged coding-origin/develop with theirs resolution 2025-07-19 10:18:49 +00:00
64cbdcf65e Auto-merged coding-origin/develop with theirs resolution 2025-07-19 08:23:12 +00:00
02ce13d86a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 06:26:41 +00:00
476cc23c5c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 04:30:01 +00:00
4278cf2f4a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 03:21:30 +00:00
428fc4108a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-19 01:26:35 +00:00
37ff442691 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 22:18:27 +00:00
1a61aad623 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 20:20:34 +00:00
112d6e7c0c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 18:26:34 +00:00
dce35cdf07 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 16:24:52 +00:00
7a7f40c7c2 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 14:18:31 +00:00
69790a564f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 12:38:50 +00:00
154ec38680 Auto-merged coding-origin/develop with theirs resolution 2025-07-18 10:20:45 +00:00
43849ee50e Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 08:25:40 +00:00
96bacb1f22 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 06:28:44 +00:00
348fdaa61e Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 04:37:49 +00:00
95c134e4ba Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 03:27:53 +00:00
2e006a73ab Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-18 01:39:23 +00:00
499cb3dfe0 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 22:18:33 +00:00
c375d9e5f2 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 20:21:04 +00:00
8968d7c2e1 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 18:27:32 +00:00
445191736f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 16:24:27 +00:00
c83f2d0aff Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 14:17:44 +00:00
43d027e12b Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 12:38:57 +00:00
2a65464e04 Auto-merged coding-origin/develop with theirs resolution 2025-07-17 10:21:19 +00:00
e0dacc72d6 Auto-merged coding-origin/develop with theirs resolution 2025-07-17 08:25:57 +00:00
49132b6764 Auto-merged coding-origin/develop with theirs resolution 2025-07-17 06:28:32 +00:00
a12f58a1c9 Auto-merged coding-origin/develop with theirs resolution 2025-07-17 04:36:32 +00:00
0134061ead Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 03:26:20 +00:00
c7f7965ac7 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 03:10:06 +00:00
85b4a84595 Auto-merged coding-origin/develop with theirs resolution 2025-07-17 02:54:20 +00:00
6c132ab921 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-17 01:38:26 +00:00
d5ac83cf2d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 22:18:15 +00:00
e1775712fb Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 20:20:45 +00:00
00bea6a449 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 18:27:10 +00:00
63ba1c3a50 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 16:24:19 +00:00
97225be72c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 14:17:39 +00:00
7d0dd2b7eb Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 12:39:37 +00:00
f32b262aba Auto-merged coding-origin/develop with theirs resolution 2025-07-16 10:20:22 +00:00
ab16377a64 Auto-merged coding-origin/develop with theirs resolution 2025-07-16 08:25:44 +00:00
83d2acbc48 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 06:28:14 +00:00
c2914f758c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 04:36:34 +00:00
675ac76ffb Auto-merged coding-origin/develop with theirs resolution 2025-07-16 03:26:25 +00:00
db80a51660 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-16 01:28:30 +00:00
a2cf753a53 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 22:18:40 +00:00
4f005d9fd6 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 20:20:47 +00:00
4371d7e056 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 18:28:00 +00:00
72cf21c68a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 16:24:08 +00:00
2d9aab8a07 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 14:17:45 +00:00
c0c5108534 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 12:39:07 +00:00
46e8df945f Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 10:20:04 +00:00
d3a79d6fb7 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 08:25:46 +00:00
5cc2bc0654 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 06:28:58 +00:00
d5c317c3d9 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 04:37:28 +00:00
02a0e8a1a5 Auto-merged coding-origin/develop with theirs resolution 2025-07-15 03:28:23 +00:00
219d711e66 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-15 01:41:14 +00:00
bd7cd7a259 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-14 20:20:48 +00:00
3165f3bb4d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-14 18:28:11 +00:00
e498978c14 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-14 16:24:10 +00:00
745f419a02 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-14 14:19:40 +00:00
ad3b5c32e2 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-14 12:38:50 +00:00
723d0808fe Auto-merged coding-origin/develop with theirs resolution 2025-07-14 10:20:38 +00:00
7a5fab6d7c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-14 08:27:44 +00:00
d52f152731 Auto-merged coding-origin/develop with theirs resolution 2025-07-14 06:29:49 +00:00
43e4424a06 Auto-merged coding-origin/develop with theirs resolution 2025-07-14 04:39:55 +00:00
b00ec02ce6 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-14 03:39:34 +00:00
a64b46222c Auto-merged coding-origin/develop with theirs resolution 2025-07-14 01:40:51 +00:00
3aec4a75f8 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 22:16:59 +00:00
788621c52a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 20:18:52 +00:00
85ba8bda08 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 18:23:56 +00:00
5bce6f070c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 16:21:03 +00:00
11040a8faa Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 14:15:58 +00:00
b824dbe462 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 12:34:44 +00:00
6bc783a3f7 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 10:19:02 +00:00
acda7e51fa Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 08:21:46 +00:00
ffc698b503 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 06:26:22 +00:00
12bc4d9066 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 04:35:27 +00:00
e46f96e198 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 03:39:10 +00:00
fe706c6a5e Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-13 01:44:22 +00:00
555ecf22d0 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 22:16:48 +00:00
c52ee1435c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 20:18:57 +00:00
65d8962070 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 18:23:41 +00:00
32e329b44a Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 16:20:52 +00:00
4963cd3812 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 14:16:04 +00:00
509c20e009 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 12:34:24 +00:00
8826f4323d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 10:18:09 +00:00
545ead9ea7 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 08:21:53 +00:00
a6f61d5320 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 06:25:32 +00:00
0c64d0cc7c Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 04:29:45 +00:00
c3faba5428 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 03:24:10 +00:00
7e15acd515 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-12 01:37:52 +00:00
be7556b7cd Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 22:17:38 +00:00
e437694087 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 20:20:09 +00:00
da01340f6d Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 18:25:47 +00:00
8e80afdbee Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 16:22:53 +00:00
d37f603cd6 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 14:18:28 +00:00
249c1e4c71 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 12:36:47 +00:00
bf9d5eeb19 Auto-merged coding-origin/develop with theirs resolution 2025-07-11 10:19:52 +00:00
2208349b33 Auto-merged coding-origin/develop with theirs resolution 2025-07-11 08:24:54 +00:00
f9acbf51c3 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 06:28:07 +00:00
646f390add Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 04:35:38 +00:00
56b8faa908 Merge remote-tracking branch 'coding-origin/develop' into develop 2025-07-11 03:49:56 +00:00
098e029482 Auto-merged coding-origin/develop with theirs resolution 2025-07-11 03:40:12 +00:00
4eb08a6503 Update main.yml 2025-07-11 11:39:07 +08:00
6b9be6fd60 Update main.yml 2025-07-11 11:37:05 +08:00
525228a13a Create main.yml 2025-07-07 10:28:57 +08:00
4181 changed files with 223454 additions and 858215 deletions

9
.Cursorignore Normal file
View File

@@ -0,0 +1,9 @@
Server/runtime/
*.png
*.jpg
*.jpeg
*.gif
*.bmp
*.webp
*.ico
*.svg

16
.codegraph/.gitignore vendored
View File

@@ -1,16 +0,0 @@
# CodeGraph data files
# These are local to each machine and should not be committed
# Database
*.db
*.db-wal
*.db-shm
# Cache
cache/
# Logs
*.log
# Hook markers
.dirty

View File

@@ -1,6 +0,0 @@
{
"pid": 74205,
"version": "0.9.9",
"socketPath": "/Users/karuo/Documents/开发/2、私域银行/cunkebao_v3/.codegraph/daemon.sock",
"startedAt": 1780473089935
}

View File

@@ -1,21 +0,0 @@
---
description: CodeGraph F21a — 经卡若AI 全局 MCP 解析代码,不依赖本仓科室配置
alwaysApply: true
---
<!-- CODEGRAPH_START · 卡若AI F21a 引用 -->
## CodeGraph全局 · 卡若AI F21a
结构读/写代码 **只走卡若AI F21a**MCP 已在 `~/.cursor/mcp.json` 由 `codegraph_mcp_serve.sh` 启动。
**真源**`/Users/karuo/Documents/个人/卡若AI/04_卡火/火种_知识模型/CodeGraph_MCP语义代码图谱/SKILL.md`
| 场景 | 工具 |
|:---|:---|
| 任务上下文 | `codegraph_context` → `codegraph_explore` |
| 找符号 | `codegraph_search` |
| 调用链 | `codegraph_callers` / `codegraph_callees` |
| 改 API 影响 | `codegraph_impact` |
无 `.codegraph/``bash …/CodeGraph_MCP语义代码图谱/脚本/codegraph_init_repo.sh "$(pwd)"`
**勿**在本仓新增 `.cursor/mcp.json` 的 codegraph 块。
<!-- CODEGRAPH_END -->

View File

@@ -1,40 +0,0 @@
---
description: 存客宝 v3 部署——Agent 自主执行、自主修错、验收至首页可访问(覆盖「部署失败再问用户」)
alwaysApply: true
---
# 存客宝 v3 · 部署自主处理
> **生效**2026-05-26。用户说「部署」时**不得**只跑脚本报成功就结束;必须验收到**首页有内容**。
## 强制流程
1. **执行**`bash 开发文档/8、部署/scripts/deploy_local_docker.sh --build`(改前端/首次/用户抱怨空白页时必须 `--build`)。
2. **脚本失败时 Agent 自行排查修复**,不要停下来问用户,常见顺序:
- Docker 未响应 → `open -a Docker`,等就绪后重试
- 全量 `--build` 中 `server` 镜像失败composer/php 拉取)→ 改为 `docker compose build cunkebao-web touchkebao-web superadmin-web && docker compose up -d`
- `vendor` 缺失 → 重建 `server` 或 `docker compose up -d --build server`
- MySQL 未 healthy → `COMPOSE_PROFILES=embedded-mysql docker compose up -d mysql`,确认 3308
- 3100 HTTP 200 但首页空白 → 查 `pageerror`(如 `homeIcons` 缺 import、`PermissionRoute` hydration、index.html 缓存;修代码后 **必须 rebuild cunkebao-web**
3. **验收(全绿才算部署成功)**
- HTTP3100 / 3101 = 200
- API登录 code 200经 `/api` 与 8082 任一)
- **首页 smoke**`node 开发文档/8、部署/scripts/verify_home_smoke.mjs` → 页面含「设备数量」「场景获客」,`pageerror` 为空
- `index.html` 响应头含 `Cache-Control: no-cache`
4. **失败则循环**:修 → rebuild 前端 → 再 deploy → 再 smoke直到通过或明确环境级阻塞仅此时向用户说明
## 禁止
- 仅 `curl` 首页 200 就宣称部署成功
- 部署失败后只给用户命令让用户自己跑
- 未 rebuild 就声称「已更新前端」
## 脚本与文档
- 本地一键:`开发文档/8、部署/scripts/deploy_local_docker.sh`
- 首页 smoke`开发文档/8、部署/scripts/verify_home_smoke.mjs`
- 腾讯云:`开发文档/8、部署/scripts/deploy_tencent_baota.sh`(用户明确要求线上时)
## 与「仅本地不 push」关系
部署修错可改本地代码与 `开发文档/`**不自动 git push**(见 `cunkebao-local-only-no-github.mdc`),除非用户本轮明确说提交/推送。

View File

@@ -1,28 +0,0 @@
---
description: 四端只 HTTP 对接、不串仓硬性铁律。改触客宝/AI数智员工/存客宝/超管任意一端时强制遵守。
globs:
- "**/*"
alwaysApply: true
---
# 四端对接铁律 · 只 HTTP · 不串仓
> 真源:`开发文档/2、架构/四端对接铁律_只HTTP不串仓.md`
## 强制
1. **各端各自仓库、各自职责** — 存客宝(3100) · 触客宝(3101) · AI数智员工(3104) · SuperAdmin(3103) **不互相改业务代码**。
2. **协作只走 HTTP API** — JWT `/v1/*` · Open `X-API-KEY` · 禁止跨项目 import 源码、禁止直连 MySQL/Mongo。
3. **数据范围** — 请求必须带当前项目 `companyId`;禁止跨项目读写。
4. **新能力** — 先更 `开发文档/5、接口/` 索引,再改 `route.php`,最后写 UI。
5. **工作手机/神射手** — 只经存客宝 BFF 或 documented HTTPAI数智员工 **禁止** `/v1/kefu/*`。
## 禁止
- 在触客宝/AI数智员工仓修改 `Server/application/cunkebao`(除非用户本轮明确「改存客宝 Server」
- copy 兄弟项目 React 业务组件进本仓(设计 token 可对齐)
- 触客宝直连 `traffic_pool` 表
## 接口索引
`开发文档/5、接口/SuperAdmin_四端对接接口索引.md` · `02-存客宝对接/触客宝PC接口对接索引.md`

View File

@@ -1,89 +0,0 @@
---
description: 存客宝 v3 · 卡若AI 对话强制规则(复盘五块 + 本轮文档可打开路径)
alwaysApply: true
---
# 存客宝 v3 · 卡若AI 对话强制规则
> **生效**2026-05-31 · 优先级:与本仓 `cunkebao-v3-devdoc-first.mdc`、`cunkebao-local-only-no-github.mdc` 并列。
## 一、身份与启动(每轮)
1. **读卡若AI**`/Users/karuo/Documents/个人/卡若AI/BOOTSTRAP.md` → `SKILL_REGISTRY.md`(热技能命中则读对应 `SKILL.md`)。
2. **回廊洗字**`运营中枢/参考资料/卡若闽南口音_ASR纠错库.json`(卡路/卡洛/卡罗拉→**卡若**)。
3. **执行前公示**(默认):正文最前 **理解 ≤200 字** + **本次要做** 有序列表;用户说「直接做」可跳过列表,**复盘仍必须**。
4. **存客宝开发**:叠加 `开发文档/开发铁律_先读文档再编码.md` Phase 0。
## 二、对话结束 · 强制复盘(卡若 v5/v6
**每条回复的最后一块**必须是完整 **[卡若复盘]YYYY-MM-DD HH:mm**,真源:
`卡若AI/运营中枢/参考资料/卡若复盘格式_固定规则.md` · 速查 `04_卡火/火炬_全栈消息/卡若复盘格式/SKILL.md`F01d
| 顺序 | 块 |
|:---:|:---|
| 1 | **🎯 目标·结果·达成率**(单行 ≤50 字,含 **0100%** |
| 2 | **📌 过程**1、2、3 分点,可验收数据) |
| 3 | **💡 反思**13 点) |
| 4 | **📝 总结** |
| 5 | **▶ 下一步执行** |
复盘块内**禁止表格**;禁止省略复盘或写「见上文」。
## 三、复盘之后 · 本轮文档Cursor 可点 · 强制)
复盘五块写完后、回复结束前,追加 **📎 本轮文档****只列本轮对话新建/修改的文件**,通常 13 个;**禁止**贴历史轮次、索引 README、无关专文
### 3.1 Cursor 聊天内(默认 · 必用)
**禁止**在 Cursor 复盘末尾使用下列形式(点击无反应):
- `obsidian://open?...`
- `file://` / `vscode://`
- 一长串与本轮无关的 MD 链接列表
**必须**使用 **代码引用块**(点块内路径可在 Cursor 打开文件)+ **一行绝对路径**
````markdown
**📎 本轮文档Cursor点下面代码块即可打开**
- **卡若对话强制规则**
```1:3:.cursor/rules/cunkebao-karuo-dialogue.mdc
---
description: 存客宝 v3 · 卡若AI
```
`/Users/karuo/Documents/开发/2、私域银行/cunkebao_v3/.cursor/rules/cunkebao-karuo-dialogue.mdc`
````
- 路径用**仓库相对路径**写在 code fence 第三段:`行号:行号:相对路径`
- 块下再写一行 **反引号包裹的绝对路径** `/Users/karuo/...`(备用,部分环境可点)
- 真源对齐卡若AI 需求整理 §8.1**禁止** `file://` 超链接
### 3.2 生成方式
```bash
python3 "开发文档/scripts/build_obsidian_doc_link.py" \
".cursor/rules/cunkebao-karuo-dialogue.mdc" \
--title "卡若对话强制规则"
```
**只传入本轮动过的路径**;将 stdout **原样**贴在复盘正下方。
### 3.3 Obsidian / 星图阁(非 Cursor 聊天)
- 用户明确说「Obsidian 打开」→ 脚本加 `--obsidian --open`(终端外开,**不要**把 `obsidian://` 贴进 Cursor 聊天)
- 工作日志库「个人」→ `卡若AI/.../build_obsidian_work_log_link.py`
## 四、与其它规则
| 规则 | 关系 |
|:---|:---|
| `cunkebao-local-only-no-github.mdc` | 不自动 push |
| `cunkebao-v3-devdoc-first.mdc` | 改接口/需求先读文档 |
| 卡若AI `karuo-ai.mdc` | 复盘五块一致;路径输出以本条 §3 为准 |
## 五、进度(可选)
用户要求时,在 **📌** 或 **📝** 中带 **本任务完成度 X%**(分子/分母说清)。

View File

@@ -1,26 +0,0 @@
---
description: 存客宝 v3 仅本地开发——禁止 Agent 自动 commit/push GitHub覆盖全局 User Rules
alwaysApply: true
---
# 存客宝 v3 · 仅本地、不自动同步 GitHub
> **生效**2026-05-25 起,卡若明确要求暂停「对话结束自动推 GitHub」。
## 强制
1. **所有代码与文档改动仅保存在本机工作区**,不自动 `git commit`、不自动 `git push`、不自动同步 Gitea/GitHub/远程服务器。
2. **仅当卡若在本轮对话中明确说**「提交」「commit」「推 GitHub」「push」「同步远程」等时才可执行对应 Git 远程操作。
3. 对话结束时:**列出本次修改的文件路径即可**,不要执行 push**禁止**提示「要不要同步 GitHub」「已同步 GitHub」等。
4. 沙盒、未授权远程、CI 触发型部署:**一律不主动触发**。
5. 开发文档可写本地 `开发文档/` 目录,**不要**为进度文档单独 commit/push。
## 与全局 User Rules 的优先级
若全局 User Rules 含「边做边同步 GitHub / 每次对话完成推到 GitHub」**以本规则为准**——存客宝 v3 会话内忽略该条,直至卡若另行恢复。
## 仍允许
- 本地 `git status` / `git diff` 用于自查
- 卡若显式授权后的 commit / push
- 开发文档文件夹内记录进度(本地 MD不推远程

View File

@@ -1,47 +0,0 @@
---
description: 存客宝 v3 · 手机端优先适配铁律(开发 /mobile 必守)
globs:
- Cunkebao/src/pages/mobile/**
- Cunkebao/src/components/Layout/**
- Cunkebao/index.html
---
# 存客宝 · 手机端优先Mobile First
> **产品定位**:存客宝 **以手机端界面为主**PC 为同套 `Layout frame=mobile` 全屏,不是桌面专属布局。
> **壳层参照**`Cunkebao/src/components/Layout/` · `Cunkebao/index.html` · 触客宝 `.cursor/skills/触客宝手机端前端壳/SKILL.md`(键盘/100dvh 同源)
## 一、结构(强制)
| 层级 | 规则 |
|:---|:---|
| 页面壳 | 用 `Layout``header` 顶 · `main` **唯一纵向滚动** · `footer` 底 |
| 多步表单 | **`embedded`**:底栏内嵌在 flex 底,**禁止**只靠 Portal 让用户上拉找「下一步」 |
| 内容区 | **禁止** `min-height: 100vh` / `padding-bottom: 100px` 撑高;滚动只发生在 `Layout.main` |
## 二、选项与排版(禁止挤压)
| 场景 | 做法 |
|:---|:---|
| ≤3 个短选项 | `radioGroupSegment` 等分横排 |
| ≥4 个或文案较长 | `radioGroupWrap` **两列换行**,禁止 `radioGroupHorizontal` 塞 5 项导致**竖排单字** |
| 长说明 | 副文案单独一行(`radioDesc`),不挤在 Radio 同一窄列 |
| 触控 | 可点区域高度 **≥ 44px**;字号 **≥ 14px**(输入 **≥ 16px** 防 iOS 放大) |
## 三、底栏按钮(下一步 / 提交)
- 固定在视口**最下**`embedded` 的 `inlineFooter` 或 `TabBarPortal`
- 主按钮 **block / flex:1**,与「上一步」并排时 `footer-btn-group`
- 主体 `main` 预留 `padding-bottom` ≥ 底栏高 + safe-area`Layout` 已处理 `embedded` 时勿重复加大留白)
## 四、新页 / 改页 Checklist
- [ ] 已读本条 + Layout 源码
- [ ] 无 `100vh` 撑高 body
- [ ] 多选项未竖排挤字
- [ ] 底栏无需上拉即可点「下一步」
- [ ] 真机或 375px 模拟器目视
## 五、需求落盘
手机适配类需求写入 `开发文档/1、需求/修改/存客宝_进行中_{YYYYMMDD}.md`REQ-CKB-11x验收**375px 宽无挤字、底栏固定可见**。

View File

@@ -1,43 +0,0 @@
---
description: 存客宝 v3 · 读需求表必先看图(强制)
globs:
- "开发文档/1、需求/**/*.md"
alwaysApply: false
---
# 存客宝 v3 · 读需求表必先看图
> **生效**2026-05-26 起。卡若明确要求:每次读 `开发文档/1、需求/修改/存-*.md` 或类似需求 MD 时,**必须**确认能看到 MD 引用的所有图片,再开始开发。
## 强制流程(开发前 Phase 0
1. **先读需求 MD**`Read` 工具读完整文件)。
2. **列出所有 `![](images/xxx.png)` 引用**(用 `Grep` 模式 `../../10、项目管理/images/[^)]+\.png`)。
3. **`ls images/`** 确认本地文件均存在;若缺图必须在对话里**明确报告缺失清单**,不要假装能看到。
4. **逐张 `Read` 关键截图**(至少所有 P0/P1 模块对应的截图与「参考样式」「标注」类图片。Read 工具支持 PNG/JPG/GIF/WebP会作为图像加载到上下文。
5. **复述**截图中看到的元素(按钮文字、颜色、布局、标注红框),与 MD 文字描述对照,若有冲突以**截图为准**并在回复中标注。
6. **图片缺失或读取失败时**:先用 `GenerateImage` 不可、改用 `WebFetch` 也不可。立刻向用户报告,**不**继续编码。
## 禁止
- 仅凭 MD 文字描述就开始编码,跳过截图。
- 用 `cat`/`head` 读 PNG无效
- 复述 MD 时把截图当作文字一并描述但实际未 Read伪装看图
## 适用范围
- `开发文档/1、需求/修改/*.md`
- `开发文档/1、需求/草稿/*.md`
- 任何外部传入的 PDF 需求(先 `Read` PDF再 `Read` 拆出的 PNG 页)
## 复盘要求
每次按本规则开发后,在复盘里说明:
- 本轮 Read 了哪些 PNG路径
- 截图中验证/纠正了哪些 MD 文字描述
- 是否有图片缺失或读取失败
## 与其他规则关系
- 与 `cunkebao-v3-devdoc-first.mdc` 协同:先读 1、需求图 → 再读 5、接口/2、架构 → 再编码。
- 与 `cunkebao-local-only-no-github.mdc` 不冲突:仍只本地,不自动推。

View File

@@ -1,54 +0,0 @@
---
description: 存客宝 v3 开发铁律——先读开发文档再编码,迭代结束回写文档(含 5、接口
alwaysApply: true
---
# 存客宝 v3 · 开发文档先行
本项目代码变更**必须**遵循 `开发文档/开发铁律_先读文档再编码.md`。
## 动手前Phase 0
1. 读 `开发文档/开发铁律_先读文档再编码.md` 的 Phase 0 表,按任务类型读完对应 MD
2. **目录/需求维护** 必读 `开发文档/开发文档目录结构铁律.md`(十大目录 · modify 收口 · 已完成进 10、项目管理
2. **接口/BFF/对接** 必读:
- `开发文档/5、接口/README.md`
- `开发文档/5、接口/07-接口分类总览Obsidian配色.md`
- `开发文档/5、接口/存客宝API全量索引.md`
- 涉及 workphone 时再读 `5、接口/02-存客宝与工作手机对接/` 下 05/06/03
3. **架构/新模块** 必读 `开发文档/2、架构/README.md` → `系统架构.md`
4. 对话中先列出「本次已读文档路径」≤5 条),再改代码
## 开发中Phase 1
- 路由真源:`Server/application/*/config/route.php`
- 不改原有业务 URL设备写操作走 `/v1/workphone/*`
- 对照 `5、接口/02-存客宝与工作手机对接/06-业务接口与工作手机一对一映射总表.md`
- **边开发边标注**:只改 `开发文档/1、需求/修改/{端}_进行中_{YYYYMMDD}.md`(⬜→🟡→✅);**禁止**同日另开 `{端}_{主题}_{date}.md`;关单节迁入 `开发文档/10、项目管理/已完成/`
- **需求落盘 Skill**`.cursor/skills/需求协调与归档/SKILL.md` §九(一项目一文件)
### ⛳ 强制打勾铁律(卡若 2026-05-31 · 最高优先)
> **每完成一项,立即在开发文档对应行打 ✅** —— 不攒批、不等收尾、不口头代替。
1. **触发即更新**:任一需求/子项验收通过的**当下**,立刻把进行中 MD 对应行 `⬜/🟡 → ✅`,并在验收列补**可验收证据**HTTP 码 / 条数 / 脚本 exit / 截图路径)。
2. **粒度到子项**:表格每行、清单每条 `- [ ] → - [x]`;一个功能含多子项的,完成几项打几个勾。
3. **顺序固定**`改代码 → 验证 → 立即打勾 ✅ + 证据 → 再继续下一项`;严禁"全部做完再统一打勾"。
4. **复盘对齐**:复盘 📌 引用的完成项,必须与 MD 中已 ✅ 的行一致;不一致以 MD 为准并补勾。
5. **跨文档同步**:接口变更同步打勾 `开发进度_接口文档.md`;其它相关文档同样"完成一项打一勾"。
6. **未完成保留**:⬜/🟡/🔒 原样留下,不删行;整节全 ✅ 才迁 `10、项目管理/已完成/` 并留索引行。
## 收尾Phase 2
- 路由变更 → 同步 `5、接口/存客宝API全量索引.md`
- BFF/SDK 变更 → 同步 02-对接目录 + `开发文档/开发进度_接口文档.md`
- 新增 MD → 运行 `开发文档/scripts/generate_doc_header_banners.py`
- 复盘须写:读了哪些文档、更新了哪些文档
## Skill
| Skill | 路径 |
|:---|:---|
| 卡若AI 开发文档先行 | `01_卡资/金盾_数据安全/存客宝/开发文档先行/SKILL.md` |
| 需求整理 W05b | `.cursor/skills/开发需求文档整理/SKILL.md` |
| 需求协调 W05c | `.cursor/skills/需求协调与归档/SKILL.md` |

View File

@@ -1,122 +0,0 @@
---
name: cunkebao-architecture-five-diagrams
description: >-
存客宝 v3 项目架构五图:对话内 GenerateImage 生成高级架构/流程图,写入
开发文档/2、架构/images/architecture-overview/,并维护 08-项目架构五图读懂指南.md。
触发:架构五图、五图读懂、项目架构图、生成架构图、一图读懂存客宝、
PM架构图、不用Python生成图片、架构 infographic。禁止用 Pillow/Python 画架构总览图。
---
# 存客宝 · 架构五图(对话内 AI 出图)
> **铁律**:架构总览 / 业务流程 / 板块关系图 → **只用 Cursor `GenerateImage`** 在对话里生成;**禁止**用 `generate_doc_header_banners.py` 或 Pillow 替代。
> **文档说明小头图**(单篇 MD 顶栏)仍走 F09a「开发文档说明头图」Skill。
---
## 一、何时触发
- 用户要「几张图看懂整个项目」「给 PM 的架构图」「五图读懂」
- 存客宝大版本架构变更后需更新总览图
- 用户明确说「不要用 Python 生成图片」
---
## 二、五图标准套装(固定 5 张)
| 序号 | 文件名 | 标题(中文) | 内容要点 |
|:----:|--------|-------------|----------|
| 1 | `01-系统四层架构总览.png` | 存客宝 v3 · 系统四层架构总览 | 客户端层 → Nginx → Server/Moncter/WebSocket → MongoDB/MySQL/Redis + 工作手机 SDK |
| 2 | `02-核心业务数据流.png` | 存客宝 · 核心业务数据流 | 工作手机 ↔ SDK ↔ ThinkPHP ↔ 三库WebSocket ↔ React 前端 |
| 3 | `03-六大业务板块关系.png` | 存客宝 · 六大业务板块 | 场景获客/工作台/设备/微信号/流量池/内容库 → 中心 BFFroute.php 真源 |
| 4 | `04-场景获客与好友添加流程.png` | 场景获客 + 好友添加 · 完整流程 | 建计划→task_customer→Workerman→健康分→SDK→状态0~5→Webhook |
| 5 | `05-定时任务与自动化调度.png` | 存客宝 · 定时任务与自动化调度 | task_scheduler 高/中/低频任务Command→Queue→Job |
**输出目录**(项目内):
```
开发文档/2、架构/images/architecture-overview/
```
**配套文档**(必须同步更新或新建):
```
开发文档/2、架构/08-项目架构五图读懂指南.md
```
---
## 三、GenerateImage 提示词规范
### 视觉风格(统一)
- 比例:**16:9 横版**
- 风格:**dark glassmorphism**,深蓝/紫渐变底,毛玻璃卡片,细线箭头
- 字体:**中文为主**大号标题PM 可读
- 质量Keynote / 企业 SaaS 发布会级别,**不要**代码截图、不要 ASCII
### 每张图调用模板
```
Professional enterprise architecture infographic, 16:9 landscape,
premium dark glassmorphism, subtle blue-purple gradient.
Chinese title: "{标题}".
{具体内容 bullet英文描述布局即可}
Clean sans-serif Chinese typography, Apple keynote quality, footer: CKB v3
```
### 并行生成
5 张图 **同一轮对话并行** 调用 5 次 `GenerateImage`,不要串行等待。
### 落盘
GenerateImage 默认写到 Cursor assets**必须 cp 到**
```bash
开发文档/2、架构/images/architecture-overview/
```
(用 Shell 复制,不要用 Python 画图。)
---
## 四、文档维护 checklist
生成图片后按序执行:
1. [ ] 5 张 PNG 已在 `architecture-overview/`
2. [ ] `08-项目架构五图读懂指南.md` 内 5 处 `![...](images/architecture-overview/...)`
3. [ ] `2、架构/README.md` 阅读顺序增加「08-五图读懂」
4. [ ] `5、接口/开发进度_接口文档.md` 或架构变更记录加一行
5. [ ] **不**运行 `generate_doc_header_banners.py` 为这五张图生成 doc-header
---
## 五、内容真源(生成前必读)
| 主题 | 文档 / 代码 |
|------|-------------|
| 四层架构 | `开发文档/2、架构/系统架构.md` |
| 数据流 | `开发文档/2、架构/核心业务链路.md` |
| 六板块 | `开发文档/5、接口/03-业务板块接口/README.md` |
| 获客加友 | `开发文档/2、架构/07-业务板块核心算法.md` |
| 定时任务 | `开发文档/2、架构/自动化工作流.md` · `Server/config/task_scheduler.php` |
---
## 六、与 F09a 头图 Skill 的分工
| 类型 | 工具 | Skill |
|------|------|-------|
| 单篇 MD 顶栏小 banner | GenerateImage首选 | F09a 开发文档说明头图 |
| **项目级架构五图** | GenerateImage强制 | **本 Skill** |
| 离线无 AI 的小色条 | Pillow fallback | F09a 仅 fallback不用于五图 |
---
## 七、变更记录
| 版本 | 日期 | 说明 |
|------|------|------|
| 1.0 | 2026-05-25 | 首版;替代 Python 批量画架构总览 |

View File

@@ -1,193 +0,0 @@
---
name: 存客宝官网开发
description: 存客宝对外官网(/官网/需求、SEO/GEO、产品矩阵截图、留资单接口、超管接口文档第9类。触发存客宝官网、官网开发、官网SEO、产品矩阵、官网留资、对外接口、官网需求、GEO、私域官网。
triggers: 存客宝官网,官网开发,官网SEO,产品矩阵,官网留资,对外接口,GEO,私域官网,触客宝官网
owner: 火炬
group: 火
version: "1.0"
updated: "2026-05-31"
---
# 存客宝官网开发 SkillF01e · 项目内)
> **代码**`/Users/karuo/Documents/开发/2、私域银行/cunkebao_v3/官网/`(静态站 · 零构建)
> **当日需求真源(唯一)**`开发文档/1、需求/修改/官网_进行中_{YYYYMMDD}.md` — **新需求只追加此文件章节**,禁止再开 `官网_{主题}_*.md` 于 `修改/`
> **鉴定/索引**`开发文档/1、需求/官网/0013` 为已整理专文,**非**当日追加口(见 [需求协调与归档](../需求协调与归档/SKILL.md) §九)
> **对话规则**`.cursor/rules/cunkebao-karuo-dialogue.mdc`(卡若复盘 + 本轮文档代码块)
---
## 一、何时启用
| 触发 | 动作 |
|:---|:---|
| 改官网页面/样式/留资 | 读 **`官网_进行中_{date}.md`**REQ/E 序)· 专文仅作参考 → 改 `官网/`**回勾进行中(⬜→🟡→✅)**`03-研发迭代日志` |
| 运营后台/访客/Webhook | 读 `_进行中` **§ REQ-WEB-60** → `scripts/analytics-store.mjs` + `admin/` → 子项打勾 |
| **AI 客服对外(新)** | **先读 14 专文 §十一** · **勿抢先写代码** · Server `official-ai/chat` → 再官网挂件 |
| 留资/对接 API | 只读 [07-官网统一留资接口](../../开发文档/1、需求/官网/03-接口与对接/07-官网统一留资接口_20260531.md)**禁止再造第二 URL** |
| SEO/关键词 | 读 [09](../../开发文档/1、需求/官网/04-SEO与增长/09-官网SEO与GEO全站优化_20260531.md);要做后台读 [12](../../开发文档/1、需求/官网/04-SEO与增长/12-官网SEO运营后台与自动挂链_20260531.md) |
| 产品矩阵配图 | 读 [08-产品矩阵界面与截图](../../开发文档/1、需求/官网/02-页面与视觉/08-产品矩阵界面与截图_20260531.md) |
| 超管接口汇总 | 同步 `SuperAdmin/lib/api-docs-index.ts` + `5、接口/SuperAdmin_四端对接接口索引.md` |
---
## 二、文档地图(按编号)
| 编号 | 文件 | 用途 |
|:---|:---|:---|
| **进行中** | `修改/官网_进行中_20260531.md` | **REQ-WEB 总表 · 待改文件 · E1E7** |
| 00 | 00-官网需求执行总表_20260531.md | 进度 % · 速查 |
| 01 | 01-需求与验收_v2.md | IA、价格、验收 smoke |
| 06 | 06-首页Hero三版_20260531.md | Hero 配色/真截图 |
| 07 | 07-官网统一留资接口_20260531.md | **鉴定版** POST /v1/api/scenarios |
| 08 | 08-产品矩阵界面与截图_20260531.md | 三端截图规范 |
| 09 | 09-官网SEO与GEO全站优化_20260531.md | 关键词矩阵 + Schema + 工具 |
| 10 | 10-官网对外接口文档_20260531.md | 改造官网索引 |
| 11 | 11-超管统一接口文档中心_20260531.md | 超管 9 类汇总 |
| 12 | 12-官网SEO运营后台与自动挂链_20260531.md | **⬜** SEO 运营中心需求(挂链/发帖/收录) |
| 13 | 13-官网AI数智员工单设备硬件专题_20260531.md | 单手机 mockup · 流程图 · `../../10、项目管理/images/官网_20260531/` |
| **14** | **14-官网AI客服获客场景_20260531.md** | **P0 · 仅文档 · 开发按 §十一Server→官网** |
| **15** | **15-官网后台用户旅程与Webhook推送_20260531.md** | **P1 · 有手机优先 · 一键推 Webhook** |
---
## 三、常用命令
```bash
# 本地预览 + smoke
bash 开发文档/8、部署/scripts/deploy_official_site.sh --smoke
# → http://127.0.0.1:8090/
# 复盘末尾生成本轮文档链接Cursor 可点)
python3 开发文档/scripts/build_obsidian_doc_link.py \
"开发文档/1、需求/官网/02-页面与视觉/08-产品矩阵界面与截图_20260531.md" \
--title "产品矩阵规范"
```
---
## 四、留资铁律(踩坑合集)
| 坑 | 正确做法 |
|:---|:---|
| 再造 `/v1/open/*` 或第二个留资 URL | **唯一** `POST /v1/api/scenarios` + apiKey/sign |
| 只传 `portrait.tags` | 必须传顶层 **`tags` + `siteTags`** 逗号串(`lead.js` 已补) |
| `portrait` 参与签名 | 签名时 **删除** portrait、apiKey、sign |
| 页脚明文微信/电话 | 只留 **data-lead** 弹窗 |
| planId 写 URL | 用 **apiKey** 绑 plan **779** |
**配置**`官网/assets/js/site-config.js` · **提交**`lead.js` · **轨迹**`track.js`
### 4.1 访客追踪 + WebhookREQ-WEB-60
| 项 | 说明 |
|:---|:---|
| 追踪 API | `POST /api/track`(公开)· 数据 `data/analytics.json` |
| 留资推送 | `POST /api/lead-local`(有手机号才推飞书)· **勿**浏览器直连飞书 |
| 飞书格式 | `{ msg_type:"text", content:{ text:"…" } }` · 成功 `code===0` |
| 合并键 | `phone` > `fingerprint`canvas+UA非真 IMEI/MAC> `visitorId` |
| 后台 | 概览/获客/旅程 · 右上 ⏰ `GET /admin/api/alerts` |
| 边做边勾 | 完成子项即改 `_进行中` REQ-WEB-60 / **REQ-WEB-61** 表状态 ✅ |
| 开发顺序 | **REQ-CKB-66 阶段1Server→ 阶段2计划UI→ REQ-WEB-61 阶段3官网** |
---
## 五、SEO / GEO 要点2026
1. **SEO 地基优先**76% Google AI 摘要 URL 在自然 Top10 — 先 meta、sitemap、Schema、内链。
2. **五类词**:核心词 / 场景词连锁·AI客服/ 长尾 FAQ / 蒸馏词 / 品牌词 → 见 09 §三。
3. **Schema**index 已有 SoftwareApplication + ItemList(产品矩阵)pricing FAQPage。
4. **工具**Screaming Frog 全站爬、Google Rich Results Test、百度搜索资源平台。
5. **GEO**E-E-A-T + 真实案例数字 + 站外知乎/百家号同实体 NAP。
6. **勿迷信单独 GEO 外包**:与 SEO 同一套内容工程。
---
## 六、产品矩阵 UI
| 端 | 截图路径 | 替换方式 |
|:---|:---|:---|
| 存客宝 | `官网/assets/img/matrix-cunkebao.png` | 3100 真机截图覆盖 |
| 触客宝 | `官网/assets/img/matrix-touchkebao.png` | 3101 真机截图覆盖 |
| AI数智员工 | `官网/assets/img/matrix-aistaff.png` | 3104 真机截图覆盖 |
- HTML`index.html#matrix` · class `matrix-shot`
- 标题:**三端入口** + **存客宝产品矩阵**(对齐设计稿)
---
## 七、超管「接口文档」同步(第 9 类)
```
改留资接口说明
→ 07/10 官网 MD
→ SuperAdmin_四端对接接口索引.md §九
→ SuperAdmin/lib/api-docs-index.ts id: official-site
→ 超管 /dashboard/ai-center?tab=docs 自测
```
**注意**Tab 挂在 AI 中心下,**内容是全平台 9 类**,文案勿写「仅 AI 接口」。
---
## 八、复盘与链接Cursor 专用 · 必记)
| 错误 | 正确 |
|:---|:---|
| 复盘贴 `obsidian://` | **点不开**;用 **代码引用块** ` ```1:3:相对路径 ` + 绝对路径一行 |
| 贴 6 个历史 MD 链接 | **只列本轮**新建/修改 13 个文件 |
| 用 `file://` 超链接 | 卡若需求整理 §8.1 **禁止** |
脚本:`开发文档/scripts/build_obsidian_doc_link.py`(默认 Cursor 块)
---
## 九、与兄弟 Skill 关系
| Skill | 关系 |
|:---|:---|
| [开发需求文档整理](../开发需求文档整理/SKILL.md) | 四端需求 MD官网专文走 **本 Skill** |
| [需求协调与归档](../需求协调与归档/SKILL.md) | 跨端 COORD |
| [存客宝宝塔部署](../存客宝宝塔部署/SKILL.md) | 公网部署官网时 |
| 卡若AI F01d 复盘格式 | 每轮收尾五块 |
---
## 十、验收清单(每轮)
- [ ] `node 官网/smoke.mjs` 全绿
- [ ] 矩阵三图不裂、alt 有意义
- [ ] 留资弹窗可提交(本地需 apiKey
- [ ] 文档 03 迭代日志 + 相关专文变更记录
- [ ] 若动接口:超管 api-docs-index 已同步
---
## 十一、SEO 运营后台(需求 · 12 号文档)
| 要点 | 说明 |
|:---|:---|
| 放哪 | **SuperAdmin 平级「SEO 运营」**,不要塞 AI 中心 |
| 自动挂链 | **站内**内链脚本 + **矩阵站**互链表;**站外**发稿任务单(知乎等多为草稿+人工) |
| 申请 | P1 接百度站长、GSC、IndexNow API |
| 禁止 | 小旋风类黑帽站群、泛目录、买链轮链 |
| 开源参考 | GitHub `SEO-Interlinking-Tool``schema-sentry` |
| 本轮 | **只写需求**,不改 `官网/` |
---
## 十二、经验沉淀evolution
| 日期 | 经验 |
|:---|:---|
| 2026-05-31 | Cursor 聊天内 obsidian:// 无效 → 复盘用代码引用块 |
| 2026-05-31 | 官网留资已有单接口,文档 07 鉴定即可 |
| 2026-05-31 | 超管接口文档 = 9 类全平台,官网为第 9 类 |
| 2026-05-31 | 产品矩阵需 UI 截图,非纯文字卡 |
| 2026-05-31 | SEO+GEO关键词矩阵在 09ItemList Schema 上 index |
| 2026-05-31 | SEO 后台要自建;站群 SaaS 黑帽不做;发帖 API 稀缺→任务单 |
| 2026-05-31 | 本机素材:`9、手册/使用手册/截图/AI数智员工-登录页.png`;海报参考在 `个人/4、工作日志/会议纪要/参考/` |
| 2026-05-31 | 单设备叙事≠海报获客;硬件专题见 13 号 REQ-WEB-5054 |
| 2026-05-31 | **当日官网需求只写 `官网_进行中`**`官网/0x` 专文=鉴定索引,勿在 `modify/` 再开主题 MD |
| 2026-05-31 | 官网 AI systemPrompt**Server 七层合并**(存客宝 §6· 默认循序渐进留资见 REQ-WEB-63 |
| 2026-05-31 | REQ-WEB-60访客 analytics + 飞书 Webhook + 后台 Karuo UI开发完须在 `_进行中` 打勾 |

View File

@@ -1,120 +0,0 @@
---
name: 存客宝宝塔部署
description: 存客宝 v3 部署到腾讯云宝塔 42.194.245.239 的标准流程、验收与踩坑防治。触发词存客宝部署、宝塔部署、kr-op部署、ckbapi部署、腾讯云部署、部署踩坑、Query.php、场景获客失败、工作台500、二维码不显示。
---
# 存客宝宝塔部署 Skill
> 生产kr-op / kr-kf / ckbapi · 服务器 42.194.245.239:22022
> 详细踩坑:`开发文档/8、部署/宝塔腾讯云部署踩坑清单.md`
## 一、部署前必读(防治清单)
| 序号 | 风险 | 防治 |
|------|------|------|
| 1 | 登录 405 | 构建 `VITE_API_BASE_URL=https://ckbapi.quwanzhi.com` + kr-op `/api` 反代 |
| 2 | 登录 10501 / where 全挂 | **禁止**覆盖 `thinkphp/library/think/db/Query.php` 为 TP5.1 精简版;脚本 2b 强制同步仓库版 |
| 3 | JWT 500 | 控制器用 `JwtAuthTrait::authUser()`,勿读 `$request->userInfo` |
| 4 | 工作台 500 | `setAttr('config')` 勿用 `$item->config` / `$item->data[]` |
| 5 | 流量池分页 1 条 | count 用 `(clone $query)->count()` |
| 6 | 场景设置保存 500 | 先跑 `20260525_acquisition_global_settings.sql` |
| 7 | 场景列表空白 / 401 | 前端 token 每次请求从 store 读401 跳登录 |
| 8 | **海报二维码 img 404** | URL **勿**带 `.png` 后缀Nginx 当静态文件 404`/v1/frontend/poster/qrcode/{id}` |
| 9 | **海报弹窗误显 H5 码** | 主二维码 = **小程序码**`poster_qrcode_mode=miniprogram`H5 表单仅下方链接区,两者隔离 |
| 10 | **小程序码进表单/错 AppId** | 落地页 **`pages/poster/index2`**(非 `input2`AppId **`wx789850448e26c91d`**(存客宝);`.env` 勿重复 `[weChat]` 段 |
| 11 | **授权手机号后无入池** | `decryptphone` 勿用 EasyWeChat `phone_number`;用 `MiniProgramQrCodeService::getUserPhoneNumber` curl 直连微信 |
| 12 | rsync 勿删 `.env` / `vendor` / `runtime` | 脚本已 exclude |
## 二、标准命令(项目根 cunkebao_v3
```bash
# 1. 数据库迁移(有 migrations 时先跑)
bash 开发文档/8、部署/scripts/run_db_migrations_tencent.sh
# 2. 全量部署
bash 开发文档/8、部署/scripts/deploy_tencent_baota.sh
# 3. 仅前端
bash 开发文档/8、部署/scripts/deploy_tencent_baota.sh --skip-server
# 4. 验收(必须 17/17扩展含 AI/支付/海报;线上全链路)
bash 开发文档/8、部署/scripts/acceptance_tencent_prod.sh
bash 开发文档/8、部署/scripts/acceptance_tencent_extended.sh
bash 开发文档/8、部署/scripts/acceptance_tencent_online_full.sh
# 5. 仅同步支付 overlay + 证书
bash 开发文档/8、部署/scripts/sync_payment_certs_tencent.sh
```
## 三、上线顺序
**迁移 SQL → rsync 后端(含 Query.php 2b→ 构建前端 → 发布 dist → 清 cache → 验收**
## 四、快速诊断
```bash
# 登录
curl -X POST https://ckbapi.quwanzhi.com/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"account":"karuo","password":"***","typeId":1}'
# 场景(需 Bearer
curl -H "Authorization: Bearer $TOKEN" \
'https://ckbapi.quwanzhi.com/v1/plan/scenes?page=1&limit=5'
# 海报二维码(应 200 image/png勿测 .png 后缀)
curl -I 'https://ckbapi.quwanzhi.com/v1/frontend/poster/qrcode/709'
# Query.php 版本(应为 375f1093… 一类newQuery 须复制 name/table
ssh -p 22022 root@42.194.245.239 \
"md5sum /www/wwwroot/ckbapi.quwanzhi.com/thinkphp/library/think/db/Query.php"
```
## 五、路径速查
| 组件 | 路径 |
|------|------|
| API | `/www/wwwroot/ckbapi.quwanzhi.com` |
| 存客宝 dist | `/www/wwwroot/auto-devlop/ckb-operation/dist` |
| 触客宝 dist | `/www/wwwroot/auto-devlop/tkb-wechat/dist` |
| monorepo | `/www/wwwroot/mckb_quwanzhi_com` |
| MySQL | 10.0.0.4:3306 / cunkebao_v3 / ck_ |
## 六、复盘归档
每次部署后更新:`开发文档/8、部署/04-部署复盘/`
---
## 七、超管 SuperAdmin本地 Docker vs 宝塔)
| 项 | 本地 Docker | 宝塔生产 |
|----|-------------|----------|
| 访问 | http://localhost:**3103** | **kr-admin.quwanzhi.com**Node/PM2 单独部署,未进 ckb dist |
| API 反代 | Next `rewrites``cunkebao-server``/api/v1/admin` | 需 Nginx 反代到 **ckbapi.quwanzhi.com/v1/admin** 或同域 `/api` |
| 构建 | `docker compose build superadmin-web`build-arg `SUPERADMIN_API_PROXY_TARGET=http://cunkebao-server` | `npm run build` + `next start``NEXT_PUBLIC_API_BASE_URL=/api/v1/admin` |
| 设备二维码 | 依赖 `api.guid` + `api.deviceSocketHost``.env.docker` | 同左,生产 `.env` 必须配置;缺则添加设备报「环境配置不完整」 |
| S2 同步设备 | 需本机/内网能访问 **rpc.API_BASE_URL**(工作手机 SDK | 宝塔 server 需能连 S2本地 Docker 常连 `workphone-sdk:8899` 或线上 kf |
| 客户池列表 | 37 万+ 数据,首屏约 **35s**V2 表);勿用旧 `traffic_source_v1` JOIN | 同架构;生产库更大时需加索引/缓存 |
| 端口冲突 | 登记 **3103**(勿与 website 3102 混用) | 宝塔单独 Node 项目,不占 3100/3101 |
### 超管常见报错
| 现象 | 原因 | 处理 |
|------|------|------|
| 添加设备网络错误 | 前端打到错误路径 `/v1/api/device/add` | 用 `POST /v1/admin/devices/qrcode` |
| 二维码环境不完整 | 缺 guid / deviceSocketHost | 补 `Server/.env` `[api]``config/device_bind.php` |
| 客户池一直 loading | 旧 trafficPool 全表 JOIN 超时 | 已改 V2确认部署含新 `GetPoolListController` |
| 转移设备失败 | 目标项目无 s2_accountId | 项目编辑页确认触客宝账号已创建 |
| 本地 3105/3103 混用 | 多个 next dev 实例 | 统一 Docker **3103** 或登记端口 |
### 超管验收(本地)
```bash
bash 开发文档/8、部署/scripts/acceptance_superadmin_docker.sh
# 扩展overview + devices
TOKEN=$(curl -s -X POST http://localhost:8082/v1/admin/auth/login -H 'Content-Type: application/json' \
-d '{"account":"admin","password":"e10adc3949ba59abbe56e057f20f883e"}' | python3 -c "import sys,json;print(json.load(sys.stdin)['data']['token'])")
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8082/v1/admin/dashboard/overview?limit=5' | python3 -m json.tool | head -30
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8082/v1/admin/devices/list?limit=5' | python3 -m json.tool | head -20
```

View File

@@ -1,180 +0,0 @@
---
name: 开发需求文档整理
description: 将录屏转写、标注截图、口述草稿合并为存客宝标准需求 MD。命名 项目_功能主题_日期modify 扁平无子目录;四端分文件。触发词:需求文档、录屏需求、截图需求、需求拆解、整理需求、开发需求文档、存客宝需求、触客宝需求、超管需求。
---
# 开发需求文档整理 Skill存客宝 v3 · W05b 项目内)
> **卡若AI 母 Skill**`/Users/karuo/Documents/个人/卡若AI/02_卡人/水泉_规划拆解/需求整理/SKILL.md`(通用)
> **本 Skill**:存客宝 **录屏全量包** + **模块拆解**;落盘纪律以本文为准。
> **协调归档**:配合 [需求协调与归档](../需求协调与归档/SKILL.md)W05c 项目内)
---
## 一、目录与命名(强制 · **2026-05-31 修订**
> **当日进行中 = 一项目一文件**。主题需求写在文件 **内章节**,不另开 `{项目}_{主题}.md`。
> 纪律真源:[需求协调与归档/SKILL.md](../需求协调与归档/SKILL.md) §二、§九
```
开发文档/1、需求/
├── 索引.md
├── 修改/
│ ├── YYYYMMDD_需求总览.md ← 唯一横表(待办+协同+审计)
│ ├── 存客宝_进行中_{YYYYMMDD}.md ← 存客宝当日唯一真源(多节)
│ ├── 触客宝_进行中_{YYYYMMDD}.md
│ ├── 官网_进行中_{YYYYMMDD}.md
│ ├── AI数智员工_进行中_{YYYYMMDD}.md
│ ├── images/
│ └── (无 images/ · 无 已完成/
开发文档/10、项目管理/
├── images/ ← 需求配图
└── 已完成/ ← 关单批次
```
| 规则 | 说明 |
|:---|:---|
| **进行中文件名** | **仅** `{端}_进行中_{YYYYMMDD}.md` |
| **禁止** | 根目录 `存客宝_设置与我的页_*``触客宝_手机_*``SuperAdmin_*_{主题}_*` 等与 `_进行中` 并存 |
| **超管/协同** | SuperAdmin、触客宝登录/BFF 等 **写入 `存客宝_进行中_{YYYYMMDD}.md` 新 §**,不另开 `SuperAdmin_*.md` |
| **文内结构** | `## §X · {功能主题}`:问题 / 根因 / 规则 / 接口 / REQ / 验收 |
| **端名称** | **触客宝**、**存客宝**、**SuperAdmin**、**AI数智员工** |
| **禁止误称** | 不写「初客宝 / 初刻宝 / 纯客宝」 |
**当日示例20260531**
| 端 | 唯一文件 | 节示例 |
|:---|:---|:---|
| 存客宝 | `存客宝_进行中_20260531.md` | §三 设置/我的 · §一 总表 dedupe |
| 触客宝 | `触客宝_进行中_20260531.md` | 功能中心二版 |
| 官网 | `官网_进行中_20260531.md` | REQ-WEB · E1E9 |
---
## 二、完成目标优先
**标题下第一句**必须是:
```markdown
完成目标:【一句话:交付什么、谁受益、如何验收】。
```
再写来源、时间轴、模块、排期。禁止先堆口述原文。
---
## 三、何时启用
| 素材 | 动作 |
|:---|:---|
| 录屏 TXT + 截图 + 草稿 | **追加**对应端 `{项目}_进行中_{日期}.md` 新 §,不新建主题 MD |
| 口述「整理需求」 | 读本文 + 项目经验 `卡若AI/.../项目经验/存客宝.md` |
| 跨端需求 | 拆到四端各一篇 + 总表COORD 写入各端「跨项目协调」节 |
---
## 四、执行流程
```
素材收集 → 完成目标 → 识别端(四端之一) → 图片入 10、项目管理/images/ → 模块拆解 → 落盘 → 更新索引
```
### Step 1 · 确定端与主题
| 端 | 端口 | 主题示例 |
|:---|:---|:---|
| 存客宝 | 3100 H5 | 微信号、场景获客、迁移、设备 |
| 触客宝 | 3101 PC | AI获客、工作台、Webhook |
| SuperAdmin | 3103 | 超管后台、客户池、项目 |
| AI数智员工 | 3104 | 扫码、飞书、协同对接 |
### Step 2 · 图片
- 进行中:`![](images/xxx.png)`(相对 `modify/*.md`
- 命名建议:`YYYYMMDD-模块-序号.png`
- Obsidian`attachmentFolderPath` = `./images`
### Step 3 · 模块拆解(每端 MD 内)
| 块 | 内容 |
|:---|:---|
| 需求总表 | 优先级 + 功能块 + 状态 |
| 分模块章节 | 说明 / 表 / 验收 / 代码路径 |
| **跨项目协调(本端 · W05c** | 见 [需求协调与归档](../需求协调与归档/SKILL.md) |
### Step 4 · 录屏全量包(可选)
大录屏可另存 `10、项目管理/已完成/` 或保留详细节;**当日进行中**仍只维护该端 **一篇** 主题 MD。
---
## 五、标准章节
见 [template.md](template.md)。必含:完成目标、需求总表、模块章节、开发计划、验收清单、变更记录。
---
## 六、与卡若AI / 项目规范
| 环节 | 动作 |
|:---|:---|
| W05b 通用 | 卡若AI `需求整理/SKILL.md`;存客宝以 **本节目录纪律** 为准 |
| W05c 协调 | 共享 `/Users/karuo/Documents/开发/_共享/跨项目协调/索引.md` |
| COORD 分配 | 001/005/006→存客宝001/003→触客宝001/002/003→SuperAdmin001/002/004→AI数智员工 |
| 开发前 | `开发文档/开发铁律_先读文档再编码.md` Phase 0 |
| Git | `cunkebao-local-only-no-github.mdc`,不自动 push |
---
## 七、质量门禁
- [ ] 当日需求已写入 `{端}_进行中_{日期}.md`(无第二份同端 MD
- [ ] 文内含 `完成目标` 或 § 内问题/根因/验收表
- [ ] 第一句 `完成目标:...`
- [ ] 无「初刻宝」等误称
- [ ] 图片 `../../10、项目管理/images/` 存在
- [ ] 本端 COORD 表已写入(有则写,无则注「当前无」)
- [ ] `1、需求/索引.md` 与当日总表已更新
---
## 八、验收后归档
1. 迁入 `10、项目管理/已完成/{项目}_{功能主题}_{日期}/` + `../../10、项目管理/images/`
2. 运行 `python3 开发文档/1、需求/scripts/flatten_requirements.py`(可选)
3. 更新 `YYYYMMDD_需求总览.md`
---
## 九、存客宝官网(独立 Skill
对外品牌站 **不走四端 `{项目}_{主题}_{日期}.md`**,走专题目录:
| 项 | 路径 |
|:---|:---|
| Skill | [.cursor/skills/存客宝官网开发/SKILL.md](../存客宝官网开发/SKILL.md) |
| **进行中真源** | [`修改/官网_进行中_20260531.md`](../修改/官网_进行中_20260531.md) |
| 文档 | `开发文档/1、需求/官网/`0012 编号专文) |
| 代码 | `官网/` 静态站 |
触发词官网需求、官网SEO、产品矩阵、官网留资 → **先读官网 Skill**,再落盘 `官网/``1、需求/官网/*.md`
**复盘链接**:官网任务同样遵守 `cunkebao-karuo-dialogue.mdc`(代码块路径,禁止 obsidian:// 贴聊天)。
---
## 十、经验沉淀
| 日期 | 说明 |
|:---|:---|
| 2026-05-31 | §一 修订:**进行中仅 `{端}_进行中_{date}.md`**;与 W05c 需求协调 Skill 对齐 |
---
## 十一、延伸阅读
- [template.md](template.md) · [examples.md](examples.md)
- [需求协调与归档/SKILL.md](../需求协调与归档/SKILL.md)
- [存客宝官网开发/SKILL.md](../存客宝官网开发/SKILL.md)
- 卡若AI`02_卡人/水泉_规划拆解/需求整理/references/项目经验/存客宝.md`

View File

@@ -1,37 +0,0 @@
# 标杆示例 · 2026-05-26 全量需求
## 文档路径
`开发文档/1、需求/修改/存-20260526_超级后台设备客服与流量池全量需求.md`
## 素材来源对照
| 类型 | 原始路径 | 处理方式 |
|------|----------|----------|
| 录屏 | `/Users/karuo/Downloads/存客宝开发 20260526.txt` | → 第一章时间轴 14 行 |
| 截图夹 | `/Users/karuo/Downloads/20260526/*.png` | → 复制为 `../../10、项目管理/images/20260526-*.png` |
| 草稿 | `修改/存-20260526.md` | → 并入模块 D微信号/流量池) |
## 结构亮点(可复制)
1. **时间轴先行**:开发先懂「口述顺序」,再按端看章节。
2. **mermaid 产品关系**:多系统(超管/存客宝/触客宝/工作手机)必读。
3. **每节固定块**:需求 + 图 + 表 + 验收 + 代码路径。
4. **P0 扫码绑 IMEI**:步骤表 + 链到 20260525 设备管理文档。
5. **无接口原则**:设备列表「无接口不展示」写进表。
6. **Phase 0 六条**:可直接开 sprint。
7. **验收 20+ checkbox**:测试无需再问产品。
## 图片命名对照(节选)
| 标准名 | 原文件名 |
|--------|----------|
| `20260526-superadmin-01.png` | 超级管理后台标注_20260526204146.png |
| `20260526-device-02.png` | 存客宝-我的-设备管理标注_20260526205229.png |
| `20260526-kefu-module-02.png` | 存客宝-我的-新增客户版块标注_20260526210031.png |
## 触发本 Skill 的用户话术示例
- 「把录屏和截图整理成需求文档,放修改目录」
- 「按上次 0526 的格式再做一份需求」
- 「需求要能直接在 Obsidian 看图,开发能照着做」

View File

@@ -1,185 +0,0 @@
# 需求文档标准模板(复制后替换占位符)
> 进行中文件名:`{项目名称}_{功能主题}_{YYYYMMDD}.md`(例 `存客宝_微信号场景迁移与设备_20260529.md`
> 位置:`开发文档/1、需求/修改/`(扁平,无子目录)
> 图片:`开发文档/10、项目管理/images/` · 已完成:`10、项目管理/已完成/{项目}_{主题}_{日期}/images/`
---
```markdown
# {项目名称}_{功能主题}_{YYYYMMDD}
完成目标:【一句话写清本轮最终交付、边界和验收结果。例:把某账号/项目/功能链路整理到可开发、可测试、可验收的稳定状态。】
> **来源**:录屏口述 `【录屏文件名】.txt`(【时长】)+ 标注截图 `【文件夹或说明】` + 草稿 `【草稿文件名,无则删】`
> **日期**YYYY-MM-DD
> **状态**:待开发
> **适用端**:【例:超级管理后台 3103 · 存客宝 H5 3100 · 触客宝】
---
## 一、录屏时间轴(与口述一一对应)
| 时间点 | 模块 | 口述要点 |
|--------|------|----------|
| MM:SS | 【模块名】 | 【一句话需求,**加粗**关键动作】 |
| MM:SSMM:SS | 【模块名】 | 【跨段合并要点】 |
| — | 草稿补充 | 【仅来自草稿、录屏未提的项】 |
---
## 二、目标拆解(先定目标,再拆需求)
| 目标层级 | 完成目标 | 验收标准 |
|----------|----------|----------|
| 总目标 | 【一句话总目标】 | 【最终验收标准】 |
| 子目标 1 | 【模块/数据/页面目标】 | 【可测试结果】 |
| 子目标 2 | 【模块/数据/页面目标】 | 【可测试结果】 |
---
## 三、产品关系(开发必读)
```mermaid
flowchart TB
subgraph A["【系统A】"]
A1[模块]
end
subgraph B["【系统B】"]
B1[模块]
end
A1 --> B1
```
- **【产品甲】**:【与产品乙的关系一句】
- **【关键链路】**:【例:扫码 → IMEI → 入库 → 超管可见】
---
## 四、模块 A · 【端名称 · 如超级管理后台】
### A1. 【功能名】P0/P1
**完成目标**:【本小节完成后达到什么状态】
**需求**:【一句话】
![【图说】](images/YYYYMMDD-模块-01.png)
| 项 | 说明 |
|----|------|
| 业务规则 | 【规则】 |
| 验收 | 【可测试句子】 |
| 建议改动 | 【组件/文件,无则写待勘察】 |
| 接口 | 【有则链 5、接口无则「无接口不展示」】 |
---
### A2. 【功能名】P0
【重复 A1 结构:说明 + 图 + 表 + 验收 + 代码入口】
---
## 五、模块 B · 【端名称】
### B1. 【功能名】P0
**完成目标**:【本小节完成后达到什么状态】
![【图说】](images/YYYYMMDD-module-02.png)
| 功能点 | 详细说明 | 优先级 |
|--------|----------|--------|
| 【点1】 | 【说明】 | P0 |
**代码入口**`【仓库相对路径】`
---
## 六、模块 C · 【可选】
【无则写:本期录屏未涉及,略。】
---
## 七、开发计划(建议排期)
### Phase 0 · 本周必做
| 编号 | 任务 | 端 | 估时 |
|------|------|-----|------|
| P0-1 | 【任务】 | 【SuperAdmin/Cunkebao/Server】 | 【0.5d】 |
### Phase 1 · 下周
| 编号 | 任务 | 端 |
|------|------|-----|
| P1-1 | 【任务】 | 【端】 |
### Phase 2 · 迭代
| 编号 | 任务 |
|------|------|
| P2-1 | 【任务】 |
---
## 八、验收清单(测试可直接勾选)
### 【端名称】
- [ ] 【验收项1】
- [ ] 【验收项2】
---
## 九、代码与文档索引(开发定位)
| 模块 | 路径 |
|------|------|
| 【模块】 | `【文件路径】` |
| 接口真源 | `开发文档/5、接口/存客宝API全量索引.md` |
---
## 十、素材清单(本目录 images
| 文件名 | 用途 |
|--------|------|
| `YYYYMMDD-xxx-01.png` | 【对应章节】 |
> **Obsidian**`![](images/xxx.png)` 与本文同级 `../../10、项目管理/images/` 目录。
---
## 十一、进度
| 维度 | 状态 |
|------|------|
| 需求拆解 | ✅ 已完成 |
| UI 素材归档 | ✅ / ⏳ |
| 开发 | ⏳ 待排期 |
| 接口文档回写 | ⏳ 开发后 |
**维护**:变更写在下方表。
| 日期 | 变更 |
|------|------|
| YYYY-MM-DD | 初版:录屏 + 截图 + 草稿合并 |
```
---
## 模块章节编号约定
| 章 | 内容 |
|----|------|
| 二 | 目标拆解(总目标与子目标) |
| 三 | 产品关系 |
| 四 | 模块 A通常超管 |
| 五 | 模块 B通常存客宝主功能 |
| 六 | 模块 C我的/客服等) |
| 七~十一 | 固定:计划、验收、索引、素材、进度 |
模块超过四个时继续「六、模块 E」**不要**打乱七~十一的固定编号。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -1,177 +0,0 @@
---
name: 触客宝手机端前端壳
description: 触客宝 Touchkebao 手机 H5 壳层开发铁律:顶底固定、禁缩放、键盘不乱动、仅中间滚动。对齐存客宝 index.html+Layout 与 AI数智员工 mobile-terminal。触发词触客宝手机端、mobile shell、viewport、键盘、顶栏固定、底栏Tab、user-scalable、visualViewport、100dvh、乱动、壳固定。
---
# 触客宝 · 手机端前端壳 Skill
> **需求真源**[触客宝_进行中 §98 / TKB-M30](../../开发文档/1、需求/修改/触客宝_进行中_20260602.md)(§十五~§十九 见 [0530归档](../../开发文档/10、项目管理/已完成/触客宝_0530进行中全文归档_20260530/)
> **参照实现**
> - 存客宝 `Cunkebao/index.html` · `Cunkebao/src/components/Layout/`
> - AI数智员工 `AI数智员工/app/layout.tsx` · `globals.css` · `mobile-app-shell.tsx`
> **路由范围**`/mobile/**` · 组件根 `Touchkebao/src/pages/mobile/`
---
## 一、何时启用
| 场景 | 必须读本 Skill |
|:---|:---|
| 新建/改 `/mobile/` 任意页面 | ✅ |
| 改 `Layout.tsx` · TabBar · 顶栏 | ✅ |
| 改聊天输入区 · 键盘交互 · 发送/AI 不遮挡 | ✅ §十八 |
| 改顶栏 · 同步态 · 算力 | ✅ §十六 |
| 改进房预加载 · Dexie 缓存 | ✅ §十七 · **PC+手机双端同一套** |
| 改 ErrorBoundary / 登录跳转 | ✅ §十九 |
| 改 `Touchkebao/index.html` | ✅ |
| 仅改 PC `/pc/` | ❌ 不必 |
---
## 二、壳三层(固定结构)
```
html.tkb-mobile锁滚
└─ #root
└─ .shellfixed · 100dvh · overflow hidden · max-width 420px
├─ .topBar ← flex-shrink:0 · z-50 · safe-area-top
├─ .body ← flex:1 · min-height:0 · 唯一纵向滚动
└─ .tabBar ← flex-shrink:0 · z-50 · safe-area-bottom
```
**全屏子页**(聊天室):`.room` 覆盖 Tab · 结构 `topBar + record(scroll) + enter(fixed bottom)`
---
## 三、index.html 清单(强制)
```html
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="format-detection" content="telephone=no" />
```
**内联 `--real-vh` 脚本**(与存客宝一致):
```javascript
function syncVh() {
var vv = window.visualViewport;
var h = (vv && vv.height) || window.innerHeight || document.documentElement.clientHeight;
document.documentElement.style.setProperty("--real-vh", h * 0.01 + "px");
}
syncVh();
window.addEventListener("resize", syncVh);
window.addEventListener("orientationchange", syncVh);
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", syncVh);
}
```
**触控锁滚**(存客宝同款):
```css
@media (hover: none) and (pointer: coarse) {
html, body { position: fixed; width: 100%; height: 100%; overflow: hidden; touch-action: manipulation; }
#root { width: 100%; height: 100%; overflow: hidden; }
}
```
---
## 四、CSS 变量与高度(强制)
```scss
.shell {
position: fixed;
inset: 0;
max-width: 420px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100dvh;
height: calc(var(--real-vh, 1vh) * 100);
max-height: calc(var(--real-vh, 1vh) * 100);
}
.body {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior-y: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
```
---
## 五、键盘 / 输入(强制)
| 规则 | 说明 |
|:---|:---|
| 输入字号 **≥ 16px** | 防 iOS focus 自动 zoom |
| **不**滚 `body` | 只滚 `.body``.record` |
| `visualViewport` 只更新 `--real-vh` | 禁止改 body top 造成跳变 |
| 输入区 `touch-action: auto` | 壳层 `touch-action: manipulation` |
| 获焦不 unmount 父 Layout | 避免 Tab 栏闪烁 |
---
## 六、禁止清单
-`user-scalable=yes` 或缺少 `maximum-scale=1`
- ❌ 整页 `html/body { overflow-y: auto }`
- ❌ 只用 `100vh` 不用 `--real-vh` / `100dvh`
- ❌ 顶栏 `position: sticky` 代替 flex 壳
- ❌ 多个并列 `position:fixed` 全屏滚动区
- ❌ 键盘弹出时隐藏 TabBar 再显示(闪屏)
---
## 七、新页面开发 Checklist
开发任意 `/mobile/` 页面前 **逐项打勾**
- [ ] 内容在 `.body` 内 · 不在 shell 外再包一层可滚 `min-h-screen`
- [ ] 未新增 `100vh` 撑高 body
- [ ] 弹层 Drawer/Modal 用 `embedded` 或 Portal · 不破坏 shell overflow
- [ ] 输入框字号 ≥ 16px
- [ ] 长列表在 `.body` 内滚 · 不绑 `window` scroll
- [ ] iPhone 真机或 390 smoke捏合不缩放 · 键盘不整页乱跳 · **发送+AI 可见**(§十八)
- [ ] 顶栏:**圆形头像** · 同步时头像闪 · **「· 聊天」保留**(§十六)
- [ ] 需求变更已写入 `触客宝_进行中_20260530.md`(一项目一文件)
---
## 八、验收命令
```bash
node 开发文档/8、部署/scripts/verify_touchkebao_mobile.mjs
# 扩展后应含viewport user-scalable=no · sessionRows · 无横向溢出
```
---
## 九、与需求协调 Skill 关系
| Skill | 职责 |
|:---|:---|
| [需求协调与归档](../需求协调与归档/SKILL.md) | 需求落盘 · 一项目一 `_进行中` |
| **本文** | 手机 H5 **壳层与键盘** 编码铁律 |
| [开发需求文档整理](../开发需求文档整理/SKILL.md) | 录屏→需求 MD |
---
## 十、参照文件速查
| 项目 | 文件 |
|:---|:---|
| 存客宝 viewport | `cunkebao_v3/Cunkebao/index.html` |
| 存客宝 Layout | `Cunkebao/src/components/Layout/Layout.tsx` · `layout.module.scss` |
| AI 终端 globals | `AI数智员工/app/globals.css``.mobile-terminal` · `.mobile-app-frame` |
| AI Shell 组件 | `AI数智员工/app/components/mobile-app-shell.tsx` |
| 触客宝工作台 | `Touchkebao/src/pages/mobile/workbench/Layout.module.scss` |
| 触客宝聊天室 | `Touchkebao/src/pages/mobile/workbench/ChatRoom/index.module.scss` |

View File

@@ -1,197 +0,0 @@
---
name: 需求协调与归档
description: 存客宝四端需求维护与文档收敛一项目一进行中MD、已完成只进10、项目管理/已完成/、关单分拣、COORD、0530维护经验。触发词需求协调、需求归档、四端汇总、已完成、进行中、修改目录整理、一项目一文件、关单、需求维护、项目管理。
---
# 需求协调与归档 Skill存客宝 v3 · 0602 维护版)
> **🚫 不再写需求总览(卡若 0602 定)**:需求**按项目、按当天**直接写进 `{项目}_进行中_{YYYYMMDD}.md`,谁的子项目就写谁的文件,简单记录;**禁止**再建/维护 `YYYYMMDD_需求总览.md` 横表(旧文件留作历史索引)。
> **🚫 不再写未完成项审计(卡若 0603 定)**:禁止 `YYYYMMDD_未完成项审计*.md`**完成度表 + §待卡若验收 + §P0 开发顺序** 写在各端当日 `_进行中`。非当天未完 **迁入当天文件** 后 **删除** 旧 `_进行中``modify/` 只留当天,详文备份在 `10、项目管理/已完成/`)。
> **端真源(唯一)**`开发文档/1、需求/修改/{项目}_进行中_{YYYYMMDD}.md`**每端最多 1 个**
> **已完成总汇**`开发文档/10、项目管理/已完成/YYYYMMDD_四端已完成与全量检测总汇/`**不在根目录、不在 modify/**
> **跨端协同**:写在**发起方**项目 `_进行中`「跨项目协调」节(标 `协同 XXX`),不再单列总览。
> **术语铁律**:统一「知识库」「流量池」;**无「流量词」概念**ASR 误识,纠错库 `卡若AI/运营中枢/参考资料/卡若闽南口音_ASR纠错库.json` 已收 流量词→流量池)。
> **卡若AI 母 Skill**`/Users/karuo/Documents/个人/卡若AI/02_卡人/水泉_规划拆解/需求协调/SKILL.md`
> **单篇整理**[开发需求文档整理/SKILL.md](../开发需求文档整理/SKILL.md)
---
## 一、何时启用
| 场景 | 动作 |
|:---|:---|
| 新 P0/P1 需求(**当日/本轮** | **只追加** `修改/{端}_进行中_{YYYYMMDD}.md` 新 § 节 · **禁止**根目录另开 `{端}_{主题}_{date}.md` |
| 需求写完仅文档 | 在 `_进行中` 内写 **固定规则+验收** · 状态标 ⬜ |
| **官网边开发边勾** | 子项完成即改 `官网_进行中` **⬜→🟡→✅**REQ-WEB-60/61**AI 客服**按 [14 专文 §十一](../../开发文档/1、需求/官网/05-AI与硬件/14-官网AI客服获客场景_20260531.md) 阶段打勾 |
| **存客宝 AI 客服** | 同步 `存客宝_进行中` **REQ-CKB-66** · Server 先于官网 |
| 验收通过 | 迁入 `10、项目管理/已完成/{项目}_{主题}_{date}/` · 更新 **已完成总汇** §索引 |
| 端 100% 关单 | **删除** 根目录 `{端}_进行中` · 总汇保留链接 |
| 日终 / 分拣 | 各端 `_进行中` 内更新状态 · 已完成项 **移出** 根目录(**不维护总览** |
| 跨端依赖 | COORD 写入 **各端 `_进行中`**「跨项目协调」小节 |
---
## 二、目录纪律2026-06-02 · 已完成迁入项目管理)
```
开发文档/1、需求/修改/
├── README.md
├── 存客宝_进行中_YYYYMMDD.md ← 需求只写各端进行中(不写总览)
├── 触客宝_进行中_YYYYMMDD.md
├── 官网_进行中_YYYYMMDD.md
└── AI数智员工_进行中_YYYYMMDD.md (有未完才建)
# YYYYMMDD_需求总览.md ← 已停用 · 仅历史索引,勿再维护
开发文档/10、项目管理/
├── README.md
├── images/ ← 需求截图(禁止 modify/images
├── 已完成/ ← 关单批次唯一落点
│ ├── README.md
│ └── {项目}_{功能主题}_{YYYYMMDD}/
├── 开发进度_*.md
└── scripts/
```
### 禁止(本轮维护教训)
| 禁止 | 正确做法 |
|:---|:---|
| `modify/已完成/` · `modify/images/` | `10、项目管理/已完成/` · `10、项目管理/images/` |
| 写「需求总览/横表」 | **不写总览**;需求按项目当天写进 `{项目}_进行中_{YYYYMMDD}.md` |
| 文档里写「流量词」 | 统一「**流量池**」ASR 误识,无流量词概念) |
| 根目录 `四端已完成*.md` | 只放 `10、项目管理/已完成/…/` |
| 新建 `10、使用手册/` | 只写 `9、手册/使用手册/…` |
| 同项目两个 MD`_进行中` + 专文) | P0 全文 **并入** `_进行中` 新章节 · 删/归档专文 |
| 根目录 `SuperAdmin_{主题}_{date}.md` | 写入 **`存客宝_进行中_{date}.md` §** |
| 文件名 初刻宝/初客宝/触刻版 | 统一 **触客宝** |
| COORD 只写横表 | **必须**写入各端 `_进行中` |
---
## 三、0530 维护期经验(固定写法)
### 3.1 需求文档结构P0 块)
每个 P0 在 `_进行中` 内必须含:
1. **问题表**(卡若原话 / 截图路径)
2. **根因**(代码文件 · 现状 vs 期望)
3. **固定规则**(归一化、合并键、禁止项 · **不可写抽象**
4. **接口清单**(不变 URL
5. **代码锚点**(待建/已有路径)
6. **验收表**(可数字 · V1Vn
### 3.2 只写文档不开发
- 状态标 **「P0 · 仅文档 · 待开发」**
- 同步 `开发文档/5、接口/开发进度_接口文档.md` 顶部一节
- **不**为专文单独占根目录文件名
### 3.3 已完成 vs 进行中边界
| 状态 | 放哪 |
|:---|:---|
| 代码 ✅ · 待目视 🟡 | 进行中留一行 · 细节进总汇或 `10、项目管理/已完成/` 批次 |
| 代码 ✅ · 验收 ✅ | 移入 `10、项目管理/已完成/` · 从 `_进行中` **删**该行 |
| 运营 P2签字/Key 轮换) | 写横表 · **不**占 P0 开发 slot |
| 全端 100% | 无 `_进行中` · 链总汇 §二 |
### 3.4 截图与 images
- 进行中:`10、项目管理/images/{项目}_{现象}_{YYYYMMDD}.png`
- 关单迁 `10、项目管理/已完成/{批次}/images/`**改相对路径**
---
## 四、COORD 分配(写入各端 `_进行中`
| COORD | 简述 | 写入文档 |
|:---|:---|:---|
| 001 | SuperAdmin 开放 API | 存客宝、SuperAdmin、触客宝、AI数智员工 |
| 006 | 设备/微信项目同源 | **存客宝、触客宝** |
(其余见历史表 · 各端「跨项目协调」小节)
---
## 五、关单流程(逐步)
```
1. 开发/文档完成 → 更新 {端}_进行中 状态 ✅
2. 全文批次 → 10、项目管理/已完成/{项目}_{主题}_{date}/同名.md
3. 更新 已完成/…/四端已完成总汇.md §索引
4. 从 _进行中 删除已完成行;端无未完 → 删除 _进行中 文件
5. 更新 YYYYMMDD_需求总览.md
6. 更新 5、接口/开发进度_接口文档.md
7. 更新 10、项目管理/已完成/README.md 表格一行
8. 禁止 git push除非卡若本轮明确说提交
```
### 脚本(可选)
```bash
python3 开发文档/1、需求/scripts/flatten_requirements.py
python3 开发文档/1、需求/scripts/reorganize_requirement_docs.py
```
---
## 六、开发前检查
1. 读待开发横表 + **本端唯一** `_进行中`
2.**跨项目协调**
3. 对话列出:已读文档 ≤5 条
4. 确认 **没有** 第二份同端 MD
5. 关单目标路径为 **`10、项目管理/已完成/`**
---
## 七、质量门禁
- [ ] 根目录无 `*已完成*` MD总汇只在 `10、项目管理/已完成/`
- [ ] 每端 **≤1** 个 `_进行中``modify/`
- [ ] **禁止**`10、项目管理/已完成/` 新建批次(仅跳转 README
- [ ] P0 块含:问题 / 根因 / 固定规则 / 验收
- [ ] 已完成批次在 `10、项目管理/已完成/{项目}_{主题}_{date}/` 且图片不断链
- [ ] `10、项目管理/README.md``已完成/README.md` 已更新
---
## 八、与卡若AI 分工
| Skill | 职责 |
|:---|:---|
| **本文** | 一项目一文件 · **已完成进 10、项目管理** · 关单分拣 |
| 开发需求文档整理 | 录屏→全量 MD · 落盘纪律以本文为准 |
---
## 九、当日需求落盘Agent 必背)
```
卡若提新需求
→ 打开 modify/{端}_进行中_{YYYYMMDD}.md 新 §
→ 更新 待开发清单 一行
→ 截图 → 10、项目管理/images/
→ 禁止 新建 modify/{端}_{主题}_{YYYYMMDD}.md
关单后
→ 迁入 10、项目管理/已完成/{项目}_{主题}_{date}/
```
---
## 十、经验沉淀
| 日期 | 教训 |
|:---|:---|
| 2026-06-02 | 十大目录收口:已完成+配图→`10、项目管理` · 三表→`需求总览` · `10、使用手册``9、手册/使用手册` |
| 2026-06-02 | CKB-90流量分发多子账号轮询 + 客服选择 UI 与好友分配一致 |
---
## 十一、延伸阅读
- [修改/README.md](../../开发文档/1、需求/修改/README.md)
- [10、项目管理/README.md](../../开发文档/10、项目管理/README.md)
- [已完成/README.md](../../开发文档/10、项目管理/已完成/README.md)
- [四端对接铁律](../../开发文档/2、架构/四端对接铁律_只HTTP不串仓.md)

51
.github/workflows/main.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Sync from Coding
on:
schedule:
- cron: '0 */2 * * *' # 每2小时执行一次
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout GitHub develop 分支
uses: actions/checkout@v4
with:
ref: develop
- name: 配置 Git 用户信息
run: |
git config user.name "zhiqun@qq.com"
git config user.email "zhiqun@qq.com"
- name: 添加 coding-origin 并获取最新代码
run: |
git remote add coding-origin https://${{ secrets.CODING_USERNAME }}:${{ secrets.CODING_TOKEN }}@e.coding.net/g-xtcy5189/cunkebao/cunkebao_v3.git
git fetch coding-origin develop
- name: 使用远程分支强制合并(保留 theirs
run: |
# 尝试合并,如果失败就自动解决冲突
git merge --no-edit --allow-unrelated-histories coding-origin/develop || true
# 检查是否有冲突文件
if [ -n "$(git ls-files -u)" ]; then
echo "检测到冲突,自动使用远程分支内容解决"
# 对所有冲突文件强制使用远程分支版本theirs
for file in $(git diff --name-only --diff-filter=U); do
git checkout --theirs "$file"
git add "$file"
done
# 提交自动合并结果
git commit -m "Auto-merged coding-origin/develop with theirs resolution"
fi
- name: 推送回 GitHub develop 分支
run: |
git push origin develop

34
.gitignore vendored
View File

@@ -1,20 +1,22 @@
.idea/
Cunkebao/.next/
Store_vue/node_modules/
*.zip
Cunkebao/.specstory/
*.cursorindexingignore
Server/.specstory/
Server/thinkphp/
Store_vue/.specstory/
Store_vue/unpackage/
*.js
Store_vue/node_modules/
Server/.specstory/
Cunkebao/.next/
Cunkebao/.specstory/
.idea/
*.zip
*.cursorindexingignore
Store_vue/.specstory/
Store_vue/.vscode/
SuperAdmin/.specstory/
Cunkebao/dist
Touchkebao/.specstory/
Serverruntime/
Moncter/提示词/
node_modules
node_modules/
.next/
out/
dist/
*.log
*.code-workspace
项目整体分析报告.md
Server/docs/traffic_pool_design.md
.DS_Store
android/app/build/
android/.gradle/
android/local.properties

13
.obsidian/app.json vendored
View File

@@ -1,13 +0,0 @@
{
"pdfExportSettings": {
"pageSize": "Letter",
"landscape": false,
"margin": "0",
"downscalePercent": 100
},
"promptDelete": false,
"attachmentFolderPath": "./images",
"useMarkdownLinks": true,
"newLinkFormat": "relative",
"alwaysUpdateLinks": true
}

View File

@@ -1,5 +0,0 @@
{
"enabledCssSnippets": [
"cunkebao-devdoc"
]
}

View File

@@ -1,33 +0,0 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}

93
.obsidian/graph.json vendored
View File

@@ -1,93 +0,0 @@
{
"collapse-filter": false,
"search": "path:开发文档",
"showTags": true,
"showAttachments": false,
"hideUnresolved": false,
"showOrphans": false,
"collapse-color-groups": false,
"colorGroups": [
{
"query": "path:开发文档/2、架构",
"color": {
"a": 1,
"rgb": 14010688
}
},
{
"query": "path:开发文档/1、需求",
"color": {
"a": 1,
"rgb": 6178001
}
},
{
"query": "path:开发文档/3、原型",
"color": {
"a": 1,
"rgb": 6178001
}
},
{
"query": "path:开发文档/4、前端",
"color": {
"a": 1,
"rgb": 162529
}
},
{
"query": "path:开发文档/5、接口",
"color": {
"a": 1,
"rgb": 162529
}
},
{
"query": "path:开发文档/6、后端",
"color": {
"a": 1,
"rgb": 162529
}
},
{
"query": "path:开发文档/7、数据库",
"color": {
"a": 1,
"rgb": 5606192
}
},
{
"query": "path:开发文档/8、部署",
"color": {
"a": 1,
"rgb": 5606192
}
},
{
"query": "path:开发文档/9、手册",
"color": {
"a": 1,
"rgb": 14010688
}
},
{
"query": "path:开发文档/10、使用手册",
"color": {
"a": 1,
"rgb": 12976168
}
}
],
"collapse-display": true,
"showArrow": true,
"textFadeMultiplier": 0,
"nodeSizeMultiplier": 1,
"lineSizeMultiplier": 1,
"collapse-forces": true,
"centerStrength": 0.5,
"repelStrength": 10,
"linkStrength": 1,
"linkDistance": 200,
"scale": 0.17544920373570672,
"close": true
}

View File

@@ -1,124 +0,0 @@
/* 存客宝 v3 · 开发文档 Obsidian 目录配色
* 启用Settings → Appearance → CSS snippets → cunkebao-devdoc
* 依赖Obsidian 文件资源管理器 nav-folder-title data-path 属性
* 规范:与工作手机 workphone-devdoc.css 四类分类色对齐
*/
/* ── 一、需求与架构 ── */
.nav-folder-title[data-path="开发文档/1、需求"] > .nav-folder-title-content {
color: #5E35B1 !important;
font-weight: 600;
}
.nav-folder-title[data-path="开发文档/2、架构"] > .nav-folder-title-content {
color: #1565C0 !important;
font-weight: 600;
}
.nav-folder-title[data-path="开发文档/3、原型"] > .nav-folder-title-content {
color: #5E35B1 !important;
font-weight: 600;
}
/* ── 二、开发与接口 ── */
.nav-folder-title[data-path="开发文档/4、前端"] > .nav-folder-title-content {
color: #0277BD !important;
font-weight: 600;
}
.nav-folder-title[data-path="开发文档/4、前端/UI"] > .nav-folder-title-content {
color: #0288D1 !important;
}
.nav-folder-title[data-path="开发文档/5、接口"] > .nav-folder-title-content {
color: #0277BD !important;
font-weight: 600;
}
.nav-folder-title[data-path^="开发文档/5、接口/0"] > .nav-folder-title-content {
color: #0288D1 !important;
}
.nav-folder-title[data-path="开发文档/5、接口/images"] > .nav-folder-title-content {
color: #78909C !important;
}
.nav-folder-title[data-path="开发文档/5、接口/02-存客宝与工作手机对接"] > .nav-folder-title-content {
color: #FFB300 !important;
font-weight: 600;
}
.nav-folder-title[data-path^="开发文档/5、接口/02-存客宝与工作手机对接/04-CodeGraph"] > .nav-folder-title-content {
color: #78909C !important;
}
.nav-folder-title[data-path="开发文档/6、后端"] > .nav-folder-title-content {
color: #0277BD !important;
font-weight: 600;
}
/* ── 三、部署与数据 ── */
.nav-folder-title[data-path="开发文档/7、数据库"] > .nav-folder-title-content {
color: #558B2F !important;
font-weight: 600;
}
.nav-folder-title[data-path="开发文档/8、部署"] > .nav-folder-title-content {
color: #558B2F !important;
font-weight: 600;
}
.nav-folder-title[data-path="开发文档/8、部署/scripts"] > .nav-folder-title-content {
color: #689F38 !important;
}
.nav-folder-title[data-path="开发文档/8、部署/01-本地环境"] > .nav-folder-title-content {
color: #689F38 !important;
}
.nav-folder-title[data-path="开发文档/8、部署/02-数据库同步"] > .nav-folder-title-content {
color: #558B2F !important;
}
.nav-folder-title[data-path="开发文档/8、部署/03-运维与CI"] > .nav-folder-title-content {
color: #558B2F !important;
}
.nav-folder-title[data-path="开发文档/8、部署/04-部署复盘"] > .nav-folder-title-content {
color: #689F38 !important;
}
.nav-folder-title[data-path="开发文档/8、部署/05-测试验收"] > .nav-folder-title-content {
color: #F9A825 !important;
font-weight: 600;
}
.nav-folder-title[data-path^="开发文档/8、部署/05-测试验收/"] > .nav-folder-title-content {
color: #FFB300 !important;
}
/* ── 四、手册与使用 ── */
.nav-folder-title[data-path="开发文档/9、手册"] > .nav-folder-title-content {
color: #1565C0 !important;
font-weight: 600;
}
.nav-folder-title[data-path="开发文档/9、手册/提示词"] > .nav-folder-title-content {
color: #0288D1 !important;
}
.nav-folder-title[data-path="开发文档/10、使用手册"] > .nav-folder-title-content {
color: #C62828 !important;
font-weight: 600;
}
.nav-folder-title[data-path="开发文档/10、使用手册/存客宝"] > .nav-folder-title-content {
color: #E65100 !important;
font-weight: 600;
}
.nav-folder-title[data-path="开发文档/10、使用手册/存客宝/工作台"] > .nav-folder-title-content {
color: #FF6F00 !important;
}
.nav-folder-title[data-path="开发文档/10、使用手册/截图"] > .nav-folder-title-content {
color: #78909C !important;
}
/* ── 辅助目录 ── */
.nav-folder-title[data-path="开发文档/工作手机对接"] > .nav-folder-title-content {
color: #90A4AE !important;
}
.nav-folder-title[data-path="开发文档/scripts"] > .nav-folder-title-content {
color: #546E7A !important;
}
/* 文档说明头图 */
.markdown-preview-view img[alt="文档说明"],
.markdown-source-view img[alt="文档说明"] {
display: block;
width: 100%;
max-width: 920px;
margin: 0.5em 0 1.2em;
border-radius: 10px;
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.08);
border: 1px solid rgba(148, 163, 184, 0.35);
}

View File

@@ -1,235 +0,0 @@
{
"main": {
"id": "413f86c90d0909cc",
"type": "split",
"children": [
{
"id": "984914435f6a9181",
"type": "tabs",
"children": [
{
"id": "4301052b31068085",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "开发文档/1、需求/修改/存客宝_进行中_20260603.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "存客宝_进行中_20260603"
}
}
]
}
],
"direction": "vertical"
},
"left": {
"id": "28b5f5be79ea5491",
"type": "split",
"children": [
{
"id": "03d49e20527f98d9",
"type": "tabs",
"children": [
{
"id": "b4bd2ff5d3f2ec66",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
},
"icon": "lucide-folder-closed",
"title": "文件列表"
}
},
{
"id": "a8ab05904e453c81",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "tag:#存客宝",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "搜索"
}
},
{
"id": "1b4c70b94399d9a5",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "书签"
}
}
]
}
],
"direction": "horizontal",
"width": 276.5004348754883
},
"right": {
"id": "213cf3c58fffb77e",
"type": "split",
"children": [
{
"id": "b9b3f94b382e2801",
"type": "tabs",
"children": [
{
"id": "7fb9573cc08dd33f",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"file": "开发文档/1、需求/修改/20260530_四端已完成与全量检测总汇.md",
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "20260530_四端已完成与全量检测总汇 的反向链接列表"
}
},
{
"id": "6e4f013221e7c63d",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"file": "开发文档/项目文档生成器_核心提示词.md",
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "项目文档生成器_核心提示词 的出链列表"
}
},
{
"id": "7b4a28550b01a9ca",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "标签"
}
},
{
"id": "b9587677fcf19822",
"type": "leaf",
"state": {
"type": "all-properties",
"state": {
"sortOrder": "frequency",
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-archive",
"title": "添加笔记属性"
}
},
{
"id": "9a63f6e4237e033b",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "开发文档/项目文档生成器_核心提示词.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "项目文档生成器_核心提示词 的大纲"
}
}
]
}
],
"direction": "horizontal",
"width": 300,
"collapsed": true
},
"left-ribbon": {
"hiddenItems": {
"switcher:打开快速切换": false,
"graph:查看关系图谱": false,
"canvas:新建白板": false,
"daily-notes:打开/创建今天的日记": false,
"templates:插入模板": false,
"command-palette:打开命令面板": false,
"bases:新建数据库": false
}
},
"active": "4301052b31068085",
"lastOpenFiles": [
"Touchkebao/dist/xiaobao-ai.png",
"Touchkebao/dist/websdk.js",
"Touchkebao/dist/manifest.json",
"Touchkebao/dist/logo.png",
"Touchkebao/dist/index.html",
"Touchkebao/dist/assets/vendor-fZy-8e4I.js",
"Touchkebao/dist/assets/utils-Bc0vVQOI.js",
"Touchkebao/dist/assets/ui-DsBy32W5.js",
"Touchkebao/dist/assets/ui-DmZQDU-f.css",
"Touchkebao/dist/assets/other/wither.png",
"Touchkebao/dist/assets/other/sun.png",
"Touchkebao/dist/assets/other/rose.png",
"Touchkebao/dist/assets/other/poop.png",
"Touchkebao/dist/assets/other/moon.png",
"Touchkebao/dist/assets/other/lips.png",
"Touchkebao/dist/assets/other/knife.png",
"Touchkebao/dist/assets/other/heart.png",
"Touchkebao/dist/assets/other/coffee.png",
"Touchkebao/dist/assets/other",
"Touchkebao/dist/assets/index-DfxITDxP.css",
"Touchkebao/dist/assets/index-DHDH-NpT.js",
"开发文档/8、部署/开发进度_五端全量验收_20260603.md",
"开发文档/1、需求/修改/官网_进行中_20260603.md",
"开发文档/1、需求/修改/未完成项与跨仓验收汇总.md",
"开发文档/10、项目管理/已完成/触客宝_0603日终复盘_20260603.md",
"Server/public/uploads/knowledge/914/ingest/20260603_203119_10_转化话术C_3.md",
"Server/public/uploads/knowledge/914/ingest/20260603_203119_10_引流玩法B_2.md",
"Server/public/uploads/knowledge/914/ingest/20260603_203119_10_测试套餐A_1.md",
"开发文档/6、后端/核心算法清单.md",
"开发文档/6、后端/核心算法图解手册.md",
"开发文档/10、项目管理/账号密码与登录环境一览.md",
"开发文档/10、项目管理/开发进度.md",
"Server/public/uploads/knowledge/914/ingest/20260603_184809_10_转化话术C_3.md",
"Server/public/uploads/knowledge/914/ingest/20260603_184809_10_引流玩法B_2.md",
"Server/public/uploads/knowledge/914/ingest/20260603_184809_10_测试套餐A_1.md",
"Server/public/uploads/knowledge/914/ingest/20260603_184041_10_转化话术C_3.md",
"Server/public/uploads/knowledge/914/ingest/20260603_184041_10_引流玩法B_2.md",
"Server/public/uploads/knowledge/914/ingest/20260603_184041_10_测试套餐A_1.md",
"开发文档/1、需求/修改/AI数智员工_进行中_20260603.md",
"Server/public/uploads/knowledge/914/ingest/20260603_183530_10_转化话术C_3.md",
"Server/public/uploads/knowledge/914/ingest/20260603_183530_10_引流玩法B_2.md",
"Server/public/uploads/knowledge/914/ingest/20260603_183530_10_测试套餐A_1.md",
"开发文档/1、需求/修改/存客宝_进行中_20260603.md",
"开发文档/1、需求/修改/触客宝_进行中_20260603.md",
"Server/public/uploads/knowledge/914/ingest/20260603_172928_13_转化话术C_3.md",
"Server/public/uploads/knowledge/914/ingest/20260603_172928_13_测试套餐A_1.md"
]
}

4
Cunkebao/.babelrc Normal file
View File

@@ -0,0 +1,4 @@
{
"presets": ["next/babel"]
}

View File

@@ -1,9 +0,0 @@
node_modules
dist
.git
.env
.env.*
*.log
.DS_Store
coverage
.vite

View File

@@ -1,12 +0,0 @@
# 开发环境直接使用线上 API腾讯云数据库
# 如需使用本地后端,改为 VITE_API_BASE_URL=/api 并确保 vite.config.ts 代理配置正确
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
VITE_APP_TITLE=存客宝
# 开发环境自动登录配置(仅开发环境使用,生产环境禁止使用)
# WARNING: 此配置仅用于开发调试,正式环境部署时必须删除或禁用
VITE_DEV_AUTO_LOGIN=true
VITE_DEV_ACCOUNT=admin
VITE_DEV_PASSWORD=k123456

View File

@@ -1 +0,0 @@
NEXT_PUBLIC_API_BASE_URL= http://yishi.com

View File

@@ -1,6 +0,0 @@
# 基础环境变量示例
VITE_API_BASE_URL=https://ckbapi.quwanzhi.com
VITE_API_BASE_URL2=https://kf.quwanzhi.com:9991
VITE_API_WS_URL=wss://kf.quwanzhi.com:9993
# VITE_API_BASE_URL=http://www.yishi.com
VITE_APP_TITLE=存客宝

View File

@@ -1,64 +0,0 @@
module.exports = {
root: true,
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended", // 这个配置会自动处理大部分冲突
],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 12,
sourceType: "module",
},
plugins: ["react", "react-hooks", "@typescript-eslint", "prettier"],
rules: {
"prettier/prettier": "error",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unnecessary-type-constraint": "warn",
"react/prop-types": "off",
"linebreak-style": "off",
"eol-last": "off",
"no-empty": "warn",
"prefer-const": "warn",
// 确保与 Prettier 完全兼容
"comma-dangle": "off",
"comma-spacing": "off",
"comma-style": "off",
"object-curly-spacing": "off",
"array-bracket-spacing": "off",
indent: "off",
quotes: "off",
semi: "off",
"arrow-parens": "off",
"no-multiple-empty-lines": "off",
"max-len": "off",
"space-before-function-paren": "off",
"space-before-blocks": "off",
"keyword-spacing": "off",
"space-infix-ops": "off",
"space-in-parens": "off",
"space-in-brackets": "off",
"object-property-newline": "off",
"array-element-newline": "off",
"function-paren-newline": "off",
"object-curly-newline": "off",
"array-bracket-newline": "off",
},
settings: {
react: {
version: "detect",
},
},
};

View File

@@ -1,27 +0,0 @@
# 设置默认行为如果core.autocrlf没有设置Git会自动处理行尾符
* text=auto
# 明确指定文本文件使用LF
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
# 二进制文件
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.svg binary
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary

32
Cunkebao/.gitignore vendored
View File

@@ -1,11 +1,23 @@
node_modules/
dist/
build/
yarn.lock
.env
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
dist/*
.cursorindexingignore
*.zip
.idea/
.next/
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

1
Cunkebao/.htaccess Normal file
View File

@@ -0,0 +1 @@
# 请将伪静态规则或自定义Apache配置填写到此处

View File

@@ -1,13 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf",
"bracketSpacing": true,
"arrowParens": "avoid",
"jsxSingleQuote": false,
"quoteProps": "as-needed"
}

View File

@@ -1,11 +0,0 @@
{
"recommendations": [
"esbenp.prettier-vscode",
"dbaeumer.vscode-eslint",
"bradlc.vscode-tailwindcss",
"ms-vscode.vscode-typescript-next",
"formulahendry.auto-rename-tag",
"christian-kohler.path-intellisense",
"ms-vscode.vscode-json"
]
}

View File

@@ -1,45 +0,0 @@
{
"files.eol": "\n",
"files.insertFinalNewline": true,
"files.trimFinalNewlines": true,
"files.trimTrailingWhitespace": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "never"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.format.enable": false,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[scss]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.suggest.autoImports": true,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.detectIndentation": false
}

View File

@@ -0,0 +1 @@
nKNDFrnOs9-cf7FZzW7Cg4MpIxhtE8pZ1etiT_7NPWA.2wKeISBpPoGcCt72TdgVrBMxtnnieXof2OZkRdHKCJI

View File

@@ -1,23 +0,0 @@
# 存客宝前端 - React + Vite
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm install -g pnpm
ARG VITE_API_BASE_URL=
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

266
Cunkebao/README.md Normal file
View File

@@ -0,0 +1,266 @@
# 内客宝 - 智能获客管理平台
## 📋 项目简介
内客宝是一个专业的微信获客和流量管理平台,基于 React 技术栈构建。平台提供智能化的客户获取、管理和运营解决方案,集成了多种自动化工具,帮助企业高效管理微信营销活动。
## 🚀 技术栈详解
### 核心框架
- **React 18.2.0** - 现代化的用户界面库
- **TypeScript 4.9.5** - 类型安全的 JavaScript 超集
- **Create React App (CRA) 5.0.1** - React 应用脚手架
- **React Router DOM 6.20.0** - 客户端路由管理
### 构建工具
- **CRACO 7.1.0** - Create React App Configuration Override
- 支持自定义 webpack 配置
- 路径别名配置
- 构建优化
### UI 组件库
- **Radix UI** - 无样式的可访问组件库
- 完整的组件生态系统30+ 组件)
- 优秀的无障碍访问支持
- 高度可定制
- **Tailwind CSS 3.4.17** - 实用优先的 CSS 框架
- 响应式设计支持
- 自定义主题配置
- 原子化 CSS 类
### 图标和样式
- **Lucide React 0.454.0** - 精美的图标库
- **Tailwind CSS Animate** - CSS 动画库
- **Class Variance Authority** - 组件变体管理
- **Tailwind Merge** - Tailwind 类名合并工具
### 状态管理和表单
- **React Hook Form 7.54.1** - 高性能表单库
- **Zod 3.24.1** - TypeScript 优先的模式验证
- **@hookform/resolvers 3.9.1** - 表单验证解析器
### 数据可视化
- **Recharts** - 基于 React 的图表库
- **Chart.js 4.5.0** - 灵活的图表库
- **@ant-design/plots** - Ant Design 图表组件
### HTTP 请求和数据处理
- **Axios 1.6.0** - HTTP 客户端
- **Crypto-js 4.2.0** - 加密库
- **Date-fns** - 日期处理库
- **XLSX 0.18.5** - Excel 文件处理
### 通知和反馈
- **React Hot Toast 2.5.2** - 轻量级通知库
- **Sonner 1.7.4** - 现代化 Toast 组件
### 高级组件
- **@tanstack/react-table** - 功能强大的表格组件
- **Embla Carousel React 8.5.1** - 轮播组件
- **React Resizable Panels 2.1.7** - 可调整大小的面板
- **Vaul 0.9.6** - 抽屉组件
- **Input OTP 1.4.1** - OTP 输入组件
- **React Day Picker** - 日期选择器
### 开发工具
- **PostCSS 8** - CSS 后处理器
- **Autoprefixer 10.4.20** - CSS 前缀自动添加
- **ESLint** - 代码质量检查
- **Jest** - 单元测试框架
- **Testing Library** - React 测试工具
## 📁 项目结构
```
nkebao/
├── public/ # 静态资源
├── src/ # 源代码
│ ├── api/ # API 接口封装
│ ├── components/ # 全局组件
│ │ ├── ui/ # UI 基础组件
│ │ └── icons/ # 图标组件
│ ├── config/ # 配置文件
│ ├── contexts/ # React Context
│ ├── hooks/ # 自定义 Hooks
│ ├── pages/ # 页面组件
│ │ ├── workspace/ # 工作台模块
│ │ │ ├── auto-like/ # 自动点赞
│ │ │ ├── auto-group/ # 自动建群
│ │ │ ├── group-push/ # 群消息推送
│ │ │ ├── moments-sync/ # 朋友圈同步
│ │ │ ├── ai-assistant/ # AI 对话助手
│ │ │ └── traffic-distribution/ # 流量分发
│ │ ├── devices/ # 设备管理
│ │ ├── scenarios/ # 场景管理
│ │ ├── content/ # 内容管理
│ │ └── ...
│ ├── types/ # TypeScript 类型定义
│ ├── utils/ # 工具函数
│ ├── App.tsx # 应用根组件
│ └── index.tsx # 应用入口
├── craco.config.js # CRACO 配置
├── tailwind.config.js # Tailwind CSS 配置
├── tsconfig.json # TypeScript 配置
└── package.json # 项目依赖
```
## 🎯 核心功能模块
### 工作台 (Workspace)
- **自动点赞** - 智能点赞管理和配置
- **自动建群** - 群组自动化创建和管理
- **群消息推送** - 群组消息批量发送
- **朋友圈同步** - 内容同步和发布
- **AI 对话助手** - 智能客服和对话管理
- **流量分发** - 流量分配和策略管理
### 设备管理 (Devices)
- 设备状态监控和配置
- 设备性能分析
- 设备权限管理
### 场景管理 (Scenarios)
- 营销场景配置
- 自动化流程设计
- 场景效果分析
### 内容管理 (Content)
- 内容创建与编辑
- 内容模板管理
- 内容发布调度
### 其他模块
- 用户管理 (Users)
- 订单管理 (Orders)
- 流量池管理 (Traffic Pool)
- 联系人导入 (Contact Import)
## 🛠️ 开发指南
### 环境要求
- **Node.js** 16+
- **npm** 或 **yarn**
### 安装依赖
```bash
# 使用 npm
npm install
# 使用 yarn
yarn install
```
### 开发环境启动
```bash
# 使用 npm
npm start
# 使用 yarn
yarn start
```
### 构建生产版本
```bash
# 使用 npm
npm run build
# 使用 yarn
yarn build
```
### 运行测试
```bash
# 使用 npm
npm test
# 使用 yarn
yarn test
```
## 🔧 配置说明
### 路径别名配置
项目使用 CRACO 配置了路径别名:
```javascript
'@': path.resolve(__dirname, 'src'),
'@/components': path.resolve(__dirname, 'src/components'),
'@/api': path.resolve(__dirname, 'src/api'),
'@/types': path.resolve(__dirname, 'src/types'),
'@/hooks': path.resolve(__dirname, 'src/hooks'),
'@/utils': path.resolve(__dirname, 'src/utils'),
'@/styles': path.resolve(__dirname, 'src/styles'),
'@/pages': path.resolve(__dirname, 'src/pages'),
```
### Tailwind CSS 配置
- 自定义字体大小和间距
- 响应式断点配置
- 主题颜色系统
### TypeScript 配置
- 严格模式启用
- 路径映射配置
- JSX 支持
## 📱 响应式设计
项目采用移动优先的响应式设计:
- 支持桌面端、平板端、移动端
- 自适应布局组件
- 触摸友好的交互设计
## 🎨 UI 设计系统
### 设计原则
- 简洁现代的设计风格
- 一致的用户体验
- 无障碍访问支持
### 组件库特点
- 基于 Radix UI 的高质量组件
- 完整的表单组件系统
- 数据展示组件
- 导航和布局组件
## 🔒 安全特性
- 身份验证和授权
- API 请求拦截
- 数据验证和清理
- 加密功能支持
## 📊 性能优化
- 代码分割和懒加载
- 组件优化
- 缓存策略
- 包大小优化
## 🧪 测试策略
- 单元测试 (Jest + Testing Library)
- 组件测试
- 集成测试支持
## 🤝 贡献指南
1. Fork 项目
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request
## 📄 许可证
本项目采用 MIT 许可证。
## 📞 联系方式
如有问题或建议,请联系开发团队。
---
**项目名称**: 内客宝 (nkebao2)
**版本**: 0.1.0
**技术栈**: React + TypeScript + CRA + Tailwind CSS
**最后更新**: 2024年12月

192
Cunkebao/api/devices.ts Normal file
View File

@@ -0,0 +1,192 @@
import { api } from "@/lib/api";
import type {
ApiResponse,
Device,
DeviceStats,
DeviceTaskRecord,
PaginatedResponse,
QueryDeviceParams,
CreateDeviceParams,
UpdateDeviceParams,
DeviceStatus,
ServerDevice,
ServerDevicesResponse
} from "@/types/device"
const API_BASE = "/api/devices"
// 获取设备列表 - 连接到服务器/v1/devices接口
export const fetchDeviceList = async (page: number = 1, limit: number = 20, keyword?: string): Promise<ServerDevicesResponse> => {
const params = new URLSearchParams();
params.append('page', page.toString());
params.append('limit', limit.toString());
if (keyword) {
params.append('keyword', keyword);
}
return api.get<ServerDevicesResponse>(`/v1/devices?${params.toString()}`);
};
// 获取设备详情 - 连接到服务器/v1/devices/:id接口
export const fetchDeviceDetail = async (id: string | number): Promise<ApiResponse<any>> => {
return api.get<ApiResponse<any>>(`/v1/devices/${id}`);
};
// 获取设备关联的微信账号
export const fetchDeviceRelatedAccounts = async (id: string | number): Promise<ApiResponse<any>> => {
return api.get<ApiResponse<any>>(`/v1/wechats/related-device/${id}`);
};
// 获取设备操作记录
export const fetchDeviceHandleLogs = async (id: string | number, page: number = 1, limit: number = 10): Promise<ApiResponse<any>> => {
return api.get<ApiResponse<any>>(`/v1/devices/${id}/handle-logs?page=${page}&limit=${limit}`);
};
// 更新设备任务配置
export const updateDeviceTaskConfig = async (
config: {
deviceId: string | number;
autoAddFriend?: boolean;
autoReply?: boolean;
momentsSync?: boolean;
aiChat?: boolean;
}
): Promise<ApiResponse<any>> => {
return api.post<ApiResponse<any>>(`/v1/devices/task-config`, {
...config
});
};
// 删除设备
export const deleteDevice = async (id: number): Promise<ApiResponse<any>> => {
return api.delete<ApiResponse<any>>(`/v1/devices/${id}`);
};
// 设备管理API
export const deviceApi = {
// 创建设备
async create(params: CreateDeviceParams): Promise<ApiResponse<Device>> {
const response = await fetch(`${API_BASE}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
})
return response.json()
},
// 更新设备
async update(params: UpdateDeviceParams): Promise<ApiResponse<Device>> {
const response = await fetch(`${API_BASE}/${params.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
})
return response.json()
},
// 获取设备详情
async getById(id: string): Promise<ApiResponse<Device>> {
const response = await fetch(`${API_BASE}/${id}`)
return response.json()
},
// 查询设备列表
async query(params: QueryDeviceParams): Promise<ApiResponse<PaginatedResponse<Device>>> {
// 创建一个新对象用于构建URLSearchParams
const queryParams: Record<string, string> = {};
// 按需将params中的属性添加到queryParams
if (params.keyword) queryParams.keyword = params.keyword;
if (params.status) queryParams.status = params.status;
if (params.type) queryParams.type = params.type;
if (params.page) queryParams.page = params.page.toString();
if (params.pageSize) queryParams.pageSize = params.pageSize.toString();
// 特殊处理需要JSON序列化的属性
if (params.tags) queryParams.tags = JSON.stringify(params.tags);
if (params.dateRange) queryParams.dateRange = JSON.stringify(params.dateRange);
// 构建查询字符串
const queryString = new URLSearchParams(queryParams).toString();
const response = await fetch(`${API_BASE}?${queryString}`)
return response.json()
},
// 删除设备
async delete(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "DELETE",
})
return response.json()
},
// 重启设备
async restart(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}/restart`, {
method: "POST",
})
return response.json()
},
// 解绑设备
async unbind(id: string): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/${id}/unbind`, {
method: "POST",
})
return response.json()
},
// 获取设备统计数据
async getStats(id: string): Promise<ApiResponse<DeviceStats>> {
const response = await fetch(`${API_BASE}/${id}/stats`)
return response.json()
},
// 获取设备任务记录
async getTaskRecords(id: string, page = 1, pageSize = 20): Promise<ApiResponse<PaginatedResponse<DeviceTaskRecord>>> {
const response = await fetch(`${API_BASE}/${id}/tasks?page=${page}&pageSize=${pageSize}`)
return response.json()
},
// 批量更新设备标签
async updateTags(ids: string[], tags: string[]): Promise<ApiResponse<void>> {
const response = await fetch(`${API_BASE}/tags`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceIds: ids, tags }),
})
return response.json()
},
// 批量导出设备数据
async exportDevices(ids: string[]): Promise<Blob> {
const response = await fetch(`${API_BASE}/export`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceIds: ids }),
})
return response.blob()
},
// 检查设备在线状态
async checkStatus(ids: string[]): Promise<ApiResponse<Record<string, DeviceStatus>>> {
const response = await fetch(`${API_BASE}/status`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceIds: ids }),
})
return response.json()
},
}

74
Cunkebao/api/route.ts Normal file
View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server"
import type { CreateScenarioParams, QueryScenarioParams, ScenarioBase, ApiResponse } from "@/types/scenario"
// 获客场景路由处理
export async function POST(request: Request) {
try {
const body: CreateScenarioParams = await request.json()
// TODO: 实现创建场景的具体逻辑
const scenario: ScenarioBase = {
id: "generated-id",
...body,
status: "draft",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
creator: "current-user-id",
}
const response: ApiResponse<ScenarioBase> = {
code: 0,
message: "创建成功",
data: scenario,
}
return NextResponse.json(response)
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "创建失败",
data: null,
},
{ status: 500 },
)
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const params: QueryScenarioParams = {
type: searchParams.get("type") as any,
status: searchParams.get("status") as any,
keyword: searchParams.get("keyword") || undefined,
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
page: Number(searchParams.get("page")) || 1,
pageSize: Number(searchParams.get("pageSize")) || 20,
}
// TODO: 实现查询场景列表的具体逻辑
return NextResponse.json({
code: 0,
message: "查询成功",
data: {
items: [],
total: 0,
page: params.page,
pageSize: params.pageSize,
totalPages: 0,
},
})
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "查询失败",
data: null,
},
{ status: 500 },
)
}
}

126
Cunkebao/api/scenarios.ts Normal file
View File

@@ -0,0 +1,126 @@
import { request } from "@/lib/api"
export interface ApiResponse<T = any> {
code: number;
msg: string;
data: T | null;
}
// 服务器返回的场景数据类型
export interface SceneItem {
id: number;
name: string;
image: string;
status: number;
createTime: number;
updateTime: number | null;
deleteTime: number | null;
}
// 服务器返回的场景列表响应类型
export interface ScenesResponse {
code: number;
msg: string;
data: SceneItem[];
}
// 前端使用的场景数据类型
export interface Channel {
id: string;
name: string;
icon: string;
stats: {
daily: number;
growth: number;
};
link?: string;
plans?: Plan[];
}
// 计划类型
export interface Plan {
id: string;
name: string;
isNew?: boolean;
status: "active" | "paused" | "completed";
acquisitionCount: number;
}
/**
* 获取获客场景列表
*
* @param params 查询参数
* @returns 获客场景列表
*/
export const fetchScenes = async (params: {
page?: number;
limit?: number;
keyword?: string;
} = {}): Promise<ScenesResponse> => {
const { page = 1, limit = 10, keyword = "" } = params;
const queryParams = new URLSearchParams();
queryParams.append("page", String(page));
queryParams.append("limit", String(limit));
if (keyword) {
queryParams.append("keyword", keyword);
}
try {
return await request<ScenesResponse>(`/v1/plan/scenes?${queryParams.toString()}`);
} catch (error) {
console.error("Error fetching scenes:", error);
// 返回一个错误响应
return {
code: 500,
msg: "获取场景列表失败",
data: []
};
}
};
/**
* 将服务器返回的场景数据转换为前端展示需要的格式
*
* @param item 服务器返回的场景数据
* @returns 前端展示的场景数据
*/
export const transformSceneItem = (item: SceneItem): Channel => {
// 为每个场景生成随机的"今日"数据和"增长百分比"
const dailyCount = Math.floor(Math.random() * 100);
const growthPercent = Math.floor(Math.random() * 40) - 10; // -10% 到 30% 的随机值
// 默认图标(如果服务器没有返回)
const defaultIcon = "/assets/icons/poster-icon.svg";
return {
id: String(item.id),
name: item.name,
icon: item.image || defaultIcon,
stats: {
daily: dailyCount,
growth: growthPercent
}
};
};
/**
* 获取场景详情
*
* @param id 场景ID
* @returns 场景详情
*/
export const fetchSceneDetail = async (id: string | number): Promise<ApiResponse<SceneItem>> => {
try {
return await request<ApiResponse<SceneItem>>(`/v1/plan/scenes/${id}`);
} catch (error) {
console.error("Error fetching scene detail:", error);
return {
code: 500,
msg: "获取场景详情失败",
data: null
};
}
};

View File

@@ -0,0 +1,203 @@
import { api } from "@/lib/api";
import {
ServerWechatAccountsResponse,
QueryWechatAccountParams,
} from "@/types/wechat-account";
// 添加接口返回数据类型定义
interface WechatAccountSummary {
accountAge: string;
activityLevel: {
allTimes: number;
dayTimes: number;
};
accountWeight: {
scope: number;
ageWeight: number;
activityWeigth: number;
restrictWeight: number;
realNameWeight: number;
};
statistics: {
todayAdded: number;
addLimit: number;
};
restrictions: {
id: number;
level: string;
reason: string;
date: string;
}[];
}
interface WechatAccountSummaryResponse {
code: number;
msg: string;
data: WechatAccountSummary;
}
/**
* 获取微信账号列表
* @param params 查询参数
* @returns 微信账号列表响应
*/
export const fetchWechatAccountList = async (params: QueryWechatAccountParams = {}): Promise<ServerWechatAccountsResponse> => {
const queryParams = new URLSearchParams();
// 添加查询参数
if (params.page) queryParams.append('page', params.page.toString());
if (params.limit) queryParams.append('limit', params.limit.toString());
if (params.keyword) queryParams.append('nickname', params.keyword); // 使用nickname作为关键词搜索参数
if (params.sort) queryParams.append('sort', params.sort);
if (params.order) queryParams.append('order', params.order);
// 发起API请求
return api.get<ServerWechatAccountsResponse>(`/v1/wechats?${queryParams.toString()}`);
};
/**
* 刷新微信账号状态
* @returns 刷新结果
*/
export const refreshWechatAccounts = async (): Promise<{ code: number; msg: string; data: any }> => {
return api.put<{ code: number; msg: string; data: any }>('/v1/wechats/refresh', {});
};
/**
* 执行微信好友转移
* @param sourceId 源微信账号ID
* @param targetId 目标微信账号ID
* @returns 转移结果
*/
export const transferWechatFriends = async (sourceId: string | number, targetId: string | number): Promise<{ code: number; msg: string; data: any }> => {
return api.post<{ code: number; msg: string; data: any }>('/v1/wechats/transfer-friends', {
source_id: sourceId,
target_id: targetId
});
};
/**
* 将服务器返回的微信账号数据转换为前端使用的格式
* @param serverAccount 服务器返回的微信账号数据
* @returns 前端使用的微信账号数据
*/
export const transformWechatAccount = (serverAccount: any): import("@/types/wechat-account").WechatAccount => {
// 从deviceInfo中提取设备信息
let deviceId = '';
let deviceName = '';
if (serverAccount.deviceInfo) {
// 尝试解析设备信息字符串
const deviceInfo = serverAccount.deviceInfo.split(' ');
if (deviceInfo.length > 0) {
// 提取数字部分作为设备ID确保是整数
const possibleId = deviceInfo[0].trim();
// 验证是否为数字
deviceId = /^\d+$/.test(possibleId) ? possibleId : '';
// 提取设备名称
if (deviceInfo.length > 1) {
deviceName = deviceInfo[1] ? deviceInfo[1].replace(/[()]/g, '').trim() : '';
}
}
}
// 如果从deviceInfo无法获取有效的设备ID使用imei作为备选
if (!deviceId && serverAccount.imei) {
deviceId = serverAccount.imei;
}
// 如果仍然没有设备ID使用微信账号的ID作为最后的备选
if (!deviceId && serverAccount.id) {
deviceId = serverAccount.id.toString();
}
// 如果没有设备名称,使用备用名称
if (!deviceName) {
deviceName = serverAccount.deviceMemo || '未命名设备';
}
// 假设每天最多可添加20个好友
const maxDailyAdds = 20;
const todayAdded = serverAccount.todayNewFriendCount || 0;
return {
id: serverAccount.id.toString(),
avatar: serverAccount.avatar || '',
nickname: serverAccount.nickname || serverAccount.accountNickname || '未命名',
wechatId: serverAccount.wechatId || '',
deviceId,
deviceName,
friendCount: serverAccount.totalFriend || 0,
todayAdded,
remainingAdds: serverAccount.canAddFriendCount || (maxDailyAdds - todayAdded),
maxDailyAdds,
status: serverAccount.wechatAlive === 1 ? "normal" : "abnormal" as "normal" | "abnormal",
lastActive: new Date().toLocaleString() // 服务端未提供,使用当前时间
};
};
/**
* 获取微信好友列表
* @param wechatId 微信账号ID
* @param page 页码
* @param pageSize 每页数量
* @param searchQuery 搜索关键词
* @returns 好友列表数据
*/
export const fetchWechatFriends = async (wechatId: string, page: number = 1, pageSize: number = 20, searchQuery: string = '') => {
try {
return api.get(`/v1/wechats/${wechatId}/friends?page=${page}&limit=${pageSize}${searchQuery ? `&search=${searchQuery}` : ''}`, true);
} catch (error) {
console.error("获取好友列表失败:", error);
throw error;
}
};
/**
* 获取微信账号概览信息
* @param id 微信账号ID
* @returns 微信账号概览信息
*/
export const fetchWechatAccountSummary = async (wechatIdid: string): Promise<WechatAccountSummaryResponse> => {
try {
return api.get<WechatAccountSummaryResponse>(`/v1/wechats/${wechatIdid}/summary`);
} catch (error) {
console.error("获取账号概览失败:", error);
throw error;
}
};
/**
* 获取好友详情信息
* @param wechatId 微信账号ID
* @param friendId 好友ID
* @returns 好友详情信息
*/
export interface WechatFriendDetail {
id: number;
avatar: string;
nickname: string;
region: string;
wechatId: string;
addDate: string;
tags: string[];
playDate: string;
memo: string;
source: string;
}
interface WechatFriendDetailResponse {
code: number;
msg: string;
data: WechatFriendDetail;
}
export const fetchWechatFriendDetail = async (wechatId: string): Promise<WechatFriendDetailResponse> => {
try {
return api.get<WechatFriendDetailResponse>(`/v1/wechats/${wechatId}`);
} catch (error) {
console.error("获取好友详情失败:", error);
throw error;
}
};

View File

@@ -0,0 +1,20 @@
import { NextResponse } from "next/server"
import type { OrderFormData } from "@/types/acquisition"
export async function POST(request: Request, { params }: { params: { planId: string } }) {
try {
const data: OrderFormData = await request.json()
// 这里应该添加实际的数据库存储逻辑
console.log("Received order:", data, "for plan:", params.planId)
// 模拟成功响应
return NextResponse.json({
success: true,
message: "订单已成功提交",
})
} catch (error) {
return NextResponse.json({ success: false, message: "订单提交失败" }, { status: 500 })
}
}

69
Cunkebao/app/api/auth.ts Normal file
View File

@@ -0,0 +1,69 @@
// API请求工具函数
import { toast } from "@/components/ui/use-toast"
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "https://api.example.com"
// 带有认证的请求函数
export async function authFetch(url: string, options: RequestInit = {}) {
const token = localStorage.getItem("token")
// 合并headers
let headers = { ...options.headers }
// 如果有token添加到请求头
if (token) {
headers = {
...headers,
Token: `${token}`,
}
}
try {
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers,
})
const data = await response.json()
// 检查token是否过期仅当有token时
if (token && (data.code === 401 || data.code === 403)) {
// 清除token
localStorage.removeItem("token")
// 暂时不重定向到登录页
// if (typeof window !== "undefined") {
// window.location.href = "/login"
// }
console.warn("登录已过期")
}
return data
} catch (error) {
console.error("API请求错误:", error)
toast({
variant: "destructive",
title: "请求失败",
description: error instanceof Error ? error.message : "网络错误,请稍后重试",
})
throw error
}
}
// 不需要认证的请求函数
export async function publicFetch(url: string, options: RequestInit = {}) {
try {
const response = await fetch(`${API_BASE_URL}${url}`, options)
return await response.json()
} catch (error) {
console.error("API请求错误:", error)
toast({
variant: "destructive",
title: "请求失败",
description: error instanceof Error ? error.message : "网络错误,请稍后重试",
})
throw error
}
}

View File

@@ -0,0 +1,82 @@
import { NextResponse } from "next/server"
import type {
CreateDeviceParams,
QueryDeviceParams,
Device,
ApiResponse,
DeviceStatus,
DeviceType,
} from "@/types/device"
// 设备管理路由处理
export async function POST(request: Request) {
try {
const body: CreateDeviceParams = await request.json()
// TODO: 实现创建设备的具体逻辑
const device: Device = {
id: "generated-id",
...body,
status: DeviceStatus.OFFLINE,
lastOnlineTime: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
const response: ApiResponse<Device> = {
code: 0,
message: "创建成功",
data: device,
}
return NextResponse.json(response)
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "创建失败",
data: null,
},
{ status: 500 },
)
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const params: QueryDeviceParams = {
keyword: searchParams.get("keyword") || undefined,
status: (searchParams.get("status") as DeviceStatus) || undefined,
type: (searchParams.get("type") as DeviceType) || undefined,
tags: searchParams.get("tags") ? JSON.parse(searchParams.get("tags")!) : undefined,
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
page: Number(searchParams.get("page")) || 1,
pageSize: Number(searchParams.get("pageSize")) || 20,
}
// TODO: 实现查询设备列表的具体逻辑
return NextResponse.json({
code: 0,
message: "查询成功",
data: {
items: [],
total: 0,
page: params.page,
pageSize: params.pageSize,
totalPages: 0,
},
})
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "查询失败",
data: null,
},
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,405 @@
"use client"
import { useState } from "react"
import { ChevronLeft, Copy, Check, Info } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { useToast } from "@/components/ui/use-toast"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { getApiGuideForScenario } from "@/docs/api-guide"
import { Badge } from "@/components/ui/badge"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'https://yishi.com'
export default function ApiDocPage({ params }: { params: { channel: string; id: string } }) {
const router = useRouter()
const { toast } = useToast()
const [copiedExample, setCopiedExample] = useState<string | null>(null)
const apiGuide = getApiGuideForScenario(params.id, params.channel)
// 假设 fullUrl 和 apiKey 可通过 props 或接口获取,这里用演示值
const [apiKey] = useState("naxf1-82h2f-vdwcm-rrhpm-q9hd1")
const [fullUrl] = useState("/v1/api/scenarios")
const testUrl = fullUrl.startsWith("http") ? fullUrl : `${API_BASE_URL}${fullUrl}`
const copyToClipboard = (text: string, exampleId: string) => {
navigator.clipboard.writeText(text)
setCopiedExample(exampleId)
toast({
title: "已复制代码",
description: "代码示例已复制到剪贴板",
})
setTimeout(() => {
setCopiedExample(null)
}, 2000)
}
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between h-14 px-4">
<div className="flex items-center">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ChevronLeft className="h-5 w-5" />
</Button>
<h1 className="ml-2 text-lg font-medium"></h1>
</div>
</div>
</header>
<div className="container mx-auto py-6 px-4 max-w-4xl">
<Card className="mb-6">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>使 <b>apiKey</b> </CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center gap-2">
<Info className="h-4 w-4 text-blue-500" />
<p className="text-sm text-gray-700">
</p>
</div>
<div className="rounded-md bg-amber-50 p-4 border border-amber-200">
<p className="text-sm text-amber-800">
<strong></strong> API密钥使
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm text-gray-700">
<div>
<b></b> <span className="font-mono">sign</span>
</div>
<div>
<b></b> sign apiKey<b></b>& <span className="font-mono">MD5</span> apiKey <span className="font-mono">MD5</span> sign
</div>
<div>
<b></b>
<ol className="list-decimal list-inside ml-4">
<li> sign apiKey namephonetimestampsourceremarktags</li>
<li><b></b> value1value2value3...</li>
<li> MD5 </li>
<li> apiKey </li>
<li> MD5 sign</li>
</ol>
</div>
<div>
<b></b>
<pre className="bg-gray-50 rounded p-2 text-xs overflow-auto">
{`参数:
name=张三
phone=18888888888
timestamp=1700000000
apiKey=naxf1-82h2f-vdwcm-rrhpm-q9hd1
排序后拼接排除apiKey直接拼接值
张三188888888881700000000
第一步MD5
md5(张三188888888881700000000) = 123456abcdef...
拼接apiKey
123456abcdef...naxf1-82h2f-vdwcm-rrhpm-q9hd1
第二步MD5
sign=md5(123456abcdef...naxf1-82h2f-vdwcm-rrhpm-q9hd1)`}
</pre>
</div>
<div className="text-xs text-amber-700 mt-2"> sign apiKey URL </div>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="mb-2">
<span className="font-mono text-sm">{testUrl}</span>
</div>
<div className="text-xs text-gray-500">
<div>: <b>phone</b> (), <b>timestamp</b> (), <b>apiKey</b> ()</div>
<div>: <b>name</b> (), <b>source</b> (), <b>remark</b> (), <b>tags</b> ()</div>
<div>: <b>POST</b> <b>GET</b></div>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="bg-gray-50 rounded-md p-3 text-xs">
<div><b>phone</b> (string, ): </div>
<div><b>timestamp</b> (int, ): 1700000000 Math.floor(Date.now() / 1000) </div>
<div><b>apiKey</b> (string, ): </div>
<div><b>name</b> (string, ): </div>
<div><b>source</b> (string, ): </div>
<div><b>remark</b> (string, ): </div>
<div><b>tags</b> (string, ): </div>
</div>
</CardContent>
</Card>
<Card className="mb-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<pre className="bg-gray-50 rounded-md p-3 text-xs overflow-auto">
{`{
"code": 200,
"msg": "导入成功",
"data": {
"customerId": "123456"
}
}`}
</pre>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Tabs defaultValue="curl">
<TabsList className="mb-4">
<TabsTrigger value="curl">cURL</TabsTrigger>
<TabsTrigger value="python">Python</TabsTrigger>
<TabsTrigger value="node">Node.js</TabsTrigger>
<TabsTrigger value="php">PHP</TabsTrigger>
<TabsTrigger value="java">Java</TabsTrigger>
</TabsList>
<TabsContent value="curl">
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`curl -X POST 'http://yishi.com/v1/plan/api/scenariosz' \
-d "phone=18888888888" \
-d "timestamp=1700000000" \
-d "name=张三" \
-d "apiKey=naxf1-82h2f-vdwcm-rrhpm-q9hd1" \
-d "sign=请用签名算法生成"`}</pre>
</TabsContent>
<TabsContent value="python">
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`import hashlib
import time
import requests
def gen_sign(params, api_key):
data = {k: v for k, v in params.items() if k not in ('sign', 'apiKey')}
s = ''.join([str(data[k]) for k in sorted(data)])
first = hashlib.md5(s.encode('utf-8')).hexdigest()
return hashlib.md5((first + api_key).encode('utf-8')).hexdigest()
api_key = 'naxf1-82h2f-vdwcm-rrhpm-q9hd1'
params = {
'phone': '18888888888',
'timestamp': int(time.time()),
'name': '张三',
}
params['apiKey'] = api_key
params['sign'] = gen_sign(params, api_key)
resp = requests.post('http://yishi.com/v1/plan/api/scenariosz', data=params)
print(resp.json())`}</pre>
</TabsContent>
<TabsContent value="node">
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`const axios = require('axios');
const crypto = require('crypto');
function genSign(params, apiKey) {
const data = {...params};
delete data.sign;
delete data.apiKey;
const keys = Object.keys(data).sort();
let str = '';
keys.forEach(k => { str += data[k]; });
const first = crypto.createHash('md5').update(str).digest('hex');
return crypto.createHash('md5').update(first + apiKey).digest('hex');
}
const apiKey = 'naxf1-82h2f-vdwcm-rrhpm-q9hd1';
const params = {
phone: '18888888888',
timestamp: Math.floor(Date.now() / 1000),
name: '张三',
};
params.apiKey = apiKey;
params.sign = genSign(params, apiKey);
axios.post('http://yishi.com/v1/plan/api/scenariosz', params)
.then(res => console.log(res.data));`}</pre>
</TabsContent>
<TabsContent value="php">
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`<?php
function md5_sign($params, $apiKey) {
unset($params['sign']);
unset($params['apiKey']);
ksort($params);
$str = '';
foreach ($params as $v) {
$str .= $v;
}
$first = md5($str);
$final = md5($first . $apiKey);
return $final;
}
$params = [
'phone' => '18888888888',
'timestamp' => time(),
'name' => '张三',
// 'source' => '', 'remark' => '', 'tags' => ''
];
$apiKey = 'naxf1-82h2f-vdwcm-rrhpm-q9hd1';
$params['apiKey'] = $apiKey;
$params['sign'] = md5_sign($params, $apiKey);
$url = '${API_BASE_URL}/v1/api/scenarios';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, 1);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
echo $response;
?>`}</pre>
</TabsContent>
<TabsContent value="java">
<pre className="bg-gray-50 p-4 rounded-md overflow-auto text-xs">{`import java.security.MessageDigest;
import java.util.*;
import java.net.*;
import java.io.*;
public class ApiSignDemo {
public static String md5(String s) throws Exception {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] array = md.digest(s.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : array) sb.append(String.format("%02x", b));
return sb.toString();
}
public static void main(String[] args) throws Exception {
Map<String, String> params = new HashMap<>();
params.put("phone", "18888888888");
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
params.put("name", "张三");
// params.put("source", ""); params.put("remark", ""); params.put("tags", "");
String apiKey = "naxf1-82h2f-vdwcm-rrhpm-q9hd1";
// 排序并拼接
List<String> keys = new ArrayList<>(params.keySet());
keys.remove("sign");
keys.remove("apiKey");
Collections.sort(keys);
StringBuilder sb = new StringBuilder();
for (String k : keys) {
sb.append(params.get(k));
}
String first = md5(sb.toString());
String sign = md5(first + apiKey);
params.put("apiKey", apiKey);
params.put("sign", sign);
// 发送POST请求
StringBuilder postData = new StringBuilder();
for (Map.Entry<String, String> entry : params.entrySet()) {
if (postData.length() > 0) postData.append("&");
postData.append(URLEncoder.encode(entry.getKey(), "UTF-8"));
postData.append("=");
postData.append(URLEncoder.encode(entry.getValue(), "UTF-8"));
}
URL url = new URL("${API_BASE_URL}/v1/api/scenarios");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
OutputStream os = conn.getOutputStream();
os.write(postData.toString().getBytes("UTF-8"));
os.flush(); os.close();
BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line; StringBuilder resp = new StringBuilder();
while ((line = in.readLine()) != null) resp.append(line);
in.close();
System.out.println(resp.toString());
}
}`}</pre>
</TabsContent>
</Tabs>
</CardContent>
</Card>
<div className="mt-8">
<h3 className="text-lg font-medium mb-4"></h3>
<Card className="mb-4">
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-2 text-sm">
<li></li>
<li>"应用集成" &gt; "外部接口"</li>
<li>"添加新接口"</li>
<li>"X-API-KEY"API密钥</li>
<li>
URL为
<code className="bg-gray-100 px-1 py-0.5 rounded">
{testUrl}
</code>
</li>
<li>name, phone等</li>
<li></li>
</ol>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-1"></h4>
<p className="text-sm text-gray-700">
X-API-KEY正确无误
</p>
</div>
<div>
<h4 className="text-sm font-medium mb-1"></h4>
<p className="text-sm text-gray-700">
</p>
</div>
<div>
<h4 className="text-sm font-medium mb-1"></h4>
<p className="text-sm text-gray-700">
API密钥每分钟最多可发送30个请求使
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,74 @@
import { NextResponse } from "next/server"
import type { CreateScenarioParams, QueryScenarioParams, ScenarioBase, ApiResponse } from "@/types/scenario"
// 获客场景路由处理
export async function POST(request: Request) {
try {
const body: CreateScenarioParams = await request.json()
// TODO: 实现创建场景的具体逻辑
const scenario: ScenarioBase = {
id: "generated-id",
...body,
status: "draft",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
creator: "current-user-id",
}
const response: ApiResponse<ScenarioBase> = {
code: 0,
message: "创建成功",
data: scenario,
}
return NextResponse.json(response)
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "创建失败",
data: null,
},
{ status: 500 },
)
}
}
export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url)
const params: QueryScenarioParams = {
type: searchParams.get("type") as any,
status: searchParams.get("status") as any,
keyword: searchParams.get("keyword") || undefined,
dateRange: searchParams.get("dateRange") ? JSON.parse(searchParams.get("dateRange")!) : undefined,
page: Number(searchParams.get("page")) || 1,
pageSize: Number(searchParams.get("pageSize")) || 20,
}
// TODO: 实现查询场景列表的具体逻辑
return NextResponse.json({
code: 0,
message: "查询成功",
data: {
items: [],
total: 0,
page: params.page,
pageSize: params.pageSize,
totalPages: 0,
},
})
} catch (error) {
return NextResponse.json(
{
code: 500,
message: "查询失败",
data: null,
},
{ status: 500 },
)
}
}

View File

@@ -0,0 +1,273 @@
import { NextResponse } from "next/server"
import type { TrafficUser } from "@/types/traffic"
// 中文名字生成器数据
const familyNames = [
"张",
"王",
"李",
"赵",
"陈",
"刘",
"杨",
"黄",
"周",
"吴",
"朱",
"孙",
"马",
"胡",
"郭",
"林",
"何",
"高",
"梁",
"郑",
"罗",
"宋",
"谢",
"唐",
"韩",
"曹",
"许",
"邓",
"萧",
"冯",
]
const givenNames1 = [
"志",
"建",
"文",
"明",
"永",
"春",
"秀",
"金",
"水",
"玉",
"国",
"立",
"德",
"海",
"和",
"荣",
"伟",
"新",
"英",
"佳",
]
const givenNames2 = [
"华",
"平",
"军",
"强",
"辉",
"敏",
"峰",
"磊",
"超",
"艳",
"娜",
"霞",
"燕",
"娟",
"静",
"丽",
"涛",
"洋",
"勇",
"龙",
]
// 生成固定的用户数据池
const userPool: TrafficUser[] = Array.from({ length: 1610 }, (_, i) => {
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
// 生成随机时间在过去7天内
const date = new Date()
date.setDate(date.getDate() - Math.floor(Math.random() * 7))
return {
id: `${Date.now()}-${i}`,
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
nickname: fullName,
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
region: [
"广东深圳",
"浙江杭州",
"江苏苏州",
"北京",
"上海",
"四川成都",
"湖北武汉",
"福建厦门",
"山东青岛",
"河南郑州",
][Math.floor(Math.random() * 10)],
note: [
"咨询产品价格",
"对产品很感兴趣",
"准备购买",
"需要更多信息",
"想了解优惠活动",
"询问产品规格",
"要求产品demo",
"索要产品目录",
"询问售后服务",
"要求上门演示",
][Math.floor(Math.random() * 10)],
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
addTime: date.toISOString(),
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页"][Math.floor(Math.random() * 6)],
assignedTo: "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
tags: [],
}
})
// 计算今日新增数量
const todayStart = new Date()
todayStart.setHours(0, 0, 0, 0)
const todayUsers = userPool.filter((user) => new Date(user.addTime) >= todayStart)
// 生成微信好友数据池
const generateWechatFriends = (wechatId: string, count: number) => {
return Array.from({ length: count }, (_, i) => {
const familyName = familyNames[Math.floor(Math.random() * familyNames.length)]
const givenName1 = givenNames1[Math.floor(Math.random() * givenNames1.length)]
const givenName2 = givenNames2[Math.floor(Math.random() * givenNames2.length)]
const fullName = Math.random() > 0.5 ? familyName + givenName1 + givenName2 : familyName + givenName1
// 生成随机时间在过去30天内
const date = new Date()
date.setDate(date.getDate() - Math.floor(Math.random() * 30))
return {
id: `wechat-${wechatId}-${i}`,
avatar: `/placeholder.svg?height=40&width=40&text=${fullName[0]}`,
nickname: fullName,
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
phone: `1${["3", "5", "7", "8", "9"][Math.floor(Math.random() * 5)]}${Array.from({ length: 9 }, () => Math.floor(Math.random() * 10)).join("")}`,
region: [
"广东深圳",
"浙江杭州",
"江苏苏州",
"北京",
"上海",
"四川成都",
"湖北武汉",
"福建厦门",
"山东青岛",
"河南郑州",
][Math.floor(Math.random() * 10)],
note: [
"咨询产品价格",
"对产品很感兴趣",
"准备购买",
"需要更多信息",
"想了解优惠活动",
"询问产品规格",
"要求产品demo",
"索要产品目录",
"询问售后服务",
"要求上门演示",
][Math.floor(Math.random() * 10)],
status: ["pending", "added", "failed"][Math.floor(Math.random() * 3)] as TrafficUser["status"],
addTime: date.toISOString(),
source: ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号", "个人主页", "微信好友"][
Math.floor(Math.random() * 7)
],
assignedTo: "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as TrafficUser["category"],
tags: [],
}
})
}
// 微信好友数据缓存
const wechatFriendsCache = new Map<string, TrafficUser[]>()
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = Number.parseInt(searchParams.get("page") || "1")
const pageSize = Number.parseInt(searchParams.get("pageSize") || "10")
const search = searchParams.get("search") || ""
const category = searchParams.get("category") || "all"
const source = searchParams.get("source") || "all"
const status = searchParams.get("status") || "all"
const startDate = searchParams.get("startDate")
const endDate = searchParams.get("endDate")
const wechatSource = searchParams.get("wechatSource") || ""
let filteredUsers = [...userPool]
// 如果有微信来源参数,生成或获取微信好友数据
if (wechatSource) {
if (!wechatFriendsCache.has(wechatSource)) {
// 生成150-300个随机好友
const friendCount = Math.floor(Math.random() * (300 - 150)) + 150
wechatFriendsCache.set(wechatSource, generateWechatFriends(wechatSource, friendCount))
}
filteredUsers = wechatFriendsCache.get(wechatSource) || []
}
// 应用过滤条件
filteredUsers = filteredUsers.filter((user) => {
const matchesSearch = search
? user.nickname.toLowerCase().includes(search.toLowerCase()) ||
user.wechatId.toLowerCase().includes(search.toLowerCase()) ||
user.phone.includes(search)
: true
const matchesCategory = category === "all" ? true : user.category === category
const matchesSource = source === "all" ? true : user.source === source
const matchesStatus = status === "all" ? true : user.status === status
const matchesDate =
startDate && endDate
? new Date(user.addTime) >= new Date(startDate) && new Date(user.addTime) <= new Date(endDate)
: true
return matchesSearch && matchesCategory && matchesSource && matchesStatus && matchesDate
})
// 按添加时间倒序排序
filteredUsers.sort((a, b) => new Date(b.addTime).getTime() - new Date(a.addTime).getTime())
// 计算分页
const total = filteredUsers.length
const totalPages = Math.ceil(total / pageSize)
const start = (page - 1) * pageSize
const end = start + pageSize
const users = filteredUsers.slice(start, end)
// 计算分类统计
const categoryStats = {
potential: userPool.filter((user) => user.category === "potential").length,
customer: userPool.filter((user) => user.category === "customer").length,
lost: userPool.filter((user) => user.category === "lost").length,
}
// 模拟网络延迟
await new Promise((resolve) => setTimeout(resolve, 500))
return NextResponse.json({
users,
pagination: {
total,
totalPages,
currentPage: page,
pageSize,
},
stats: {
total: wechatSource ? filteredUsers.length : userPool.length,
todayNew: wechatSource ? Math.floor(filteredUsers.length * 0.1) : todayUsers.length,
categoryStats,
},
})
}

View File

@@ -0,0 +1,48 @@
"use client"
import "./globals.css"
import BottomNav from "./components/BottomNav"
import "regenerator-runtime/runtime"
import type React from "react"
import ErrorBoundary from "./components/ErrorBoundary"
import { VideoTutorialButton } from "@/components/VideoTutorialButton"
import { AuthProvider } from "@/app/components/AuthProvider"
import { usePathname } from "next/navigation"
// 创建一个包装组件来使用 usePathname hook
function LayoutContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
// 只在主页路径显示底部导航栏
const showBottomNav =
pathname === "/" || pathname === "/devices" || pathname === "/content" || pathname === "/profile"
return (
<div className="mx-auto w-full">
<main className="w-full mx-auto bg-white min-h-screen flex flex-col relative lg:max-w-7xl xl:max-w-[1200px]">
{children}
{showBottomNav && <BottomNav />}
{showBottomNav && <VideoTutorialButton />}
</main>
</div>
)
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body className="bg-gray-100">
<AuthProvider>
<ErrorBoundary>
<LayoutContent>{children}</LayoutContent>
</ErrorBoundary>
</AuthProvider>
</body>
</html>
)
}

View File

@@ -0,0 +1,54 @@
"use client"
import { useState } from "react"
import { X } from "lucide-react"
import { Button } from "./ui/button"
import { Card } from "./ui/card"
interface AIRewriteModalProps {
isOpen: boolean
onClose: () => void
originalContent: string
}
export function AIRewriteModal({ isOpen, onClose, originalContent }: AIRewriteModalProps) {
const [rewrittenContent, setRewrittenContent] = useState("")
const handleRewrite = async () => {
// 这里应该调用 AI 改写 API
// 为了演示,我们只是简单地反转字符串
setRewrittenContent(originalContent.split("").reverse().join(""))
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="w-full max-w-lg p-6">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">AI </h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2"></h3>
<p className="text-sm text-gray-600">{originalContent}</p>
</div>
<div>
<h3 className="font-medium mb-2"></h3>
<p className="text-sm text-gray-600">{rewrittenContent || '点击"开始改写"按钮生成内容'}</p>
</div>
</div>
<div className="mt-6 flex justify-end space-x-2">
<Button variant="outline" onClick={onClose}>
</Button>
<Button onClick={handleRewrite}></Button>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,194 @@
"use client"
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
import { useRouter } from "next/navigation"
import { validateToken } from "@/lib/api"
// 安全的localStorage访问方法
const safeLocalStorage = {
getItem: (key: string): string | null => {
if (typeof window !== 'undefined') {
return localStorage.getItem(key)
}
return null
},
setItem: (key: string, value: string): void => {
if (typeof window !== 'undefined') {
localStorage.setItem(key, value)
}
},
removeItem: (key: string): void => {
if (typeof window !== 'undefined') {
localStorage.removeItem(key)
}
}
}
interface User {
id: number;
username: string;
account?: string;
avatar?: string;
}
interface AuthContextType {
isAuthenticated: boolean
token: string | null
user: User | null
login: (token: string, userData: User) => void
logout: () => void
updateToken: (newToken: string) => void
}
// 创建默认上下文
const AuthContext = createContext<AuthContextType>({
isAuthenticated: false,
token: null,
user: null,
login: () => {},
logout: () => {},
updateToken: () => {}
})
export const useAuth = () => useContext(AuthContext)
interface AuthProviderProps {
children: ReactNode
}
export function AuthProvider({ children }: AuthProviderProps) {
// 避免在服务端渲染时设置初始状态
const [token, setToken] = useState<string | null>(null)
const [user, setUser] = useState<User | null>(null)
const [isAuthenticated, setIsAuthenticated] = useState(false)
// 初始页面加载时显示为false避免在服务端渲染和客户端水合时不匹配
const [isLoading, setIsLoading] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const router = useRouter()
// 初始化认证状态
useEffect(() => {
// 仅在客户端执行初始化
setIsLoading(true)
const initAuth = async () => {
try {
const storedToken = safeLocalStorage.getItem("token")
if (storedToken) {
// 首先尝试从localStorage获取用户信息
const userDataStr = safeLocalStorage.getItem("userInfo")
if (userDataStr) {
try {
// 如果能解析用户数据,先设置登录状态
const userData = JSON.parse(userDataStr) as User
setToken(storedToken)
setUser(userData)
setIsAuthenticated(true)
// 然后在后台尝试验证token但不影响当前登录状态
validateToken().then(isValid => {
// 只有在确认token绝对无效时才登出
// 网络错误等情况默认保持登录状态
if (isValid === false) {
console.warn('验证token失败但仍允许用户保持登录状态')
}
}).catch(error => {
// 捕获所有验证过程中的错误,并记录日志
console.error('验证token过程中出错:', error)
// 网络错误等不会导致登出
})
} catch (parseError) {
// 用户数据无法解析,需要清除
console.error('解析用户数据失败:', parseError)
handleLogout()
}
} else {
// 有token但没有用户信息可能是部分数据丢失
console.warn('找到token但没有用户信息尝试保持登录状态')
// 尝试验证token并获取用户信息
try {
const isValid = await validateToken()
if (isValid) {
// 如果token有效尝试从API获取用户信息
// 这里简化处理直接使用token
setToken(storedToken)
setIsAuthenticated(true)
} else {
// token确认无效清除
handleLogout()
}
} catch (error) {
// 验证过程出错,记录日志但不登出
console.error('验证token过程中出错:', error)
// 保留token允许用户继续使用
setToken(storedToken)
setIsAuthenticated(true)
}
}
}
} catch (error) {
console.error("初始化认证状态时出错:", error)
// 非401错误不应强制登出
if (error instanceof Error &&
(error.message.includes('401') ||
error.message.includes('未授权') ||
error.message.includes('token'))) {
handleLogout()
}
} finally {
setIsLoading(false)
setIsInitialized(true)
}
}
initAuth()
}, []) // 空依赖数组,仅在组件挂载时执行一次
const handleLogout = () => {
// 先清除所有认证相关的状态
safeLocalStorage.removeItem("token")
safeLocalStorage.removeItem("token_expired")
safeLocalStorage.removeItem("s2_accountId")
safeLocalStorage.removeItem("userInfo")
safeLocalStorage.removeItem("user")
setToken(null)
setUser(null)
setIsAuthenticated(false)
// 使用 window.location 而不是 router.push避免状态更新和路由跳转的竞态条件
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
}
const login = (newToken: string, userData: User) => {
safeLocalStorage.setItem("token", newToken)
safeLocalStorage.setItem("userInfo", JSON.stringify(userData))
setToken(newToken)
setUser(userData)
setIsAuthenticated(true)
}
const logout = () => {
handleLogout()
}
// 用于刷新 token 的方法
const updateToken = (newToken: string) => {
safeLocalStorage.setItem("token", newToken)
setToken(newToken)
}
return (
<AuthContext.Provider value={{ isAuthenticated, token, user, login, logout, updateToken }}>
{isLoading && isInitialized ? (
<div className="flex h-screen w-screen items-center justify-center">...</div>
) : (
children
)}
</AuthContext.Provider>
)
}

View File

@@ -0,0 +1,32 @@
"use client"
import { useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { QrCode } from "lucide-react"
export function BindDouyinQRCode() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<Button variant="ghost" size="icon" onClick={() => setIsOpen(true)}>
<QrCode className="h-4 w-4" />
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center p-4">
<div className="w-64 h-64 bg-gray-100 rounded-lg flex items-center justify-center">
<img src="/placeholder.svg?height=256&width=256" alt="抖音二维码" className="w-full h-full" />
</div>
<p className="mt-4 text-sm text-gray-600">使APP扫描二维码进行绑定</p>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -0,0 +1,36 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Home, Users, User, Briefcase } from "lucide-react"
const navItems = [
{ href: "/", icon: Home, label: "首页" },
{ href: "/scenarios", icon: Users, label: "场景获客" },
{ href: "/workspace", icon: Briefcase, label: "工作台" },
{ href: "/profile", icon: User, label: "我的" },
]
export default function BottomNav() {
const pathname = usePathname()
return (
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200">
<div className="w-full mx-auto flex justify-around">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`flex flex-col items-center py-2 px-3 ${
pathname === item.href ? "text-blue-500" : "text-gray-500"
}`}
>
<item.icon className="w-6 h-6" />
<span className="text-xs mt-1">{item.label}</span>
</Link>
))}
</div>
</nav>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import {
LineChart as RechartsLineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts"
import { BarChart as RechartsBarChart, Bar } from "recharts"
const lineData = [
{ name: "周一", 新增微信号: 12 },
{ name: "周二", 新增微信号: 19 },
{ name: "周三", 新增微信号: 3 },
{ name: "周四", 新增微信号: 5 },
{ name: "周五", 新增微信号: 2 },
{ name: "周六", 新增微信号: 3 },
{ name: "周日", 新增微信号: 10 },
]
const barData = [
{ name: "周一", 新增好友: 120 },
{ name: "周二", 新增好友: 190 },
{ name: "周三", 新增好友: 30 },
{ name: "周四", 新增好友: 50 },
{ name: "周五", 新增好友: 20 },
{ name: "周六", 新增好友: 30 },
{ name: "周日", 新增好友: 100 },
]
export function LineChart() {
return (
<ResponsiveContainer width="100%" height={180}>
<RechartsLineChart data={lineData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="新增微信号" stroke="#8884d8" />
</RechartsLineChart>
</ResponsiveContainer>
)
}
export function BarChart() {
return (
<ResponsiveContainer width="100%" height={180}>
<RechartsBarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Bar dataKey="新增好友" fill="#82ca9d" />
</RechartsBarChart>
</ResponsiveContainer>
)
}
export function TrendChart({ data, dataKey = "value", height = 300 }) {
return (
<div className="w-full" style={{ height: `${height}px` }}>
<ResponsiveContainer width="100%" height="100%">
<RechartsLineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey={dataKey}
stroke="#3b82f6"
strokeWidth={2}
dot={{ r: 4, fill: "#3b82f6" }}
activeDot={{ r: 6, fill: "#3b82f6" }}
isAnimationActive={true}
/>
</RechartsLineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { Avatar } from "@/components/ui/avatar"
import { cn } from "@/lib/utils"
interface ChatMessageProps {
content: string
isUser: boolean
timestamp: Date
avatar?: string
}
export function ChatMessage({ content, isUser, timestamp, avatar }: ChatMessageProps) {
return (
<div className={cn("flex w-full gap-3 p-4", isUser ? "justify-end" : "justify-start")}>
{!isUser && (
<Avatar className="h-8 w-8">
<div className="bg-primary text-primary-foreground flex h-full w-full items-center justify-center rounded-full text-sm font-semibold">
AI
</div>
</Avatar>
)}
<div className={cn("rounded-lg p-3 max-w-[80%]", isUser ? "bg-primary text-primary-foreground" : "bg-muted")}>
<p className="whitespace-pre-wrap">{content}</p>
<div className={cn("text-xs mt-1", isUser ? "text-primary-foreground/70" : "text-muted-foreground")}>
{timestamp.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
{isUser && avatar && (
<Avatar className="h-8 w-8">
<img src={avatar || "/placeholder.svg"} alt="User" className="rounded-full" />
</Avatar>
)}
</div>
)
}

View File

@@ -0,0 +1,106 @@
"use client"
import { useState } from "react"
import { Card } from "../ui/card"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { ChevronLeft, Search, Plus } from "lucide-react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"
interface ContentLibrary {
id: string
name: string
type: string
count: number
}
const mockLibraries: ContentLibrary[] = [
{
id: "1",
name: "卡若朋友圈",
type: "朋友圈",
count: 307,
},
{
id: "2",
name: "业务推广内容",
type: "朋友圈",
count: 156,
},
]
export function ContentSelector({ onPrev, onFinish }) {
const [selectedLibraries, setSelectedLibraries] = useState<string[]>([])
return (
<Card className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold"></h2>
<Button>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="flex items-center space-x-4 mb-6">
<div className="flex-1">
<Input placeholder="搜索内容库" className="w-full" prefix={<Search className="w-4 h-4 text-gray-400" />} />
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockLibraries.map((library) => (
<TableRow key={library.id}>
<TableCell>
<input
type="checkbox"
checked={selectedLibraries.includes(library.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedLibraries([...selectedLibraries, library.id])
} else {
setSelectedLibraries(selectedLibraries.filter((id) => id !== library.id))
}
}}
/>
</TableCell>
<TableCell>{library.name}</TableCell>
<TableCell>{library.type}</TableCell>
<TableCell>{library.count}</TableCell>
<TableCell>
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between mt-8">
<Button variant="outline" onClick={onPrev}>
<ChevronLeft className="w-4 h-4 mr-2" />
</Button>
<Button
onClick={onFinish}
disabled={selectedLibraries.length === 0}
className="bg-green-500 hover:bg-green-600"
>
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,123 @@
"use client"
import { useState } from "react"
import { Card } from "../ui/card"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table"
import { ChevronLeft, ChevronRight, Search, Plus } from "lucide-react"
interface Device {
id: string
imei: string
status: "online" | "offline"
friendStatus: string
}
const mockDevices: Device[] = [
{
id: "1",
imei: "123456789012345",
status: "online",
friendStatus: "正常",
},
{
id: "2",
imei: "987654321098765",
status: "offline",
friendStatus: "异常",
},
]
export function DeviceSelector({ onNext, onPrev }) {
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
return (
<Card className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold"></h2>
<Button>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
<div className="flex items-center space-x-4 mb-6">
<div className="flex-1">
<Input
placeholder="搜索设备IMEI/备注/手机号"
className="w-full"
prefix={<Search className="w-4 h-4 text-gray-400" />}
/>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12"></TableHead>
<TableHead>IMEI//</TableHead>
<TableHead>线</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mockDevices.map((device) => (
<TableRow key={device.id}>
<TableCell>
<input
type="checkbox"
checked={selectedDevices.includes(device.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedDevices([...selectedDevices, device.id])
} else {
setSelectedDevices(selectedDevices.filter((id) => id !== device.id))
}
}}
/>
</TableCell>
<TableCell>{device.imei}</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</span>
</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs ${
device.friendStatus === "正常" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{device.friendStatus}
</span>
</TableCell>
<TableCell>
<Button variant="ghost" size="sm">
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex justify-between mt-8">
<Button variant="outline" onClick={onPrev}>
<ChevronLeft className="w-4 h-4 mr-2" />
</Button>
<Button onClick={onNext} disabled={selectedDevices.length === 0}>
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,101 @@
"use client"
import { useState } from "react"
import { Card } from "../ui/card"
import { Button } from "../ui/button"
import { Input } from "../ui/input"
import { Switch } from "../ui/switch"
import { Label } from "../ui/label"
import { ChevronLeft, ChevronRight, Plus, Minus } from "lucide-react"
interface TaskSetupProps {
onNext?: () => void
onPrev?: () => void
step: number
}
export function TaskSetup({ onNext, onPrev, step }: TaskSetupProps) {
const [syncCount, setSyncCount] = useState(5)
const [startTime, setStartTime] = useState("06:00")
const [endTime, setEndTime] = useState("23:59")
const [isEnabled, setIsEnabled] = useState(true)
const [accountType, setAccountType] = useState("business") // business or personal
return (
<Card className="p-6 max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-semibold"></h2>
<div className="flex items-center space-x-2">
<Label htmlFor="task-enabled"></Label>
<Switch id="task-enabled" checked={isEnabled} onCheckedChange={setIsEnabled} />
</div>
</div>
<div className="space-y-6">
<div className="grid gap-4">
<Label></Label>
<Input placeholder="请输入任务名称" />
</div>
<div className="grid gap-4">
<Label></Label>
<div className="flex items-center space-x-2">
<Input type="time" value={startTime} onChange={(e) => setStartTime(e.target.value)} className="w-32" />
<span></span>
<Input type="time" value={endTime} onChange={(e) => setEndTime(e.target.value)} className="w-32" />
</div>
</div>
<div className="grid gap-4">
<Label></Label>
<div className="flex items-center space-x-4">
<Button variant="outline" size="icon" onClick={() => setSyncCount(Math.max(1, syncCount - 1))}>
<Minus className="h-4 w-4" />
</Button>
<span className="w-12 text-center">{syncCount}</span>
<Button variant="outline" size="icon" onClick={() => setSyncCount(syncCount + 1)}>
<Plus className="h-4 w-4" />
</Button>
<span className="text-gray-500"></span>
</div>
</div>
<div className="grid gap-4">
<Label></Label>
<div className="flex space-x-4">
<Button
variant={accountType === "business" ? "default" : "outline"}
onClick={() => setAccountType("business")}
className="w-24"
>
</Button>
<Button
variant={accountType === "personal" ? "default" : "outline"}
onClick={() => setAccountType("personal")}
className="w-24"
>
</Button>
</div>
</div>
</div>
<div className="flex justify-between mt-8">
{step > 1 ? (
<Button variant="outline" onClick={onPrev}>
<ChevronLeft className="w-4 h-4 mr-2" />
</Button>
) : (
<div />
)}
<Button onClick={onNext}>
<ChevronRight className="w-4 h-4 ml-2" />
</Button>
</div>
</Card>
)
}

View File

@@ -0,0 +1,257 @@
"use client"
import { useState, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Filter, Search, RefreshCw, AlertCircle } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { toast } from "@/components/ui/use-toast"
import { Progress } from "@/components/ui/progress"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
import { ImeiDisplay } from "@/components/ImeiDisplay"
interface WechatAccount {
wechatId: string
nickname: string
remainingAdds: number
maxDailyAdds: number
todayAdded: number
}
interface Device {
id: string
imei: string
name: string
status: "online" | "offline"
wechatAccounts: WechatAccount[]
usedInPlans: number
}
interface DeviceSelectorProps {
onSelect: (selectedDevices: string[]) => void
initialSelectedDevices?: string[]
excludeUsedDevices?: boolean
}
export function DeviceSelector({
onSelect,
initialSelectedDevices = [],
excludeUsedDevices = true,
}: DeviceSelectorProps) {
const [devices, setDevices] = useState<Device[]>([])
const [selectedDevices, setSelectedDevices] = useState<string[]>(initialSelectedDevices)
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [currentPage, setCurrentPage] = useState(1)
const devicesPerPage = 10
useEffect(() => {
// 模拟获取设备数据
const fetchDevices = async () => {
await new Promise((resolve) => setTimeout(resolve, 500))
const mockDevices = Array.from({ length: 42 }, (_, i) => ({
id: `device-${i + 1}`,
imei: `IMEI-${Math.random().toString(36).substr(2, 9)}`,
name: `设备 ${i + 1}`,
status: Math.random() > 0.3 ? "online" : "offline",
wechatAccounts: Array.from({ length: Math.floor(Math.random() * 2) + 1 }, (_, j) => ({
wechatId: `wxid_${Math.random().toString(36).substr(2, 8)}`,
nickname: `微信号 ${j + 1}`,
remainingAdds: Math.floor(Math.random() * 10) + 5,
maxDailyAdds: 20,
todayAdded: Math.floor(Math.random() * 15),
})),
usedInPlans: Math.floor(Math.random() * 3),
}))
setDevices(mockDevices)
}
fetchDevices()
}, [])
const handleRefresh = () => {
toast({
title: "刷新成功",
description: "设备列表已更新",
})
}
const filteredDevices = devices.filter((device) => {
const matchesSearch =
device.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.imei.toLowerCase().includes(searchQuery.toLowerCase()) ||
device.wechatAccounts.some((account) => account.wechatId.toLowerCase().includes(searchQuery.toLowerCase()))
const matchesStatus = statusFilter === "all" || device.status === statusFilter
const matchesUsage = !excludeUsedDevices || device.usedInPlans === 0
return matchesSearch && matchesStatus && matchesUsage
})
const paginatedDevices = filteredDevices.slice((currentPage - 1) * devicesPerPage, currentPage * devicesPerPage)
const handleSelectAll = () => {
if (selectedDevices.length === paginatedDevices.length) {
setSelectedDevices([])
} else {
setSelectedDevices(paginatedDevices.map((device) => device.id))
}
onSelect(selectedDevices)
}
const handleDeviceSelect = (deviceId: string) => {
const updatedSelection = selectedDevices.includes(deviceId)
? selectedDevices.filter((id) => id !== deviceId)
: [...selectedDevices, deviceId]
setSelectedDevices(updatedSelection)
onSelect(updatedSelection)
}
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center justify-between">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="online">线</SelectItem>
<SelectItem value="offline">线</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" onClick={handleSelectAll}>
{selectedDevices.length === paginatedDevices.length ? "取消全选" : "全选"}
</Button>
</div>
<div className="space-y-2">
{paginatedDevices.map((device) => (
<Card key={device.id} className="p-3 hover:shadow-md transition-shadow">
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedDevices.includes(device.id)}
onCheckedChange={() => handleDeviceSelect(device.id)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<div className="font-medium truncate">{device.name}</div>
<div
className={`px-2 py-1 rounded-full text-xs ${
device.status === "online" ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-800"
}`}
>
{device.status === "online" ? "在线" : "离线"}
</div>
</div>
<div className="text-sm text-gray-500 flex items-center">
<span className="mr-1">IMEI:</span>
<ImeiDisplay imei={device.imei} containerWidth={160} />
</div>
<div className="mt-2 space-y-2">
{device.wechatAccounts.map((account) => (
<div key={account.wechatId} className="bg-gray-50 rounded-lg p-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{account.nickname}</span>
<span className="text-gray-500">{account.wechatId}</span>
</div>
<div className="mt-1 space-y-1">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-1">
<span></span>
<span className="font-medium">{account.remainingAdds}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<AlertCircle className="h-4 w-4 text-gray-400" />
</TooltipTrigger>
<TooltipContent>
<p> {account.maxDailyAdds} </p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<span className="text-sm text-gray-500">
{account.todayAdded}/{account.maxDailyAdds}
</span>
</div>
<Progress value={(account.todayAdded / account.maxDailyAdds) * 100} className="h-1.5" />
</div>
</div>
))}
</div>
{!excludeUsedDevices && device.usedInPlans > 0 && (
<div className="text-sm text-orange-500 mt-2"> {device.usedInPlans} </div>
)}
</div>
</div>
</Card>
))}
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage((prev) => Math.max(1, prev - 1))
}}
/>
</PaginationItem>
{Array.from({ length: Math.ceil(filteredDevices.length / devicesPerPage) }, (_, i) => i + 1).map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
isActive={currentPage === page}
onClick={(e) => {
e.preventDefault()
setCurrentPage(page)
}}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault()
setCurrentPage((prev) => Math.min(Math.ceil(filteredDevices.length / devicesPerPage), prev + 1))
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}

View File

@@ -0,0 +1,47 @@
"use client"
import React, { type ErrorInfo } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
interface ErrorBoundaryProps {
children: React.ReactNode
}
interface ErrorBoundaryState {
hasError: boolean
error?: Error
}
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo)
}
render() {
if (this.state.hasError) {
return (
<Card className="p-6 max-w-md mx-auto mt-8">
<h2 className="text-2xl font-bold mb-4 text-red-600"></h2>
<p className="text-gray-600 mb-4"></p>
<p className="text-sm text-gray-500 mb-4">{this.state.error?.message}</p>
<Button onClick={() => this.setState({ hasError: false })}></Button>
</Card>
)
}
return this.props.children
}
}
export default ErrorBoundary

View File

@@ -0,0 +1,150 @@
"use client"
import type React from "react"
import { useState, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Upload, X } from "lucide-react"
import { Progress } from "@/components/ui/progress"
interface FileUploaderProps {
onFileUploaded: (file: File) => void
acceptedTypes?: string
maxSize?: number // in MB
}
export function FileUploader({
onFileUploaded,
acceptedTypes = ".pdf,.doc,.docx,.jpg,.jpeg,.png",
maxSize = 10, // 10MB default
}: FileUploaderProps) {
const [dragActive, setDragActive] = useState(false)
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [uploadProgress, setUploadProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const handleDrag = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true)
} else if (e.type === "dragleave") {
setDragActive(false)
}
}
const validateFile = (file: File): boolean => {
// 检查文件类型
const fileType = file.name.split(".").pop()?.toLowerCase() || ""
const isValidType = acceptedTypes.includes(fileType)
// 检查文件大小
const isValidSize = file.size <= maxSize * 1024 * 1024
if (!isValidType) {
setError(`不支持的文件类型。请上传 ${acceptedTypes} 格式的文件。`)
return false
}
if (!isValidSize) {
setError(`文件过大。最大支持 ${maxSize}MB。`)
return false
}
return true
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setDragActive(false)
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0]
handleFile(file)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
e.preventDefault()
if (e.target.files && e.target.files[0]) {
const file = e.target.files[0]
handleFile(file)
}
}
const handleFile = (file: File) => {
setError(null)
if (validateFile(file)) {
setSelectedFile(file)
simulateUpload(file)
}
}
const simulateUpload = (file: File) => {
// 模拟上传进度
setUploadProgress(0)
const interval = setInterval(() => {
setUploadProgress((prev) => {
if (prev >= 100) {
clearInterval(interval)
onFileUploaded(file)
return 100
}
return prev + 10
})
}, 200)
}
const handleButtonClick = () => {
inputRef.current?.click()
}
const cancelUpload = () => {
setSelectedFile(null)
setUploadProgress(0)
setError(null)
}
return (
<div className="w-full">
{!selectedFile ? (
<div
className={`border-2 border-dashed rounded-lg p-6 text-center ${
dragActive ? "border-blue-500 bg-blue-50" : "border-gray-300"
}`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
<input ref={inputRef} type="file" className="hidden" onChange={handleChange} accept={acceptedTypes} />
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm font-medium text-gray-700"></p>
<Button variant="outline" onClick={handleButtonClick} className="mt-2">
</Button>
<p className="mt-1 text-xs text-gray-500">
{acceptedTypes.replace(/\./g, "")} {maxSize}MB
</p>
</div>
) : (
<div className="border rounded-lg p-4">
<div className="flex justify-between items-center mb-2">
<div className="font-medium truncate">{selectedFile.name}</div>
<Button variant="ghost" size="icon" onClick={cancelUpload} className="h-6 w-6 min-w-6">
<X className="h-4 w-4" />
</Button>
</div>
<Progress value={uploadProgress} className="h-2" />
<div className="text-xs text-right mt-1 text-gray-500">{uploadProgress}%</div>
</div>
)}
{error && <div className="mt-2 text-sm text-red-500">{error}</div>}
</div>
)
}

View File

@@ -0,0 +1,58 @@
"use client"
import { usePathname } from "next/navigation"
import BottomNav from "./BottomNav"
import { VideoTutorialButton } from "@/components/VideoTutorialButton"
import type React from "react"
import { createContext, useContext, useState, useEffect } from "react"
// 创建视图模式上下文
const ViewModeContext = createContext<{ viewMode: "desktop" | "mobile" }>({ viewMode: "desktop" })
// 创建视图模式钩子函数
export function useViewMode() {
const context = useContext(ViewModeContext)
if (!context) {
throw new Error("useViewMode must be used within a LayoutWrapper")
}
return context
}
export default function LayoutWrapper({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const [viewMode, setViewMode] = useState<"desktop" | "mobile">("desktop")
// 检测视图模式
useEffect(() => {
const checkViewMode = () => {
setViewMode(window.innerWidth < 768 ? "mobile" : "desktop")
}
// 初始检测
checkViewMode()
// 监听窗口大小变化
window.addEventListener("resize", checkViewMode)
return () => {
window.removeEventListener("resize", checkViewMode)
}
}, [])
// 只在四个主页显示底部导航栏:首页、场景获客、工作台和我的
const mainPages = ["/", "/scenarios", "/workspace", "/profile"]
const showBottomNav = mainPages.includes(pathname)
return (
<ViewModeContext.Provider value={{ viewMode }}>
<div className="mx-auto w-full">
<main className="w-full mx-auto bg-white min-h-screen flex flex-col relative lg:max-w-7xl xl:max-w-[1200px]">
{children}
{showBottomNav && <BottomNav />}
{showBottomNav && <VideoTutorialButton />}
</main>
</div>
</ViewModeContext.Provider>
)
}

View File

@@ -0,0 +1,72 @@
"use client"
import { useState, useEffect } from "react"
import { toast } from "@/components/ui/use-toast"
interface SpeechToTextProcessorProps {
audioUrl: string
onTranscriptReady: (transcript: string) => void
onQuestionExtracted: (question: string) => void
enabled: boolean
}
export function SpeechToTextProcessor({
audioUrl,
onTranscriptReady,
onQuestionExtracted,
enabled = true,
}: SpeechToTextProcessorProps) {
const [isProcessing, setIsProcessing] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || !audioUrl) return
const processAudio = async () => {
try {
setIsProcessing(true)
setError(null)
// 模拟API调用延迟
await new Promise((resolve) => setTimeout(resolve, 2000))
// 模拟转录结果
const mockTranscript = `
客服: 您好这里是XX公司客服请问有什么可以帮到您
客户: 请问贵公司的产品有什么特点?
客服: 我们的产品主要有以下几个特点:首先,质量非常可靠;其次,价格比较有竞争力;第三,售后服务非常完善。
客户: 那你们的价格是怎么样的?
客服: 我们有多种套餐可以选择基础版每月只需99元高级版每月299元具体可以根据您的需求来选择。
客户: 好的,我了解了,谢谢。
客服: 不客气,如果您有兴趣,我可以添加您的微信,给您发送更详细的产品资料。
客户: 可以的,谢谢。
客服: 好的,稍后我会添加您为好友,再次感谢您的咨询。
`
onTranscriptReady(mockTranscript)
// 提取首句问题
const questionMatch = mockTranscript.match(/客户: (.*?)\n/)
if (questionMatch && questionMatch[1]) {
onQuestionExtracted(questionMatch[1])
} else {
onQuestionExtracted("未识别到有效问题")
}
setIsProcessing(false)
} catch (err) {
setError("处理音频时出错")
setIsProcessing(false)
toast({
title: "处理失败",
description: "语音转文字处理失败,请重试",
variant: "destructive",
})
}
}
processAudio()
}, [audioUrl, enabled, onTranscriptReady, onQuestionExtracted])
return null // 这是一个功能性组件不渲染任何UI
}

View File

@@ -0,0 +1,177 @@
"use client"
import { useState, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Plus, Pencil, Trash2 } from "lucide-react"
interface TrafficTeam {
id: string
name: string
commission: number
}
interface TrafficTeamSettingsProps {
formData?: any
onChange: (data: any) => void
}
export function TrafficTeamSettings({ formData = {}, onChange }: TrafficTeamSettingsProps) {
// Initialize teams with an empty array if formData.trafficTeams is undefined
const [teams, setTeams] = useState<TrafficTeam[]>([])
const [isAddTeamOpen, setIsAddTeamOpen] = useState(false)
const [editingTeam, setEditingTeam] = useState<TrafficTeam | null>(null)
const [newTeam, setNewTeam] = useState<Partial<TrafficTeam>>({
name: "",
commission: 0,
})
// Initialize teams state safely
useEffect(() => {
if (formData && Array.isArray(formData.trafficTeams)) {
setTeams(formData.trafficTeams)
} else {
// If formData.trafficTeams is undefined or not an array, initialize with empty array
// Also update the parent formData to include the empty trafficTeams array
setTeams([])
onChange({ ...formData, trafficTeams: [] })
}
}, [formData])
const handleAddTeam = () => {
if (!newTeam.name) return
const updatedTeams = [...teams]
if (editingTeam) {
const index = updatedTeams.findIndex((team) => team.id === editingTeam.id)
if (index !== -1) {
updatedTeams[index] = {
...updatedTeams[index],
name: newTeam.name || updatedTeams[index].name,
commission: newTeam.commission !== undefined ? newTeam.commission : updatedTeams[index].commission,
}
}
} else {
updatedTeams.push({
id: Date.now().toString(),
name: newTeam.name,
commission: newTeam.commission || 0,
})
}
setTeams(updatedTeams)
setIsAddTeamOpen(false)
setNewTeam({ name: "", commission: 0 })
setEditingTeam(null)
// Ensure we're creating a new object for formData to trigger proper updates
const updatedFormData = { ...(formData || {}), trafficTeams: updatedTeams }
onChange(updatedFormData)
}
const handleEditTeam = (team: TrafficTeam) => {
setEditingTeam(team)
setNewTeam(team)
setIsAddTeamOpen(true)
}
const handleDeleteTeam = (teamId: string) => {
const updatedTeams = teams.filter((team) => team.id !== teamId)
setTeams(updatedTeams)
// Ensure we're creating a new object for formData to trigger proper updates
const updatedFormData = { ...(formData || {}), trafficTeams: updatedTeams }
onChange(updatedFormData)
}
return (
<Card className="p-6">
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold"></h2>
<Button onClick={() => setIsAddTeamOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teams.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-gray-500">
</TableCell>
</TableRow>
) : (
teams.map((team) => (
<TableRow key={team.id}>
<TableCell>{team.name}</TableCell>
<TableCell>{team.commission}%</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button variant="ghost" size="sm" onClick={() => handleEditTeam(team)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDeleteTeam(team.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
<Dialog open={isAddTeamOpen} onOpenChange={setIsAddTeamOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTeam ? "编辑团队" : "添加团队"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label></Label>
<Input
value={newTeam.name}
onChange={(e) => setNewTeam({ ...newTeam, name: e.target.value })}
placeholder="请输入团队名称"
/>
</div>
<div className="space-y-2">
<Label> (%)</Label>
<Input
type="number"
value={newTeam.commission}
onChange={(e) => setNewTeam({ ...newTeam, commission: Number(e.target.value) })}
placeholder="请输入佣金比例"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddTeamOpen(false)}>
</Button>
<Button onClick={handleAddTeam}>{editingTeam ? "保存" : "添加"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
)
}

View File

@@ -0,0 +1,63 @@
"use client"
import { useEffect, useState } from "react"
interface VoiceRecognitionProps {
onResult: (text: string) => void
onStop: () => void
}
export function VoiceRecognition({ onResult, onStop }: VoiceRecognitionProps) {
const [isListening, setIsListening] = useState(true)
useEffect(() => {
// 模拟语音识别
const timer = setTimeout(() => {
const mockResults = [
"你好,我想了解一下私域运营的基本策略",
"请帮我分析一下最近的销售数据",
"我需要一份客户画像分析报告",
"如何提高朋友圈内容的互动率?",
"帮我生成一个营销方案",
]
const randomResult = mockResults[Math.floor(Math.random() * mockResults.length)]
onResult(randomResult)
setIsListening(false)
}, 2000)
return () => {
clearTimeout(timer)
}
}, [onResult])
useEffect(() => {
if (!isListening) {
onStop()
}
}, [isListening, onStop])
return (
<div className="fixed inset-0 bg-black/20 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 shadow-xl max-w-sm w-full">
<div className="flex flex-col items-center">
<div className="relative w-20 h-20 mb-4">
<div className="absolute inset-0 bg-blue-100 rounded-full animate-ping opacity-25"></div>
<div className="relative bg-blue-500 rounded-full w-20 h-20 flex items-center justify-center">
<div className="w-4 h-16 bg-white rounded-full animate-pulse"></div>
</div>
</div>
<h3 className="text-lg font-medium mb-2">...</h3>
<p className="text-gray-500 text-center"></p>
<button
className="mt-4 px-4 py-2 bg-gray-200 rounded-md hover:bg-gray-300 transition-colors"
onClick={onStop}
>
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,99 @@
"use client"
import { useState, useEffect } from "react"
import { ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid, Area, AreaChart, Legend } from "recharts"
interface AcquisitionPlanChartProps {
data: { date: string; customers: number }[]
}
export function AcquisitionPlanChart({ data }: AcquisitionPlanChartProps) {
const [chartData, setChartData] = useState<any[]>([])
// 生成更真实的数据
useEffect(() => {
if (!data || data.length === 0) return
// 使用获客计划中的获客数和添加数作为指标模拟近7天数据
const enhancedData = data.map((item) => {
// 添加数通常是获客数的一定比例这里使用70%-90%的随机比例
const addRate = 0.7 + Math.random() * 0.2
const addedCount = Math.round(item.customers * addRate)
return {
date: item.date,
获客数: item.customers,
添加数: addedCount,
}
})
setChartData(enhancedData)
}, [data])
// 如果没有数据,显示空状态
if (!data || data.length === 0 || chartData.length === 0) {
return <div className="h-[180px] flex items-center justify-center text-gray-400"></div>
}
return (
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
<defs>
<linearGradient id="colorCustomers" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.8} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="colorAdded" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: "#6b7280" }} width={30} />
<Tooltip
contentStyle={{
backgroundColor: "rgba(255, 255, 255, 0.9)",
borderRadius: "6px",
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
border: "none",
padding: "8px",
}}
labelStyle={{ fontWeight: "bold", marginBottom: "4px" }}
/>
<Legend
verticalAlign="top"
height={36}
iconType="circle"
iconSize={8}
formatter={(value) => <span style={{ color: "#6b7280", fontSize: "12px" }}>{value}</span>}
/>
<Area
type="monotone"
dataKey="获客数"
name="获客数"
stroke="#3b82f6"
fillOpacity={1}
fill="url(#colorCustomers)"
strokeWidth={2}
dot={{ r: 3, strokeWidth: 2 }}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
<Area
type="monotone"
dataKey="添加数"
name="添加数"
stroke="#8b5cf6"
fillOpacity={0.5}
fill="url(#colorAdded)"
strokeWidth={2}
dot={{ r: 3, strokeWidth: 2 }}
activeDot={{ r: 5, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,55 @@
"use client"
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts"
interface DailyAcquisitionData {
date: string
acquired: number
added: number
}
interface DailyAcquisitionChartProps {
data: DailyAcquisitionData[]
height?: number
}
export function DailyAcquisitionChart({ data, height = 200 }: DailyAcquisitionChartProps) {
return (
<div className="w-full" style={{ height: `${height}px` }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" stroke="#666" />
<YAxis stroke="#666" />
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "6px",
}}
/>
<Legend />
<Line
type="monotone"
dataKey="acquired"
name="获客数量"
stroke="#3b82f6"
strokeWidth={2}
dot={{ fill: "#3b82f6" }}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="added"
name="添加成功"
stroke="#10b981"
strokeWidth={2}
dot={{ fill: "#10b981" }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,75 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from "recharts"
// 模拟数据
const weeklyData = [
{ day: "周一", 获客数: 12, 添加好友: 8 },
{ day: "周二", 获客数: 18, 添加好友: 12 },
{ day: "周三", 获客数: 15, 添加好友: 10 },
{ day: "周四", 获客数: 25, 添加好友: 18 },
{ day: "周五", 获客数: 30, 添加好友: 22 },
{ day: "周六", 获客数: 18, 添加好友: 14 },
{ day: "周日", 获客数: 15, 添加好友: 11 },
]
const monthlyData = [
{ day: "1月", 获客数: 120, 添加好友: 85 },
{ day: "2月", 获客数: 180, 添加好友: 130 },
{ day: "3月", 获客数: 150, 添加好友: 110 },
{ day: "4月", 获客数: 250, 添加好友: 180 },
{ day: "5月", 获客数: 300, 添加好友: 220 },
{ day: "6月", 获客数: 280, 添加好友: 210 },
]
export function DeviceTreeChart() {
const [period, setPeriod] = useState("week")
const data = period === "week" ? weeklyData : monthlyData
return (
<Card className="w-full mt-4">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-medium"></CardTitle>
<Tabs defaultValue="week" value={period} onValueChange={setPeriod} className="h-9">
<TabsList className="grid w-[180px] grid-cols-2">
<TabsTrigger value="week"></TabsTrigger>
<TabsTrigger value="month"></TabsTrigger>
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={250}>
<LineChart data={data} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="day" axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12 }} />
<Tooltip />
<Line
type="monotone"
dataKey="获客数"
stroke="#4f46e5"
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
<Line
type="monotone"
dataKey="添加好友"
stroke="#10b981"
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,205 @@
"use client"
import { useState } from "react"
import { ChevronDown, ChevronUp, MoreVertical, Copy, Pencil, Trash2, Clock, Link } from "lucide-react"
import { Button } from "@/components/ui/button"
import { useRouter } from "next/navigation"
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from "recharts"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { DailyAcquisitionChart } from "./DailyAcquisitionChart"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
interface Task {
id: string
name: string
status: "running" | "paused" | "completed"
stats: {
devices: number
acquired: number
added: number
}
lastUpdated: string
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
dailyData?: { date: string; acquired: number; added: number }[]
}
interface ExpandableAcquisitionCardProps {
task: Task
channel: string
onCopy: (taskId: string) => void
onDelete: (taskId: string) => void
onOpenSettings?: (taskId: string) => void
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
}
export function ExpandableAcquisitionCard({
task,
channel,
onCopy,
onDelete,
onOpenSettings,
onStatusChange,
}: ExpandableAcquisitionCardProps) {
const router = useRouter()
const [expanded, setExpanded] = useState(false)
const { devices: deviceCount, acquired: acquiredCount, added: addedCount } = task.stats
const passRate = calculatePassRate(acquiredCount, addedCount)
const handleEdit = (taskId: string) => {
router.push(`/scenarios/${channel}/edit/${taskId}`)
}
const toggleTaskStatus = () => {
if (onStatusChange) {
onStatusChange(task.id, task.status === "running" ? "paused" : "running")
}
}
return (
<div className="mb-6">
<Card className="p-6 hover:shadow-lg transition-all bg-white/80 backdrop-blur-sm">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Badge
variant={task.status === "running" ? "success" : "secondary"}
className="cursor-pointer hover:opacity-80"
onClick={toggleTaskStatus}
>
{task.status === "running" ? "进行中" : "已暂停"}
</Badge>
</div>
<div className="relative z-20">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => handleEdit(task.id)}>
<Pencil className="w-4 h-4 mr-2" />
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onCopy(task.id)}>
<Copy className="w-4 h-4 mr-2" />
</DropdownMenuItem>
{onOpenSettings && (
<DropdownMenuItem onClick={() => onOpenSettings(task.id)}>
<Link className="w-4 h-4 mr-2" />
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onDelete(task.id)} className="text-red-600">
<Trash2 className="w-4 h-4 mr-2" />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="grid grid-cols-4 gap-2 mb-4">
<a href={`/scenarios/${channel}/devices`}>
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{deviceCount}</div>
</Card>
</a>
<a href={`/scenarios/${channel}/acquired`}>
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{acquiredCount}</div>
</Card>
</a>
<a href={`/scenarios/${channel}/added`}>
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{addedCount}</div>
</Card>
</a>
<Card className="p-2">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{passRate}%</div>
</Card>
</div>
<div className="h-48 bg-white rounded-lg p-4 mb-4">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={task.trend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
<XAxis dataKey="date" stroke="#666" />
<YAxis stroke="#666" />
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e5e7eb",
borderRadius: "6px",
}}
/>
<Line
type="monotone"
dataKey="customers"
name="获客数"
stroke="#3b82f6"
strokeWidth={2}
dot={{ fill: "#3b82f6" }}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.lastUpdated}</span>
</div>
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.nextExecutionTime}</span>
</div>
</div>
</Card>
{expanded && task.dailyData && (
<div className="mt-4 bg-white p-6 rounded-lg shadow-sm">
<h4 className="text-lg font-medium mb-4"></h4>
<div className="h-64">
<DailyAcquisitionChart data={task.dailyData} />
</div>
</div>
)}
<div className="flex justify-center mt-2">
<Button variant="ghost" size="sm" onClick={() => setExpanded(!expanded)} className="text-gray-500">
{expanded ? (
<>
<ChevronUp className="h-4 w-4 mr-1" />
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-1" />
</>
)}
</Button>
</div>
</div>
)
}
// 计算通过率
function calculatePassRate(acquired: number, added: number) {
if (acquired === 0) return 0
return Math.round((added / acquired) * 100)
}

View File

@@ -0,0 +1,97 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { TrafficTeamSettings } from "@/app/components/TrafficTeamSettings"
interface NewAcquisitionPlanFormProps {
onSubmit: (data: any) => void
onCancel: () => void
}
export function NewAcquisitionPlanForm({ onSubmit, onCancel }: NewAcquisitionPlanFormProps) {
const [formData, setFormData] = useState({
name: "",
description: "",
trafficTeams: [], // Initialize trafficTeams as an empty array
})
const [currentStep, setCurrentStep] = useState(1)
const handleChange = (data: any) => {
setFormData(data)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit(formData)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="steps-container">
{/* 步骤指示器 */}
<div className="step-indicators flex justify-between mb-8">
{[1, 2, 3, 4].map((step) => (
<div
key={step}
className={`flex flex-col items-center ${currentStep >= step ? "text-primary" : "text-gray-400"}`}
>
<div
className={`flex items-center justify-center w-8 h-8 rounded-full mb-2 ${
currentStep >= step ? "bg-primary text-white" : "bg-gray-100 text-gray-400"
}`}
>
{step}
</div>
<div className="text-sm font-medium">
{step === 1 && "基础设置"}
{step === 2 && "好友设置"}
{step === 3 && "消息设置"}
{step === 4 && "流量标签"}
</div>
</div>
))}
</div>
</div>
<Card className="p-6">
<div className="space-y-4">
<h2 className="text-lg font-semibold"></h2>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="输入计划名称"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder="输入计划描述"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
</div>
</Card>
<TrafficTeamSettings formData={formData} onChange={handleChange} />
<div className="flex justify-end space-x-4">
<Button type="button" variant="outline" onClick={onCancel}>
</Button>
<Button type="submit"></Button>
</div>
</form>
)
}

View File

@@ -0,0 +1,212 @@
"use client"
import { Card } from "@/components/ui/card"
import type React from "react"
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { MoreHorizontal, Copy, Pencil, Trash2, Clock, Link } from "lucide-react"
interface Task {
id: string
name: string
status: "running" | "paused" | "completed"
stats: {
devices: number
acquired: number
added: number
}
lastUpdated: string
executionTime: string
nextExecutionTime: string
trend: { date: string; customers: number }[]
reqConf?: {
device?: string[]
selectedDevices?: string[]
}
}
interface ScenarioAcquisitionCardProps {
task: Task
channel: string
onEdit: (taskId: string) => void
onCopy: (taskId: string) => void
onDelete: (taskId: string) => void
onOpenSettings?: (taskId: string) => void
onStatusChange?: (taskId: string, newStatus: "running" | "paused") => void
}
export function ScenarioAcquisitionCard({
task,
channel,
onEdit,
onCopy,
onDelete,
onOpenSettings,
onStatusChange,
}: ScenarioAcquisitionCardProps) {
// 兼容后端真实数据结构
const deviceCount = Array.isArray(task.reqConf?.device)
? task.reqConf.device.length
: Array.isArray(task.reqConf?.selectedDevices)
? task.reqConf.selectedDevices.length
: 0
// 获客数和已添加数可根据 msgConf 或其它字段自定义
const acquiredCount = task.acquiredCount ?? 0
const addedCount = task.addedCount ?? 0
const passRate = task.passRate ?? 0
const [menuOpen, setMenuOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const isActive = task.status === 1;
const handleStatusChange = (e: React.MouseEvent) => {
e.stopPropagation()
if (onStatusChange) {
onStatusChange(task.id, task.status === "running" ? "paused" : "running")
}
}
const handleEdit = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(false)
onEdit(task.id)
}
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(false)
onCopy(task.id)
}
const handleOpenSettings = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(false)
if (onOpenSettings) {
onOpenSettings(task.id)
}
}
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(false)
onDelete(task.id)
}
const toggleMenu = (e: React.MouseEvent) => {
e.stopPropagation()
setMenuOpen(!menuOpen)
}
// 点击外部关闭菜单
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setMenuOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => {
document.removeEventListener("mousedown", handleClickOutside)
}
}, [])
return (
<Card className="p-6 hover:shadow-lg transition-all mb-4 bg-white/80">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center space-x-3">
<h3 className="font-medium text-lg">{task.name}</h3>
<Badge
variant={isActive ? "success" : "secondary"}
className="cursor-pointer hover:opacity-80"
onClick={handleStatusChange}
>
{isActive ? "进行中" : "已暂停"}
</Badge>
</div>
<div className="relative z-20" ref={menuRef}>
<Button variant="ghost" size="icon" className="h-8 w-8 hover:bg-gray-100 rounded-full" onClick={toggleMenu}>
<MoreHorizontal className="h-4 w-4" />
</Button>
{menuOpen && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg z-50 py-1 border">
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleEdit}
>
<Pencil className="w-4 h-4 mr-2" />
</button>
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleCopy}
>
<Copy className="w-4 h-4 mr-2" />
</button>
{onOpenSettings && (
<button
className="flex items-center w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={handleOpenSettings}
>
<Link className="w-4 h-4 mr-2" />
</button>
)}
<button
className="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-gray-100"
onClick={handleDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
</button>
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-2 mb-4">
<a href={`/scenarios/${channel}/devices`} className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{deviceCount}</div>
</Card>
</a>
<a href={`/scenarios/${channel}/acquired`} className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{acquiredCount}</div>
</Card>
</a>
<a href={`/scenarios/${channel}/added`} className="block">
<Card className="p-2 hover:bg-gray-50 transition-colors cursor-pointer">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{addedCount}</div>
</Card>
</a>
<Card className="p-2">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-2xl font-semibold">{passRate}%</div>
</Card>
</div>
<div className="flex items-center justify-between text-sm border-t pt-4 text-gray-500">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>{task.lastUpdated}</span>
</div>
</div>
</Card>
)
}
// 计算通过率
function calculatePassRate(acquired: number, added: number) {
if (acquired === 0) return 0
return Math.round((added / acquired) * 100)
}

View File

@@ -0,0 +1,27 @@
"use client"
import { useEffect } from "react"
import { useRouter, usePathname } from "next/navigation"
export function AuthCheck({ children }: { children: React.ReactNode }) {
const router = useRouter()
const pathname = usePathname()
useEffect(() => {
// 排除不需要登录的页面
const publicPaths = ['/login', '/register', '/forgot-password']
if (publicPaths.includes(pathname)) {
return
}
const token = localStorage.getItem('token')
if (!token) {
// 如果没有token重定向到登录页面并携带当前页面URL作为回调
const currentPath = window.location.pathname + window.location.search
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`)
return
}
}, [router, pathname])
return <>{children}</>
}

View File

@@ -0,0 +1,341 @@
"use client"
import { useState, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Battery, Smartphone, MessageCircle, Users, Clock, Search, Power, RefreshCcw, Settings, AlertTriangle } from "lucide-react"
import { ImeiDisplay } from "@/components/ImeiDisplay"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
export interface Device {
id: string
imei: string
name: string
status: "online" | "offline"
battery: number
wechatId: string
friendCount: number
todayAdded: number
messageCount: number
lastActive: string
addFriendStatus: "normal" | "abnormal"
}
interface DeviceGridProps {
devices: Device[]
selectable?: boolean
selectedDevices?: string[]
onSelect?: (deviceIds: string[]) => void
itemsPerRow?: number
}
export function DeviceGrid({
devices,
selectable = false,
selectedDevices = [],
onSelect,
itemsPerRow = 2,
}: DeviceGridProps) {
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null)
const [searchTerm, setSearchTerm] = useState("")
const [filteredDevices, setFilteredDevices] = useState(devices)
useEffect(() => {
const filtered = devices.filter(
(device) =>
device.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
device.imei.includes(searchTerm) ||
device.wechatId.toLowerCase().includes(searchTerm.toLowerCase())
)
setFilteredDevices(filtered)
}, [searchTerm, devices])
const handleSelectAll = () => {
if (selectedDevices.length === filteredDevices.length) {
onSelect?.([])
} else {
onSelect?.(filteredDevices.map((d) => d.id))
}
}
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="relative w-full sm:w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-gray-500" />
<Input
placeholder="搜索设备..."
className="pl-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{selectable && (
<div className="flex items-center justify-between w-full sm:w-auto">
<div className="flex items-center space-x-2">
<Checkbox
checked={selectedDevices.length === filteredDevices.length && filteredDevices.length > 0}
onCheckedChange={handleSelectAll}
/>
<span className="text-sm"></span>
</div>
<span className="text-sm text-gray-500 ml-4"> {selectedDevices.length} </span>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredDevices.map((device) => (
<Card
key={device.id}
className={`p-4 hover:shadow-md transition-all cursor-pointer ${
selectedDevices.includes(device.id) ? "ring-2 ring-primary" : ""
}`}
onClick={() => {
if (selectable) {
const newSelection = selectedDevices.includes(device.id)
? selectedDevices.filter((id) => id !== device.id)
: [...selectedDevices, device.id]
onSelect?.(newSelection)
} else {
setSelectedDevice(device)
}
}}
>
<div className="flex items-start space-x-3">
{selectable && (
<Checkbox
checked={selectedDevices.includes(device.id)}
className="mt-1"
onClick={(e) => e.stopPropagation()}
/>
)}
<div className="flex-1 space-y-2">
<div className="relative">
<div className="flex items-center justify-between">
<div className="font-medium flex items-center">
<span>{device.name}</span>
{device.addFriendStatus === "abnormal" && (
<Badge variant="destructive" className="ml-2 text-xs">
</Badge>
)}
</div>
<div className="absolute top-0 right-0">
<Badge
variant={device.status === "online" ? "default" : "secondary"}
className={`${
device.status === "online"
? "bg-green-100 text-green-800 hover:bg-green-200"
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
}`}
>
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
device.status === "online" ? "bg-green-500" : "bg-gray-500"
}`} />
<span>{device.status === "online" ? "在线" : "离线"}</span>
</div>
</Badge>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
<Battery className={`w-4 h-4 ${
device.battery < 20
? "text-red-500"
: device.battery < 50
? "text-yellow-500"
: "text-green-500"
}`} />
<span className={`${
device.battery < 20
? "text-red-700"
: device.battery < 50
? "text-yellow-700"
: "text-green-700"
}`}>{device.battery}%</span>
</div>
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
<Users className="w-4 h-4 text-blue-500" />
<span className="text-blue-700">{device.friendCount}</span>
</div>
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
<MessageCircle className="w-4 h-4 text-purple-500" />
<span className="text-purple-700">{device.messageCount}</span>
</div>
<div className="flex items-center space-x-2 bg-gray-50 rounded-lg p-2">
<Clock className="w-4 h-4 text-indigo-500" />
<span className="text-indigo-700">+{device.todayAdded}</span>
</div>
</div>
<div className="text-sm space-y-1.5 mt-3">
<div className="flex items-center text-gray-600">
<span className="w-16">IMEI:</span>
<ImeiDisplay imei={device.imei} containerWidth={120} />
</div>
<div className="flex items-center text-gray-600">
<span className="w-16">:</span>
<span className="font-mono">{device.wechatId}</span>
</div>
</div>
</div>
</div>
</Card>
))}
</div>
<Dialog open={!!selectedDevice} onOpenChange={() => setSelectedDevice(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedDevice && (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`p-3 rounded-lg ${
selectedDevice.status === "online"
? "bg-green-100"
: "bg-gray-100"
}`}>
<Smartphone className={`w-6 h-6 ${
selectedDevice.status === "online"
? "text-green-700"
: "text-gray-700"
}`} />
</div>
<div>
<h3 className="font-medium flex items-center space-x-2">
<span>{selectedDevice.name}</span>
<Badge
variant={selectedDevice.status === "online" ? "default" : "secondary"}
className={`${
selectedDevice.status === "online"
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}`}
>
<div className="flex items-center space-x-1">
<div className={`w-2 h-2 rounded-full ${
selectedDevice.status === "online" ? "bg-green-500" : "bg-gray-500"
}`} />
<span>{selectedDevice.status === "online" ? "在线" : "离线"}</span>
</div>
</Badge>
</h3>
<div className="text-sm text-gray-500 mt-1 space-x-4">
<span>IMEI: <ImeiDisplay imei={selectedDevice.imei} containerWidth={160} /></span>
<span>: {selectedDevice.wechatId}</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<Button variant="outline" size="sm" className="space-x-1">
<RefreshCcw className="w-4 h-4" />
<span></span>
</Button>
<Button variant="outline" size="sm" className="space-x-1">
<Power className="w-4 h-4" />
<span></span>
</Button>
<Button variant="outline" size="sm">
<Settings className="w-4 h-4" />
</Button>
</div>
</div>
<Tabs defaultValue="status" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="status"></TabsTrigger>
<TabsTrigger value="stats"></TabsTrigger>
<TabsTrigger value="tasks"></TabsTrigger>
<TabsTrigger value="logs"></TabsTrigger>
</TabsList>
<TabsContent value="status" className="space-y-4">
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Battery className={`w-5 h-5 ${
selectedDevice.battery < 20
? "text-red-500"
: selectedDevice.battery < 50
? "text-yellow-500"
: "text-green-500"
}`} />
<span className={`font-medium ${
selectedDevice.battery < 20
? "text-red-700"
: selectedDevice.battery < 50
? "text-yellow-700"
: "text-green-700"
}`}>{selectedDevice.battery}%</span>
</div>
</div>
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Users className="w-5 h-5 text-blue-500" />
<span className="font-medium text-blue-700">{selectedDevice.friendCount}</span>
</div>
</div>
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<Users className="w-5 h-5 text-green-500" />
<span className="font-medium text-green-700">+{selectedDevice.todayAdded}</span>
</div>
</div>
<div className="space-y-1 bg-gray-50 p-4 rounded-lg">
<div className="text-sm text-gray-500"></div>
<div className="flex items-center space-x-2">
<MessageCircle className="w-5 h-5 text-purple-500" />
<span className="font-medium text-purple-700">{selectedDevice.messageCount}</span>
</div>
</div>
</div>
{selectedDevice.addFriendStatus === "abnormal" && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mt-4">
<div className="flex items-center space-x-2 text-red-800">
<AlertTriangle className="w-5 h-5" />
<span className="font-medium"></span>
</div>
<p className="text-sm text-red-600 mt-1">
</p>
</div>
)}
</TabsContent>
<TabsContent value="stats">
<div className="text-center text-gray-500 py-8">
...
</div>
</TabsContent>
<TabsContent value="tasks">
<div className="text-center text-gray-500 py-8">
...
</div>
</TabsContent>
<TabsContent value="logs">
<div className="text-center text-gray-500 py-8">
...
</div>
</TabsContent>
</Tabs>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,206 @@
"use client"
import { useState, useEffect } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Search, RefreshCw, Filter } from "lucide-react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Checkbox } from "@/components/ui/checkbox"
import { api } from "@/lib/api"
interface Device {
id: string
name: string
imei: string
status: "online" | "offline"
wechatAccounts: {
wechatId: string
nickname: string
remainingAdds: number
maxDailyAdds: number
}[]
}
interface DeviceSelectionDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedDevices: string[]
onSelect: (deviceIds: string[]) => void
}
export function DeviceSelectionDialog({ open, onOpenChange, selectedDevices, onSelect }: DeviceSelectionDialogProps) {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [selectedDeviceIds, setSelectedDeviceIds] = useState<string[]>([])
useEffect(() => {
if (open) setSelectedDeviceIds(selectedDevices)
}, [open, selectedDevices])
useEffect(() => {
if (!open) return
const fetchDevices = async () => {
setLoading(true)
try {
const params = []
if (searchQuery) params.push(`keyword=${encodeURIComponent(searchQuery)}`)
if (statusFilter !== "all") params.push(`status=${statusFilter}`)
params.push("page=1", "limit=100")
const url = `/v1/devices?${params.join("&")}`
const response = await api.get<any>(url)
const list = response.data?.list || response.data?.items || []
const devices = list.map((device: any) => ({
id: device.id?.toString() || device.id,
imei: device.imei || "",
name: device.memo || device.name || `设备_${device.id}`,
status: device.alive === 1 || device.status === "online" ? "online" : "offline",
wechatAccounts: [
{
wechatId: device.wechatId || device.wxid || "",
nickname: device.nickname || "",
remainingAdds: device.remainingAdds || 0,
maxDailyAdds: device.maxDailyAdds || 0,
},
],
}))
setDevices(devices)
} catch {
setDevices([])
} finally {
setLoading(false)
}
}
fetchDevices()
}, [open, searchQuery, statusFilter])
const handleSelectDevice = (deviceId: string) => {
setSelectedDeviceIds((prev) =>
prev.includes(deviceId) ? prev.filter((id) => id !== deviceId) : [...prev, deviceId]
)
}
const handleSelectAll = () => {
if (selectedDeviceIds.length === filteredDevices.length) {
setSelectedDeviceIds([])
} else {
setSelectedDeviceIds(filteredDevices.map((device) => device.id))
}
}
const handleConfirm = () => {
onSelect(selectedDeviceIds)
onOpenChange(false)
}
const handleCancel = () => {
setSelectedDeviceIds(selectedDevices)
onOpenChange(false)
}
const filteredDevices = devices.filter((device) => {
const searchLower = searchQuery.toLowerCase()
const matchesSearch =
(device.name || '').toLowerCase().includes(searchLower) ||
(device.imei || '').toLowerCase().includes(searchLower) ||
(device.wechatAccounts[0]?.wechatId || '').toLowerCase().includes(searchLower)
const matchesStatus =
statusFilter === "all" ||
(statusFilter === "online" && device.status === "online") ||
(statusFilter === "offline" && device.status === "offline")
return matchesSearch && matchesStatus
})
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl w-full p-0 rounded-2xl shadow-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="text-lg font-bold text-center py-3 border-b"></DialogTitle>
</DialogHeader>
<div className="p-6 pt-4">
{/* 搜索和筛选 */}
<div className="flex items-center gap-2 mb-4">
<Input
placeholder="搜索设备IMEI/备注/微信号"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="flex-1 rounded-lg border-gray-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100"
/>
<select
className="border rounded-lg px-3 py-2 text-sm bg-gray-50 focus:border-blue-500"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
>
<option value="all"></option>
<option value="online">线</option>
<option value="offline">线</option>
</select>
</div>
{/* 设备列表 */}
<div className="max-h-[400px] overflow-y-auto space-y-2 pr-2">
{loading ? (
<div className="text-center text-gray-400 py-8">...</div>
) : filteredDevices.length === 0 ? (
<div className="text-center text-gray-400 py-8"></div>
) : (
filteredDevices.map(device => {
const checked = selectedDeviceIds.includes(device.id)
const wx = device.wechatAccounts[0] || {}
return (
<label
key={device.id}
className={`
flex items-center gap-3 p-4 rounded-xl border
${checked ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"}
hover:border-blue-400 transition-colors cursor-pointer
`}
>
<input
type="checkbox"
className="accent-blue-500 scale-110"
checked={checked}
onChange={() => {
setSelectedDeviceIds(prev =>
prev.includes(device.id)
? prev.filter(id => id !== device.id)
: [...prev, device.id]
)
}}
/>
<div className="flex-1">
<div className="font-semibold text-base">{device.name}</div>
<div className="text-xs text-gray-500">IMEI: {device.imei}</div>
<div className="text-xs text-gray-400">: {wx.wechatId || '--'}{wx.nickname || '--'}</div>
</div>
<span className="flex items-center gap-1 text-xs font-medium">
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-green-500' : 'bg-gray-300'}`}></span>
<span className={device.status === 'online' ? 'text-green-600' : 'text-gray-400'}>
{device.status === 'online' ? '在线' : '离线'}
</span>
</span>
</label>
)
})
)}
</div>
{/* 确认按钮 */}
<div className="flex justify-center mt-8">
<Button
className="w-4/5 py-3 rounded-full text-base font-bold shadow-md"
onClick={() => {
onSelect(selectedDeviceIds)
onOpenChange(false)
}}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Check } from "lucide-react"
interface PosterTemplate {
id: string
title: string
type: "领取" | "了解"
imageUrl: string
}
interface PosterSelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
onSelect: (template: PosterTemplate) => void
}
const templates: PosterTemplate[] = [
{
id: "1",
title: "点击领取",
type: "领取",
imageUrl: "/placeholder.svg?height=400&width=300",
},
{
id: "2",
title: "点击了解",
type: "了解",
imageUrl: "/placeholder.svg?height=400&width=300",
},
// ... 其他模板
]
export function PosterSelector({ open, onOpenChange, onSelect }: PosterSelectorProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div>
<h3 className="text-sm text-gray-500 mb-4">使</h3>
<div className="grid grid-cols-3 gap-6">
{templates.map((template) => (
<div
key={template.id}
className="group relative cursor-pointer"
onClick={() => {
onSelect(template)
onOpenChange(false)
}}
>
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-gray-100">
<img
src={template.imageUrl || "/placeholder.svg"}
alt={template.title}
className="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 bg-black/50 group-hover:opacity-100 transition-opacity">
<Check className="w-8 h-8 text-white" />
</div>
<div className="mt-2 text-center">
<div className="font-medium">{template.title}</div>
<div className="text-sm text-gray-500">{template.type}</div>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end space-x-2 mt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button></Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,324 @@
"use client"
import { useState, useEffect } from "react"
import { Card } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, Filter } from "lucide-react"
import { Checkbox } from "@/components/ui/checkbox"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
interface UserTag {
id: string
name: string
color: string
}
interface TrafficUser {
id: string
avatar: string
nickname: string
wechatId: string
phone: string
region: string
note: string
status: "pending" | "added" | "failed"
addTime: string
source: string
assignedTo: string
category: "potential" | "customer" | "lost"
tags: UserTag[]
}
interface TrafficPoolSelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
selectedUsers: TrafficUser[]
onSelect: (users: TrafficUser[]) => void
}
export function TrafficPoolSelector({ open, onOpenChange, selectedUsers, onSelect }: TrafficPoolSelectorProps) {
const [users, setUsers] = useState<TrafficUser[]>([])
const [loading, setLoading] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [activeCategory, setActiveCategory] = useState("all")
const [sourceFilter, setSourceFilter] = useState("all")
const [tagFilter, setTagFilter] = useState("all")
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([])
// 初始化已选用户
useEffect(() => {
if (open) {
setSelectedUserIds(selectedUsers.map((user) => user.id))
}
}, [open, selectedUsers])
// 模拟获取用户数据
useEffect(() => {
if (!open) return
const fetchUsers = async () => {
setLoading(true)
try {
// 模拟API请求
await new Promise((resolve) => setTimeout(resolve, 800))
// 生成模拟数据
const mockUsers: TrafficUser[] = Array.from({ length: 30 }, (_, i) => {
// 随机标签
const tagPool = [
{ id: "tag1", name: "潜在客户", color: "bg-blue-100 text-blue-800" },
{ id: "tag2", name: "高意向", color: "bg-green-100 text-green-800" },
{ id: "tag3", name: "已成交", color: "bg-purple-100 text-purple-800" },
{ id: "tag4", name: "需跟进", color: "bg-yellow-100 text-yellow-800" },
{ id: "tag5", name: "活跃用户", color: "bg-indigo-100 text-indigo-800" },
{ id: "tag6", name: "沉默用户", color: "bg-gray-100 text-gray-800" },
{ id: "tag7", name: "企业客户", color: "bg-red-100 text-red-800" },
{ id: "tag8", name: "个人用户", color: "bg-pink-100 text-pink-800" },
]
const randomTags = Array.from(
{ length: Math.floor(Math.random() * 3) + 1 },
() => tagPool[Math.floor(Math.random() * tagPool.length)],
)
// 确保标签唯一
const uniqueTags = randomTags.filter((tag, index, self) => index === self.findIndex((t) => t.id === tag.id))
const sources = ["抖音直播", "小红书", "微信朋友圈", "视频号", "公众号"]
const statuses = ["pending", "added", "failed"] as const
return {
id: `user-${i + 1}`,
avatar: `/placeholder.svg?height=40&width=40`,
nickname: `用户${i + 1}`,
wechatId: `wxid_${Math.random().toString(36).substring(2, 10)}`,
phone: `1${Math.floor(Math.random() * 9 + 1)}${Array(9)
.fill(0)
.map(() => Math.floor(Math.random() * 10))
.join("")}`,
region: ["北京", "上海", "广州", "深圳", "杭州"][Math.floor(Math.random() * 5)],
note: Math.random() > 0.7 ? `这是用户${i + 1}的备注信息` : "",
status: statuses[Math.floor(Math.random() * 3)],
addTime: new Date(Date.now() - Math.floor(Math.random() * 30) * 24 * 60 * 60 * 1000).toISOString(),
source: sources[Math.floor(Math.random() * sources.length)],
assignedTo: Math.random() > 0.5 ? `销售${Math.floor(Math.random() * 5) + 1}` : "",
category: ["potential", "customer", "lost"][Math.floor(Math.random() * 3)] as
| "potential"
| "customer"
| "lost",
tags: uniqueTags,
}
})
setUsers(mockUsers)
} catch (error) {
console.error("Failed to fetch users:", error)
} finally {
setLoading(false)
}
}
fetchUsers()
}, [open])
// 过滤用户
const filteredUsers = users.filter((user) => {
const matchesSearch =
searchQuery === "" ||
user.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.wechatId.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.phone.includes(searchQuery)
const matchesCategory = activeCategory === "all" || user.category === activeCategory
const matchesSource = sourceFilter === "all" || user.source === sourceFilter
const matchesTag = tagFilter === "all" || user.tags.some((tag) => tag.id === tagFilter)
return matchesSearch && matchesCategory && matchesSource && matchesTag
})
// 处理选择用户
const handleSelectUser = (userId: string) => {
setSelectedUserIds((prev) => (prev.includes(userId) ? prev.filter((id) => id !== userId) : [...prev, userId]))
}
// 处理全选
const handleSelectAll = () => {
if (selectedUserIds.length === filteredUsers.length) {
setSelectedUserIds([])
} else {
setSelectedUserIds(filteredUsers.map((user) => user.id))
}
}
// 处理确认选择
const handleConfirm = () => {
const selectedUsersList = users.filter((user) => selectedUserIds.includes(user.id))
onSelect(selectedUsersList)
onOpenChange(false)
}
// 获取所有标签选项
const allTags = Array.from(new Set(users.flatMap((user) => user.tags).map((tag) => JSON.stringify(tag)))).map(
(tag) => JSON.parse(tag) as UserTag,
)
// 获取所有来源选项
const allSources = Array.from(new Set(users.map((user) => user.source)))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col">
{/* 搜索和筛选区域 */}
<div className="space-y-4 mb-4">
<div className="flex items-center space-x-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索用户名/微信号/手机号"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="icon">
<Filter className="h-4 w-4" />
</Button>
</div>
{/* 分类标签页 */}
<Tabs defaultValue="all" value={activeCategory} onValueChange={setActiveCategory}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="potential"></TabsTrigger>
<TabsTrigger value="customer"></TabsTrigger>
<TabsTrigger value="lost"></TabsTrigger>
</TabsList>
</Tabs>
{/* 筛选器 */}
<div className="flex space-x-2">
<Select value={sourceFilter} onValueChange={setSourceFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="来源" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{allSources.map((source) => (
<SelectItem key={source} value={source}>
{source}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tagFilter} onValueChange={setTagFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="标签" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{allTags.map((tag) => (
<SelectItem key={tag.id} value={tag.id}>
{tag.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" className="ml-auto" onClick={handleSelectAll}>
{selectedUserIds.length === filteredUsers.length && filteredUsers.length > 0 ? "取消全选" : "全选"}
</Button>
</div>
</div>
{/* 用户列表 */}
<ScrollArea className="flex-1">
{loading ? (
<div className="flex items-center justify-center h-40">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
) : filteredUsers.length === 0 ? (
<div className="text-center py-8 text-gray-500">
{searchQuery || activeCategory !== "all" || sourceFilter !== "all" || tagFilter !== "all"
? "没有符合条件的用户"
: "暂无用户数据"}
</div>
) : (
<div className="space-y-2 pr-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className={`p-3 hover:shadow-md transition-shadow ${
selectedUserIds.includes(user.id) ? "border-primary" : ""
}`}
>
<div className="flex items-center space-x-3">
<Checkbox
checked={selectedUserIds.includes(user.id)}
onCheckedChange={() => handleSelectUser(user.id)}
id={`user-${user.id}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<label htmlFor={`user-${user.id}`} className="font-medium truncate cursor-pointer">
{user.nickname}
</label>
<div
className={`px-2 py-1 rounded-full text-xs ${
user.status === "added"
? "bg-green-100 text-green-800"
: user.status === "pending"
? "bg-yellow-100 text-yellow-800"
: "bg-red-100 text-red-800"
}`}
>
{user.status === "added" ? "已添加" : user.status === "pending" ? "待处理" : "已失败"}
</div>
</div>
<div className="text-sm text-gray-500">: {user.wechatId}</div>
<div className="text-sm text-gray-500">: {user.source}</div>
{/* 标签展示 */}
<div className="flex flex-wrap gap-1 mt-2">
{user.tags.map((tag) => (
<span key={tag.id} className={`text-xs px-2 py-0.5 rounded-full ${tag.color}`}>
{tag.name}
</span>
))}
</div>
</div>
</div>
</Card>
))}
</div>
)}
</ScrollArea>
</div>
<DialogFooter className="flex items-center justify-between pt-4 border-t">
<div className="text-sm">
<span className="font-medium text-primary">{selectedUserIds.length}</span>
</div>
<div className="space-x-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleConfirm}></Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,42 @@
"use client"
import type React from "react"
import { cn } from "@/app/lib/utils"
interface CardGridProps {
children: React.ReactNode
className?: string
columns?: {
sm?: number
md?: number
lg?: number
xl?: number
}
gap?: "none" | "sm" | "md" | "lg"
}
/**
* 自适应卡片网格组件
* 根据屏幕尺寸自动调整卡片布局
*/
export function CardGrid({ children, className, columns = { sm: 1, md: 2, lg: 3, xl: 4 }, gap = "md" }: CardGridProps) {
// 根据gap参数设置间距
const gapClasses = {
none: "gap-0",
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
}
// 根据columns参数设置网格列数
const getGridCols = () => {
const cols = []
if (columns.sm) cols.push(`grid-cols-${columns.sm}`)
if (columns.md) cols.push(`md:grid-cols-${columns.md}`)
if (columns.lg) cols.push(`lg:grid-cols-${columns.lg}`)
if (columns.xl) cols.push(`xl:grid-cols-${columns.xl}`)
return cols.join(" ")
}
return <div className={cn("grid w-full", getGridCols(), gapClasses[gap], className)}>{children}</div>
}

View File

@@ -0,0 +1,138 @@
"use client"
import { Card } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Clock, TrendingUp, Users } from "lucide-react"
interface StatsCardProps {
title: string
value: string | number
description?: string
trend?: number
trendLabel?: string
className?: string
valueClassName?: string
}
/**
* 统计数据卡片
* 用于展示关键指标数据
*/
export function StatsCard({
title,
value,
description,
trend,
trendLabel,
className = "",
valueClassName = "text-xl font-bold text-blue-600",
}: StatsCardProps) {
return (
<Card className={`p-3 ${className}`}>
<div className="text-sm text-gray-500">{title}</div>
<div className={valueClassName}>{value}</div>
{description && <div className="text-xs text-gray-500 mt-1">{description}</div>}
{trend !== undefined && (
<div className={`flex items-center text-xs mt-1 ${trend >= 0 ? "text-green-600" : "text-red-600"}`}>
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend)}% {trendLabel || ""}
</div>
)}
</Card>
)
}
interface DistributionPlanCardProps {
id: string
name: string
status: "active" | "paused" | "completed"
source: string
sourceIcon: string
targetGroups: string[]
totalUsers: number
dailyAverage: number
lastUpdated: string
createTime: string
creator: string
onView?: (id: string) => void
onEdit?: (id: string) => void
onDelete?: (id: string) => void
onToggleStatus?: (id: string, status: "active" | "paused") => void
}
/**
* 流量分发计划卡片
* 用于展示流量分发计划信息
*/
export function DistributionPlanCard({
id,
name,
status,
source,
sourceIcon,
targetGroups,
totalUsers,
dailyAverage,
lastUpdated,
createTime,
creator,
onView,
onEdit,
onDelete,
onToggleStatus,
}: DistributionPlanCardProps) {
return (
<Card className="p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<span className="text-xl mr-1">{sourceIcon}</span>
<h3 className="font-medium">{name}</h3>
<Badge variant={status === "active" ? "success" : status === "completed" ? "outline" : "secondary"}>
{status === "active" ? "进行中" : status === "completed" ? "已完成" : "已暂停"}
</Badge>
</div>
<div className="flex items-center space-x-2">
{onToggleStatus && status !== "completed" && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleStatus(id, status === "active" ? "paused" : "active")}
>
{status === "active" ? "暂停" : "启动"}
</Button>
)}
{onView && (
<Button variant="ghost" size="sm" onClick={() => onView(id)}>
<Users className="h-4 w-4 mr-1" />
</Button>
)}
{onEdit && (
<Button variant="ghost" size="sm" onClick={() => onEdit(id)}>
<TrendingUp className="h-4 w-4 mr-1" />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="text-sm text-gray-500">
<div>{targetGroups.join(", ")}</div>
<div>{totalUsers} </div>
</div>
<div className="text-sm text-gray-500">
<div>{dailyAverage} </div>
<div>{creator}</div>
</div>
</div>
<div className="flex items-center justify-between text-xs text-gray-500 border-t pt-4">
<div className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{lastUpdated}
</div>
<div>{createTime}</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,87 @@
"use client"
import type React from "react"
import { cn } from "@/app/lib/utils"
interface FormLayoutProps {
children: React.ReactNode
className?: string
layout?: "vertical" | "horizontal" | "responsive"
labelWidth?: string
gap?: "none" | "sm" | "md" | "lg"
}
/**
* 自适应表单布局组件
* 支持垂直、水平和响应式布局
*/
export function FormLayout({
children,
className,
layout = "responsive",
labelWidth = "w-32",
gap = "md",
}: FormLayoutProps) {
// 根据gap参数设置间距
const gapClasses = {
none: "space-y-0",
sm: "space-y-2",
md: "space-y-4",
lg: "space-y-6",
}
// 根据layout参数设置布局类
const getLayoutClasses = () => {
switch (layout) {
case "horizontal":
return "form-horizontal"
case "vertical":
return "form-vertical"
case "responsive":
return "form-vertical md:form-horizontal"
default:
return "form-vertical"
}
}
return (
<div
className={cn("w-full", getLayoutClasses(), gapClasses[gap], className, {
[`[--label-width:${labelWidth}]`]: layout !== "vertical",
})}
>
{children}
</div>
)
}
export default FormLayout
interface FormItemProps {
children: React.ReactNode
label?: React.ReactNode
className?: string
required?: boolean
error?: string
}
/**
* 表单项组件
* 配合FormLayout使用
*/
export function FormItem({ children, label, className, required, error }: FormItemProps) {
return (
<div className={cn("form-item", className)}>
{label && (
<div className="form-label">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</div>
)}
<div className="form-control">
{children}
{error && <div className="text-red-500 text-sm mt-1">{error}</div>}
</div>
</div>
)
}

View File

@@ -0,0 +1,273 @@
"use client"
/**
* 表单组件模板
*
* 包含项目中常用的各种表单组件
*/
import type React from "react"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Slider } from "@/components/ui/slider"
import { Switch } from "@/components/ui/switch"
interface FormFieldProps {
label: string
htmlFor: string
required?: boolean
description?: string
error?: string
children: React.ReactNode
}
/**
* 表单字段容器
* 用于包装表单控件
*/
export function FormField({ label, htmlFor, required = false, description, error, children }: FormFieldProps) {
return (
<div className="space-y-2">
<Label htmlFor={htmlFor}>
{label} {required && <span className="text-red-500">*</span>}
</Label>
{children}
{description && <p className="text-xs text-gray-500">{description}</p>}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
)
}
interface TextInputFieldProps {
label: string
id: string
value: string
onChange: (value: string) => void
placeholder?: string
required?: boolean
description?: string
error?: string
type?: string
}
/**
* 文本输入字段
* 用于文本输入
*/
export function TextInputField({
label,
id,
value,
onChange,
placeholder,
required = false,
description,
error,
type = "text",
}: TextInputFieldProps) {
return (
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
<Input id={id} type={type} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
</FormField>
)
}
interface TextareaFieldProps {
label: string
id: string
value: string
onChange: (value: string) => void
placeholder?: string
required?: boolean
description?: string
error?: string
}
/**
* 多行文本输入字段
* 用于多行文本输入
*/
export function TextareaField({
label,
id,
value,
onChange,
placeholder,
required = false,
description,
error,
}: TextareaFieldProps) {
return (
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
<Textarea id={id} value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
</FormField>
)
}
interface SelectFieldProps {
label: string
id: string
value: string
onChange: (value: string) => void
options: Array<{ value: string; label: string }>
placeholder?: string
required?: boolean
description?: string
error?: string
}
/**
* 下拉选择字段
* 用于从选项中选择
*/
export function SelectField({
label,
id,
value,
onChange,
options,
placeholder = "请选择",
required = false,
description,
error,
}: SelectFieldProps) {
return (
<FormField label={label} htmlFor={id} required={required} description={description} error={error}>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
)
}
interface RadioFieldProps {
label: string
name: string
value: string
onChange: (value: string) => void
options: Array<{ value: string; label: string; description?: string }>
required?: boolean
description?: string
error?: string
}
/**
* 单选按钮组字段
* 用于从选项中单选
*/
export function RadioField({
label,
name,
value,
onChange,
options,
required = false,
description,
error,
}: RadioFieldProps) {
return (
<div className="space-y-2">
<Label>
{label} {required && <span className="text-red-500">*</span>}
</Label>
<RadioGroup value={value} onValueChange={onChange} className="space-y-3">
{options.map((option) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${name}-${option.value}`} />
<Label htmlFor={`${name}-${option.value}`} className="cursor-pointer">
{option.label}
</Label>
{option.description && <span className="text-xs text-gray-500 ml-2">({option.description})</span>}
</div>
))}
</RadioGroup>
{description && <p className="text-xs text-gray-500">{description}</p>}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
)
}
interface SliderFieldProps {
label: string
id: string
value: number
onChange: (value: number) => void
min: number
max: number
step?: number
unit?: string
required?: boolean
description?: string
error?: string
}
/**
* 滑块字段
* 用于数值范围选择
*/
export function SliderField({
label,
id,
value,
onChange,
min,
max,
step = 1,
unit = "",
required = false,
description,
error,
}: SliderFieldProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={id}>
{label} {required && <span className="text-red-500">*</span>}
</Label>
<span className="text-sm font-medium">
{value} {unit}
</span>
</div>
<Slider id={id} min={min} max={max} step={step} value={[value]} onValueChange={(values) => onChange(values[0])} />
{description && <p className="text-xs text-gray-500">{description}</p>}
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
)
}
interface SwitchFieldProps {
label: string
id: string
checked: boolean
onCheckedChange: (checked: boolean) => void
description?: string
disabled?: boolean
}
/**
* 开关字段
* 用于布尔值选择
*/
export function SwitchField({ label, id, checked, onCheckedChange, description, disabled = false }: SwitchFieldProps) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
<Switch id={id} checked={checked} onCheckedChange={onCheckedChange} disabled={disabled} />
</div>
{description && <p className="text-xs text-gray-500">{description}</p>}
</div>
)
}

View File

@@ -0,0 +1,12 @@
/**
* 存客宝UI组件模板库
*
* 这个文件导出所有可重用的UI组件模板方便在项目中快速引用
*/
export * from "./cards"
export * from "./forms"
export * from "./layouts"
export * from "./tables"
export * from "./stats"
export * from "./selectors"

View File

@@ -0,0 +1,119 @@
"use client"
/**
* 布局组件模板
*
* 包含项目中常用的各种布局组件
*/
import type React from "react"
import { Button } from "@/components/ui/button"
import { ChevronLeft } from "lucide-react"
interface PageHeaderProps {
title: string
onBack?: () => void
actionButton?: React.ReactNode
}
/**
* 页面头部组件
* 用于页面顶部的标题和操作区
*/
export function PageHeader({ title, onBack, actionButton }: PageHeaderProps) {
return (
<header className="sticky top-0 z-10 bg-white border-b">
<div className="flex items-center justify-between p-4">
<div className="flex items-center space-x-3">
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack}>
<ChevronLeft className="h-5 w-5" />
</Button>
)}
<h1 className="text-lg font-medium">{title}</h1>
</div>
{actionButton}
</div>
</header>
)
}
interface StepIndicatorProps {
steps: Array<{
step: number
title: string
icon: React.ReactNode
}>
currentStep: number
}
/**
* 步骤指示器组件
* 用于多步骤流程的导航
*/
export function StepIndicator({ steps, currentStep }: StepIndicatorProps) {
return (
<div className="mb-6">
<div className="flex items-center justify-between">
{steps.map(({ step, title, icon }) => (
<div key={step} className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
step === currentStep
? "bg-blue-600 text-white"
: step < currentStep
? "bg-green-500 text-white"
: "bg-gray-200 text-gray-500"
}`}
>
{step < currentStep ? "✓" : icon}
</div>
<span className="text-xs mt-1">{title}</span>
</div>
))}
</div>
<div className="relative mt-2">
<div className="absolute top-0 left-0 right-0 h-1 bg-gray-200"></div>
<div
className="absolute top-0 left-0 h-1 bg-blue-600 transition-all"
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
></div>
</div>
</div>
)
}
interface StepNavigationProps {
onBack: () => void
onNext: () => void
nextLabel?: string
backLabel?: string
isLastStep?: boolean
isNextDisabled?: boolean
}
/**
* 步骤导航组件
* 用于多步骤流程的前进后退按钮
*/
export function StepNavigation({
onBack,
onNext,
nextLabel = "下一步",
backLabel = "上一步",
isLastStep = false,
isNextDisabled = false,
}: StepNavigationProps) {
return (
<div className="pt-4 flex justify-between">
<Button variant="outline" onClick={onBack}>
<ChevronLeft className="mr-2 h-4 w-4" />
{backLabel}
</Button>
<Button onClick={onNext} disabled={isNextDisabled}>
{isLastStep ? "完成" : nextLabel}
{!isLastStep && <span className="ml-2"></span>}
</Button>
</div>
)
}

View File

@@ -0,0 +1,49 @@
"use client"
import type React from "react"
import { cn } from "@/app/lib/utils"
import { Button } from "@/app/components/ui/button"
import { ChevronLeft } from "lucide-react"
import { useRouter } from "next/navigation"
interface PageHeaderProps {
title: React.ReactNode
subtitle?: React.ReactNode
backButton?: boolean
backUrl?: string
actions?: React.ReactNode
className?: string
}
/**
* 页面标题组件
* 支持返回按钮和操作按钮
*/
export function PageHeader({ title, subtitle, backButton = false, backUrl, actions, className }: PageHeaderProps) {
const router = useRouter()
const handleBack = () => {
if (backUrl) {
router.push(backUrl)
} else {
router.back()
}
}
return (
<div className={cn("flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6", className)}>
<div className="flex items-center gap-2">
{backButton && (
<Button variant="ghost" size="icon" onClick={handleBack} className="h-8 w-8">
<ChevronLeft className="h-4 w-4" />
</Button>
)}
<div>
<h1 className="text-2xl font-bold">{title}</h1>
{subtitle && <p className="text-muted-foreground mt-1">{subtitle}</p>}
</div>
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
)
}

View File

@@ -0,0 +1,103 @@
"use client"
import React from "react"
import { cn } from "@/app/lib/utils"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/app/components/ui/table"
interface Column<T> {
key: string
title: React.ReactNode
render?: (value: any, record: T, index: number) => React.ReactNode
width?: number | string
className?: string
responsive?: boolean // 是否在小屏幕上显示
}
interface ResponsiveTableProps<T> {
columns: Column<T>[]
dataSource: T[]
rowKey?: string | ((record: T) => string)
className?: string
loading?: boolean
emptyText?: React.ReactNode
}
/**
* 响应式表格组件
* 在小屏幕上自动隐藏不重要的列
*/
export function ResponsiveTable<T extends Record<string, any>>({
columns,
dataSource,
rowKey = "id",
className,
loading = false,
emptyText = "暂无数据",
}: ResponsiveTableProps<T>) {
// 根据屏幕尺寸过滤列
const [visibleColumns, setVisibleColumns] = React.useState(columns)
React.useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 768) {
// 在小屏幕上只显示responsive不为false的列
setVisibleColumns(columns.filter((col) => col.responsive !== false))
} else {
setVisibleColumns(columns)
}
}
handleResize()
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [columns])
// 获取行的唯一键
const getRowKey = (record: T, index: number) => {
if (typeof rowKey === "function") {
return rowKey(record)
}
return record[rowKey] || index
}
return (
<div className="responsive-table-container">
<Table className={cn("w-full", className)}>
<TableHeader>
<TableRow>
{visibleColumns.map((column) => (
<TableHead key={column.key} className={column.className} style={{ width: column.width }}>
{column.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="text-center h-24">
...
</TableCell>
</TableRow>
) : dataSource.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length} className="text-center h-24">
{emptyText}
</TableCell>
</TableRow>
) : (
dataSource.map((record, index) => (
<TableRow key={getRowKey(record, index)}>
{visibleColumns.map((column) => (
<TableCell key={column.key} className={column.className}>
{column.render ? column.render(record[column.key], record, index) : record[column.key]}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,130 @@
"use client"
import { Card } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
interface CustomerServiceRepProps {
id: string
name: string
deviceId: string
status: "online" | "offline"
avatar?: string
selected: boolean
disabled?: boolean
onSelect: (id: string, checked: boolean) => void
}
/**
* 客服代表选择卡片
* 用于选择客服代表
*/
export function CustomerServiceRepCard({
id,
name,
deviceId,
status,
avatar,
selected,
disabled = false,
onSelect,
}: CustomerServiceRepProps) {
return (
<Card
className={`p-3 hover:shadow-md transition-shadow cursor-pointer ${
selected ? "border-primary bg-blue-50" : ""
} ${disabled ? "opacity-50 pointer-events-none" : ""}`}
onClick={() => !disabled && onSelect(id, !selected)}
>
<div className="flex items-center space-x-3">
<Checkbox
checked={selected}
onCheckedChange={() => !disabled && onSelect(id, !selected)}
disabled={disabled}
id={`rep-${id}`}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<label htmlFor={`rep-${id}`} className="font-medium truncate cursor-pointer">
{name}
</label>
<div
className={`px-2 py-1 rounded-full text-xs ${
status === "online" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"
}`}
>
{status === "online" ? "在线" : "离线"}
</div>
</div>
<div className="text-sm text-gray-500">ID: {deviceId}</div>
<div className="text-sm text-gray-500">ID: {id}</div>
</div>
</div>
</Card>
)
}
interface DeviceSelectProps {
allDevices: boolean
newDevices: boolean
targetDevices: string[]
onAllDevicesChange: (checked: boolean) => void
onNewDevicesChange: (checked: boolean) => void
onShowDeviceSelector: () => void
}
/**
* 设备选择组件
* 用于选择设备
*/
export function DeviceSelectSection({
allDevices,
newDevices,
targetDevices,
onAllDevicesChange,
onNewDevicesChange,
onShowDeviceSelector,
}: DeviceSelectProps) {
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="allDevices"
checked={allDevices}
onCheckedChange={(checked) => onAllDevicesChange(checked === true)}
/>
<Label htmlFor="allDevices" className="font-medium">
</Label>
<span className="text-xs text-gray-500">(线)</span>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="newDevices"
checked={newDevices}
onCheckedChange={(checked) => onNewDevicesChange(checked === true)}
disabled={allDevices}
/>
<Label htmlFor="newDevices" className="font-medium">
</Label>
<span className="text-xs text-gray-500">()</span>
</div>
<div className="space-y-2 pt-4">
<Label className="font-medium"></Label>
<p className="text-xs text-gray-500 mb-2"></p>
<button
className="w-full flex items-center justify-center space-x-2 border border-gray-300 rounded-md py-2 px-4 hover:bg-gray-50 transition-colors"
disabled={allDevices}
onClick={onShowDeviceSelector}
>
<span></span>
{targetDevices?.length > 0 && <Badge variant="secondary"> {targetDevices.length} </Badge>}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,58 @@
/**
* 统计组件模板
*
* 包含项目中常用的各种统计和数据展示组件
*/
import type React from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
interface StatsSummaryProps {
stats: Array<{
title: string
value: string | number
color?: string
}>
}
/**
* 统计摘要组件
* 用于展示多个统计数据
*/
export function StatsSummary({ stats }: StatsSummaryProps) {
return (
<div className="grid grid-cols-2 gap-3">
{stats.map((stat, index) => (
<Card key={index} className="p-3">
<div className="text-sm text-gray-500">{stat.title}</div>
<div className={`text-xl font-bold ${stat.color || "text-blue-600"}`}>{stat.value}</div>
</Card>
))}
</div>
)
}
interface DataCardProps {
title: string
children: React.ReactNode
className?: string
headerAction?: React.ReactNode
}
/**
* 数据卡片组件
* 用于包装数据展示内容
*/
export function DataCard({ title, children, className = "", headerAction }: DataCardProps) {
return (
<Card className={className}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{title}</CardTitle>
{headerAction}
</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,46 @@
"use client"
import { cn } from "@/app/lib/utils"
interface StepIndicatorProps {
steps: { id: number; title: string; subtitle?: string }[]
currentStep: number
className?: string
}
export function StepIndicator({ steps, currentStep, className }: StepIndicatorProps) {
return (
<div className={cn("w-full", className)}>
<div className="flex justify-between items-center mb-2">
{steps.map((step) => (
<div
key={step.id}
className={cn("flex flex-col items-center", currentStep === step.id ? "text-blue-600" : "text-gray-400")}
>
<div
className={cn(
"w-10 h-10 rounded-full flex items-center justify-center text-lg font-medium mb-1",
currentStep === step.id
? "bg-blue-600 text-white"
: currentStep > step.id
? "bg-blue-100 text-blue-600"
: "bg-gray-200 text-gray-500",
)}
>
{step.id}
</div>
<div className="text-xs">{step.title}</div>
{step.subtitle && <div className="text-xs font-medium">{step.subtitle}</div>}
</div>
))}
</div>
<div className="relative h-1 bg-gray-200 mt-2">
<div
className="absolute top-0 left-0 h-full bg-blue-600 transition-all duration-300"
style={{ width: `${((currentStep - 1) / (steps.length - 1)) * 100}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,198 @@
"use client"
/**
* 表格组件模板
*
* 包含项目中常用的各种表格组件
*/
import type React from "react"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Checkbox } from "@/components/ui/checkbox"
import { Badge } from "@/components/ui/badge"
interface Column<T> {
header: string
accessorKey: keyof T | ((row: T) => React.ReactNode)
cell?: (row: T) => React.ReactNode
}
interface DataTableProps<T> {
data: T[]
columns: Column<T>[]
keyField: keyof T
selectable?: boolean
selectedRows?: (string | number)[]
onSelectRow?: (id: string | number, checked: boolean) => void
onSelectAll?: (checked: boolean) => void
onRowClick?: (row: T) => void
emptyMessage?: string
}
/**
* 通用数据表格
* 用于展示表格数据
*/
export function DataTable<T extends Record<string, any>>({
data,
columns,
keyField,
selectable = false,
selectedRows = [],
onSelectRow,
onSelectAll,
onRowClick,
emptyMessage = "没有数据",
}: DataTableProps<T>) {
const handleRowClick = (row: T) => {
if (onRowClick) {
onRowClick(row)
}
}
return (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
{selectable && onSelectRow && onSelectAll && (
<TableHead className="w-12">
<Checkbox
checked={data.length > 0 && selectedRows.length === data.length}
onCheckedChange={(checked) => onSelectAll(checked === true)}
/>
</TableHead>
)}
{columns.map((column, index) => (
<TableHead key={index}>{column.header}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length + (selectable ? 1 : 0)} className="text-center py-6 text-gray-500">
{emptyMessage}
</TableCell>
</TableRow>
) : (
data.map((row) => (
<TableRow
key={String(row[keyField])}
className={onRowClick ? "cursor-pointer hover:bg-gray-50" : ""}
onClick={() => handleRowClick(row)}
>
{selectable && onSelectRow && (
<TableCell className="w-12" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selectedRows.includes(row[keyField])}
onCheckedChange={(checked) => onSelectRow(row[keyField], checked === true)}
/>
</TableCell>
)}
{columns.map((column, index) => (
<TableCell key={index}>
{column.cell
? column.cell(row)
: typeof column.accessorKey === "function"
? column.accessorKey(row)
: row[column.accessorKey]}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
)
}
interface DeviceTableProps {
devices: Array<{
id: string
name: string
imei: string
status: "online" | "offline"
battery?: number
wechatId?: string
lastActive?: string
}>
selectedDevices: string[]
onSelectDevice: (deviceId: string, checked: boolean) => void
onSelectAll: (checked: boolean) => void
onDeviceClick: (deviceId: string) => void
}
/**
* 设备表格
* 用于展示设备列表
*/
export function DeviceTableTemplate({
devices,
selectedDevices,
onSelectDevice,
onSelectAll,
onDeviceClick,
}: DeviceTableProps) {
const columns: Column<(typeof devices)[0]>[] = [
{
header: "设备名称",
accessorKey: "name",
cell: (row) => (
<div>
<div className="font-medium">{row.name}</div>
<div className="text-xs text-gray-500">IMEI: {row.imei}</div>
</div>
),
},
{
header: "状态",
accessorKey: "status",
cell: (row) => (
<Badge variant={row.status === "online" ? "success" : "secondary"}>
{row.status === "online" ? "在线" : "离线"}
</Badge>
),
},
{
header: "微信号",
accessorKey: "wechatId",
cell: (row) => row.wechatId || "-",
},
{
header: "电量",
accessorKey: "battery",
cell: (row) => (
<div className="flex items-center">
<div className="w-16 h-2 bg-gray-200 rounded-full overflow-hidden mr-2">
<div
className={`h-full ${(row.battery || 0) > 20 ? "bg-green-500" : "bg-red-500"}`}
style={{ width: `${row.battery || 0}%` }}
></div>
</div>
<span>{row.battery || 0}%</span>
</div>
),
},
{
header: "最后活跃",
accessorKey: "lastActive",
cell: (row) => row.lastActive || "-",
},
]
return (
<DataTable
data={devices}
columns={columns}
keyField="id"
selectable={true}
selectedRows={selectedDevices}
onSelectRow={onSelectDevice}
onSelectAll={onSelectAll}
onRowClick={(row) => onDeviceClick(row.id)}
emptyMessage="没有找到设备"
/>
)
}

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className={cn(
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
className,
)}
{...props}
>
<div className="pb-4 pt-0">{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -0,0 +1,41 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,30 @@
import type * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
)
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, children, ...props }, ref) => {
// If asChild is true and the first child is a valid element, clone it with the button props
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children, {
className: cn(buttonVariants({ variant, size, className })),
ref,
...props,
...children.props,
})
}
return (
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props}>
{children}
</button>
)
},
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,55 @@
"use client"
import type * as React from "react"
import { ChevronLeft, ChevronRight } from "lucide-react"
import { DayPicker } from "react-day-picker"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
export type CalendarProps = React.ComponentProps<typeof DayPicker>
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside: "text-muted-foreground opacity-50",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
)
}
Calendar.displayName = "Calendar"
export { Calendar }

View File

@@ -0,0 +1,44 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
)
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
)
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
)
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
)
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
)
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

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