Compare commits
1387 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f7a7c56ce4 | ||
|
|
2753265edb | ||
|
|
ac82726e12 | ||
|
|
dbe7b7aa7c | ||
|
|
16a700459e | ||
|
|
3aa7ecf8c0 | ||
|
|
b5b40e8bf3 | ||
|
|
7793093d0a | ||
|
|
5182529a58 | ||
|
|
0643e78a79 | ||
|
|
5517457929 | ||
|
|
711783baf5 | ||
|
|
11ad2df6f3 | ||
|
|
751543ab7a | ||
|
|
2f6187d2fa | ||
|
|
42e597349a | ||
|
|
949ec2ec6a | ||
|
|
0a7604f9ab | ||
|
|
fd38980751 | ||
|
|
0264644c2a | ||
|
|
10396c9ac9 | ||
|
|
b83ee46978 | ||
|
|
99251dcef8 | ||
|
|
4c8174d75d | ||
|
|
06a7a7be5c | ||
|
|
93387f64e0 | ||
|
|
fa5185952d | ||
|
|
3df3bcd9a5 | ||
|
|
c49dc76a04 | ||
|
|
1daae21236 | ||
|
|
63963b5ee6 | ||
|
|
ec45da57cf | ||
|
|
1775f0d45a | ||
|
|
3a4a09eedb | ||
|
|
ea226961bd | ||
|
|
7f5b96b88f | ||
|
|
2815287c2e | ||
|
|
03ae8c07b6 | ||
|
|
a3bda0ccac | ||
|
|
1cd7027e1a | ||
|
|
84c5085955 | ||
|
|
c9bd563fed | ||
|
|
0df622bbfd | ||
|
|
148962cbc2 | ||
|
|
a3179fae9d | ||
|
|
beb13bb280 | ||
|
|
5b0766d2b4 | ||
|
|
448662cce7 | ||
|
|
a6ba0af9f8 | ||
|
|
17ac4da44a | ||
|
|
9614a3138d | ||
|
|
b5facefde8 | ||
|
|
48fc5a7a7d | ||
|
|
cd7d00b4f8 | ||
|
|
f9d6c3034f | ||
|
|
175f9942e1 | ||
|
|
3e0cc92cfc | ||
|
|
db85605352 | ||
|
|
434d09183b | ||
|
|
c553198d5a | ||
|
|
c05fe80b89 | ||
|
|
700f3ab5d2 | ||
|
|
4a8ef1aec8 | ||
|
|
82bc96884a | ||
|
|
745d14c93f | ||
|
|
b838ae8fc1 | ||
|
|
06ca494e02 | ||
|
|
4ed9821c5d | ||
|
|
f0ba9b387d | ||
|
|
12cd0808bc | ||
|
|
07bfc890c2 | ||
|
|
67dfc0a87c | ||
|
|
ffd66d0f5e | ||
|
|
afe4dbd131 | ||
|
|
1f46065ebc | ||
|
|
597941fbd1 | ||
|
|
ec2d4957e4 | ||
|
|
5c1463ffa9 | ||
|
|
6ec8df94dc | ||
|
|
a72772d5ee | ||
|
|
ac47668016 | ||
|
|
3f7e1a4af6 | ||
|
|
f40649a534 | ||
|
|
99cbabbaaf | ||
|
|
04747667b5 | ||
|
|
9eaedd2c28 | ||
|
|
667a4ea656 | ||
|
|
2e856b1922 | ||
|
|
e403704ab3 | ||
|
|
d1c926205a | ||
|
|
b8fa23b6a5 | ||
|
|
96b828b1bf | ||
|
|
9ddcf79bac | ||
|
|
65fa2986b1 | ||
|
|
69e7c282ca | ||
|
|
109e9fef37 | ||
|
|
8ce1455555 | ||
|
|
69bdce481f | ||
|
|
10527ecfa8 | ||
|
|
82c15699cc | ||
|
|
092161316e | ||
|
|
faf907b61b | ||
|
|
2ecf9e331d | ||
|
|
1537b06278 | ||
|
|
bcbc92ad57 | ||
|
|
41ffb8a220 | ||
|
|
1d11f01115 | ||
|
|
1deebf3f5c | ||
|
|
ae1120f1e3 | ||
|
|
b597ed2275 | ||
|
|
1ecfacce49 | ||
|
|
b20f00be53 | ||
|
|
168fff56c1 | ||
|
|
689705ef70 | ||
|
|
6629a9ad14 | ||
|
|
af79d1e860 | ||
|
|
38379fac65 | ||
|
|
7534dd79fa | ||
|
|
f956fc949b | ||
|
|
442be3b02c | ||
|
|
208c25b2f7 | ||
|
|
dd4b43c10e | ||
|
|
519a9b1d1b | ||
|
|
f0c278a35f | ||
|
|
ab5885f2a0 | ||
|
|
9bc1d7d7bf | ||
|
|
4d8fd96190 | ||
|
|
7f7a5357a0 | ||
|
|
28adf38f2f | ||
|
|
b2abe9f570 | ||
|
|
5aa8657640 | ||
|
|
44dbe20b03 | ||
|
|
3a0621aab8 | ||
|
|
28f4479d3c | ||
|
|
e356ae67c7 | ||
|
|
6b31889261 | ||
|
|
16aec83f02 | ||
|
|
9fe0f9f677 | ||
|
|
ee4dcb1b8b | ||
|
|
5dfaba943b | ||
|
|
bac66de046 | ||
|
|
9b8f3b1b2b | ||
|
|
56d5f0b5a3 | ||
|
|
51a731ea68 | ||
|
|
002f03e037 | ||
|
|
a250819d87 | ||
|
|
135e58f1e2 | ||
|
|
58b6cd2340 | ||
|
|
b21f40942e | ||
|
|
dd9fa3d0f2 | ||
|
|
430b8eee91 | ||
|
|
cec64b889b | ||
|
|
443ef6ad2d | ||
|
|
3e8b607948 | ||
|
|
9eeaaa70c4 | ||
|
|
80ae205cb3 | ||
|
|
cd38e0eedd | ||
|
|
66d217d5f1 | ||
|
|
2b128195bf | ||
|
|
e469537ac5 | ||
|
|
b2e84a2259 | ||
|
|
228d59402f | ||
|
|
ea2dd8cab2 | ||
|
|
efcbb06eb2 | ||
|
|
61e720c247 | ||
|
|
88418386a4 | ||
|
|
f096e07ec8 | ||
|
|
046b00404e | ||
|
|
9bb6390f9a | ||
|
|
cb612746a6 | ||
|
|
8beb2cb6fe | ||
|
|
aabd8037a6 | ||
|
|
408c6a2029 | ||
|
|
ba0ebcf273 | ||
|
|
0457528dd0 | ||
|
|
e6037ccfd5 | ||
|
|
ce1c434ea8 | ||
|
|
b4c813311b | ||
|
|
08d2a811b7 | ||
|
|
256afaee12 | ||
|
|
198c62081b | ||
|
|
53b679aeb6 | ||
|
|
64358df1c1 | ||
|
|
b0d9481cdb | ||
|
|
db0985504a | ||
|
|
82f9b9371a | ||
|
|
d9ee7e9402 | ||
|
|
b609eeb0eb | ||
|
|
203e8c8eaf | ||
|
|
2c90f6f33c | ||
|
|
88bc7ef3cd | ||
|
|
5ae0d997d9 | ||
|
|
879396e91c | ||
|
|
b8754f6174 | ||
|
|
302617cd81 | ||
|
|
79fd133821 | ||
|
|
4f2b34522f | ||
|
|
853a7f32d2 | ||
|
|
eea18cc0c7 | ||
|
|
0e173668c5 | ||
|
|
abdcff813c | ||
|
|
0ac84bd033 | ||
|
|
4c12619f0e | ||
|
|
dc1f194bcc | ||
|
|
7a89b80c8f | ||
|
|
7a4f40de54 | ||
|
|
988d7fcdd3 | ||
|
|
236ef09538 | ||
|
|
d914f097c0 | ||
|
|
c37e5aca35 | ||
|
|
5e198d1021 | ||
|
|
cc91552c36 | ||
|
|
f208bbdb9a | ||
|
|
20f567da37 | ||
|
|
9b45ebea0e | ||
|
|
7a0ae54705 | ||
|
|
3f5a2189ce | ||
|
|
36d6f94177 | ||
|
|
c046bdab30 | ||
|
|
0c8f1d6419 | ||
|
|
f8810f5199 | ||
|
|
bc6421b1af | ||
|
|
2f00ee176b | ||
|
|
44da1feed3 | ||
|
|
66c8623138 | ||
|
|
e12fbad859 | ||
|
|
183df2cac3 | ||
|
|
7f1fda40dc | ||
|
|
d42a2df286 | ||
|
|
1a345fff79 | ||
|
|
7724d60a05 | ||
|
|
1cb7903e37 | ||
|
|
d8183909ca | ||
|
|
7f7d5d5c39 | ||
|
|
72cf3fd81c | ||
|
|
65167be7f8 | ||
|
|
7dda34a779 | ||
|
|
bde1d36005 | ||
|
|
631e8d00e2 | ||
|
|
3720987997 | ||
|
|
8e4ce2aee2 | ||
|
|
08fc18da86 | ||
|
|
b2760cde0e | ||
|
|
61926503cf | ||
|
|
84a51b8f91 | ||
|
|
4f8a69a511 | ||
|
|
4537305dfc | ||
|
|
be901a92f6 | ||
|
|
1067a15916 | ||
|
|
2a41d88ce3 | ||
|
|
80027e8fa3 | ||
|
|
df893296ba | ||
|
|
77e9f763cd | ||
|
|
c531957ce8 | ||
|
|
257ca5014b | ||
|
|
1c3e567b9a | ||
|
|
4aacb7bcb6 | ||
|
|
e7fef596d4 | ||
|
|
5d95ca6f53 | ||
|
|
3cd6bf9dba | ||
|
|
67a4bd6e3a | ||
|
|
c33c62aa6d | ||
|
|
581e09d45b | ||
|
|
688a200999 | ||
|
|
cc89df7957 | ||
|
|
198e0434b3 | ||
|
|
55fe2b46df | ||
|
|
c7e934f23d | ||
|
|
7752dd136f | ||
|
|
af2f32da4a | ||
|
|
81859dfacb | ||
|
|
3e145ca123 | ||
|
|
c9a5d3091f | ||
|
|
4374ad5036 | ||
|
|
1ca2ca7058 | ||
|
|
9fe15b1bec | ||
|
|
a557319b3e | ||
|
|
f06eb0e9d7 | ||
|
|
1be091522e | ||
|
|
63df35b79c | ||
|
|
2697da15aa | ||
|
|
ced0f993de | ||
|
|
55629a8ce4 | ||
|
|
923c6a5904 | ||
|
|
b63e87d0cf | ||
|
|
96caa71f03 | ||
|
|
b9b1877e04 | ||
|
|
81daa8cfdc | ||
|
|
903ea005e3 | ||
|
|
580e9f4f73 | ||
|
|
51e08d1b2a | ||
|
|
3708f5a61e | ||
|
|
58377b4670 | ||
|
|
5bb5aa3d5a | ||
|
|
71def21369 | ||
|
|
1ea565fbdf | ||
|
|
56a9a015f1 | ||
|
|
0708b5a134 | ||
|
|
242065d298 | ||
|
|
b5d6214521 | ||
|
|
9c5bccb23c | ||
|
|
5f5b567749 | ||
|
|
354d6c4f62 | ||
|
|
2c37314c4f | ||
|
|
1dbdee155e | ||
|
|
2939b63998 | ||
|
|
ff7097f2d9 | ||
|
|
33cd4de3c5 | ||
|
|
0bc6c3a22a | ||
|
|
44edfe7a81 | ||
|
|
d3fae3bbd0 | ||
|
|
11a4ec8c67 | ||
|
|
75aab08a72 | ||
|
|
2cc2184002 | ||
|
|
57c398f537 | ||
|
|
7d9f94984c | ||
|
|
8659049ae9 | ||
|
|
1ca5b30c15 | ||
|
|
b9bc0a71a9 | ||
|
|
13ae8bd76f | ||
|
|
31fdabaa15 | ||
|
|
43e9930b45 | ||
|
|
a77192ebc2 | ||
|
|
181a792d0a | ||
|
|
43f6a061e1 | ||
|
|
05bba1a461 | ||
|
|
97fc9959bb | ||
|
|
ad2d1c27ab | ||
|
|
97144e4ebe | ||
|
|
0cc30e5cb1 | ||
|
|
eebb73f49e | ||
|
|
72626e3367 | ||
|
|
07a44e104d | ||
|
|
e4a8975baa | ||
|
|
23de3cac7e | ||
|
|
0244871fc1 | ||
|
|
5691d78004 | ||
|
|
9e76d39707 | ||
|
|
5087190816 | ||
|
|
ce47856c81 | ||
|
|
a03c162962 | ||
|
|
e658a32244 | ||
|
|
637dcddee2 | ||
|
|
5c7940538d | ||
|
|
499b36b03b | ||
|
|
9e101e924e | ||
|
|
10f876f2f6 | ||
|
|
ed4d305165 | ||
|
|
a5cc4e5e18 | ||
|
|
65da14032b | ||
|
|
7e2dd2914d | ||
|
|
644dd8912d | ||
|
|
d7e08fe420 | ||
|
|
cd27506388 | ||
|
|
78de6c92b7 | ||
|
|
cd41190663 | ||
|
|
bbdfdc136b | ||
|
|
5a52b56d89 | ||
|
|
d8d1003d7a | ||
|
|
c801490c2f | ||
|
|
30b33ec974 | ||
|
|
4140e6d3d7 | ||
|
|
1786dec900 | ||
|
|
bda57a84e8 | ||
|
|
deac306f69 | ||
|
|
43b60a0049 | ||
|
|
ab2ee050de | ||
|
|
21de87ca32 | ||
|
|
782031cbc6 | ||
|
|
6c9551be05 | ||
|
|
36eb67a217 | ||
|
|
7dcbcfacc1 | ||
|
|
2c9bda734e | ||
|
|
d296d019e5 | ||
|
|
ccf947c64d | ||
|
|
3faee0d8be | ||
|
|
fe12f78c1d | ||
|
|
a2b0041490 | ||
|
|
235e3ed994 | ||
|
|
4baa82449c | ||
|
|
84ced0c32a | ||
|
|
beabe1c191 | ||
|
|
719ed98996 | ||
|
|
b0a60e428c | ||
|
|
16a8656b49 | ||
|
|
7fdb993f57 | ||
|
|
0e56f0808c | ||
|
|
bd45a9252a | ||
|
|
5a5160b92d | ||
|
|
3e78122659 | ||
|
|
57d85765c3 | ||
|
|
067ae0fd10 | ||
|
|
bf421e1a49 | ||
|
|
339f06e630 | ||
|
|
367a250fba | ||
|
|
73110acb37 | ||
|
|
0912a0067e | ||
|
|
5faf83e437 | ||
|
|
3f76f37a4c | ||
|
|
f0574ef555 | ||
|
|
304059bef7 | ||
|
|
c60e361aa3 | ||
|
|
273ea73b1b | ||
|
|
faae2906b2 | ||
|
|
4db65c2cbf | ||
|
|
bb2b681ae0 | ||
|
|
e9aa500800 | ||
|
|
6eeabe4203 | ||
|
|
caef9d7d46 | ||
|
|
f7c0679abc | ||
|
|
fb9e61aafe | ||
|
|
316cac8149 | ||
|
|
4ca75d4907 | ||
|
|
7d9f9fbd08 | ||
|
|
95eed69b66 | ||
|
|
8fccc61e38 | ||
|
|
1582e22756 | ||
|
|
4684e880b1 | ||
|
|
227fcae39d | ||
|
|
8d5869e6c2 | ||
|
|
eca5549592 | ||
|
|
01bf7ee271 | ||
|
|
240743c563 | ||
|
|
94fa7a8279 | ||
|
|
5715849de5 | ||
|
|
dbe4ee692c | ||
|
|
b3924cdb71 | ||
|
|
c41d8125da | ||
|
|
58243d9c17 | ||
|
|
3200f09836 | ||
|
|
93b4834539 | ||
|
|
268da6e47c | ||
|
|
a74f9b9557 | ||
|
|
dfe4ff076d | ||
|
|
a6ee45f3e3 | ||
|
|
cec0eff031 | ||
|
|
ae4a165b07 | ||
|
|
bfaf21a180 | ||
|
|
75e35f52ab | ||
|
|
eb5dbe5066 | ||
|
|
5da3bc3000 | ||
|
|
d6a003c3e2 | ||
|
|
f28f9049d5 | ||
|
|
e9994bcd8a | ||
|
|
b1b68f4397 | ||
|
|
87d1e1c44f | ||
|
|
bb72038e2b | ||
|
|
2f804c7d40 | ||
|
|
5a287a42ac | ||
|
|
d940d36123 | ||
|
|
a8b0add92b | ||
|
|
49d8c4f818 | ||
|
|
674bbe1c3e | ||
|
|
daf504d5fd | ||
|
|
d722c03744 | ||
|
|
6eddf2f6db | ||
|
|
02398e9ced | ||
|
|
35c9369d3d | ||
|
|
018a6162da | ||
|
|
b46c808aa8 | ||
|
|
921ce713fa | ||
|
|
cf75ee4150 | ||
|
|
a997734c5c | ||
|
|
f9c08b6091 | ||
|
|
8daa0a5d5c | ||
|
|
a3af9de4a9 | ||
|
|
e467a8e6e0 | ||
|
|
46c67e3bfd | ||
|
|
a6e39c32c1 | ||
|
|
fa802b50ad | ||
|
|
e163ada783 | ||
|
|
888e2b376a | ||
|
|
adc22c7d19 | ||
|
|
ac605baa24 | ||
|
|
05c5915e7b | ||
|
|
a4060fec1b | ||
|
|
469ab4a085 | ||
|
|
367a76a54f | ||
|
|
998e65d98d | ||
|
|
a8ba45c08a | ||
|
|
055b300c5a | ||
|
|
baf389ec65 | ||
|
|
ca7a2e954f | ||
|
|
eada5fe8b1 | ||
|
|
fe347965ff | ||
|
|
8d017ed2cb | ||
|
|
eafca9ae85 | ||
|
|
990c7a2693 | ||
|
|
da87f9a865 | ||
|
|
515c85e600 | ||
|
|
6b3948d454 | ||
|
|
e3afa24795 | ||
|
|
903a29ed09 | ||
|
|
6a64e3854c | ||
|
|
0d1d082d9e | ||
|
|
8ae8e66a3a | ||
|
|
f684c4cb3f | ||
|
|
44a8fbe0c9 | ||
|
|
9c5e253d75 | ||
|
|
5fb15592ff | ||
|
|
da8bcb04c9 | ||
|
|
1862b11c0d | ||
|
|
942f08bbf2 | ||
|
|
175c78c3d0 | ||
|
|
0decc34593 | ||
|
|
18a7055a4e | ||
|
|
5638d8c66a | ||
|
|
1644e478c7 | ||
|
|
d1c35b480f | ||
|
|
3d8c656224 | ||
|
|
364a52b553 | ||
|
|
8cff6750f9 | ||
|
|
d01c91e6ee | ||
|
|
e1893e1506 | ||
|
|
ae4b592bb6 | ||
|
|
c65001399f | ||
|
|
494ccbbe5e | ||
|
|
8b28194d00 | ||
|
|
3ff3e40d8e | ||
|
|
77072594d2 | ||
|
|
3c2fec2122 | ||
|
|
f58070495f | ||
|
|
51c60c55b0 | ||
|
|
781662026a | ||
|
|
422fed2719 | ||
|
|
27a108027b | ||
|
|
5f1a1e9f0e | ||
|
|
aa3c07a0b2 | ||
|
|
e409618b7a | ||
|
|
7d8573b52a | ||
|
|
28cbcea4f9 | ||
|
|
e1d4f678ab | ||
|
|
bb6453d3a2 | ||
|
|
1b02666531 | ||
|
|
3b82908e8a | ||
|
|
8941b87e03 | ||
|
|
1f081cdddc | ||
|
|
dc58109829 | ||
|
|
3b36b19d01 | ||
|
|
26af225fff | ||
|
|
1c9f986a97 | ||
|
|
7056e00fcc | ||
|
|
5c86c2fbf2 | ||
|
|
7f4bc0487e | ||
|
|
75e62cce72 | ||
|
|
a5b1861e7d | ||
|
|
68a5350c19 | ||
|
|
81f225d9cb | ||
|
|
268aedbb6e | ||
|
|
c2dce8344b | ||
|
|
ef45bedf83 | ||
|
|
35b38e8542 | ||
|
|
c3f1433868 | ||
|
|
f137bd2c07 | ||
|
|
5b9ae2b805 | ||
|
|
eef514bea1 | ||
|
|
9be85c4c2d | ||
|
|
17e81cafc2 | ||
|
|
b6b1d581a3 | ||
|
|
7db48facbd | ||
|
|
0958c5f9c7 | ||
|
|
7475dbd07c | ||
|
|
19734415f0 | ||
|
|
252d02e7b9 | ||
|
|
64ad480bbb | ||
|
|
4df670d1c9 | ||
|
|
52f4a2b1e1 | ||
|
|
527d5341db | ||
|
|
a775596719 | ||
|
|
3fbf54df99 | ||
|
|
fbb78ebd50 | ||
|
|
87bf284943 | ||
|
|
d319264349 | ||
|
|
3230bbe2e5 | ||
|
|
fc5ae31b2b | ||
|
|
8bf6cbe1d4 | ||
|
|
1083822513 | ||
|
|
db22100846 | ||
|
|
d751bbfcd4 | ||
|
|
ceffea4c5b | ||
|
|
a716315311 | ||
|
|
edf5abcec9 | ||
|
|
d26e5a324d | ||
|
|
0275eea6e7 | ||
|
|
7091adb6a0 | ||
|
|
f11bc7f686 | ||
|
|
3b0b83aae1 | ||
|
|
038c4149d8 | ||
|
|
811ebff52f | ||
|
|
8908da5baf | ||
|
|
204791075e | ||
|
|
da63930f5e | ||
|
|
892e82c1d6 | ||
|
|
806df16a0b | ||
|
|
077f4fd488 | ||
|
|
0b30cc3ae7 | ||
|
|
b31e1a47f0 | ||
|
|
7c36100553 | ||
|
|
f2c25927eb | ||
|
|
6e2fc369e2 | ||
|
|
57f2e0d649 | ||
|
|
381eb37f1a | ||
|
|
2c46cb956f | ||
|
|
31c7980a9a | ||
|
|
b067134ae7 | ||
|
|
be5a848342 | ||
|
|
1f3d39a1bd | ||
|
|
e96c65c1ab | ||
|
|
80495c3594 | ||
|
|
1667e5240d | ||
|
|
afb8ba0ef3 | ||
|
|
ddc3e855d4 | ||
|
|
d8f5771dcb | ||
|
|
5313bc6a18 | ||
|
|
ff7b583f07 | ||
|
|
e1fce63876 | ||
|
|
85149e5af0 | ||
|
|
713956aa4f | ||
|
|
0da8a75fbe | ||
|
|
4a946062b3 | ||
|
|
5f0c1c8b97 | ||
|
|
0e6b885095 | ||
|
|
f5480bdc58 | ||
|
|
5102f02083 | ||
|
|
e38b0fde51 | ||
|
|
340f074f01 | ||
|
|
cb0256c820 | ||
|
|
ed43e67047 | ||
|
|
a923615190 | ||
|
|
d8074502e0 | ||
|
|
1bd3883b18 | ||
|
|
0ae2265c50 | ||
|
|
9fa09bb743 | ||
|
|
c90628a71f | ||
|
|
7b3df95152 | ||
|
|
372847f398 | ||
|
|
f9dd2c6bd6 | ||
|
|
34083441d4 | ||
|
|
8796ec5fbc | ||
|
|
c6639a8259 | ||
|
|
3b063ff64c | ||
|
|
ef71d83321 | ||
|
|
a73481808c | ||
|
|
8ff912a4a7 | ||
|
|
c22ab933c7 | ||
|
|
31354272e7 | ||
|
|
0b50724b2f | ||
|
|
43209882e4 | ||
|
|
5ea49ef6f1 | ||
|
|
7699397bda | ||
|
|
31217c6fd7 | ||
|
|
acabbf30da | ||
|
|
adc5dd1742 | ||
|
|
0b398b372e | ||
|
|
1d5d46c9ce | ||
|
|
0beeb15245 | ||
|
|
43b63908b5 | ||
|
|
c40d3fe044 | ||
|
|
52c93b80f6 | ||
|
|
30fd27e570 | ||
|
|
6d7c5202c2 | ||
|
|
4e63e533d3 | ||
|
|
e0feb6dff1 | ||
|
|
8e9313eafa | ||
|
|
fa602f3efa | ||
|
|
f96377ff12 | ||
|
|
7a01efaa52 | ||
|
|
1e1b65684d | ||
|
|
e506334689 | ||
|
|
d2ab632f2f | ||
|
|
5acdd13966 | ||
|
|
b3bf342320 | ||
|
|
27dfb17e70 | ||
|
|
6fa17a8f81 | ||
|
|
0b48be15aa | ||
|
|
0159a246ac | ||
|
|
e00bbf576a | ||
|
|
88653c1656 | ||
|
|
31dad88078 | ||
|
|
ebc24432ff | ||
|
|
664d86838d | ||
|
|
58dfbe3084 | ||
|
|
5a1c0321a6 | ||
|
|
1cc5605291 | ||
|
|
fb0cce836d | ||
|
|
55be0c18e2 | ||
|
|
a9680d2464 | ||
|
|
7060bcce01 | ||
|
|
3c89d312c6 | ||
|
|
8e9b2920ed | ||
|
|
1b2a6d5acd | ||
|
|
c32ff2cf29 | ||
|
|
424fc518ff | ||
|
|
ac5c445b6f | ||
|
|
59115fbf69 | ||
|
|
7560389e35 | ||
|
|
74b39ac480 | ||
|
|
7ed368a137 | ||
|
|
a8a9cf76df | ||
|
|
fe82be5f21 | ||
|
|
65fe93421b | ||
|
|
f0cd287f9a | ||
|
|
4c2c3db052 | ||
|
|
3a90b1d163 | ||
|
|
0eb6716263 | ||
|
|
92242bb8ff | ||
|
|
8742560e6c | ||
|
|
fbfe0ba1fd | ||
|
|
7b39e68363 | ||
|
|
8f0c6c30dc | ||
|
|
1423b28663 | ||
|
|
1b15125bee | ||
|
|
c6adee4502 | ||
|
|
51a7f4f2ee | ||
|
|
0c846a86b9 | ||
|
|
0fe7f86ad6 | ||
|
|
69613450c7 | ||
|
|
a9b769e510 | ||
|
|
deb0eb85b5 | ||
|
|
ce1eddad68 | ||
|
|
a9c41aa9c3 | ||
|
|
b0d5068192 | ||
|
|
2bd82fc6ef | ||
|
|
17279390d4 | ||
|
|
91a95e20a2 | ||
|
|
cb7a37fe9e | ||
|
|
4498f3542b | ||
|
|
fab5204c56 | ||
|
|
24bb2adc70 | ||
|
|
75230dd3b8 | ||
|
|
4440da92bf | ||
|
|
07c22e06ba | ||
|
|
ef839f65c1 | ||
|
|
ce6e752905 | ||
|
|
9c1cfda40b | ||
|
|
d87d3ed9e1 | ||
|
|
c21387874b | ||
|
|
5b35786c5c | ||
|
|
3829da428c | ||
|
|
d8ef4d522c | ||
|
|
26b7eb7cd8 | ||
|
|
f69659309d | ||
|
|
18f2ebf455 | ||
|
|
c625e12075 | ||
|
|
ed58b824ac | ||
|
|
6fa89dbdf4 | ||
|
|
8dc19a522f | ||
|
|
dd18b32a09 | ||
|
|
b916180ccd | ||
|
|
593e6c4670 | ||
|
|
8a7a717801 | ||
|
|
a53460b4cc | ||
|
|
f0f64dd118 | ||
|
|
ac53861a85 | ||
|
|
85c73a5069 | ||
|
|
94d40308d2 | ||
|
|
e9fbebdfad | ||
|
|
f58b95cd95 | ||
|
|
d110c97424 | ||
|
|
5b5a05d8c5 | ||
|
|
dc93c7f9f4 | ||
|
|
065b516bd7 | ||
|
|
7935936da1 | ||
|
|
bedd05c6ae | ||
|
|
a32d0781fc | ||
|
|
15a17b5e1d | ||
|
|
527a76c480 | ||
|
|
02c94a12d5 | ||
|
|
706be3efd1 | ||
|
|
3477b5d575 | ||
|
|
680c16c7da | ||
|
|
c9eb19a064 | ||
|
|
8b2c24d7a1 | ||
|
|
18b2735f80 | ||
|
|
d11ae4365c | ||
|
|
a251697813 | ||
|
|
f9f798417b | ||
|
|
4dc377c16e | ||
|
|
e528d8dc51 | ||
|
|
81e0f23075 | ||
|
|
a7124c800e | ||
|
|
93f0220a71 | ||
|
|
1d94368579 | ||
|
|
21396f5a1d | ||
|
|
4d69c8e570 | ||
|
|
16e3ccada8 | ||
|
|
a723b2801e | ||
|
|
f577dbdd70 | ||
|
|
d84c11a459 | ||
|
|
5c5059681d | ||
|
|
c88431b564 | ||
|
|
d802b9da1f | ||
|
|
c91e54370f | ||
|
|
27f0532491 | ||
|
|
d95e07285c | ||
|
|
2b3c41ce36 | ||
|
|
410a43e6a1 | ||
|
|
a270b8ce3e | ||
|
|
62e8039138 | ||
|
|
9f01ad0665 | ||
|
|
268afb7209 | ||
|
|
fb362a56af | ||
|
|
e5bf979682 | ||
|
|
cb05b5beb3 | ||
|
|
7cf6f1b9fa | ||
|
|
5c42c8ad3c | ||
|
|
06c8b43950 | ||
|
|
7937fea5c3 | ||
|
|
4a48bec42d | ||
|
|
575e0f2924 | ||
|
|
7749947138 | ||
|
|
66ddb75c13 | ||
|
|
625705eaf0 | ||
|
|
287c2637ed | ||
|
|
f640733276 | ||
|
|
cc432ba18f | ||
|
|
fe03a4e9c1 | ||
|
|
4c3c32d5a4 | ||
|
|
0be87ee2db | ||
|
|
7d3b5dbbe9 | ||
|
|
2e3ac230ca | ||
|
|
55286bdfe5 | ||
|
|
8b64c1ab0d | ||
|
|
02b864d657 | ||
|
|
ca1eefb906 | ||
|
|
f2fb543f93 | ||
|
|
d3ee0bde5a | ||
|
|
1ccdfa3e12 | ||
|
|
9816d7ec18 | ||
|
|
a2a0cb8461 | ||
|
|
76e5004830 | ||
|
|
14d9790b5c | ||
|
|
2480f79a34 | ||
|
|
084dd23bc2 | ||
|
|
b989c19a8a | ||
|
|
caa57b3e8b | ||
|
|
f6b0e7b0d9 | ||
|
|
4d6b516662 | ||
|
|
1caf567f10 | ||
|
|
1e34ecffc2 | ||
|
|
dd2f58dc78 | ||
|
|
1977aa50c2 | ||
|
|
60173a0efb | ||
|
|
fbfce10afb | ||
|
|
2c539f4316 | ||
|
|
db9e4eb004 | ||
|
|
bea5f44565 | ||
|
|
9b3181576f | ||
|
|
648a61d09a | ||
|
|
18fbf79fa8 | ||
|
|
ff97490a2b | ||
|
|
dc60dacb02 | ||
|
|
b69ad40013 | ||
|
|
8d386d9bdf | ||
|
|
32f50d676e | ||
|
|
ecb5a741bf | ||
|
|
19977cc536 | ||
|
|
51312a3a2a | ||
|
|
919f0dc120 | ||
|
|
23c54af27d | ||
|
|
9ed298bf3e | ||
|
|
8507b640b2 | ||
|
|
3a66a3af3f | ||
|
|
95303d2960 | ||
|
|
ad60e684fc | ||
|
|
0f1edf3f9c | ||
|
|
51b1918f72 | ||
|
|
46d6641259 | ||
|
|
984b9af719 | ||
|
|
42e74a05f5 | ||
|
|
4364546bc9 | ||
|
|
2bcaadbeb6 | ||
|
|
4385995742 | ||
|
|
2c7d90e49d | ||
|
|
bc34346301 | ||
|
|
5619d2b00e | ||
|
|
56a4711189 | ||
|
|
4b84d5f013 | ||
|
|
c6304d75cf | ||
|
|
8fb8e83632 | ||
|
|
390d88e0e9 | ||
|
|
e08c290a66 | ||
|
|
f60bf294c1 | ||
|
|
ae4fcbad67 | ||
|
|
543e274737 | ||
|
|
98a9aa7701 | ||
|
|
04589154ca | ||
|
|
03056186c6 | ||
|
|
514b077da4 | ||
|
|
c4866e3865 | ||
|
|
d0953585a7 | ||
|
|
6782f188f5 | ||
|
|
cc58e4420e | ||
|
|
ef95bcb8bc | ||
|
|
a5064263ba | ||
|
|
7fcde76981 | ||
|
|
24c99c7b95 | ||
|
|
575aa833b5 | ||
|
|
c9f9530d09 | ||
|
|
e76e5629bd | ||
|
|
7853a3ff97 | ||
|
|
983dbf0009 | ||
|
|
f724e91421 | ||
|
|
f0bf8f737f | ||
|
|
9a25812b81 | ||
|
|
79ccb20135 | ||
|
|
fbc40bb4be | ||
|
|
cb44735eec | ||
|
|
c5bc67771f | ||
|
|
eb073b04ac | ||
|
|
2b48dd8722 | ||
|
|
9fa647e40e | ||
|
|
fe5e7b1633 | ||
|
|
6c6d0f328e | ||
|
|
9a8af03932 | ||
|
|
6c8becb201 | ||
|
|
cc180dcb65 | ||
|
|
d98b056b57 | ||
|
|
527402425f | ||
|
|
dba6ae164c | ||
|
|
97cb2b072b | ||
|
|
f13d623d14 | ||
|
|
4e8c728457 | ||
|
|
e960c0863f | ||
|
|
79c1a539bb | ||
|
|
bd6f1064fb | ||
|
|
57ceae97a1 | ||
|
|
4fa7834510 | ||
|
|
8ac5413765 | ||
|
|
524eef9a54 | ||
|
|
a4d19c8fc9 | ||
|
|
d62701fa85 | ||
|
|
59da085676 | ||
|
|
1305bfac59 | ||
|
|
b84a3255c6 | ||
|
|
8682cdab51 | ||
|
|
2033faff08 | ||
|
|
7f5daa0f85 | ||
|
|
b2db8e8f5a | ||
|
|
1c99eee96b | ||
|
|
689e6d18df | ||
|
|
c4ba459b20 | ||
|
|
ae1ef52113 | ||
|
|
03145afa4f | ||
|
|
b03b457e7e | ||
|
|
f31941425d | ||
|
|
18ec2752ba | ||
|
|
bbf0bee19b | ||
|
|
e3bf844420 | ||
|
|
afa58712cf | ||
|
|
f76029c2d9 | ||
|
|
25da77a3b1 | ||
|
|
70256b252c | ||
|
|
e7c109eab1 | ||
|
|
66241f32af | ||
|
|
2e7f890218 | ||
|
|
d31edeef65 | ||
|
|
55ff880993 | ||
|
|
7c9ae9e339 | ||
|
|
f1a2ee6170 | ||
|
|
25a77db3c6 | ||
|
|
914538d2ed | ||
|
|
9c9b982a56 | ||
|
|
0d18623473 | ||
|
|
876b4e4a5d | ||
|
|
a6134bb455 | ||
|
|
711159aa2b | ||
|
|
451d737d90 | ||
|
|
12d8c39ccf | ||
|
|
54464efdcd | ||
|
|
923f0e99b9 | ||
|
|
2d78c2a37a | ||
|
|
37da3e8350 | ||
|
|
890e3c338b | ||
|
|
8d9d069267 | ||
|
|
3c68a603af | ||
|
|
1f0ef3c64d | ||
|
|
df0bbddbd5 | ||
|
|
7bd7bd3564 | ||
|
|
30cafc5619 | ||
|
|
7e5617bd80 | ||
|
|
533c16be3f | ||
|
|
ff96312d53 | ||
|
|
0bddc4dc5b | ||
|
|
9cdfe552e0 | ||
|
|
c27642bc12 | ||
|
|
a3ba45626d | ||
|
|
ef853d2736 | ||
|
|
8b1387a8ed | ||
|
|
647e2a5f7e | ||
|
|
d3ae45a360 | ||
|
|
48880bed0d | ||
|
|
c6da2062f2 | ||
|
|
414660a0a5 | ||
|
|
2995755cda | ||
|
|
0d6ac88dea | ||
|
|
eb63dc1d8d | ||
|
|
22560a5ce2 | ||
|
|
4c5e049c16 | ||
|
|
92e9b69348 | ||
|
|
b08086e3f5 | ||
|
|
70877c8c96 | ||
|
|
e35403eaf4 | ||
|
|
8d8068f5d6 | ||
|
|
fd82dfc5e0 | ||
|
|
d5e609aa37 | ||
|
|
b3f6d4f6e7 | ||
|
|
4fd8c1f5bf | ||
|
|
45c496313d | ||
|
|
97bb1c1fa9 | ||
|
|
316a237297 | ||
|
|
efa34a1ac6 | ||
|
|
c146796987 | ||
|
|
a67ca442a0 | ||
|
|
e7b795f744 | ||
|
|
20658c3ca5 | ||
|
|
cfa8182789 | ||
|
|
a867f25481 | ||
|
|
9906f14446 | ||
|
|
cca999a18d | ||
|
|
3198959cb7 | ||
|
|
832be374b5 | ||
|
|
aec0bad35f | ||
|
|
f77e8709a8 | ||
|
|
f05efe464a | ||
|
|
3cb10e217b | ||
|
|
52430d6466 | ||
|
|
64c75772bb | ||
|
|
5437a47e37 | ||
|
|
1605b96173 | ||
|
|
09e78277b5 | ||
|
|
58ad5b3254 | ||
|
|
14813013a7 | ||
|
|
343c142d87 | ||
|
|
25b983a9ae | ||
|
|
8e16067cf3 | ||
|
|
b9d88160a2 | ||
|
|
b780a4db58 | ||
|
|
0975995486 | ||
|
|
14f0a6d7f3 | ||
|
|
136e0f6225 | ||
|
|
8581432bf4 | ||
|
|
5bdd299dad | ||
|
|
d4336ed447 | ||
|
|
fa1b2b329f | ||
|
|
13cb684abd | ||
|
|
f6837f7819 | ||
|
|
bf2e017ca0 | ||
|
|
1defb30632 | ||
|
|
b3aa52cd91 | ||
|
|
06e0ce5106 | ||
|
|
46c31ac653 | ||
|
|
778f1a3a11 | ||
|
|
fb10e43055 | ||
|
|
2deb80fdbf | ||
|
|
1d731c0006 | ||
|
|
3dba61f9d9 | ||
|
|
2ba9616f49 | ||
|
|
27aa23179f | ||
|
|
5a23174e8b | ||
|
|
e80a31905e | ||
|
|
c8d63bf93a | ||
|
|
02b394f31b | ||
|
|
a5dcb64a10 | ||
|
|
db4bc8651d | ||
|
|
8e177534f4 | ||
|
|
49a94318ac | ||
|
|
9fc2dee5d7 | ||
|
|
d89682027e | ||
|
|
bc7cc6810d | ||
|
|
bdc94d853d | ||
|
|
190f2df15e | ||
|
|
7a4831e200 | ||
|
|
db2133a4b0 | ||
|
|
26e519bec0 | ||
|
|
bcc2022826 | ||
|
|
faefefe186 | ||
|
|
519f66f46d | ||
|
|
bc6d85acca | ||
|
|
d878a1fcaa | ||
|
|
b4eb2919b1 | ||
|
|
64b5518309 | ||
|
|
e2ff3dd7c5 | ||
|
|
7430f4deb8 | ||
|
|
b89860bc93 | ||
|
|
87a1002aa5 | ||
|
|
ca44dab358 | ||
|
|
53a78e6744 | ||
|
|
e050da41e5 | ||
|
|
0a6f892ba8 | ||
|
|
f7b28d876d | ||
|
|
b0496e309e | ||
|
|
31d75be68e | ||
|
|
1053d4125f | ||
|
|
fdc6c15d88 | ||
|
|
ee5d7fef31 | ||
|
|
640daf2431 | ||
|
|
703656d3c4 | ||
|
|
3c1337b213 | ||
|
|
57f59b8827 | ||
|
|
3bf91cedc9 | ||
|
|
93f4f1dc5a | ||
|
|
35c5cf60d4 | ||
|
|
0d5e34cfdd | ||
|
|
29e6bf8582 | ||
|
|
79ccd8580e | ||
|
|
968480669e | ||
|
|
1334942fc0 | ||
|
|
08dc74cb67 | ||
|
|
3645075473 | ||
|
|
a26b465ff6 | ||
|
|
c7b15896ac | ||
|
|
f7a27c7c63 | ||
|
|
c12633b2b1 | ||
|
|
83ae92b2b6 | ||
|
|
465c63ed1c | ||
|
|
812cb97713 | ||
|
|
f099bb5420 | ||
|
|
0b1872cc02 | ||
|
|
b3cf0243dd | ||
|
|
b32e6cac0c | ||
|
|
32ea075e90 | ||
|
|
5ce77e0adc | ||
|
|
647a053ccc | ||
|
|
d726be7d66 | ||
|
|
d0bd7d4cd7 | ||
|
|
eadb7a8f3e | ||
|
|
cfb752509a | ||
|
|
8e89c5ba73 | ||
|
|
61e1ffc6c4 | ||
|
|
ad5fc0bb52 | ||
|
|
0681c69d67 | ||
|
|
3f4101a87d | ||
|
|
87aea20056 | ||
|
|
3d944770f1 | ||
|
|
1d93368eb0 | ||
|
|
fcc4805220 | ||
|
|
d93165bfac | ||
|
|
640f6fca02 | ||
|
|
82c2300496 | ||
|
|
d2560784a2 | ||
|
|
16fae82c17 | ||
|
|
f9be0fad2d | ||
|
|
50db041021 | ||
|
|
a6945d1dc9 | ||
|
|
9c2f275534 | ||
|
|
bd317d8fc0 | ||
|
|
eda7f00453 | ||
|
|
b6cef907f9 | ||
|
|
0a792a5406 | ||
|
|
0468531655 | ||
|
|
dac3d51f88 | ||
|
|
273ba8073c | ||
|
|
96e22b14aa | ||
|
|
1b1bd7536d | ||
|
|
60e5a682bd | ||
|
|
8af3ad3549 | ||
|
|
7dd20b0a9b | ||
|
|
85f4ca9744 | ||
|
|
c7e90fc917 | ||
|
|
639d7d3cb6 | ||
|
|
504220f630 | ||
|
|
94196b1fe3 | ||
|
|
9cd285223b | ||
|
|
25ae2458f0 | ||
|
|
a66f9b01eb | ||
|
|
695ccdd177 | ||
|
|
d17ed6e5c3 | ||
|
|
5f58a1a2c4 | ||
|
|
7be8ff2e94 | ||
|
|
27b9ef910c | ||
|
|
10c78470b3 | ||
|
|
566ca54fcf | ||
|
|
db4e2f9d43 | ||
|
|
0e71a6701d | ||
|
|
b4384c4329 | ||
|
|
3c71a2be96 | ||
|
|
907451df48 | ||
|
|
94df1469c0 | ||
|
|
e53f31487e | ||
|
|
f5dd5e8191 | ||
|
|
2c509d5aa1 | ||
|
|
5ef40510ce | ||
|
|
140c0ed7e0 | ||
|
|
e2dfd44874 | ||
|
|
f10af2741e | ||
|
|
720686fb88 | ||
|
|
f0e06e4197 | ||
|
|
22cc5755fa | ||
|
|
4a70635ae4 | ||
|
|
6380b8752b | ||
|
|
46ce2f7e8d | ||
|
|
13577c302d | ||
|
|
4e78eb6c5e | ||
|
|
628d940a3e | ||
|
|
949930f2cf | ||
|
|
aa583e6748 | ||
|
|
be9e07cfea | ||
|
|
c847eff9fc | ||
|
|
746d3af943 | ||
|
|
2693e573f6 | ||
|
|
9170857d8a | ||
|
|
240c749806 | ||
|
|
88a007de7c | ||
|
|
14c882ff08 | ||
|
|
67fac4b2f2 | ||
|
|
0baf472473 | ||
|
|
faf8a6e234 | ||
|
|
f6b620383c | ||
|
|
d40710de3c | ||
|
|
1750dc13eb | ||
|
|
19bce0bfa1 | ||
|
|
05333b4345 | ||
|
|
c62c06a072 | ||
|
|
1b8f64b4fb | ||
|
|
863a4fd31f | ||
|
|
1b7f8bae6c | ||
|
|
239e518676 | ||
|
|
60465774d4 | ||
|
|
9c3bc5200a | ||
|
|
dd4446fd8c | ||
|
|
ea4e36d759 | ||
|
|
4a5f9579b4 | ||
|
|
69e2acd156 | ||
|
|
cdd5d88e2d | ||
|
|
d8effe2dcb | ||
|
|
bea245ced9 | ||
|
|
90f29da343 | ||
|
|
1d5441b84a | ||
|
|
8c9a660afd | ||
|
|
0af7969fcc | ||
|
|
dc296336e0 | ||
|
|
82a8c8b374 | ||
|
|
2432f7817c | ||
|
|
14b1230f72 | ||
|
|
e0c0c59e73 | ||
|
|
18c57904bb | ||
|
|
c0982905dd | ||
|
|
227ddbb776 | ||
|
|
cef477dfee | ||
|
|
ee82732880 | ||
|
|
9dbd2d2a0c | ||
|
|
0abddfbfb3 | ||
|
|
8749a59957 | ||
|
|
b7d94b56f0 | ||
|
|
d785f11aff | ||
|
|
a06e691420 | ||
|
|
98b3d0dff7 | ||
|
|
08d851ec81 | ||
|
|
a339dd898b | ||
|
|
b06a0e16b0 | ||
|
|
bb06d3e4f8 | ||
|
|
504e8ff9b6 | ||
|
|
67c76d8b04 | ||
|
|
662a192b81 | ||
|
|
b33231b3b4 | ||
|
|
f592784c11 | ||
|
|
ab32093caf | ||
|
|
f78cac645c | ||
|
|
cd88e44464 | ||
|
|
94c484dbe4 | ||
|
|
7a2553940c | ||
|
|
d4c10de30a | ||
|
|
7ebece0491 | ||
|
|
f59ce8809d | ||
|
|
b53929cb22 | ||
|
|
070d6b70d6 | ||
|
|
2deddf6fd4 | ||
|
|
a1ae5e9364 | ||
|
|
ed99243890 | ||
|
|
302670ed99 | ||
|
|
ff801d2169 | ||
|
|
dca72fac8c | ||
|
|
57a32e252d | ||
|
|
982b2c024b | ||
|
|
ebff15fbed | ||
|
|
9b1cfb6e03 | ||
|
|
e63441c254 | ||
|
|
192c8c3c0a | ||
|
|
2ef2aff18f | ||
|
|
a4be3d534f | ||
|
|
7621b91f15 | ||
|
|
38f5fcf360 | ||
|
|
f2a10e249f | ||
|
|
e36f602e6a | ||
|
|
d838108025 | ||
|
|
02a42dd4b2 | ||
|
|
6ed19f7a84 | ||
|
|
7f5da492ee | ||
|
|
eedd687e47 | ||
|
|
73407ceb8d | ||
|
|
fe62708aa3 | ||
|
|
9ef72679b3 | ||
|
|
05783db2b1 | ||
|
|
65f80c55b0 | ||
|
|
947f5c0f6b | ||
|
|
4967f31791 | ||
|
|
ae4cbd46dc | ||
|
|
56c9c24937 | ||
|
|
688e3faacd | ||
|
|
91ddc42667 | ||
|
|
fca202fd76 | ||
|
|
2e01757519 | ||
|
|
fb543f99ba | ||
|
|
de0f8fafda | ||
|
|
fddda27ca8 | ||
|
|
325d53cad6 | ||
|
|
b59ddea3b6 | ||
|
|
3cc48d33e6 | ||
|
|
165349172b | ||
|
|
fa36c21672 | ||
|
|
efec6d6857 | ||
|
|
ecddbf2226 | ||
|
|
031558c483 | ||
|
|
02d0031079 | ||
|
|
101676cef3 | ||
|
|
9193bda39f | ||
|
|
c5d4e80e7c | ||
|
|
599d67141f | ||
|
|
529e5d3b83 | ||
|
|
68fe9946d2 | ||
|
|
f4df55b9e1 | ||
|
|
3c4d0fcb5f | ||
|
|
1c154c6f48 | ||
|
|
c4b41daf6a | ||
|
|
2577fd81eb | ||
|
|
75987baef8 | ||
|
|
e3ccf61fd2 | ||
|
|
f430718e5f | ||
|
|
9815134536 | ||
|
|
78e2105a62 | ||
|
|
c78134f05c | ||
|
|
0693f5de74 | ||
|
|
f78af9e77c | ||
|
|
6638e24901 | ||
|
|
4a4e9a611f | ||
|
|
51495a93ff | ||
|
|
0059c3ba3b | ||
|
|
035c0602b5 | ||
|
|
e4b1cf4a15 | ||
|
|
0941124ab6 | ||
|
|
89a7c12fa0 | ||
|
|
2da76dfcff | ||
|
|
62ceca1d34 | ||
|
|
dd2db73e9d | ||
|
|
2824e6582c | ||
|
|
d2d9e53655 | ||
|
|
54b84f8bf6 | ||
|
|
e45344a1c3 | ||
|
|
2b978997c7 | ||
|
|
834ccea786 | ||
|
|
13a940b8c2 | ||
|
|
a4f63f0d70 | ||
|
|
3995ef55e1 | ||
|
|
92fa2975c2 | ||
|
|
ea2336fe1e | ||
|
|
d9f2e134b1 | ||
|
|
922d1357fa | ||
|
|
c1cc74c2f3 | ||
|
|
dce6910371 | ||
|
|
10dd9869d4 | ||
|
|
600e2a52ad | ||
|
|
dcb1c95215 | ||
|
|
1c1062b235 | ||
|
|
d1d8391e1d | ||
|
|
63df76bf01 | ||
|
|
d277cd6518 | ||
|
|
8a06e6504d | ||
|
|
f6beb38502 | ||
|
|
da11868d48 | ||
|
|
7e9b8e2341 | ||
|
|
8d2dcad9ca | ||
|
|
6d768f3a97 | ||
|
|
3a444a29b9 | ||
|
|
232d4834be | ||
|
|
e1ed0e5dff | ||
|
|
a9306bb8ba | ||
|
|
9facfe2ff8 | ||
|
|
c8742de888 | ||
|
|
14e397ab26 | ||
|
|
cbc8c4a501 | ||
|
|
ed5ff02b91 | ||
|
|
4608e23b60 | ||
|
|
0cee80e6d7 | ||
|
|
2234fdf172 | ||
|
|
a243e66746 | ||
| cea4a01a4a |
@@ -1,9 +0,0 @@
|
||||
Server/runtime/
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.bmp
|
||||
*.webp
|
||||
*.ico
|
||||
*.svg
|
||||
16
.codegraph/.gitignore
vendored
Normal file
16
.codegraph/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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
|
||||
6
.codegraph/daemon.pid
Normal file
6
.codegraph/daemon.pid
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"pid": 74205,
|
||||
"version": "0.9.9",
|
||||
"socketPath": "/Users/karuo/Documents/开发/2、私域银行/cunkebao_v3/.codegraph/daemon.sock",
|
||||
"startedAt": 1780473089935
|
||||
}
|
||||
21
.cursor/rules/codegraph.mdc
Normal file
21
.cursor/rules/codegraph.mdc
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
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 -->
|
||||
40
.cursor/rules/cunkebao-deploy-autofix.mdc
Normal file
40
.cursor/rules/cunkebao-deploy-autofix.mdc
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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. **验收(全绿才算部署成功)**:
|
||||
- HTTP:3100 / 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`),除非用户本轮明确说提交/推送。
|
||||
28
.cursor/rules/cunkebao-four-end-http-only.mdc
Normal file
28
.cursor/rules/cunkebao-four-end-http-only.mdc
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
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 HTTP;AI数智员工 **禁止** `/v1/kefu/*`。
|
||||
|
||||
## 禁止
|
||||
|
||||
- 在触客宝/AI数智员工仓修改 `Server/application/cunkebao`(除非用户本轮明确「改存客宝 Server」)
|
||||
- copy 兄弟项目 React 业务组件进本仓(设计 token 可对齐)
|
||||
- 触客宝直连 `traffic_pool` 表
|
||||
|
||||
## 接口索引
|
||||
|
||||
`开发文档/5、接口/SuperAdmin_四端对接接口索引.md` · `02-存客宝对接/触客宝PC接口对接索引.md`
|
||||
89
.cursor/rules/cunkebao-karuo-dialogue.mdc
Normal file
89
.cursor/rules/cunkebao-karuo-dialogue.mdc
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
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 字,含 **0~100%**) |
|
||||
| 2 | **📌 过程**(1、2、3 分点,可验收数据) |
|
||||
| 3 | **💡 反思**(1~3 点) |
|
||||
| 4 | **📝 总结** |
|
||||
| 5 | **▶ 下一步执行** |
|
||||
|
||||
复盘块内**禁止表格**;禁止省略复盘或写「见上文」。
|
||||
|
||||
## 三、复盘之后 · 本轮文档(Cursor 可点 · 强制)
|
||||
|
||||
复盘五块写完后、回复结束前,追加 **📎 本轮文档**(**只列本轮对话新建/修改的文件**,通常 1~3 个;**禁止**贴历史轮次、索引 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%**(分子/分母说清)。
|
||||
26
.cursor/rules/cunkebao-local-only-no-github.mdc
Normal file
26
.cursor/rules/cunkebao-local-only-no-github.mdc
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
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,不推远程)
|
||||
47
.cursor/rules/cunkebao-mobile-first.mdc
Normal file
47
.cursor/rules/cunkebao-mobile-first.mdc
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
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 宽无挤字、底栏固定可见**。
|
||||
43
.cursor/rules/cunkebao-requirement-images-must-read.mdc
Normal file
43
.cursor/rules/cunkebao-requirement-images-must-read.mdc
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
description: 存客宝 v3 · 读需求表必先看图(强制)
|
||||
globs:
|
||||
- "开发文档/1、需求/**/*.md"
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# 存客宝 v3 · 读需求表必先看图
|
||||
|
||||
> **生效**:2026-05-26 起。卡若明确要求:每次读 `开发文档/1、需求/修改/存-*.md` 或类似需求 MD 时,**必须**确认能看到 MD 引用的所有图片,再开始开发。
|
||||
|
||||
## 强制流程(开发前 Phase 0)
|
||||
|
||||
1. **先读需求 MD**(`Read` 工具读完整文件)。
|
||||
2. **列出所有 `` 引用**(用 `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` 不冲突:仍只本地,不自动推。
|
||||
54
.cursor/rules/cunkebao-v3-devdoc-first.mdc
Normal file
54
.cursor/rules/cunkebao-v3-devdoc-first.mdc
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
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` |
|
||||
122
.cursor/skills/cunkebao-architecture-five-diagrams/SKILL.md
Normal file
122
.cursor/skills/cunkebao-architecture-five-diagrams/SKILL.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
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` | 存客宝 · 六大业务板块 | 场景获客/工作台/设备/微信号/流量池/内容库 → 中心 BFF,route.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 处 ``
|
||||
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 批量画架构总览 |
|
||||
193
.cursor/skills/存客宝官网开发/SKILL.md
Normal file
193
.cursor/skills/存客宝官网开发/SKILL.md
Normal file
@@ -0,0 +1,193 @@
|
||||
---
|
||||
name: 存客宝官网开发
|
||||
description: 存客宝对外官网(/官网/)需求、SEO/GEO、产品矩阵截图、留资单接口、超管接口文档第9类。触发:存客宝官网、官网开发、官网SEO、产品矩阵、官网留资、对外接口、官网需求、GEO、私域官网。
|
||||
triggers: 存客宝官网,官网开发,官网SEO,产品矩阵,官网留资,对外接口,GEO,私域官网,触客宝官网
|
||||
owner: 火炬
|
||||
group: 火
|
||||
version: "1.0"
|
||||
updated: "2026-05-31"
|
||||
---
|
||||
|
||||
# 存客宝官网开发 Skill(F01e · 项目内)
|
||||
|
||||
> **代码**:`/Users/karuo/Documents/开发/2、私域银行/cunkebao_v3/官网/`(静态站 · 零构建)
|
||||
> **当日需求真源(唯一)**:`开发文档/1、需求/修改/官网_进行中_{YYYYMMDD}.md` — **新需求只追加此文件章节**,禁止再开 `官网_{主题}_*.md` 于 `修改/`
|
||||
> **鉴定/索引**:`开发文档/1、需求/官网/00~13` 为已整理专文,**非**当日追加口(见 [需求协调与归档](../需求协调与归档/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 总表 · 待改文件 · E1–E7** |
|
||||
| 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 访客追踪 + Webhook(REQ-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 阶段1(Server)→ 阶段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 链接 | **只列本轮**新建/修改 1~3 个文件 |
|
||||
| 用 `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:关键词矩阵在 09,ItemList Schema 上 index |
|
||||
| 2026-05-31 | SEO 后台要自建;站群 SaaS 黑帽不做;发帖 API 稀缺→任务单 |
|
||||
| 2026-05-31 | 本机素材:`9、手册/使用手册/截图/AI数智员工-登录页.png`;海报参考在 `个人/4、工作日志/会议纪要/参考/` |
|
||||
| 2026-05-31 | 单设备叙事≠海报获客;硬件专题见 13 号 REQ-WEB-50~54 |
|
||||
| 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;开发完须在 `_进行中` 打勾 |
|
||||
120
.cursor/skills/存客宝宝塔部署/SKILL.md
Normal file
120
.cursor/skills/存客宝宝塔部署/SKILL.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
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 万+ 数据,首屏约 **3~5s**(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
|
||||
```
|
||||
180
.cursor/skills/开发需求文档整理/SKILL.md
Normal file
180
.cursor/skills/开发需求文档整理/SKILL.md
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
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 · E1~E9 |
|
||||
|
||||
---
|
||||
|
||||
## 二、完成目标优先
|
||||
|
||||
**标题下第一句**必须是:
|
||||
|
||||
```markdown
|
||||
完成目标:【一句话:交付什么、谁受益、如何验收】。
|
||||
```
|
||||
|
||||
再写来源、时间轴、模块、排期。禁止先堆口述原文。
|
||||
|
||||
---
|
||||
|
||||
## 三、何时启用
|
||||
|
||||
| 素材 | 动作 |
|
||||
|:---|:---|
|
||||
| 录屏 TXT + 截图 + 草稿 | **追加**对应端 `{项目}_进行中_{日期}.md` 新 §,不新建主题 MD |
|
||||
| 口述「整理需求」 | 读本文 + 项目经验 `卡若AI/.../项目经验/存客宝.md` |
|
||||
| 跨端需求 | 拆到四端各一篇 + 总表;COORD 写入各端「跨项目协调」节 |
|
||||
|
||||
---
|
||||
|
||||
## 四、执行流程
|
||||
|
||||
```
|
||||
素材收集 → 完成目标 → 识别端(四端之一) → 图片入 10、项目管理/images/ → 模块拆解 → 落盘 → 更新索引
|
||||
```
|
||||
|
||||
### Step 1 · 确定端与主题
|
||||
|
||||
| 端 | 端口 | 主题示例 |
|
||||
|:---|:---|:---|
|
||||
| 存客宝 | 3100 H5 | 微信号、场景获客、迁移、设备 |
|
||||
| 触客宝 | 3101 PC | AI获客、工作台、Webhook |
|
||||
| SuperAdmin | 3103 | 超管后台、客户池、项目 |
|
||||
| AI数智员工 | 3104 | 扫码、飞书、协同对接 |
|
||||
|
||||
### Step 2 · 图片
|
||||
|
||||
- 进行中:``(相对 `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→SuperAdmin;001/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、需求/官网/`(00~12 编号专文) |
|
||||
| 代码 | `官网/` 静态站 |
|
||||
|
||||
触发词:官网需求、官网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`
|
||||
37
.cursor/skills/开发需求文档整理/examples.md
Normal file
37
.cursor/skills/开发需求文档整理/examples.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 标杆示例 · 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 看图,开发能照着做」
|
||||
185
.cursor/skills/开发需求文档整理/template.md
Normal file
185
.cursor/skills/开发需求文档整理/template.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 需求文档标准模板(复制后替换占位符)
|
||||
|
||||
> 进行中文件名:`{项目名称}_{功能主题}_{YYYYMMDD}.md`(例 `存客宝_微信号场景迁移与设备_20260529.md`)
|
||||
> 位置:`开发文档/1、需求/修改/`(扁平,无子目录)
|
||||
> 图片:`开发文档/10、项目管理/images/` · 已完成:`10、项目管理/已完成/{项目}_{主题}_{日期}/images/`
|
||||
|
||||
---
|
||||
|
||||
```markdown
|
||||
# {项目名称}_{功能主题}_{YYYYMMDD}
|
||||
|
||||
完成目标:【一句话写清本轮最终交付、边界和验收结果。例:把某账号/项目/功能链路整理到可开发、可测试、可验收的稳定状态。】
|
||||
|
||||
> **来源**:录屏口述 `【录屏文件名】.txt`(【时长】)+ 标注截图 `【文件夹或说明】` + 草稿 `【草稿文件名,无则删】`
|
||||
> **日期**:YYYY-MM-DD
|
||||
> **状态**:待开发
|
||||
> **适用端**:【例:超级管理后台 3103 · 存客宝 H5 3100 · 触客宝】
|
||||
|
||||
---
|
||||
|
||||
## 一、录屏时间轴(与口述一一对应)
|
||||
|
||||
| 时间点 | 模块 | 口述要点 |
|
||||
|--------|------|----------|
|
||||
| MM:SS | 【模块名】 | 【一句话需求,**加粗**关键动作】 |
|
||||
| MM:SS~MM:SS | 【模块名】 | 【跨段合并要点】 |
|
||||
| — | 草稿补充 | 【仅来自草稿、录屏未提的项】 |
|
||||
|
||||
---
|
||||
|
||||
## 二、目标拆解(先定目标,再拆需求)
|
||||
|
||||
| 目标层级 | 完成目标 | 验收标准 |
|
||||
|----------|----------|----------|
|
||||
| 总目标 | 【一句话总目标】 | 【最终验收标准】 |
|
||||
| 子目标 1 | 【模块/数据/页面目标】 | 【可测试结果】 |
|
||||
| 子目标 2 | 【模块/数据/页面目标】 | 【可测试结果】 |
|
||||
|
||||
---
|
||||
|
||||
## 三、产品关系(开发必读)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph A["【系统A】"]
|
||||
A1[模块]
|
||||
end
|
||||
subgraph B["【系统B】"]
|
||||
B1[模块]
|
||||
end
|
||||
A1 --> B1
|
||||
```
|
||||
|
||||
- **【产品甲】**:【与产品乙的关系一句】
|
||||
- **【关键链路】**:【例:扫码 → IMEI → 入库 → 超管可见】
|
||||
|
||||
---
|
||||
|
||||
## 四、模块 A · 【端名称 · 如超级管理后台】
|
||||
|
||||
### A1. 【功能名】(P0/P1)
|
||||
|
||||
**完成目标**:【本小节完成后达到什么状态】
|
||||
|
||||
**需求**:【一句话】
|
||||
|
||||

|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 业务规则 | 【规则】 |
|
||||
| 验收 | 【可测试句子】 |
|
||||
| 建议改动 | 【组件/文件,无则写待勘察】 |
|
||||
| 接口 | 【有则链 5、接口;无则「无接口不展示」】 |
|
||||
|
||||
---
|
||||
|
||||
### A2. 【功能名】(P0)
|
||||
|
||||
【重复 A1 结构:说明 + 图 + 表 + 验收 + 代码入口】
|
||||
|
||||
---
|
||||
|
||||
## 五、模块 B · 【端名称】
|
||||
|
||||
### B1. 【功能名】(P0)
|
||||
|
||||
**完成目标**:【本小节完成后达到什么状态】
|
||||
|
||||

|
||||
|
||||
| 功能点 | 详细说明 | 优先级 |
|
||||
|--------|----------|--------|
|
||||
| 【点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**:`` 与本文同级 `../../10、项目管理/images/` 目录。
|
||||
|
||||
---
|
||||
|
||||
## 十一、进度
|
||||
|
||||
| 维度 | 状态 |
|
||||
|------|------|
|
||||
| 需求拆解 | ✅ 已完成 |
|
||||
| UI 素材归档 | ✅ / ⏳ |
|
||||
| 开发 | ⏳ 待排期 |
|
||||
| 接口文档回写 | ⏳ 开发后 |
|
||||
|
||||
**维护**:变更写在下方表。
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| YYYY-MM-DD | 初版:录屏 + 截图 + 草稿合并 |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 模块章节编号约定
|
||||
|
||||
| 章 | 内容 |
|
||||
|----|------|
|
||||
| 二 | 目标拆解(总目标与子目标) |
|
||||
| 三 | 产品关系 |
|
||||
| 四 | 模块 A(通常超管) |
|
||||
| 五 | 模块 B(通常存客宝主功能) |
|
||||
| 六 | 模块 C(我的/客服等) |
|
||||
| 七~十一 | 固定:计划、验收、索引、素材、进度 |
|
||||
|
||||
模块超过四个时继续「六、模块 E」,**不要**打乱七~十一的固定编号。
|
||||
BIN
.cursor/skills/开发需求文档整理/存客宝-需求到交付流程图.png
Normal file
BIN
.cursor/skills/开发需求文档整理/存客宝-需求到交付流程图.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
177
.cursor/skills/触客宝手机端前端壳/SKILL.md
Normal file
177
.cursor/skills/触客宝手机端前端壳/SKILL.md
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
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
|
||||
└─ .shell(fixed · 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` |
|
||||
197
.cursor/skills/需求协调与归档/SKILL.md
Normal file
197
.cursor/skills/需求协调与归档/SKILL.md
Normal file
@@ -0,0 +1,197 @@
|
||||
---
|
||||
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. **验收表**(可数字 · V1~Vn)
|
||||
|
||||
### 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
51
.github/workflows/main.yml
vendored
@@ -1,51 +0,0 @@
|
||||
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
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,22 +1,20 @@
|
||||
Store_vue/unpackage/
|
||||
*.js
|
||||
Store_vue/node_modules/
|
||||
Server/.specstory/
|
||||
Cunkebao/.next/
|
||||
Cunkebao/.specstory/
|
||||
.idea/
|
||||
Cunkebao/.next/
|
||||
Store_vue/node_modules/
|
||||
*.zip
|
||||
Cunkebao/.specstory/
|
||||
*.cursorindexingignore
|
||||
Server/.specstory/
|
||||
Server/thinkphp/
|
||||
Store_vue/.specstory/
|
||||
Store_vue/unpackage/
|
||||
Store_vue/.vscode/
|
||||
SuperAdmin/.specstory/
|
||||
node_modules
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
Cunkebao/dist
|
||||
Touchkebao/.specstory/
|
||||
Serverruntime/
|
||||
Moncter/提示词/
|
||||
*.log
|
||||
.DS_Store
|
||||
android/app/build/
|
||||
android/.gradle/
|
||||
android/local.properties
|
||||
*.code-workspace
|
||||
项目整体分析报告.md
|
||||
Server/docs/traffic_pool_design.md
|
||||
|
||||
13
.obsidian/app.json
vendored
Normal file
13
.obsidian/app.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"pdfExportSettings": {
|
||||
"pageSize": "Letter",
|
||||
"landscape": false,
|
||||
"margin": "0",
|
||||
"downscalePercent": 100
|
||||
},
|
||||
"promptDelete": false,
|
||||
"attachmentFolderPath": "./images",
|
||||
"useMarkdownLinks": true,
|
||||
"newLinkFormat": "relative",
|
||||
"alwaysUpdateLinks": true
|
||||
}
|
||||
5
.obsidian/appearance.json
vendored
Normal file
5
.obsidian/appearance.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledCssSnippets": [
|
||||
"cunkebao-devdoc"
|
||||
]
|
||||
}
|
||||
33
.obsidian/core-plugins.json
vendored
Normal file
33
.obsidian/core-plugins.json
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"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
Normal file
93
.obsidian/graph.json
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
124
.obsidian/snippets/cunkebao-devdoc.css
vendored
Normal file
124
.obsidian/snippets/cunkebao-devdoc.css
vendored
Normal file
@@ -0,0 +1,124 @@
|
||||
/* 存客宝 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);
|
||||
}
|
||||
235
.obsidian/workspace.json
vendored
Normal file
235
.obsidian/workspace.json
vendored
Normal file
@@ -0,0 +1,235 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"presets": ["next/babel"]
|
||||
}
|
||||
|
||||
9
Cunkebao/.dockerignore
Normal file
9
Cunkebao/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage
|
||||
.vite
|
||||
12
Cunkebao/.env.development
Normal file
12
Cunkebao/.env.development
Normal file
@@ -0,0 +1,12 @@
|
||||
# 开发环境直接使用线上 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
|
||||
1
Cunkebao/.env.local
Normal file
1
Cunkebao/.env.local
Normal file
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL= http://yishi.com
|
||||
6
Cunkebao/.env.production
Normal file
6
Cunkebao/.env.production
Normal file
@@ -0,0 +1,6 @@
|
||||
# 基础环境变量示例
|
||||
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=存客宝
|
||||
64
Cunkebao/.eslintrc.js
Normal file
64
Cunkebao/.eslintrc.js
Normal file
@@ -0,0 +1,64 @@
|
||||
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",
|
||||
},
|
||||
},
|
||||
};
|
||||
27
Cunkebao/.gitattributes
vendored
Normal file
27
Cunkebao/.gitattributes
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# 设置默认行为,如果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
32
Cunkebao/.gitignore
vendored
@@ -1,23 +1,11 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
yarn.lock
|
||||
.env
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
dist/*
|
||||
.cursorindexingignore
|
||||
*.zip
|
||||
.idea/
|
||||
.next/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
# 请将伪静态规则或自定义Apache配置填写到此处
|
||||
13
Cunkebao/.prettierrc
Normal file
13
Cunkebao/.prettierrc
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"jsxSingleQuote": false,
|
||||
"quoteProps": "as-needed"
|
||||
}
|
||||
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
11
Cunkebao/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
45
Cunkebao/.vscode/settings.json
vendored
Normal file
45
Cunkebao/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
nKNDFrnOs9-cf7FZzW7Cg4MpIxhtE8pZ1etiT_7NPWA.2wKeISBpPoGcCt72TdgVrBMxtnnieXof2OZkRdHKCJI
|
||||
23
Cunkebao/Dockerfile
Normal file
23
Cunkebao/Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# 存客宝前端 - 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;"]
|
||||
@@ -1,266 +0,0 @@
|
||||
# 内客宝 - 智能获客管理平台
|
||||
|
||||
## 📋 项目简介
|
||||
|
||||
内客宝是一个专业的微信获客和流量管理平台,基于 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月
|
||||
@@ -1,192 +0,0 @@
|
||||
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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
"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 参数,将其余所有参数(如 name、phone、timestamp、source、remark、tags)按参数名升序排序</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>导航至"应用集成" > "外部接口"</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,273 +0,0 @@
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"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
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"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}</>
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,324 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
"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>
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* 存客宝UI组件模板库
|
||||
*
|
||||
* 这个文件导出所有可重用的UI组件模板,方便在项目中快速引用
|
||||
*/
|
||||
|
||||
export * from "./cards"
|
||||
export * from "./forms"
|
||||
export * from "./layouts"
|
||||
export * from "./tables"
|
||||
export * from "./stats"
|
||||
export * from "./selectors"
|
||||
@@ -1,119 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* 统计组件模板
|
||||
*
|
||||
* 包含项目中常用的各种统计和数据展示组件
|
||||
*/
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
"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>
|
||||
)
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
"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="没有找到设备"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"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 }
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"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 }
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
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 }
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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 }
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"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 }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user