Compare commits
782
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
049bbb0d24 | ||
|
|
bedf39d4f7 | ||
|
|
04fbcc8d8b | ||
|
|
c2971f828f | ||
|
|
45576e7d97 | ||
|
|
1fb75676fc | ||
|
|
fac54b5667 | ||
|
|
1565eeebc5 | ||
|
|
a754a9e004 | ||
|
|
289e458e7c | ||
|
|
3f18b28227 | ||
|
|
7c3ef21762 | ||
|
|
148b89c3ca | ||
|
|
ae92f8b78c | ||
|
|
59116b93d8 | ||
|
|
07d476e9ac | ||
|
|
5afcfe1513 | ||
|
|
516106c045 | ||
|
|
5e504eddaf | ||
|
|
da3f2cac49 | ||
|
|
a31b58316e | ||
|
|
299dc549cd | ||
|
|
c091960b87 | ||
|
|
ee380cfcc1 | ||
|
|
9b13968762 | ||
|
|
7dcedf974d | ||
|
|
33a5cc4f94 | ||
|
|
b4b629a497 | ||
|
|
2aa737edaa | ||
|
|
fe855e0fa2 | ||
|
|
437be9dac0 | ||
|
|
780769ddee | ||
|
|
b917f19db5 | ||
|
|
4754151cbb | ||
|
|
58e061c9e7 | ||
|
|
6ed82b2e04 | ||
|
|
eb7c60684e | ||
|
|
e3719eae09 | ||
|
|
45b9409fe8 | ||
|
|
81ef619ad3 | ||
|
|
a49ed802f4 | ||
|
|
809ff47120 | ||
|
|
890c1ea83e | ||
|
|
ba32d6f55e | ||
|
|
392b9f4117 | ||
|
|
a1b82635c7 | ||
|
|
41b7cd395b | ||
|
|
b9fd07f293 | ||
|
|
f38f9b09ab | ||
|
|
5bcc7888c2 | ||
|
|
0e47719d5c | ||
|
|
decabb5c42 | ||
|
|
80d0fd26f1 | ||
|
|
487c15779d | ||
|
|
190cf54f0f | ||
|
|
baf97b5fc5 | ||
|
|
7237e8715f | ||
|
|
c1092f0deb | ||
|
|
e83f963534 | ||
|
|
9f7dc731b4 | ||
|
|
2bbcb75328 | ||
|
|
86c65f9ec8 | ||
|
|
d1e8eb7316 | ||
|
|
e364c46ce4 | ||
|
|
a699c9bab8 | ||
|
|
7777454b16 | ||
|
|
e8e590948c | ||
|
|
6d30347cdb | ||
|
|
e77d78db1e | ||
|
|
e2a2b6a16f | ||
|
|
e4fe42991d | ||
|
|
ab16456579 | ||
|
|
40689de151 | ||
|
|
c691416434 | ||
|
|
d0de2f321a | ||
|
|
ec4d507890 | ||
|
|
bffa786d47 | ||
|
|
f530870370 | ||
|
|
afac8dac09 | ||
|
|
a241329ea6 | ||
|
|
9f89e186d6 | ||
|
|
e24465ac39 | ||
|
|
1498a11a9b | ||
|
|
9538f6157d | ||
|
|
f5d7cfadb4 | ||
|
|
44b865e1ca | ||
|
|
61c3761745 | ||
|
|
1029555fdc | ||
|
|
166a020bed | ||
|
|
820f601b32 | ||
|
|
70f2261d68 | ||
|
|
0fb20777f7 | ||
|
|
990cdd3ff5 | ||
|
|
395d979208 | ||
|
|
dc3c4f7e4d | ||
|
|
4766bab453 | ||
|
|
1e54e3f7f4 | ||
|
|
277231339c | ||
|
|
d6e6c77278 | ||
|
|
0c511f3451 | ||
|
|
a55d4d649c | ||
|
|
517bcc3531 | ||
|
|
ceb930bd5a | ||
|
|
5de637ab6b | ||
|
|
6192aba4b2 | ||
|
|
ebd22d62a1 | ||
|
|
fe24e51b97 | ||
|
|
97edb17062 | ||
|
|
5d2c63ced1 | ||
|
|
d2c85b03df | ||
|
|
33d5bfd997 | ||
|
|
15deed1cf4 | ||
|
|
7077d05d6a | ||
|
|
570292266e | ||
|
|
9b3541264f | ||
|
|
1f711fed7f | ||
|
|
2d9abd5218 | ||
|
|
cfcec301d2 | ||
|
|
3d144a4640 | ||
|
|
8d5c900411 | ||
|
|
8fbf239e01 | ||
|
|
9114b45841 | ||
|
|
0576169773 | ||
|
|
1bdafa25cb | ||
|
|
4eb0360134 | ||
|
|
ac73de70b4 | ||
|
|
0195175231 | ||
|
|
af1dd20789 | ||
|
|
da08fc43cb | ||
|
|
acac39c73c | ||
|
|
8325ae1f71 | ||
|
|
03ed98ce9b | ||
|
|
bc7eee681d | ||
|
|
663c4fa9db | ||
|
|
4685b475af | ||
|
|
357657d7cd | ||
|
|
e55077a833 | ||
|
|
0e20981383 | ||
|
|
a445198ecb | ||
|
|
42f103a945 | ||
|
|
68e902b4a4 | ||
|
|
053dde7546 | ||
|
|
cc0f8e5fd8 | ||
|
|
d5cb11dc60 | ||
|
|
7ea612d374 | ||
|
|
19d6580704 | ||
|
|
920d884e92 | ||
|
|
9463b94a3e | ||
|
|
2931e67226 | ||
|
|
06c2159b15 | ||
|
|
7ecae0eb61 | ||
|
|
54b8e1a360 | ||
|
|
4fdbe0c946 | ||
|
|
ed2e1715d1 | ||
|
|
06bf5c4db7 | ||
|
|
73243ff497 | ||
|
|
966d1ff5d3 | ||
|
|
cb682b4867 | ||
|
|
6b934b07dc | ||
|
|
6db0e2d3d5 | ||
|
|
69e7dec807 | ||
|
|
d0ccb04b07 | ||
|
|
eee8105d0e | ||
|
|
3f1c4063b0 | ||
|
|
eec5862465 | ||
|
|
6b78d5830d | ||
|
|
d111977023 | ||
|
|
b41e1d6607 | ||
|
|
def054c147 | ||
|
|
1cb957d20d | ||
|
|
a63039c146 | ||
|
|
d8c5258822 | ||
|
|
7afd5b1d5e | ||
|
|
04fad1af9d | ||
|
|
b32ed4129c | ||
|
|
9b13d01d72 | ||
|
|
4afda8992c | ||
|
|
6f37134c8c | ||
|
|
7683ebb186 | ||
|
|
0aa884304d | ||
|
|
4f1ccd3f37 | ||
|
|
79c09151ba | ||
|
|
b374a541ea | ||
|
|
56f380aa33 | ||
|
|
65fc424583 | ||
|
|
5538ddaa43 | ||
|
|
ea06edec3e | ||
|
|
6283bb8ceb | ||
|
|
fb3b95b687 | ||
|
|
3c6deed786 | ||
|
|
2a9c2d868c | ||
|
|
76d03e7de1 | ||
|
|
880e7ed515 | ||
|
|
40efcf55cf | ||
|
|
1aaef451b8 | ||
|
|
2403b38cae | ||
|
|
c0a58fdac3 | ||
|
|
c025303ef8 | ||
|
|
a2e98ef3a3 | ||
|
|
4af74acf25 | ||
|
|
d5a964a4d9 | ||
|
|
fe5a59d725 | ||
|
|
53e01f6f74 | ||
|
|
46b59ff76c | ||
|
|
c145efd0b1 | ||
|
|
4104774174 | ||
|
|
4563b686e0 | ||
|
|
e264214b77 | ||
|
|
a7d8bd25bb | ||
|
|
bbc9bd95bd | ||
|
|
d202316708 | ||
|
|
519c1e58b9 | ||
|
|
bba29d79a2 | ||
|
|
34ca98f9eb | ||
|
|
1aa7ff02f4 | ||
|
|
2d3d096fd7 | ||
|
|
8ca6f0fedb | ||
|
|
5b4449fe97 | ||
|
|
71ec6f7d47 | ||
|
|
f0ff573650 | ||
|
|
c5292fcf03 | ||
|
|
edfeea6f98 | ||
|
|
395702fbd5 | ||
|
|
098a639be6 | ||
|
|
a7c99839de | ||
|
|
6a7de32b3a | ||
|
|
3db586cd60 | ||
|
|
52f53a0c63 | ||
|
|
c80e2150f5 | ||
|
|
4d872036b2 | ||
|
|
38040dfb0d | ||
|
|
e44885461b | ||
|
|
01c55721ca | ||
|
|
085e85967a | ||
|
|
41feb480ee | ||
|
|
5036efe9aa | ||
|
|
3188d77b88 | ||
|
|
3e900a43f0 | ||
|
|
6db989dc42 | ||
|
|
b1a8c3ed5a | ||
|
|
51673453d8 | ||
|
|
7d9c50a74d | ||
|
|
0ffb35e2df | ||
|
|
9756a86304 | ||
|
|
51f9deecde | ||
|
|
a9190ba4e1 | ||
|
|
b4166ecadb | ||
|
|
4ade6bb347 | ||
|
|
9637e5ba79 | ||
|
|
21ff42653c | ||
|
|
5b937f652b | ||
|
|
b2b322bcfb | ||
|
|
dc2e527977 | ||
|
|
6ff8866ca3 | ||
|
|
6039b5ee75 | ||
|
|
adfc2aa4f4 | ||
|
|
399aefe61f | ||
|
|
4ab2baae43 | ||
|
|
fed6ba2e19 | ||
|
|
18fe13dd9f | ||
|
|
36e4bec90e | ||
|
|
af60e6720b | ||
|
|
21208e68d8 | ||
|
|
b79162654d | ||
|
|
778da67b34 | ||
|
|
a7cd5a634c | ||
|
|
0962fa7820 | ||
|
|
344fb789a4 | ||
|
|
6cc1655c69 | ||
|
|
c64cf06c33 | ||
|
|
cba3450596 | ||
|
|
39160822f7 | ||
|
|
32673f0f25 | ||
|
|
742c8688bc | ||
|
|
37588d0f05 | ||
|
|
f5f4be2986 | ||
|
|
d2a30c58ca | ||
|
|
111c74c7c6 | ||
|
|
8641d507e6 | ||
|
|
330ac27b1e | ||
|
|
508732bc76 | ||
|
|
614ae1b4d6 | ||
|
|
63e1a43b2a | ||
|
|
8270dc6500 | ||
|
|
d0bd171ab5 | ||
|
|
698626a15a | ||
|
|
0fd93e53b9 | ||
|
|
70345de660 | ||
|
|
d07cd508ee | ||
|
|
f00c36c262 | ||
|
|
468184d352 | ||
|
|
ad2e40bc67 | ||
|
|
bbb0916ada | ||
|
|
6518010a36 | ||
|
|
8b4149f0c5 | ||
|
|
1e54c0dc11 | ||
|
|
8f51f218fc | ||
|
|
1ff5e6e050 | ||
|
|
6ddfda578f | ||
|
|
8c74aaa889 | ||
|
|
f3a14a9e31 | ||
|
|
f2f8625425 | ||
|
|
51104449a7 | ||
|
|
f920b58e71 | ||
|
|
d99062ca98 | ||
|
|
4f0cd6baf6 | ||
|
|
f11e23ec00 | ||
|
|
493b72922e | ||
|
|
dbd1024f86 | ||
|
|
0231067770 | ||
|
|
8d4ed4a3d1 | ||
|
|
353a5d5093 | ||
|
|
452ba3ad05 | ||
|
|
54ecb5368c | ||
|
|
356d4860e3 | ||
|
|
65f1514e89 | ||
|
|
0abfda7f2d | ||
|
|
3f1e84f0c9 | ||
|
|
a75b863f16 | ||
|
|
2c0a51883c | ||
|
|
a9f3260b10 | ||
|
|
3e001ce66f | ||
|
|
be2930b0e8 | ||
|
|
e0143462bb | ||
|
|
2b996e114e | ||
|
|
23ab0a2027 | ||
|
|
796b533add | ||
|
|
96de545a40 | ||
|
|
4b2787a785 | ||
|
|
b040bede05 | ||
|
|
54bf0a53f5 | ||
|
|
8d740a8fde | ||
|
|
abef1c8df9 | ||
|
|
790d546451 | ||
|
|
c05a52498e | ||
|
|
8d5230e8fe | ||
|
|
0148bea66a | ||
|
|
6bc05a00d0 | ||
|
|
349a93c18b | ||
|
|
581507d15d | ||
|
|
dff9bd6c0e | ||
|
|
3bb778f51a | ||
|
|
d3814939ed | ||
|
|
b2b0f51109 | ||
|
|
fec47bf47a | ||
|
|
ef5044dc9b | ||
|
|
bb53063358 | ||
|
|
465ed9fa04 | ||
|
|
3259e3d184 | ||
|
|
d085a6d2ec | ||
|
|
2882d37112 | ||
|
|
5545af003f | ||
|
|
657505e63b | ||
|
|
2d419b171e | ||
|
|
c684348613 | ||
|
|
2e491780b1 | ||
|
|
50a8e5fe3f | ||
|
|
9cef3106f3 | ||
|
|
5ebb64a5c4 | ||
|
|
2698216aac | ||
|
|
35ffa317e7 | ||
|
|
141a2051ba | ||
|
|
5bd72b89de | ||
|
|
a0def59ece | ||
|
|
b92dd75fd4 | ||
|
|
d0d3cc56e2 | ||
|
|
98e8fe145f | ||
|
|
9f63adcd0a | ||
|
|
0fb2c5f165 | ||
|
|
6026ec8670 | ||
|
|
a0086a15c7 | ||
|
|
48b41f7ab9 | ||
|
|
01b520ac49 | ||
|
|
64714c6d42 | ||
|
|
5ffe8e0893 | ||
|
|
6ae5e0d663 | ||
|
|
e94225995c | ||
|
|
ac869d351f | ||
|
|
3574a9f1cf | ||
|
|
d80f5c3577 | ||
|
|
88bdccd727 | ||
|
|
4a7c845138 | ||
|
|
4375e4980c | ||
|
|
4036ce9eea | ||
|
|
126754c933 | ||
|
|
7e7cd6bd6e | ||
|
|
5ae7f5b64b | ||
|
|
ab995ce3cf | ||
|
|
32688ab52c | ||
|
|
a3f67a76f8 | ||
|
|
c49f93dcd9 | ||
|
|
cc74a2ee8b | ||
|
|
6ce40baad3 | ||
|
|
f6ef4a7fc1 | ||
|
|
b85f4739ef | ||
|
|
4da83e4859 | ||
|
|
73c559ed74 | ||
|
|
420b9147fe | ||
|
|
4818bbc2e9 | ||
|
|
3f5d3d0194 | ||
|
|
3768220a78 | ||
|
|
60ed40df2f | ||
|
|
c3ad806e10 | ||
|
|
3c77b79629 | ||
|
|
0a16199c27 | ||
|
|
3a0aed8647 | ||
|
|
e72f54b65d | ||
|
|
0a5f697c04 | ||
|
|
cd17a12c06 | ||
|
|
df251b83f5 | ||
|
|
cff69423d7 | ||
|
|
4412bc9378 | ||
|
|
04110db524 | ||
|
|
732cdd6ed1 | ||
|
|
f4d278eae4 | ||
|
|
056a26c42b | ||
|
|
1517730c8b | ||
|
|
f515362c8e | ||
|
|
789142ac6c | ||
|
|
9b213b1551 | ||
|
|
8389ae1db6 | ||
|
|
eac29371ea | ||
|
|
26079d03ab | ||
|
|
ab67bdf19e | ||
|
|
fa4f48a62f | ||
|
|
14e0fd8b25 | ||
|
|
0aa47f455a | ||
|
|
9c69e3b67a | ||
|
|
30c1ea970d | ||
|
|
e15197a437 | ||
|
|
25f3ecf14c | ||
|
|
2bd7d6ecae | ||
|
|
34e566f6bf | ||
|
|
05b6062bd0 | ||
|
|
c68215c177 | ||
|
|
16b3d223c1 | ||
|
|
75d1f7a428 | ||
|
|
3c0647a6aa | ||
|
|
a0016b436e | ||
|
|
84c4683d88 | ||
|
|
d8a02778f1 | ||
|
|
1fb732c7a8 | ||
|
|
9474c4f725 | ||
|
|
c99f0f8c8f | ||
|
|
fca85b0157 | ||
|
|
c9174a22d2 | ||
|
|
2bdee80406 | ||
|
|
7f9a70d1ee | ||
|
|
b5538ee27a | ||
|
|
7e6318742c | ||
|
|
30db9fb425 | ||
|
|
741b138a22 | ||
|
|
9ba24a4f51 | ||
|
|
94302719b2 | ||
|
|
54410c7854 | ||
|
|
e4cf7799a4 | ||
|
|
8d9b970ac4 | ||
|
|
1705c992e8 | ||
|
|
778a89cabb | ||
|
|
28b1d24173 | ||
|
|
899732aa54 | ||
|
|
4df90b6d85 | ||
|
|
a54256d5af | ||
|
|
a53f75a2bb | ||
|
|
b1e328d92d | ||
|
|
8d5a210949 | ||
|
|
61d74fcc04 | ||
|
|
4992191da9 | ||
|
|
8d3116f7fa | ||
|
|
a967f32245 | ||
|
|
5d25317f68 | ||
|
|
3916dbf6ae | ||
|
|
f8fc44c68a | ||
|
|
67287cc4ef | ||
|
|
036d8efc15 | ||
|
|
08bc4f14bb | ||
|
|
2d7b8ca334 | ||
|
|
5a98d7c1f2 | ||
|
|
b0a56eccde | ||
|
|
545c4ffc55 | ||
|
|
82db946631 | ||
|
|
69e664a3a2 | ||
|
|
1dd356fd9d | ||
|
|
488c98617d | ||
|
|
9edaeb4031 | ||
|
|
b39dae6d55 | ||
|
|
ce3c49d00e | ||
|
|
0d5534dbca | ||
|
|
6190236fc0 | ||
|
|
4a401d101c | ||
|
|
d39404f1b7 | ||
|
|
33a78cb4a5 | ||
|
|
0fcc21a2f5 | ||
|
|
913c1d4026 | ||
|
|
d3bd00fe47 | ||
|
|
ac87ac7059 | ||
|
|
b93f93d3f8 | ||
|
|
049a269374 | ||
|
|
43202caa15 | ||
|
|
86fff5fd83 | ||
|
|
28c77fad38 | ||
|
|
b96e4ec4bf | ||
|
|
6b7604dd38 | ||
|
|
854a251d22 | ||
|
|
3272e8167b | ||
|
|
11e0fae735 | ||
|
|
44aba3516b | ||
|
|
fa56acf455 | ||
|
|
1f67f2cb27 | ||
|
|
d0aa82e1cd | ||
|
|
60ad714010 | ||
|
|
575a6f7499 | ||
|
|
c50f0c4fdc | ||
|
|
9275556db3 | ||
|
|
0061068ac8 | ||
|
|
c2b203f658 | ||
|
|
b8d48f6ad3 | ||
|
|
0fba6fdf09 | ||
|
|
8b21d7cdde | ||
|
|
aa076e4e9a | ||
|
|
9731083079 | ||
|
|
f55f4e82cb | ||
|
|
80c721a940 | ||
|
|
6d4dcb33b9 | ||
|
|
20e6740e48 | ||
|
|
1b9d54e884 | ||
|
|
f6c5032c7b | ||
|
|
7a2d127c92 | ||
|
|
91aac6a931 | ||
|
|
1f812908a7 | ||
|
|
5441fbc8b9 | ||
|
|
5d620b58ff | ||
|
|
5302d7f531 | ||
|
|
5e70064751 | ||
|
|
288c0ea1cc | ||
|
|
0c562fb325 | ||
|
|
1656637702 | ||
|
|
34a877ea8c | ||
|
|
13ede48105 | ||
|
|
889715444b | ||
|
|
ab5814ef0d | ||
|
|
eb1ae69d84 | ||
|
|
cbe111548a | ||
|
|
efe92c4c2c | ||
|
|
cf16ed8dfe | ||
|
|
4cd07c0019 | ||
|
|
04f34c1075 | ||
|
|
823a58d86f | ||
|
|
ecf430badb | ||
|
|
69282a47b5 | ||
|
|
1fe6f21fe1 | ||
|
|
632ea108f4 | ||
|
|
0d345761e8 | ||
|
|
d2f9e50b59 | ||
|
|
0aa65c0723 | ||
|
|
37cb96e05d | ||
|
|
dddda824d6 | ||
|
|
3587e99e25 | ||
|
|
803e004f84 | ||
|
|
9b575d7bfe | ||
|
|
291b240805 | ||
|
|
c6df04a895 | ||
|
|
87c0739863 | ||
|
|
9309a7ffdd | ||
|
|
ee2867ff1a | ||
|
|
43f16338bd | ||
|
|
3543be4628 | ||
|
|
a011e4575b | ||
|
|
40b3df3219 | ||
|
|
838215736f | ||
|
|
7f35f658d4 | ||
|
|
01f8abec0b | ||
|
|
935af9ee2d | ||
|
|
6b4fc7fc17 | ||
|
|
86e5bafad2 | ||
|
|
b98b748c4b | ||
|
|
0bf54bfb50 | ||
|
|
7c5580c76a | ||
|
|
976fa1b5a6 | ||
|
|
571067f8ed | ||
|
|
9e6d6f4d74 | ||
|
|
ecb0f512c3 | ||
|
|
5865f8d3b3 | ||
|
|
0c63512a29 | ||
|
|
f5dc4a0075 | ||
|
|
1389d373c9 | ||
|
|
b48979d4c4 | ||
|
|
ef9d9e8a74 | ||
|
|
0541fc0046 | ||
|
|
a3e70b5d49 | ||
|
|
b78b0428c0 | ||
|
|
8f152d29fe | ||
|
|
5cd5d9f24f | ||
|
|
b6e44565e7 | ||
|
|
1154aab919 | ||
|
|
240107ca34 | ||
|
|
72a1b6e2b0 | ||
|
|
37203cdbaf | ||
|
|
9fb81dcfc1 | ||
|
|
195c809915 | ||
|
|
e0a8082d8c | ||
|
|
f24ac1c4f7 | ||
|
|
79a0dd449c | ||
|
|
9028b33072 | ||
|
|
84559eaded | ||
|
|
6bd43180c3 | ||
|
|
024b1e7a34 | ||
|
|
702a8b3a5d | ||
|
|
88c31dce9f | ||
|
|
d6b32620b7 | ||
|
|
7c2071aa80 | ||
|
|
0cf4bd554b | ||
|
|
c05639fb47 | ||
|
|
1e24195bd6 | ||
|
|
8bbd36a1de | ||
|
|
2a73919879 | ||
|
|
67c2484b8d | ||
|
|
66c3328e23 | ||
|
|
152392e3d9 | ||
|
|
188119009e | ||
|
|
63c2f0fb33 | ||
|
|
13970941d5 | ||
|
|
f61e5035ff | ||
|
|
3f376d63bf | ||
|
|
25fa41f894 | ||
|
|
241bfd4aed | ||
|
|
f35422fda2 | ||
|
|
1ad9b0ff14 | ||
|
|
f4a6be6e39 | ||
|
|
31d2a0d6cd | ||
|
|
e66cb3e865 | ||
|
|
c5f6268829 | ||
|
|
9dd1bcdefc | ||
|
|
43d12894c1 | ||
|
|
bc8e4c9fff | ||
|
|
696172108b | ||
|
|
d03f6b4368 | ||
|
|
fabab1dbe9 | ||
|
|
39440f2ad4 | ||
|
|
776e122adf | ||
|
|
92fb64aa99 | ||
|
|
b21f35582e | ||
|
|
627087797b | ||
|
|
8f9140b6e7 | ||
|
|
450c9e538d | ||
|
|
b26b9e6af1 | ||
|
|
6c8543a5f6 | ||
|
|
ab02f7ef34 | ||
|
|
a7019f1344 | ||
|
|
54cd7c0e19 | ||
|
|
ded24b4a91 | ||
|
|
5bdd5b4eb3 | ||
|
|
a986ddf037 | ||
|
|
f2dbfbf62f | ||
|
|
ef9522a47b | ||
|
|
4f2be8a137 | ||
|
|
cf1ca2be12 | ||
|
|
de63c44ba3 | ||
|
|
a78c93df13 | ||
|
|
02864677ec | ||
|
|
ee89a5ab38 | ||
|
|
5c9fb2e713 | ||
|
|
14dae263bb | ||
|
|
30e263e7d7 | ||
|
|
4e33900317 | ||
|
|
87bbc5c1c1 | ||
|
|
0903543994 | ||
|
|
d82c7a1490 | ||
|
|
5a07fbb36f | ||
|
|
6ce6c23b55 | ||
|
|
7d4eaebb79 | ||
|
|
c6997789dc | ||
|
|
9c51aac97b | ||
|
|
6120e03183 | ||
|
|
04f9a4e893 | ||
|
|
02cd4a582b | ||
|
|
5ef4719d4e | ||
|
|
47ebe17459 | ||
|
|
76a0c1be4e | ||
|
|
e21ba57597 | ||
|
|
7e2a44c0a1 | ||
|
|
157e7e5bba | ||
|
|
b1aa4f4a52 | ||
|
|
fc5fb41d42 | ||
|
|
9b37cd789e | ||
|
|
9bf181e14c | ||
|
|
479e1a91fb | ||
|
|
f7a5ec4b58 | ||
|
|
43ed6c0868 | ||
|
|
c0e8d1caae | ||
|
|
2c899196d9 | ||
|
|
ee03242e6c | ||
|
|
02c068e693 | ||
|
|
81d0c20422 | ||
|
|
7b37fcfc61 | ||
|
|
8fa5dc4308 | ||
|
|
af932cce33 | ||
|
|
24fac8fc1b | ||
|
|
06cdab9cbc | ||
|
|
faf31c8141 | ||
|
|
e62956af79 | ||
|
|
e431cb1caf | ||
|
|
f7331a8b73 | ||
|
|
6be98fe3bf | ||
|
|
a58380cc5e | ||
|
|
fbc6354f76 | ||
|
|
b383df8b17 | ||
|
|
b152085918 | ||
|
|
ae850de128 | ||
|
|
c06c4b1dde | ||
|
|
c794b33a0e | ||
|
|
8c33c824ab | ||
|
|
a6db2bf400 | ||
|
|
5268f21705 | ||
|
|
3b9e5fab10 | ||
|
|
72fb7995ca | ||
|
|
d6b6732ec2 | ||
|
|
f3675924dc | ||
|
|
857c70f493 | ||
|
|
db3bddca8c | ||
|
|
509892ca7b | ||
|
|
cee7093db6 | ||
|
|
abc53234f1 | ||
|
|
6fcc3f8a0c | ||
|
|
4efb360b93 | ||
|
|
9f8aca5f0d | ||
|
|
278c9f96a1 | ||
|
|
2d59b8f335 | ||
|
|
d82001c60f | ||
|
|
06eee7ae47 | ||
|
|
a8494c08a5 | ||
|
|
772aa21fae | ||
|
|
fc0bbad3ba | ||
|
|
3c3797a734 | ||
|
|
5d038268ce | ||
|
|
fa2e56d021 | ||
|
|
8f00e35cc5 | ||
|
|
5f06fd06cf | ||
|
|
690d1baba0 | ||
|
|
50afcf02d0 | ||
|
|
30db7d3bf5 | ||
|
|
537c121361 | ||
|
|
78c3f36ca2 | ||
|
|
a0a60bd121 | ||
|
|
6da4fe78f3 | ||
|
|
5f8e2ce93a | ||
|
|
a382c27dc3 | ||
|
|
586bce2aeb | ||
|
|
87e412ef74 | ||
|
|
5fc1e43d4e | ||
|
|
3c41f56ce5 | ||
|
|
3ae2fe8d01 | ||
|
|
7102683191 | ||
|
|
ef31453ffa | ||
|
|
bdb5ce891c | ||
|
|
8aafd7de32 | ||
|
|
28c6e2c772 | ||
|
|
7f1b18314e | ||
|
|
313471c1dd | ||
|
|
143ae52878 | ||
|
|
9b1a88d131 | ||
|
|
749b576f6c | ||
|
|
8be899d5e4 | ||
|
|
adb6b09094 | ||
|
|
4a1118a3ec | ||
|
|
ddaa64aef0 | ||
|
|
da056ea64b | ||
|
|
3b5e428eb8 | ||
|
|
fb55c03dd8 | ||
|
|
242030c50b | ||
|
|
b88546ab66 | ||
|
|
0985027746 | ||
|
|
5041660ca4 | ||
|
|
a37b0e4435 | ||
|
|
270628049b | ||
|
|
58b1372232 | ||
|
|
03e5b049b4 | ||
|
|
ada0243919 | ||
|
|
5e432c2d3f | ||
|
|
73567b4143 | ||
|
|
5d67b30b9a | ||
|
|
721bcd97e4 |
+8
-3
@@ -8,13 +8,17 @@ __pycache__/
|
||||
.claude/
|
||||
.vscode/
|
||||
.trae/
|
||||
video-gen-app/dist/
|
||||
video-gen-api/dist/
|
||||
# video-gen-app/dist/
|
||||
#video-gen-api/dist/
|
||||
bak/
|
||||
|
||||
# 使用通配符
|
||||
*.log
|
||||
*.tmp
|
||||
.DS_Store
|
||||
.playwright-mcp
|
||||
*.zip
|
||||
*.bat
|
||||
|
||||
# 否定规则(不忽略)
|
||||
# !important.log
|
||||
@@ -22,4 +26,5 @@ video-gen-api/dist/
|
||||
# 忽略特定类型文件但保留目录
|
||||
# *.pyc
|
||||
# !dir/*.pycnode_modules/
|
||||
*.tmp.*
|
||||
*.tmp.*
|
||||
*_上线.py
|
||||
+953
@@ -0,0 +1,953 @@
|
||||
# 对外开放模型 API v3 接口文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述视频/图片生成平台的对外开放 API v3 接口。外部调用方通过 API Key 认证,调用 AI 视频和图片生成能力。
|
||||
|
||||
- **Base URL**: `http://your-domain.com/api/v3`
|
||||
- **认证方式**: `Authorization: Bearer {api-key}`
|
||||
- **数据格式**: JSON
|
||||
- **字符编码**: UTF-8
|
||||
|
||||
---
|
||||
|
||||
## 认证
|
||||
|
||||
所有接口均需在请求头中携带 API Key:
|
||||
|
||||
```
|
||||
Authorization: Bearer vk_xxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
认证失败时返回:
|
||||
|
||||
```json
|
||||
// 401 API Key 无效或过期
|
||||
{
|
||||
"error": {
|
||||
"code": "invalid_api_key",
|
||||
"message": "无效的 API Key"
|
||||
}
|
||||
}
|
||||
|
||||
// 429 配额不足
|
||||
{
|
||||
"error": {
|
||||
"code": "quota_exceeded",
|
||||
"message": "配额不足 (需要 1.00 元, 剩余 0.50 元)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 获取可用模型列表
|
||||
|
||||
获取当前 API Key 可调用的所有视频和图片模型(仅返回已配置价格的模型)。
|
||||
|
||||
```
|
||||
GET /api/v3/models
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| Authorization | 是 | `Bearer {api-key}` |
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"model": "doubao-seedance-2-0-260128",
|
||||
"engine_type": "video",
|
||||
"engine_id": "eng_xxxx",
|
||||
"supported_ratios": ["16:9", "9:16", "1:1", "4:3"],
|
||||
"supported_resolutions": ["480p", "720p", "1080p"],
|
||||
"supported_durations": [3, 4, 5, 6, 7, 8, 9, 10, 15]
|
||||
},
|
||||
{
|
||||
"model": "doubao-seedream-5-0-260128",
|
||||
"engine_type": "image",
|
||||
"engine_id": "eng_yyyy",
|
||||
"supported_sizes": ["2K", "4K"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| models | array | 可用模型列表 |
|
||||
| models[].model | string | 模型名称 |
|
||||
| models[].engine_type | string | 引擎类型: `video` / `image` |
|
||||
| models[].engine_id | string | 引擎 ID |
|
||||
| models[].supported_ratios | string[] | 视频支持的比例列表 |
|
||||
| models[].supported_resolutions | string[] | 视频支持的分辨率列表 |
|
||||
| models[].supported_durations | int[] | 视频支持的时长列表(秒) |
|
||||
| models[].supported_sizes | string[] | 图片支持的尺寸列表 |
|
||||
|
||||
---
|
||||
|
||||
### 2. 创建视频生成任务(异步)
|
||||
|
||||
创建视频生成任务,接口立即返回 `task_id`,调用方通过轮询查询任务状态和结果。
|
||||
|
||||
**并发排队机制:**
|
||||
- 每个 API Key 可配置最大并发视频任务数(`max_concurrent_video_tasks`)
|
||||
- 未超并发:任务立即执行,`status="queued"`
|
||||
- 超过并发:任务排队等待,`status="pending_queue"`
|
||||
- 当有任务完成/失败时,自动从队列中启动下一个任务
|
||||
|
||||
```
|
||||
POST /api/v3/videos
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| Authorization | 是 | `Bearer {api-key}` |
|
||||
| Content-Type | 是 | `application/json` |
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "doubao-seedance-2-0-260128",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "一只猫在草地上奔跑"
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": "https://..."},
|
||||
"role": "reference_image"
|
||||
},
|
||||
{
|
||||
"type": "video_url",
|
||||
"video_url": {"url": "https://..."},
|
||||
"role": "reference_video"
|
||||
},
|
||||
{
|
||||
"type": "audio_url",
|
||||
"audio_url": {"url": "https://..."},
|
||||
"role": "reference_audio"
|
||||
}
|
||||
],
|
||||
"ratio": "16:9",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
"generate_audio": true,
|
||||
"watermark": false,
|
||||
"idempotency_key": "unique-key-123"
|
||||
}
|
||||
```
|
||||
|
||||
**请求字段:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| model | string | 是 | - | 模型名称 |
|
||||
| content | array | 是 | - | 生成内容数组(见下方) |
|
||||
| ratio | string | 否 | `16:9` | 视频比例: `16:9` / `9:16` / `1:1` / `4:3` / `3:4` / `21:9` |
|
||||
| duration | int | 否 | `5` | 视频时长(秒): 3-15 |
|
||||
| resolution | string | 否 | `480p` | 分辨率: `480p` / `720p` / `1080p` |
|
||||
| generate_audio | bool | 否 | `true` | 是否生成音频 |
|
||||
| watermark | bool | 否 | `false` | 是否添加水印 |
|
||||
| idempotency_key | string | 否 | - | 幂等键,防止重复创建 |
|
||||
|
||||
**content 数组元素 (ApiVideoContentPart):**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| type | string | 是 | 内容类型: `text` / `image_url` / `video_url` / `audio_url` |
|
||||
| text | string | 否 | 文本内容(type=text 时必填) |
|
||||
| image_url | object | 否 | 图片URL对象: `{"url": "..."}`(type=image_url 时必填) |
|
||||
| video_url | object | 否 | 视频URL对象: `{"url": "..."}`(type=video_url 时必填) |
|
||||
| audio_url | object | 否 | 音频URL对象: `{"url": "..."}`(type=audio_url 时必填) |
|
||||
| role | string | 否 | 参考角色: `first_frame` / `last_frame` / `reference_image` / `reference_video` / `reference_audio` |
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cgt-20260730183334-wdgfl"
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | string | 任务 ID,用于查询状态 |
|
||||
|
||||
---
|
||||
|
||||
### 3. 查询视频任务状态
|
||||
|
||||
根据 `task_id` 查询视频生成任务的状态和结果。
|
||||
|
||||
```
|
||||
GET /api/v3/videos/{task_id}
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| Authorization | 是 | `Bearer {api-key}` |
|
||||
|
||||
**路径参数:**
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| task_id | string | 创建任务时返回的 task_id |
|
||||
|
||||
**响应示例(排队中):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cgt-20260730183334-wdgfl",
|
||||
"model": "doubao-seedance-2-0-mini-260615",
|
||||
"status": "queued",
|
||||
"created_at": 1785407620,
|
||||
"updated_at": 1785407620,
|
||||
"content": null,
|
||||
"duration": null,
|
||||
"ratio": null,
|
||||
"resolution": null
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(运行中):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cgt-20260730183334-wdgfl",
|
||||
"model": "doubao-seedance-2-0-mini-260615",
|
||||
"status": "running",
|
||||
"created_at": 1785407620,
|
||||
"updated_at": 1785407650,
|
||||
"content": null,
|
||||
"duration": null,
|
||||
"ratio": null,
|
||||
"resolution": null
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(成功):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cgt-20260730183334-wdgfl",
|
||||
"model": "doubao-seedance-2-0-mini-260615",
|
||||
"status": "succeeded",
|
||||
"created_at": 1785407620,
|
||||
"updated_at": 1785407723,
|
||||
"content": {
|
||||
"video_url": "https://..."
|
||||
},
|
||||
"duration": 4,
|
||||
"ratio": "9:16",
|
||||
"resolution": "480p"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例(失败):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "cgt-20260730183334-wdgfl",
|
||||
"model": "doubao-seedance-2-0-mini-260615",
|
||||
"status": "failed",
|
||||
"created_at": 1785407620,
|
||||
"updated_at": 1785407650,
|
||||
"content": null,
|
||||
"duration": null,
|
||||
"ratio": null,
|
||||
"resolution": null
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | string | 任务 ID |
|
||||
| model | string | 模型名称 |
|
||||
| status | string | 任务状态(见下方状态说明) |
|
||||
| created_at | int | 创建时间戳(Unix) |
|
||||
| updated_at | int | 更新时间戳(Unix) |
|
||||
| content | object | 视频内容(成功时返回,包含 video_url) |
|
||||
| duration | int | 视频时长(秒) |
|
||||
| ratio | string | 视频比例 |
|
||||
| resolution | string | 分辨率 |
|
||||
|
||||
**任务状态说明:**
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `queued` | 排队中 |
|
||||
| `running` | 任务运行中 |
|
||||
| `succeeded` | 任务成功 |
|
||||
| `failed` | 任务失败 |
|
||||
| `expired` | 任务超时 |
|
||||
|
||||
---
|
||||
|
||||
### 4. 生成图片(同步)
|
||||
|
||||
同步生成图片,接口阻塞等待完成后直接返回结果。
|
||||
|
||||
```
|
||||
POST /api/v3/images
|
||||
```
|
||||
|
||||
**请求头:**
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| Authorization | 是 | `Bearer {api-key}` |
|
||||
| Content-Type | 是 | `application/json` |
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "doubao-seedream-5-0-260128",
|
||||
"prompt": "一只可爱的猫咪",
|
||||
"size": "2K",
|
||||
"response_format": "url",
|
||||
"watermark": false,
|
||||
"image": ["https://example.com/ref1.jpg"],
|
||||
"output_format": "png",
|
||||
"sequential_image_generation": "auto",
|
||||
"generation_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**请求字段:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| model | string | 是 | - | 模型名称 |
|
||||
| prompt | string | 是 | - | 图片描述提示词 |
|
||||
| size | string | 否 | `2K` | 图片尺寸: `2K` / `4K` 或 `2048x2048` |
|
||||
| response_format | string | 否 | `url` | 返回格式: `url` / `b64_json` |
|
||||
| watermark | bool | 否 | `false` | 是否添加水印 |
|
||||
| image | string[] | 否 | - | 参考图片 URL 列表 |
|
||||
| output_format | string | 否 | - | 输出格式: `jpeg` / `png` / `webp` |
|
||||
| sequential_image_generation | string | 否 | - | 组图模式: `auto` 开启 |
|
||||
| generation_count | int | 否 | `1` | 生成数量: 1-5 |
|
||||
|
||||
**响应示例 (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"created": 1721000000,
|
||||
"data": [
|
||||
{
|
||||
"url": "https://volc.example.com/image/xxx.png",
|
||||
"size": "2K",
|
||||
"output_format": "png"
|
||||
}
|
||||
],
|
||||
"model": "doubao-seedream-5-0-260128"
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| created | int | 创建时间戳(Unix) |
|
||||
| data | array | 图片结果列表 |
|
||||
| data[].url | string | 图片 URL |
|
||||
| data[].b64_json | string | Base64 编码图片(response_format=b64_json 时) |
|
||||
| data[].size | string | 图片尺寸 |
|
||||
| data[].output_format | string | 输出格式 |
|
||||
| model | string | 使用的模型名称 |
|
||||
|
||||
---
|
||||
|
||||
## 虚拟素材库接口(中转)
|
||||
|
||||
虚拟素材库用于在**火山方舟私域可信素材服务**中管理专属的图片/视频素材(如客户专属虚拟人)。数据与前台用户私域素材库完全隔离,归属按 API Key 管理。
|
||||
|
||||
**启用前置条件:**
|
||||
1. 管理员在后台「API Key 管理 → 详情 → 虚拟素材库配额」中配置项目数/素材数/存储 MB 上限(默认 0=不可用)
|
||||
2. 任一上限大于 0 即表示该 API Key 启用了虚拟素材库功能
|
||||
3. 所有操作占用对应配额,超出上限返回 403 `quota_exceeded`
|
||||
|
||||
**生命周期流程(新,一步到位):**
|
||||
1. 创建虚拟项目(CreateAssetGroup 建远端组)
|
||||
2. 直接调用「项目下创建素材」接口,**仅传一个公网可访问的 URL**(http/https 图片/视频直链)
|
||||
- 服务端先将该 URL 的文件**下载保存到本地存储系统**(路径见下),占用存储配额
|
||||
- 保存成功后,再将**本地公网 URL** 同步提交给火山平台 CreateAsset 做异步审核
|
||||
3. 素材状态 Creating → 轮询 `/assets/{id}` 或 `/assets/{id}/sync` 主动刷新 → 状态 Active(可使用)
|
||||
4. AI 创作时通过 `/selectable-assets` 选择器拿到已就绪素材
|
||||
5. 素材/项目删除(软删本地 + 同步清理本地落盘文件 → 异步删火山远端,返回 `remote_delete_status=pending`)
|
||||
|
||||
**素材文件本地保存路径(服务端自动处理,调用方无需关心):**
|
||||
- 图片:`/uploads/images/vp_v3_virtual/{api_key_id_short}/yyyy/mm/dd/vp_v3_{uuid}.{ext}`
|
||||
- 视频:`/uploads/videos/vp_v3_virtual/{api_key_id_short}/yyyy/mm/dd/vp_v3_{uuid}.{ext}`
|
||||
|
||||
**技术细节 & 错误处理(保证数据一致性):**
|
||||
1. URL 下载阶段失败(网络/超时/4xx/5xx/大文件/非法 MIME):立即清理临时文件,不占用任何配额,返回对应错误码
|
||||
2. 下载成功但**配额不足**:立即删除已下载的本地文件,释放磁盘,再抛 403 `quota_exceeded`
|
||||
3. 视频时长:若请求未传 `video_duration`,服务端自动 ffprobe 探测;两者都失败则删本地文件并 400 要求显式传时长
|
||||
4. 本地写库成功但**火山 CreateAsset 失败**:保留本地文件(已占配额和素材数),素材状态标记为 `Failed`,错误信息记录在 `error_message` / `moderation_json`。调用方可选择:
|
||||
- 保留并排查(后续可调用 DELETE 删除 → 自动清理本地文件 + 返还配额)
|
||||
- 直接 DELETE 重试
|
||||
5. 删除素材:**本地 commit 时同步删除本地落盘文件**(目录穿越防御,仅允许删 `/uploads` 目录内),返还存储配额和素材数配额;火山远端异步删除
|
||||
|
||||
---
|
||||
|
||||
### 5. 获取配额配置
|
||||
|
||||
获取当前 API Key 的虚拟素材库配额上限和已使用量,判断是否可用。
|
||||
|
||||
```
|
||||
GET /api/v3/virtual-portrait/config
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"project_limit": 5,
|
||||
"asset_limit": 50,
|
||||
"storage_mb_limit": 500,
|
||||
"project_used": 2,
|
||||
"asset_used": 18,
|
||||
"storage_mb_used": 128.43,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
**响应字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| project_limit | int | 虚拟项目上限,0=不可创建 |
|
||||
| asset_limit | int | 虚拟素材总数上限(图片+视频),0=不可上传 |
|
||||
| storage_mb_limit | int | 上传存储上限 MB,0=不可上传文件 |
|
||||
| project_used | int | 已创建项目数(未删除) |
|
||||
| asset_used | int | 已上传素材数(未删除) |
|
||||
| storage_mb_used | float | 已占用存储 MB |
|
||||
| enabled | bool | 该 Key 是否可使用虚拟素材库功能(任一上限>0即可) |
|
||||
|
||||
---
|
||||
|
||||
### 6. 获取枚举元数据
|
||||
|
||||
返回素材/项目所有枚举值及其说明,便于前端展示筛选选项。
|
||||
|
||||
```
|
||||
GET /api/v3/virtual-portrait/enums
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": { "Image": "图片素材", "Video": "视频素材" },
|
||||
"asset_status": {
|
||||
"creating": "创建中/审核中",
|
||||
"active": "已就绪/可用",
|
||||
"failed": "失败",
|
||||
"deleting": "删除中"
|
||||
},
|
||||
"project_status": {
|
||||
"creating_remote_group": "远端组创建中",
|
||||
"active": "就绪",
|
||||
"create_group_failed": "远端组创建失败",
|
||||
"deleting": "删除中"
|
||||
},
|
||||
"remote_delete_status": {
|
||||
"none": "未删除",
|
||||
"pending": "待异步删除",
|
||||
"processing": "远端删除中",
|
||||
"deleted": "远端已删除",
|
||||
"failed": "远端删除失败"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 虚拟项目 CRUD
|
||||
|
||||
#### 7.1 创建项目
|
||||
|
||||
创建一个虚拟素材项目(同步调火山 CreateAssetGroup 创建远端素材组)。
|
||||
|
||||
```
|
||||
POST /api/v3/virtual-portrait/projects
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "客户A的虚拟人素材",
|
||||
"description": "用于客户A的电商视频生成(可选)"
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明:**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| name | string | 是 | 项目名称,1-100 字符 |
|
||||
| description | string | 否 | 项目描述,最多 500 字符 |
|
||||
|
||||
**响应(VpV3ProjectOut):**
|
||||
|
||||
```json
|
||||
{
|
||||
"Id": "0019xxxxxxxxxxxxxxxx"
|
||||
}
|
||||
```
|
||||
|
||||
#### 7.2 查询项目列表
|
||||
|
||||
```
|
||||
GET /api/v3/virtual-portrait/projects
|
||||
```
|
||||
|
||||
**Query 参数:**
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| page | int | 否 | 页码,默认 1 |
|
||||
| page_size | int | 否 | 每页数量 1-100,默认 20 |
|
||||
| keyword | string | 否 | 项目名称模糊搜索 |
|
||||
| status | string | 否 | 项目状态筛选(不传查全部) |
|
||||
|
||||
**响应:** `{ "items": [...], "total": N, "page": X, "page_size": Y }`
|
||||
|
||||
#### 7.3 项目详情
|
||||
|
||||
```
|
||||
GET /api/v3/virtual-portrait/projects/{project_id}
|
||||
```
|
||||
|
||||
#### 7.4 更新项目
|
||||
|
||||
修改展示信息(名称/描述),不会重新创建火山远端 Group。
|
||||
|
||||
```
|
||||
PUT /api/v3/virtual-portrait/projects/{project_id}
|
||||
```
|
||||
|
||||
#### 7.5 删除项目
|
||||
|
||||
软删项目和其下所有素材。**本地 commit 后会投递 Celery 异步任务去删除火山远端 AssetGroup/Asset**,接口返回 `remote_delete_status=pending` 表示远端删除处理中,可通过项目详情接口轮询最终状态。
|
||||
|
||||
```
|
||||
DELETE /api/v3/virtual-portrait/projects/{project_id}
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{ "success": true, "remote_delete_status": "pending" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. 虚拟素材 CRUD
|
||||
|
||||
#### 8.1 在项目下创建素材
|
||||
|
||||
创建素材(一步到位:**仅传 URL**:
|
||||
1. 服务端先将 `source_url`(公网 http(s))下载保存到本地存储系统(自动校验 URL/网络/MIME/大小)
|
||||
2. 下载成功后占用 **本地存储配额 & 素材数配额
|
||||
3. 再将本地公网 URL 同步提交给火山方舟 CreateAsset 进行异步审核
|
||||
|
||||
```
|
||||
POST /api/v3/virtual-portrait/projects/{project_id}/assets
|
||||
```
|
||||
|
||||
**请求体:**
|
||||
|
||||
```json
|
||||
{
|
||||
"source_url": "https://cdn.example.com/avatars/portrait_01.png",
|
||||
"name": "虚拟人正面照片",
|
||||
"asset_type": "Image"
|
||||
}
|
||||
```
|
||||
|
||||
**请求字段(新):**
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| source_url | string | 是 | **公网可访问的 http(s) 图片/视频直链**,最多 2000 字符。<br/>⚠️ 不允许指向内网/本机地址(SSRF 防御) |
|
||||
| name | string | 否 | 素材展示名,1-100 字符;不传自动从 URL 文件名或响应头 Content-Disposition 推断 |
|
||||
| asset_type | string | 是 | `Image` / `Video` |
|
||||
| video_duration | float | Video 可选 | 视频时长秒(1-60)。Video 不传会自动 ffprobe 探测,两者都失败则返回 400 需显式传入 |
|
||||
| video_cover_url | string | 否(Video) | 视频封面图 URL(可选,仅 Video 用) |
|
||||
|
||||
> 💡 **不再需要**:`upload_resource_id` / `file_size_bytes` / `mime_type` —— 服务端自动探测并写入。
|
||||
|
||||
**创建流程时序(服务端内部处理步骤):**
|
||||
1. 校验 URL 格式(http/https + 非内网) → 400
|
||||
2. 下载 URL 文件到临时目录
|
||||
- HTTP 4xx/5xx → 502(含前 200B 响应片段);连接/读取超时 → 502
|
||||
- Content-Type 非法 → 415(application/octet-stream 除外);大小超限 → 413
|
||||
- 任一步失败:立即清理临时文件,不占配额
|
||||
3. 配额校验(素材数 + 存储 MB,按真实大小)→ 不足则删除刚下载的本地文件,403
|
||||
4. Video 时长合并校验(payload 优先,否则 ffprobe 探测) → 非法删本地文件并 400
|
||||
5. 写 VpV3Asset(status=Creating)+ 刷新 next_poll_at
|
||||
6. 调火山 CreateAsset(url=本地公网 URL) → 成功返回 Creating + remote_asset_id;失败则 status=Failed, error_message=错误
|
||||
|
||||
**响应:VpV3AssetOut**(字段见素材详情)
|
||||
|
||||
创建成功后 `status=Creating`,火山审核 3-30 秒,建议:
|
||||
- 轮询 GET /assets/{id} 或 POST /assets/{id}/sync 主动刷新
|
||||
- 直到 status=Active 才能在 AI 创作中使用
|
||||
- 如果 status=Failed,读取 `error_message` / `moderation_json` 查看原因;可选择 DELETE 后重试
|
||||
|
||||
#### 8.2 查询项目下素材列表
|
||||
|
||||
```
|
||||
GET /api/v3/virtual-portrait/projects/{project_id}/assets
|
||||
```
|
||||
|
||||
**Query 参数:**
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| page / page_size | int | 分页,默认 1/20 |
|
||||
| status | string | 素材状态筛选:Creating/Active/Failed/Deleting |
|
||||
| keyword | string | 素材名称模糊搜索 |
|
||||
| asset_type | string | Image / Video |
|
||||
|
||||
#### 8.3 素材详情
|
||||
|
||||
```
|
||||
GET /api/v3/virtual-portrait/assets/{asset_id}
|
||||
```
|
||||
|
||||
#### 8.4 主动同步素材状态
|
||||
|
||||
主动调火山 GetAsset 刷新素材状态、URL、审核结果(轮询中断或前端主动刷新时使用)。
|
||||
|
||||
```
|
||||
POST /api/v3/virtual-portrait/assets/{asset_id}/sync
|
||||
```
|
||||
|
||||
#### 8.5 删除素材
|
||||
|
||||
软删素材。本地 commit 时**同步删除本地落盘文件**(自动清理 `/uploads/...` 目录下的文件,带目录穿越防御),再投递 Celery 异步任务删除火山远端 Asset,返回 `remote_delete_status=pending` 表示处理中。
|
||||
|
||||
```
|
||||
DELETE /api/v3/virtual-portrait/assets/{asset_id}
|
||||
```
|
||||
|
||||
**响应示例:** `{ "success": true, "remote_delete_status": "pending" }`
|
||||
|
||||
> 说明:
|
||||
> - 本地文件删除失败只打日志,不会影响素材状态置为 deleting + soft-delete(避免远端删除也回滚)
|
||||
> - 删除成功后存储/素材数配额会自动返还(`/config` 接口再次查询可见 used 降低)
|
||||
|
||||
---
|
||||
|
||||
### 9. AI 创作选择器素材
|
||||
|
||||
只返回当前 API Key 虚拟素材库中 **status=Active** 的图片/视频素材,提供给 AI 创作参考素材选择器使用。
|
||||
|
||||
```
|
||||
GET /api/v3/virtual-portrait/selectable-assets
|
||||
```
|
||||
|
||||
**Query 参数:**
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| page / page_size | int | 分页,默认 1/20(1-100) |
|
||||
| project_id | string | 可选,按项目筛选 |
|
||||
| keyword | string | 可选,素材名称模糊搜索 |
|
||||
| asset_type | string | 可选,Image / Video |
|
||||
|
||||
**响应字段(VpV3SelectableAssetOut):**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| asset_id | string | 素材 ID,供后续带入生成(预留使用:`source=vp_v3_asset, asset_id`) |
|
||||
| project_id | string | 所属项目 ID |
|
||||
| name | string | 素材名称 |
|
||||
| asset_type | string | Image/Video |
|
||||
| status | string | Active |
|
||||
| source_url | string | 原始上传 URL |
|
||||
| preview_url | string | 显示用预览 URL(直接绑定 img/video src) |
|
||||
| video_duration | float | 视频时长秒(Video 时有值) |
|
||||
| video_cover_url | string | 视频封面 |
|
||||
| file_size_bytes | int | 文件大小字节 |
|
||||
| created_at | datetime | 创建时间 |
|
||||
|
||||
---
|
||||
|
||||
### 虚拟素材库配额限制错误
|
||||
|
||||
当 API Key 的虚拟素材库配额不足时,会返回 403:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "quota_exceeded",
|
||||
"message": "虚拟素材库配额不足:素材总数 上限 50,已使用 50,本次需要 1,超出上限"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果管理员完全没有配置配额(全 0),任何虚拟素材库操作都返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "forbidden",
|
||||
"message": "当前 API Key 未开启虚拟素材库功能,请联系管理员配置配额"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 价格计算规则
|
||||
|
||||
API 采用**先扣后退回**策略:任务创建/生成前预扣配额,失败时自动退回。
|
||||
|
||||
### 视频价格公式
|
||||
|
||||
```
|
||||
基础费用 = (base_price + per_second_price × duration) × price_ratio
|
||||
|
||||
传入视频附加 = (input_video_base_price + input_video_per_second_price × 视频时长) × input_video_ratio
|
||||
传入图片附加 = (input_image_base_price + input_image_per_image_price × 图片数量) × input_image_ratio
|
||||
|
||||
总价格 = 基础费用 + 传入视频附加 + 传入图片附加
|
||||
```
|
||||
|
||||
### 图片价格公式
|
||||
|
||||
```
|
||||
基础费用 = base_price × price_ratio
|
||||
|
||||
传入图片附加 = (input_image_base_price + input_image_per_image_price × 图片数量) × input_image_ratio
|
||||
|
||||
总价格 = 基础费用 + 传入图片附加
|
||||
```
|
||||
|
||||
### 价格查找优先级
|
||||
|
||||
1. 引擎专属规则: `gen_type + engine_id + resolution`
|
||||
2. 降级: `gen_type + resolution`(取 base_price 最高的)
|
||||
3. 未配置价格的模型不会出现在可用列表中
|
||||
|
||||
---
|
||||
|
||||
## 状态码说明
|
||||
|
||||
| 状态码 | 说明 |
|
||||
|--------|------|
|
||||
| 200 | 请求成功 |
|
||||
| 202 | 任务已创建(视频接口) |
|
||||
| 401 | API Key 无效或缺失 |
|
||||
| 403 | API Key 过期或无权限 |
|
||||
| 404 | 资源不存在 |
|
||||
| 422 | 请求参数校验失败 |
|
||||
| 429 | 配额不足或并发超限 |
|
||||
| 500 | 服务器内部错误 |
|
||||
| 504 | 生成超时(图片接口) |
|
||||
|
||||
---
|
||||
|
||||
## 使用示例
|
||||
|
||||
### cURL 示例
|
||||
|
||||
```bash
|
||||
# 1. 获取可用模型
|
||||
curl -X GET http://localhost:8000/api/v3/models \
|
||||
-H "Authorization: Bearer vk_xxxxxxxxxxxx"
|
||||
|
||||
# 2. 创建视频任务
|
||||
curl -X POST http://localhost:8000/api/v3/videos \
|
||||
-H "Authorization: Bearer vk_xxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "doubao-seedance-2-0-260128",
|
||||
"content": [{"type": "text", "text": "一只猫在草地上奔跑"}],
|
||||
"ratio": "16:9",
|
||||
"duration": 5,
|
||||
"resolution": "1080p"
|
||||
}'
|
||||
|
||||
# 3. 查询视频状态
|
||||
curl -X GET http://localhost:8000/api/v3/videos/{task_id} \
|
||||
-H "Authorization: Bearer vk_xxxxxxxxxxxx"
|
||||
|
||||
# 4. 生成图片
|
||||
curl -X POST http://localhost:8000/api/v3/images \
|
||||
-H "Authorization: Bearer vk_xxxxxxxxxxxx" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"model": "doubao-seedream-5-0-260128",
|
||||
"prompt": "一只可爱的猫咪",
|
||||
"size": "2K"
|
||||
}'
|
||||
```
|
||||
|
||||
### Python 示例
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8000/api/v3"
|
||||
API_KEY = "vk_xxxxxxxxxxxx"
|
||||
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
|
||||
|
||||
# 获取模型列表
|
||||
resp = requests.get(f"{BASE_URL}/models", headers=HEADERS)
|
||||
models = resp.json()["models"]
|
||||
|
||||
# 创建视频任务
|
||||
resp = requests.post(f"{BASE_URL}/videos", headers=HEADERS, json={
|
||||
"model": "doubao-seedance-2-0-260128",
|
||||
"content": [{"type": "text", "text": "一只猫在草地上奔跑"}],
|
||||
"ratio": "16:9",
|
||||
"duration": 5,
|
||||
"resolution": "1080p",
|
||||
})
|
||||
task_id = resp.json()["task_id"]
|
||||
|
||||
# 轮询视频状态
|
||||
import time
|
||||
while True:
|
||||
resp = requests.get(f"{BASE_URL}/videos/{task_id}", headers=HEADERS)
|
||||
data = resp.json()
|
||||
if data["status"] == "completed":
|
||||
print(f"视频URL: {data['video_url']}")
|
||||
break
|
||||
elif data["status"] == "failed":
|
||||
print(f"失败: {data['error']}")
|
||||
break
|
||||
time.sleep(30)
|
||||
|
||||
# 生成图片
|
||||
resp = requests.post(f"{BASE_URL}/images", headers=HEADERS, json={
|
||||
"model": "doubao-seedream-5-0-260128",
|
||||
"prompt": "一只可爱的猫咪",
|
||||
"size": "2K",
|
||||
})
|
||||
images = resp.json()["data"]
|
||||
for img in images:
|
||||
print(f"图片URL: {img['url']}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP 状态码
|
||||
|
||||
所有接口 HTTP 状态码固定返回 **200**,业务结果通过响应体中的 `code` 字段判断:
|
||||
|
||||
| code | 说明 |
|
||||
|------|------|
|
||||
| 0 | 成功 |
|
||||
| 40000 | 请求参数错误 |
|
||||
| 40001 | 模型+分辨率未配置价格 |
|
||||
| 40100 | API Key 无效或缺失 |
|
||||
| 40300 | API Key 过期或无权限 |
|
||||
| 40400 | 资源不存在 |
|
||||
| 42200 | 参数校验失败 |
|
||||
| 42900 | 配额不足或并发超限 |
|
||||
| 50000 | 服务器内部错误 |
|
||||
| 50400 | 生成超时(图片接口) |
|
||||
|
||||
---
|
||||
|
||||
## 统一响应格式
|
||||
|
||||
**成功响应:**
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": { ... },
|
||||
"message": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
**错误响应:**
|
||||
```json
|
||||
{
|
||||
"code": 40001,
|
||||
"data": null,
|
||||
"message": "模型 'eng_xxxx' 在分辨率 '1080p' 下未配置,无法生成"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **配额预扣**: 视频任务创建时预扣配额,失败时自动退回
|
||||
2. **并发限制**: 每个 API Key 有最大并发视频任务数限制
|
||||
3. **轮询间隔**: 视频任务建议轮询间隔 30 秒(前 10 分钟可缩短至 30 秒,之后逐步增加)
|
||||
4. **超时时间**: 视频任务最长 24 小时,超时自动失败并退回配额
|
||||
5. **幂等键**: 视频接口支持 `idempotency_key`,相同键重复请求返回同一任务
|
||||
6. **图片同步**: 图片接口为同步阻塞调用,建议设置 300 秒超时
|
||||
7. **统一格式**: 所有接口 HTTP 状态码固定 200,通过 `code` 字段判断业务结果
|
||||
|
||||
---
|
||||
|
||||
## 部署与运维
|
||||
|
||||
### Celery Worker 启动命令
|
||||
|
||||
API v3 视频异步生成依赖以下 3 个 Celery 队列:
|
||||
|
||||
| 队列 | 用途 | 推荐并发 |
|
||||
|------|------|---------|
|
||||
| `gen_api_create` | 视频任务创建(调用 Volcano Ark SDK) | 2-4 |
|
||||
| `gen_api_poll` | 视频状态轮询 | 2-4 |
|
||||
| `gen_api_download` | 视频下载与超分 | 2-4 |
|
||||
|
||||
```bash
|
||||
# 启动 API v3 专用 Worker
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_api_create,gen_api_poll,gen_api_download \
|
||||
--concurrency=4 -n worker_api@%h
|
||||
|
||||
# 启动 Beat 调度器(定时恢复任务)
|
||||
celery -A app.tasks.celery_app beat -l info
|
||||
```
|
||||
|
||||
### 容灾恢复
|
||||
|
||||
服务重启后,Worker 会自动触发恢复扫描:
|
||||
- 扫描 48 小时内未完成的 `ApiGenerationTask`
|
||||
- 重新入队中断的 Celery 任务
|
||||
- 每分钟定时扫描(Beat 调度)
|
||||
|
||||
### 日志目录
|
||||
|
||||
```
|
||||
storage/logs/external/
|
||||
├── requests/ # 外部 API 请求/响应日志
|
||||
├── models/ # 模型调用日志
|
||||
├── upscale/ # 超分轮询日志
|
||||
└── errors/ # 错误日志
|
||||
```
|
||||
+882
@@ -0,0 +1,882 @@
|
||||
# VideoGen 代码架构指南
|
||||
|
||||
> 本文档面向开发者或 AI 模型,帮助快速理解代码风格、架构约定和修改规则。
|
||||
# AI 工作规则(最高优先级)
|
||||
|
||||
当你执行开发任务时:
|
||||
|
||||
1. 不要扫描整个 Repository。
|
||||
2. 不要执行全量目录探索。
|
||||
3. 仅阅读本任务涉及的目录。
|
||||
4. 优先按照本文档中的"快速定位表"寻找文件。
|
||||
5. 如果无法定位文件,再进行有限范围搜索。
|
||||
6. 单次最多搜索一级目录。
|
||||
7. 不允许重复搜索已经访问过的目录。
|
||||
8. 阅读完目标文件后立即开始修改代码。
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
video_item/
|
||||
├── video-gen-api/ # 后端 (Python FastAPI)
|
||||
├── video-gen-app/ # 前台/用户端 (React + Vite) ──┤
|
||||
└── video-gen-admin/ # 后台管理端 (React + Vite) ──┘ 两者共用同一个后端 API
|
||||
```
|
||||
|
||||
**核心原则:** 三个项目完全分离,前台和后台是两个独立的 SPA,部署在不同的子域名上,但调用同一套后端接口。
|
||||
|
||||
---
|
||||
|
||||
## 二、后端架构 (video-gen-api)
|
||||
|
||||
### 2.1 目录结构
|
||||
|
||||
```
|
||||
app/
|
||||
├── api/ # API 路由层(薄层,仅做参数解析和调用 service)
|
||||
│ ├── v1/ # 前台 + 部分管理接口(FastAPI 意义上的 v1 版本)
|
||||
│ │ ├── __init__.py # 聚合所有 v1 router
|
||||
│ │ ├── auth.py
|
||||
│ │ ├── projects.py
|
||||
│ │ ├── generation.py
|
||||
│ │ ├── admin.py # prefix="/admin"(管理接口混入 v1)
|
||||
│ │ └── ...
|
||||
│ └── admin/ # 额外的管理端细分路由
|
||||
│ ├── __init__.py
|
||||
│ ├── team.py
|
||||
│ ├── home_material.py
|
||||
│ └── ...
|
||||
├── schemas/ # Pydantic 模型(请求入参 + 响应序列化)
|
||||
│ ├── common.py # 公共基类:NaiveDatetime, PaginatedResponse
|
||||
│ ├── generation.py
|
||||
│ ├── auth.py
|
||||
│ └── ...
|
||||
├── models/ # SQLAlchemy ORM 模型(41 个)
|
||||
│ ├── base.py # Base, TimestampMixin, SoftDeleteMixin
|
||||
│ ├── user.py
|
||||
│ └── ...
|
||||
├── services/ # 业务逻辑层(核心业务写在这里)
|
||||
│ ├── auth.py # JWT 认证、密码哈希
|
||||
│ ├── credits.py # 积分扣减
|
||||
│ ├── operation_log.py # 操作日志
|
||||
│ └── ...
|
||||
├── enums/ # 枚举定义(全部用 str, Enum)
|
||||
│ ├── generation_status.py
|
||||
│ ├── celery_queue.py
|
||||
│ └── ...
|
||||
├── tasks/ # Celery 异步任务
|
||||
│ ├── celery_app.py # Celery 实例 + 配置
|
||||
│ ├── cleanup.py
|
||||
│ └── ...
|
||||
├── middleware/ # FastAPI 中间件
|
||||
│ ├── logging.py
|
||||
│ ├── rate_limit.py
|
||||
│ ├── request_encrypt.py
|
||||
│ ├── anti_crawler.py
|
||||
│ └── ...
|
||||
├── utils/ # 通用工具
|
||||
│ ├── id_gen.py # generate_id(), generate_order_no()
|
||||
│ ├── exceptions.py # 自定义 HTTPException
|
||||
│ └── security.py # AES 加密、HMAC
|
||||
├── dependencies.py # FastAPI 依赖注入(get_db, get_current_user 等)
|
||||
├── config.py # Pydantic Settings,读取 .env
|
||||
└── main.py # FastAPI 应用入口、lifespan、种子数据
|
||||
```
|
||||
|
||||
### 2.2 分层约定(重要)
|
||||
|
||||
请求处理严格遵循 **路由 → 服务 → 模型** 三层:
|
||||
|
||||
```
|
||||
Route (api/) → Service (services/) → Model (models/)
|
||||
解析参数 业务逻辑 数据库操作
|
||||
调用 service 编排调用 纯 CRUD
|
||||
返回序列化结果 不直接操作 ORM 无业务规则
|
||||
```
|
||||
|
||||
**禁止:**
|
||||
- 路由中直接写复杂 SQL/ORM 操作 → 应抽到 service
|
||||
- Model 文件中写业务逻辑 → model 只定义字段和关系
|
||||
- Service 中混入 HTTP 相关代码 → 不 import FastAPI 对象
|
||||
|
||||
### 2.3 SQLAlchemy 模型约定
|
||||
|
||||
使用 SQLAlchemy 2.0 风格的 `mapped_column`:
|
||||
|
||||
```python
|
||||
# app/models/project.py — 标准模板
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.models.base import Base, TimestampMixin, SoftDeleteMixin
|
||||
|
||||
class Project(Base, TimestampMixin, SoftDeleteMixin):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
user_id: Mapped[str] = mapped_column(String(32), ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(128))
|
||||
industry: Mapped[str] = mapped_column(String(32))
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 主键统一用 `String(32)`,由 `generate_id()` 生成(13 位时间戳 hex + 6 位随机 hex)
|
||||
- 继承 `TimestampMixin` 自动获得 `created_at` / `updated_at`
|
||||
- 需要软删除的继承 `SoftDeleteMixin` 获得 `deleted_at`
|
||||
- 外键统一加 `index=True`,`ondelete="CASCADE"` 按需
|
||||
- 所有 `str` 类型指定长度:`String(32)` / `String(128)` / `String(255)` 等
|
||||
- 时间统一用 `DateTime(timezone=True)`
|
||||
- **新增 model 后**必须在 `app/models/__init__.py` 中 import
|
||||
|
||||
```python
|
||||
# app/models/base.py — 公共 Mixin
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
class SoftDeleteMixin:
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
```
|
||||
|
||||
### 2.4 Pydantic Schema 约定
|
||||
|
||||
```python
|
||||
# app/schemas/project.py
|
||||
from pydantic import BaseModel, Field
|
||||
from app.schemas.common import NaiveDatetime
|
||||
|
||||
class ProjectCreate(BaseModel): # 请求入参(客户端 → 服务端)
|
||||
name: str = Field(..., max_length=128)
|
||||
industry: str = Field(..., max_length=64)
|
||||
|
||||
class ProjectOut(BaseModel): # 响应序列化(服务端 → 客户端)
|
||||
id: str
|
||||
name: str
|
||||
industry: str
|
||||
created_at: NaiveDatetime
|
||||
updated_at: NaiveDatetime
|
||||
|
||||
model_config = {"from_attributes": True} # ← 必加!允许从 ORM 对象直接构造
|
||||
```
|
||||
|
||||
**命名约定:**
|
||||
- `XxxCreate` — POST 创建请求
|
||||
- `XxxUpdate` — PUT 更新请求
|
||||
- `XxxOut` / `XxxResponse` — 响应
|
||||
- `XxxListOut` — 列表响应(含 items + total)
|
||||
- `XxxRequest` — 通用请求
|
||||
|
||||
**公共工具:**
|
||||
- `NaiveDatetime` / `NaiveDatetimeOptional` — 自动将带时区的 datetime 转为北京时间 naive datetime
|
||||
- `PaginatedResponse` — 分页响应基类
|
||||
|
||||
### 2.5 API 路由约定
|
||||
|
||||
```python
|
||||
# app/api/v1/projects.py — 标准模板
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import get_db, get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.project import Project
|
||||
from app.schemas.project import ProjectCreate, ProjectOut
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
router = APIRouter(prefix="/projects", tags=["projects"]) # prefix 不含 /api
|
||||
|
||||
@router.get("", response_model=list[ProjectOut]) # GET 列表用 list[XxxOut]
|
||||
async def list_projects(
|
||||
current_user: User = Depends(get_current_user), # 鉴权依赖
|
||||
db: AsyncSession = Depends(get_db), # 数据库 session
|
||||
):
|
||||
result = await db.execute(
|
||||
select(Project).where(
|
||||
Project.user_id == current_user.id,
|
||||
Project.deleted_at.is_(None), # 软删除过滤
|
||||
).order_by(Project.created_at.desc())
|
||||
)
|
||||
return result.scalars().all() # 直接返回 ORM 对象,Pydantic 自动序列化
|
||||
|
||||
@router.post("", response_model=ProjectOut)
|
||||
async def create_project(
|
||||
req: ProjectCreate, # 请求体自动校验
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
project = Project(
|
||||
id=generate_id(), # 主键手工生成
|
||||
user_id=current_user.id,
|
||||
name=req.name,
|
||||
industry=req.industry,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush() # flush 获取自增字段但不出事务
|
||||
return project
|
||||
```
|
||||
|
||||
**关键约定:**
|
||||
- `prefix` 不含 `/api`(`/api` 在 `main.py` 的 `include_router` 统一加)
|
||||
- 受保护接口用 `Depends(get_current_user)` 获取当前用户
|
||||
- 管理员接口用 `Depends(get_admin_user)`
|
||||
- 可选登录用 `Depends(get_optional_current_user)`
|
||||
- 查询过滤软删除: `.where(Model.deleted_at.is_(None))`
|
||||
- 返回 ORM 对象时 Schema 必须有 `model_config = {"from_attributes": True}`
|
||||
- 错误抛 `HTTPException` 或用自定义异常(见 `app/utils/exceptions.py`)
|
||||
|
||||
### 2.6 依赖注入约定
|
||||
|
||||
```python
|
||||
# app/dependencies.py
|
||||
get_db # 生成 AsyncSession,自动 commit/rollback
|
||||
get_current_user # 验证 JWT → 返回 User,要求密码已设置
|
||||
get_current_user_allow_password_pending # 验证 JWT → 返回 User(允许未设置密码)
|
||||
get_optional_current_user # 验证 JWT → User | None(不强制登录)
|
||||
get_admin_user # 验证 JWT + is_admin + user_type=="admin"
|
||||
get_backend_user # 验证 JWT + user_type=="admin"(不要求 is_admin)
|
||||
```
|
||||
|
||||
### 2.7 服务层约定
|
||||
|
||||
```python
|
||||
# app/services/team_service.py — 标准模板
|
||||
from __future__ import annotations # 前向引用必需的 import
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.team import Team
|
||||
from app.schemas.team import TeamCreate, TeamUpdate
|
||||
from app.utils.id_gen import generate_id
|
||||
|
||||
async def create_team(db: AsyncSession, req: TeamCreate) -> Team:
|
||||
team = Team(
|
||||
id=generate_id(),
|
||||
name=req.name,
|
||||
code=req.code,
|
||||
description=req.description,
|
||||
)
|
||||
db.add(team)
|
||||
await db.flush()
|
||||
return team
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 函数签名统一 `(db: AsyncSession, ...) → Model | list[Model] | dict`
|
||||
- 不处理 HTTP 异常,HTTP 相关处理留给 route 层
|
||||
- 用 `from __future__ import annotations` 支持前向引用
|
||||
- 纯查询类服务可以接收 `db` 作为第一个参数
|
||||
|
||||
### 2.8 枚举约定
|
||||
|
||||
```python
|
||||
# app/enums/generation_status.py
|
||||
from enum import Enum
|
||||
|
||||
class GenerationStatus(str, Enum): # 继承 str 使值可直接序列化
|
||||
prompt_optimized = "prompt_optimized"
|
||||
generating = "generating"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
class GenerationType(str, Enum):
|
||||
video = "video"
|
||||
image = "image"
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 所有枚举继承 `str, Enum`(可序列化为 JSON)
|
||||
- 枚举值用 snake_case
|
||||
- 枚举文件统一放 `app/enums/`
|
||||
- 在 schema 中引用枚举做校验: `gen_type: GenerationType = Field(...)`
|
||||
|
||||
### 2.9 异常处理约定
|
||||
|
||||
```python
|
||||
# app/utils/exceptions.py — 预定义异常
|
||||
InsufficientCreditsError # 402 积分不足
|
||||
CaptchaFailedError # 400 验证码失败
|
||||
RecordNotFoundError # 404 记录不存在
|
||||
ProjectNotFoundError # 404 项目不存在
|
||||
InvalidStatusError # 400 状态不允许操作
|
||||
|
||||
# 在 service 或 route 中抛出
|
||||
raise InsufficientCreditsError()
|
||||
```
|
||||
|
||||
### 2.10 认证约定
|
||||
|
||||
```python
|
||||
# Token 创建
|
||||
create_access_token(user_id, remember_me=False) → str # JWT 令牌
|
||||
|
||||
# Token 解码
|
||||
decode_access_token(token) → str | None # 返回 user_id 或 None
|
||||
|
||||
# 密码
|
||||
hash_password(plain) → str # bcrypt 哈希
|
||||
verify_password(plain, hashed) → bool # bcrypt 校验
|
||||
```
|
||||
|
||||
**鉴权流程:**
|
||||
1. 前端 Authorization header: `Bearer <token>`
|
||||
2. `HTTPBearer` 提取 token → `decode_access_token` 获取 `user_id`
|
||||
3. 查 User 表验证 `is_active`
|
||||
4. 检查 `user_must_set_password`(短信注册用户首次需设置密码)
|
||||
|
||||
### 2.11 数据库 Session 模式
|
||||
|
||||
```python
|
||||
# dependencies.py 中的 get_db — 自动管理事务
|
||||
async def get_db():
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit() # 成功自动提交
|
||||
except Exception:
|
||||
await session.rollback() # 异常自动回滚
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
```
|
||||
|
||||
**注意:** 在 route 中如果只读查询不需要手动 commit(get_db 自动处理)。在 service 中做写入时需要调用者负责 commit,或由 get_db 处理。
|
||||
|
||||
### 2.12 中间件约定
|
||||
|
||||
中间件按**注册顺序**从外到内执行(`main.py`):
|
||||
|
||||
```python
|
||||
application.add_middleware(RequestLoggingMiddleware) # 1. 请求日志
|
||||
application.add_middleware(AntiCrawlerMiddleware) # 2. 反爬虫
|
||||
application.add_middleware(RateLimitMiddleware) # 3. 限流 (Redis)
|
||||
application.add_middleware(RequestEncryptMiddleware) # 4. 加密/解密
|
||||
application.add_middleware(CORSMiddleware, ...) # 5. CORS
|
||||
```
|
||||
|
||||
**加密中间件行为:**
|
||||
- 请求有 `X-Encrypted: true` header → 解密请求体
|
||||
- GET 请求无 body 但**响应仍加密**
|
||||
- 支付回调路径白名单跳过加密 (`/payments/alipay/callback`, `/payments/wechat/callback`)
|
||||
|
||||
### 2.13 Celery 任务约定
|
||||
|
||||
```python
|
||||
# app/tasks/cleanup.py — 标准模板
|
||||
from app.tasks.celery_app import celery_app
|
||||
|
||||
@celery_app.task # 装饰器注册任务
|
||||
def cleanup_expired_video_urls():
|
||||
"""Run hourly. Clear expired video URL tokens."""
|
||||
asyncio.run(_cleanup_urls()) # 同步任务内跑异步代码
|
||||
|
||||
async def _cleanup_urls(): # 实际逻辑写在 async 函数里
|
||||
from app.models.base import async_session # 延迟导入避免循环
|
||||
async with async_session() as db:
|
||||
...
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 任务装饰器: `@celery_app.task`
|
||||
- 同步入口 → `asyncio.run()` 包异步逻辑
|
||||
- 任务名自动生成: `文件名.函数名`(如 `app.tasks.cleanup.cleanup_expired_video_urls`)
|
||||
- 队列路由在 `celery_app.py` 的 `task_routes` 配置
|
||||
- 必需参数通过 `apply_async(args=[...], queue="xxx", priority=0)` 传递
|
||||
|
||||
#### Celery 队列清单(12 个)
|
||||
|
||||
| 队列 | 用途 | 推荐并发 |
|
||||
|------|------|---------|
|
||||
| `gen_chatapi_create` | ChatAPI 生成任务创建 | 2-4 |
|
||||
| `gen_provider_poll` | 轮询火山引擎生成状态 | 2-4 |
|
||||
| `gen_result_download` | 下载生成的视频/图片结果 | 2-4 |
|
||||
| `gen_video_upscale_local` | 本地视频超分(FFmpeg) | 1-2 |
|
||||
| `gen_video_upscale_remote` | 远程视频超分(火山 MediaKit) | 1-2 |
|
||||
| `gen_recovery` | 容灾恢复任务 | 1 |
|
||||
| `gen_private_portrait` | 真人素材认证与同步 | 1-2 |
|
||||
| `gen_shot_analysis` | 拆镜分析 | 1-2 |
|
||||
| `gen_shot_split` | 拆镜切片 | 1-2 |
|
||||
| `gen_api_create` | API v3 视频任务创建 | 2-4 |
|
||||
| `gen_api_poll` | API v3 视频状态轮询 | 2-4 |
|
||||
| `gen_api_download` | API v3 视频下载 | 2-4 |
|
||||
| `gen_api_upscale` | API v3 超分 | 2-4 |
|
||||
| `default` | 默认队列(用户 OAuth、清理任务等) | 1-2 |
|
||||
|
||||
#### 完整启动命令
|
||||
|
||||
```bash
|
||||
# ── 单机部署(所有队列一个 Worker)──
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_video_upscale_local,gen_video_upscale_remote,gen_recovery,gen_private_portrait,gen_shot_analysis,gen_shot_split,gen_api_create,gen_api_poll,gen_api_download,gen_api_upscale,default \
|
||||
--concurrency=4
|
||||
|
||||
# ── 生产环境(按功能分离 Worker)──
|
||||
|
||||
# 业务 Worker
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download \
|
||||
--concurrency=4 -n worker_busy@%h
|
||||
|
||||
# 超分 Worker
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale \
|
||||
--concurrency=2 -n worker_upscale@%h
|
||||
|
||||
# 恢复 Worker
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_recovery --concurrency=1 -n worker_recovery@%h
|
||||
|
||||
# 其他 Worker
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_private_portrait,gen_shot_analysis,gen_shot_split,default \
|
||||
--concurrency=2 -n worker_other@%h
|
||||
|
||||
# Beat 调度器(定时任务)
|
||||
celery -A app.tasks.celery_app beat -l info
|
||||
```
|
||||
|
||||
#### Beat 定时任务清单
|
||||
|
||||
| 任务 | 频率 | 说明 |
|
||||
|------|------|------|
|
||||
| `dispatch-due-poll-tasks` | 每分钟 | 调度到期的轮询任务 |
|
||||
| `generation-create-recovery` | 每分钟 | 恢复未完成的创建任务 |
|
||||
| `module-async-recovery` | 每分钟 | 恢复模块异步任务 |
|
||||
| `video-upscale-recovery` | 每分钟 | 恢复未完成的超分任务 |
|
||||
| `generation-download-recovery` | 每分钟 | 恢复未完成的下载任务 |
|
||||
| `shot-split-recovery` | 每分钟 | 恢复拆镜切片任务 |
|
||||
| `shot-analysis-recovery` | 每分钟 | 恢复拆镜分析任务 |
|
||||
| `api-generation-recovery` | 分钟 | 恢复 API v3 未完成任务 |
|
||||
| `celery-runtime-reconcile` | 每 5 分钟 | Worker 实例协调 |
|
||||
| `celery-runtime-registry-gc` | 每 10 分钟 | Worker 注册表 GC |
|
||||
| `private-portrait-sync-due-assets` | 每分钟 | 同步到期素材 |
|
||||
| `private-portrait-recover-remote-deletes` | 每 5 分钟 | 恢复远程删除任务 |
|
||||
|
||||
### 2.14 配置约定
|
||||
|
||||
```python
|
||||
# app/config.py — 基于 pydantic-settings
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./videogen.db"
|
||||
SECRET_KEY: str = "change-me"
|
||||
# ... 所有配置项有默认值,生产通过 .env 覆盖
|
||||
|
||||
settings = Settings() # 全局单例
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 所有环境变量在 `config.py` 中有类型注解和默认值
|
||||
- 通过 `settings.XXX` 全局访问
|
||||
- 布尔值用 `bool` 类型(pydantic 自动解析 "true"/"false" 字符串)
|
||||
- 列表值用 `list[str]`(pydantic 自动解析 JSON 数组字符串)
|
||||
|
||||
### 2.15 操作日志约定
|
||||
|
||||
```python
|
||||
from app.services.operation_log import log_operation
|
||||
|
||||
# 在路由中调用(通常在变更操作后)
|
||||
await log_operation(
|
||||
db,
|
||||
admin.id,
|
||||
admin.username,
|
||||
f"创建团队 {team.name}", # action 描述
|
||||
"POST", # HTTP method
|
||||
"/admin/teams", # path
|
||||
detail=None, # 可选详情
|
||||
ip=None, # 可选 IP
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、前端架构 (video-gen-app & video-gen-admin)
|
||||
|
||||
### 3.1 相同点
|
||||
|
||||
两个前端项目遵循完全相同的架构约定:
|
||||
- React 19 + TypeScript + Vite 8 + Ant Design 6 + Tailwind CSS 3
|
||||
- Zustand 状态管理
|
||||
- 统一的 API 调用层(crypto/client/index 三层)
|
||||
- 路由结构 SPA(react-router-dom v7)
|
||||
|
||||
### 3.2 目录结构(以 video-gen-app 为例)
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API 调用层
|
||||
│ ├── client.ts # 核心:apiRequest<T>(加密/解密、错误处理)
|
||||
│ ├── crypto.ts # AES-GCM 加密/解密(Web Crypto API)
|
||||
│ ├── index.ts # 所有业务 API 函数(按业务域分组)
|
||||
│ └── mock.ts # Mock 数据(VITE_USE_MOCK=true 时使用)
|
||||
├── store/ # Zustand 状态
|
||||
│ ├── useAuthStore.ts # 认证状态(登录/登出/当前用户)
|
||||
│ └── useAppStore.ts # 应用状态
|
||||
├── types/ # TypeScript 类型定义
|
||||
│ └── index.ts # 所有 interface/type
|
||||
├── pages/ # 页面组件(路由对应)
|
||||
│ ├── LoginPage.tsx
|
||||
│ ├── ProjectsPage.tsx
|
||||
│ └── ...
|
||||
├── components/ # 可复用组件
|
||||
│ ├── Layout/
|
||||
│ │ ├── AppLayout.tsx # 主布局(Header + Sider + Content)
|
||||
│ │ └── AppLayout.css
|
||||
│ ├── privatePortrait/
|
||||
│ └── ...
|
||||
├── utils/ # 工具函数
|
||||
│ ├── formatDate.ts
|
||||
│ └── ...
|
||||
├── App.tsx # 路由定义 + 全局配置
|
||||
└── main.tsx # 入口
|
||||
```
|
||||
|
||||
### 3.3 API 调用约定(核心)
|
||||
|
||||
```typescript
|
||||
// ── client.ts — 核心请求函数 ──
|
||||
// 所有请求统一走 apiRequest<T>,自动处理加密和响应解析
|
||||
async function apiRequest<T>(path: string, options: RequestOptions): Promise<T>
|
||||
|
||||
// 调用参数:
|
||||
interface RequestOptions {
|
||||
method?: string // GET/POST/PUT/DELETE,默认 GET
|
||||
body?: unknown // 请求体(自动 JSON 序列化 + 加密)
|
||||
auth?: boolean // 是否带 Authorization header,默认 true
|
||||
encryptBody?: boolean // 是否加密,默认跟随全局开关
|
||||
signal?: AbortSignal // 取消请求
|
||||
skipAuthRedirect?: boolean // 401 时不跳转登录页
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// ── index.ts — 业务 API 函数 ──
|
||||
// 命名约定:动词 + 业务名,camelCase
|
||||
export async function login(username: string, password: string): Promise<User> {
|
||||
if (USE_MOCK) return mock.mockLogin({ username, password });
|
||||
const res = await api.post<{ accessToken: string; user: User }>(
|
||||
'/auth/login',
|
||||
{ username, password },
|
||||
false, // auth: false(登录不需要 token)
|
||||
true // encryptBody: true(登录凭证需要加密)
|
||||
);
|
||||
setToken(res.accessToken);
|
||||
return res.user;
|
||||
}
|
||||
|
||||
// GET 列表
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
if (USE_MOCK) return mock.mockGetProjects();
|
||||
return api.get<Project[]>('/projects');
|
||||
}
|
||||
|
||||
// POST 创建
|
||||
export async function createProject(name: string, industry: Industry): Promise<Project> {
|
||||
if (USE_MOCK) return mock.mockCreateProject(name, industry);
|
||||
return api.post<Project>('/projects', { name, industry });
|
||||
}
|
||||
|
||||
// 带查询参数的 GET
|
||||
export async function getRecordsPage(params): Promise<GenerationRecordPageListOut> {
|
||||
const query = new URLSearchParams();
|
||||
if (params.projectId) query.set('project_id', params.projectId);
|
||||
query.set('page', String(page));
|
||||
return api.get<GenerationRecordPageListOut>(`/generation-records?${query.toString()}`);
|
||||
}
|
||||
```
|
||||
|
||||
**关键约定:**
|
||||
- `api.get/post/put/delete<T>(path, body?, auth?, encryptBody?)` — 返回**已解析的 T 类型数据**(不含响应包裹层)
|
||||
- 后端返回 `{ data: ... }` 的结构,`apiRequest` 会自动解包
|
||||
- 所有 id 字段前端用 `string`(不转 number)
|
||||
- 后端 `snake_case` 字段在 `client.ts` 自动转 `camelCase`(user_id → userId)
|
||||
- 文件上传不走 `apiRequest`,直接用 `fetch` + `FormData`(见 `uploadAudio/uploadImage` 等)
|
||||
- 上传相关 API 直接读 `import.meta.env.VITE_API_BASE` 拼 URL
|
||||
|
||||
### 3.4 状态管理约定(Zustand)
|
||||
|
||||
```typescript
|
||||
// store/useAuthStore.ts — 标准模板
|
||||
import { create } from 'zustand';
|
||||
import type { User } from '../types';
|
||||
import * as api from '../api';
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>; // 启动时验证 token 有效性
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
loading: true,
|
||||
|
||||
login: async (username, password) => {
|
||||
const user = await api.login(username, password);
|
||||
set({ user });
|
||||
},
|
||||
// ...
|
||||
}));
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 全局状态用 Zustand `create`,不用 Redux
|
||||
- API 调用写在 store actions 或页面中,**不要**写在 components 里
|
||||
- 状态更新用 `set()`,异步操作加 `async/await`
|
||||
- 组件中消费: `const { user, login } = useAuthStore()`
|
||||
|
||||
### 3.5 页面组件约定
|
||||
|
||||
```typescript
|
||||
// pages/ProjectsPage.tsx — 标准模板
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Empty, Form, Input, message, Modal, Typography } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import { getIndustries } from '../api';
|
||||
import type { IndustryConfig } from '../types';
|
||||
|
||||
const ProjectsPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getProjects(); // 调 API
|
||||
setProjects(data);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>我的项目</Typography.Title>
|
||||
{/* ... */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectsPage;
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 页面组件是 `React.FC`,默认导出
|
||||
- 业务数据类型从 `../types` 导入(`import type`)
|
||||
- API 函数从 `../api` 导入
|
||||
- 不用 `axios`,所有请求走 `api/index.ts`
|
||||
- 用 `message.success/error/warning` 显示操作反馈
|
||||
- 用 `useNavigate()` 编程式导航
|
||||
- 列表加载用 `loading` 状态 + `Spin` 组件
|
||||
- 错误统一 `catch (e: any)` + `message.error(e?.message || '默认消息')`
|
||||
|
||||
### 3.6 路由约定
|
||||
|
||||
```typescript
|
||||
// App.tsx — 路由结构
|
||||
// 公开路由
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/join-team" element={<JoinTeamPage />} />
|
||||
|
||||
// 受保护路由(需要登录)
|
||||
<Route path="/" element={<ProtectedRoute><AppLayout /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="/projects" replace />} />
|
||||
<Route path="projects" element={<ProjectsPage />} />
|
||||
<Route path="projects/:projectId/generate" element={<GeneratePage />} />
|
||||
<Route path="*" element={<Navigate to="/projects" replace />} />
|
||||
</Route>
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 路由定义集中在 `App.tsx`
|
||||
- 登录保护通过 `ProtectedRoute` 包装器实现(检查 `useAuthStore.user`)
|
||||
- 未登录用户访问受保护路由 → 跳 `/login`
|
||||
- 动态路由参数用 `:paramName`
|
||||
- 嵌套路由用 `<Outlet />` 渲染子页面
|
||||
|
||||
### 3.7 TypeScript 类型约定
|
||||
|
||||
```typescript
|
||||
// types/index.ts — 全局类型定义
|
||||
export interface User {
|
||||
id: string; // 所有 ID 都是 string
|
||||
username: string;
|
||||
credits: number;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**约定:**
|
||||
- 全局共享类型在 `types/index.ts` 定义
|
||||
- 类型/接口名 PascalCase + 业务含义
|
||||
- 所有 ID、外键字段类型为 `string`
|
||||
- 可选字段用 `?:`,不可选但不一定传的用联合类型 `string | null`
|
||||
- 从其他文件导入类型: `import type { ... } from '../types'`(用 `import type` 优化打包)
|
||||
|
||||
### 3.8 加密约定
|
||||
|
||||
```typescript
|
||||
// crypto.ts
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
|
||||
|
||||
// 前端加密流程:
|
||||
// 1. apiRequest 中如果 encryptBody=true:
|
||||
// body → JSON.stringify → encrypt() → { data: "<密文>" }
|
||||
// headers['X-Encrypted'] = 'true'
|
||||
// 2. 收到响应如果 encryptBody=true 且 parsed.data 是字符串:
|
||||
// decrypt(parsed.data) → JSON.parse → 返回
|
||||
//
|
||||
// 自动判断: USE_ENCRYPTION 全局开关控制是否启用加密
|
||||
// 加密算法: AES-256-GCM (12 字节 IV + 128 位认证标签)
|
||||
// 密钥来源: VITE_ENCRYPTION_KEY (32 字节 base64)
|
||||
```
|
||||
|
||||
### 3.9 Mock 模式约定
|
||||
|
||||
当 `VITE_USE_MOCK=true` 时,前端走本地假数据,无需后端。
|
||||
|
||||
```typescript
|
||||
// api/index.ts 中每个 API 函数都检查 USE_MOCK
|
||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK === 'true';
|
||||
|
||||
export async function getProjects(): Promise<Project[]> {
|
||||
if (USE_MOCK) return mock.mockGetProjects(); // ← 先看 mock
|
||||
return api.get<Project[]>('/projects'); // ← 后走真实请求
|
||||
}
|
||||
```
|
||||
|
||||
**新增 API 函数时**必须同时提供 mock 数据函数(在 `mock.ts` 中)。
|
||||
|
||||
---
|
||||
|
||||
## 四、跨项目约定(前后端协作)
|
||||
|
||||
### 4.1 字段名转换
|
||||
|
||||
| 后端 (Python) | 前端 (TypeScript) | 转换 |
|
||||
|--------------|-------------------|------|
|
||||
| `snake_case` | `camelCase` | client.ts 自动转 |
|
||||
| `created_at` | `createdAt` | 自动 |
|
||||
| `user_id` | `userId` | 自动 |
|
||||
|
||||
**新增 API 时**:
|
||||
- 后端 request schema 字段用 `snake_case`
|
||||
- 前端 types/interface 字段用 `camelCase`
|
||||
- **不需要**手动转,`client.ts` 的 `keysToCamel()` 递归转换所有 key
|
||||
|
||||
### 4.2 API 路径约定
|
||||
|
||||
```
|
||||
/api/auth/login → 登录(公开)
|
||||
/api/projects → 项目管理(需登录)
|
||||
/api/admin/users → 用户管理(需管理员)
|
||||
/admin/teams → 团队管理(需管理员,二级路由)
|
||||
```
|
||||
|
||||
- `/api/` 前缀统一
|
||||
- 管理接口路径含 `/admin/`
|
||||
- 路径用小写 + 短横线(kebab-case)
|
||||
|
||||
### 4.3 分页约定
|
||||
|
||||
后端返回:
|
||||
```python
|
||||
@router.get("", response_model=GenerationRecordPageListOut)
|
||||
# { items: [...], total: 100, page: 1, page_size: 20 }
|
||||
```
|
||||
|
||||
前端类型:
|
||||
```typescript
|
||||
export interface GenerationRecordPageListOut {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: GenerationRecord[];
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 错误处理约定
|
||||
|
||||
后端抛 `HTTPException(status_code, detail)` → 前端 `apiRequest` 捕获后抛 `Error` → 页面 `catch` + `message.error(e.message)`。
|
||||
|
||||
常见错误码:
|
||||
- `401` — token 无效/过期 → 自动跳 `/login`
|
||||
- `403` — 权限不足
|
||||
- `404` — 资源不存在
|
||||
- `402` — 积分不足(`InsufficientCreditsError`)
|
||||
- `400` — 参数错误
|
||||
|
||||
---
|
||||
|
||||
## 五、修改检查清单
|
||||
|
||||
### 新增后端接口时 ✅
|
||||
|
||||
- [ ] 在 `app/schemas/` 中定义 Request/Response Schema(含 `from_attributes`)
|
||||
- [ ] 在 `app/services/` 中实现业务逻辑函数
|
||||
- [ ] 在 `app/api/v1/` (或 `app/api/admin/`) 中定义路由(`APIRouter` + `Depends`)
|
||||
- [ ] 确认 `__init__.py` 中注册了新 router
|
||||
- [ ] 复杂操作记录 `log_operation()`
|
||||
- [ ] 写入操作后正确 `flush()` + `commit()`
|
||||
|
||||
### 新增后端 Model 时 ✅
|
||||
|
||||
- [ ] 在 `app/models/` 中新建或编辑 model 文件
|
||||
- [ ] 继承 `Base` + 按需加 `TimestampMixin` / `SoftDeleteMixin`
|
||||
- [ ] 主键用 `generate_id()`,外键加 `index=True`
|
||||
- [ ] 在 `app/models/__init__.py` 中 import
|
||||
- [ ] 执行 `alembic revision --autogenerate -m "描述"` 生成迁移
|
||||
- [ ] 执行 `alembic upgrade head` 应用迁移
|
||||
|
||||
### 新增前端页面/功能时 ✅
|
||||
|
||||
- [ ] 在 `types/index.ts` 中定义/补充类型
|
||||
- [ ] 在 `api/index.ts` 中添加 API 函数(含 mock 分支)
|
||||
- [ ] 在 `pages/` 中新建页面组件(`React.FC` + 默认导出)
|
||||
- [ ] 在 `App.tsx` 中注册路由
|
||||
- [ ] 受保护路由包裹 `<ProtectedRoute>`
|
||||
- [ ] 在 `mock.ts` 中添加对应 mock 数据函数
|
||||
|
||||
---
|
||||
|
||||
## 六、常见反模式(应避免)
|
||||
|
||||
| ❌ 反模式 | ✅ 正确做法 |
|
||||
|----------|-----------|
|
||||
| Route 中写复杂 ORM 查询 | 抽到 `services/` 函数 |
|
||||
| Model 中有业务逻辑 | Model 只定义字段和关系 |
|
||||
| 前端直接用 `fetch` 调后端 API | 走 `api/index.ts` + `apiRequest` |
|
||||
| 新增 Model 不加到 `__init__.py` | 立即 import |
|
||||
| 主键用数据库自增 int | 用 `generate_id()` 生成 string |
|
||||
| Schema 不加 `from_attributes` | 响应 Schema 必须加 |
|
||||
| 前端用 `any` 不定义类型 | 在 `types/index.ts` 定义 |
|
||||
| 密码明文存储 | bcrypt 哈希(通过 `hash_password`) |
|
||||
|
||||
---
|
||||
|
||||
## 七、快速定位表
|
||||
|
||||
| 想修改什么 | 去哪里 |
|
||||
|-----------|--------|
|
||||
| 新增/改表字段 | `app/models/xxx.py` + `alembic revision` |
|
||||
| 新增接口 | `app/api/v1/xxx.py` + `app/schemas/xxx.py` + `app/services/xxx.py` |
|
||||
| 改业务规则 | `app/services/xxx.py` |
|
||||
| 改枚举值 | `app/enums/xxx.py` |
|
||||
| 加新页面 | `src/pages/XxxPage.tsx` + `App.tsx` 路由 + `src/types/index.ts` |
|
||||
| 加新 API 调用 | `src/api/index.ts` + `src/api/mock.ts` |
|
||||
| 改全局状态 | `src/store/useAuthStore.ts` / `useAppStore.ts` |
|
||||
| 改配置项 | `app/config.py` + `.env.example` |
|
||||
| 改中间件行为 | `app/middleware/xxx.py` |
|
||||
| 加异步任务 | `app/tasks/xxx.py` + `celery_app.py` 的 `task_routes` |
|
||||
+357
-72
@@ -5,11 +5,13 @@
|
||||
```
|
||||
video_item/
|
||||
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
|
||||
├── video-gen-app/ # 前台 (React 19 + Vite 8 + Ant Design 6 + Tailwind)
|
||||
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
|
||||
├── video-gen-app/ # 前台/用户端 (React 19 + Vite 8 + Ant Design 6)
|
||||
├── video-gen-admin/ # 后台管理端 (React 19 + Vite 8 + Ant Design 6)
|
||||
└── DEPLOYMENT.md # 本文档
|
||||
```
|
||||
|
||||
**三个前端/后端的关系:** `video-gen-app` (用户前台) 和 `video-gen-admin` (管理后台) 都连接同一个 `video-gen-api` 后端。
|
||||
|
||||
---
|
||||
|
||||
## 一、环境要求
|
||||
@@ -18,14 +20,13 @@ video_item/
|
||||
|------|---------|------|
|
||||
| Python | >= 3.10 | 推荐 3.12 |
|
||||
| Node.js | >= 18 | 推荐 20+ |
|
||||
| PostgreSQL | >= 14 | 推荐 16 |
|
||||
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
|
||||
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
|
||||
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
|
||||
| wechatpayv3 | >=2.0.2 | 可选,用于支付 |
|
||||
| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 |
|
||||
| PostgreSQL | >= 14 | 推荐 16;**必须** |
|
||||
| Redis | >= 6 | 推荐用于: 限流/验证码/Celery/任务状态 |
|
||||
| FFmpeg | 任意 | 用于视频封面截帧,留空时从 PATH 自动查找 |
|
||||
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
|
||||
|
||||
| alipay-sdk-python | >=3.7.1160 | 可选,支付宝支付 |
|
||||
| wechatpayv3 | >=2.0.2 | 可选,微信支付 |
|
||||
| volcengine-python-sdk | >=1.1.0 | 可选,视频生成/短信 |
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +35,7 @@ video_item/
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
|
||||
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS(支付宝/火山等)全部失败
|
||||
# CentOS/RHEL
|
||||
sudo yum install -y ca-certificates
|
||||
# Ubuntu/Debian
|
||||
@@ -49,7 +50,10 @@ python -m venv .venv
|
||||
# Linux/Mac
|
||||
source .venv/bin/activate
|
||||
|
||||
# 安装基础依赖 + PostgreSQL 驱动
|
||||
# 安装基础依赖
|
||||
pip install -e .
|
||||
|
||||
# 安装 PostgreSQL 驱动(生产环境必须)
|
||||
pip install -e ".[pg]"
|
||||
|
||||
# 如需 Redis 支持(限流、验证码、Celery)
|
||||
@@ -58,21 +62,19 @@ pip install -e ".[pg,redis]"
|
||||
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
||||
pip install -e ".[pg,redis,celery]"
|
||||
|
||||
#安装阿里支付sdk
|
||||
# 如需支付宝
|
||||
pip install -e ".[pg,redis,celery,alipay]"
|
||||
|
||||
# 安装微信支付sdk
|
||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3]"
|
||||
|
||||
#安装火山sdk
|
||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]"
|
||||
|
||||
# 如需微信支付
|
||||
pip install -e ".[pg,redis,celery,alipay,wxpay]"
|
||||
|
||||
# 如需火山引擎 SDK(短信等)
|
||||
pip install -e ".[pg,redis,celery,alipay,wxpay,volc]"
|
||||
```
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
复制 `.env.example` 为 `.env`,修改以下关键配置:
|
||||
复制 `.env.example` 为 `.env`,修改关键配置:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
@@ -81,13 +83,14 @@ cp .env.example .env
|
||||
```ini
|
||||
# ── 基础配置 ──
|
||||
APP_NAME=VideoGen API
|
||||
APP_VERSION=1.0.0
|
||||
DEBUG=false
|
||||
SECRET_KEY=改成一个随机的长字符串
|
||||
SECRET_KEY=改成一个随机的长字符串(JWT 签名密钥)
|
||||
|
||||
# ── 数据库 ──
|
||||
# ── 数据库(必须) ──
|
||||
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
|
||||
|
||||
# ── Redis(可选,留空则禁用限流和验证码) ──
|
||||
# ── Redis(可选,留空则禁用限流/验证码/Celery) ──
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# ── JWT ──
|
||||
@@ -101,24 +104,30 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
||||
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
|
||||
|
||||
# ── LLM 提示词优化 ──
|
||||
LLM_MOCK=false
|
||||
LLM_API_BASE=https://api.openai.com/v1
|
||||
LLM_API_KEY=
|
||||
LLM_MODEL=gpt-4o
|
||||
LLM_MOCK=true # true=使用 mock 响应,不调用真实 LLM
|
||||
|
||||
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
|
||||
ENCRYPTION_KEY=你的32字节base64密钥
|
||||
|
||||
# ── 存储路径 ──
|
||||
# ── 存储路径(本地存储) ──
|
||||
STORAGE_TYPE=local
|
||||
STORAGE_LOCAL_PATH=./storage/generate/videos
|
||||
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
|
||||
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
|
||||
UPLOAD_LOCAL_PATH=./storage/uploads
|
||||
|
||||
# ── 跨域(生产环境务必限制域名) ──
|
||||
# ── 跨域(生产环境务必限制域名,默认 ["*"] 允许所有) ──
|
||||
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
|
||||
|
||||
# ── 回调基础地址 ──
|
||||
BASE_URL=https://你的域名
|
||||
|
||||
# ── 验证码 ──
|
||||
CAPTCHA_ENABLED=true
|
||||
|
||||
# ── 短信(火山引擎 SDK,SMS_MOCK=false 时生效) ──
|
||||
SMS_MOCK=true
|
||||
VOLC_SMS_ACCESS_KEY_ID=
|
||||
@@ -129,6 +138,12 @@ VOLC_SMS_SIGN=短信签名
|
||||
|
||||
# ── 支付(PAYMENT_MOCK=false 时生效) ──
|
||||
PAYMENT_MOCK=true
|
||||
WECHAT_MCH_ID=
|
||||
WECHAT_API_KEY=
|
||||
ALIPAY_APP_ID=
|
||||
ALIPAY_PRIVATE_KEY=
|
||||
ALIPAY_PUBLIC_KEY=
|
||||
ALIPAY_NOTIFY_URL=
|
||||
|
||||
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
|
||||
CELERY_BROKER_URL=redis://localhost:6379/5
|
||||
@@ -138,27 +153,30 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/6
|
||||
FFMPEG_BIN=/usr/bin/ffmpeg
|
||||
```
|
||||
|
||||
> **关于 ENCRYPTION_KEY 的生成:** 需要 32 字节(256 位)的 base64 编码字符串。生成方式:`openssl rand -base64 32`。前后端必须使用**完全相同**的密钥。
|
||||
|
||||
### 3. 初始化数据库
|
||||
|
||||
```bash
|
||||
# 创建 PostgreSQL 数据库
|
||||
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
|
||||
|
||||
# 启动后端(首次启动自动建表 + 填充种子数据)
|
||||
# 启动后端(首次启动自动建表)
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
首次启动会自动完成:
|
||||
- 创建所有数据表(23 个 model)
|
||||
- 创建管理员账号:`admin` / `123456`
|
||||
- 创建演示用户:`demo` / `123456`(手机号 `13888888888`)
|
||||
- 填充系统配置、视频引擎(Seedance 2.0 / 2.0 fast)、图片引擎(Seedream 5.0)、模型配置、积分比例、菜单配置、充值套餐(4 档)、行业配置
|
||||
首次启动时会**自动创建所有数据表**(41 个 model)。
|
||||
|
||||
**首次部署后务必修改默认密码。**
|
||||
> ⚠️ **关于种子数据:** 代码中包含 `_seed_data()` 函数(创建管理员/演示用户、系统配置、引擎配置等),但当前在 `main.py` 中被**注释掉了**(`# await _seed_data()`)。因此首次启动**不会**自动创建管理员账号。
|
||||
>
|
||||
> **如果你需要种子数据,**有以下选择:
|
||||
> 1. 在 `main.py` 中取消注释 `# await _seed_data()` 后重启
|
||||
> 2. 手动通过 API 或数据库脚本创建管理员账号
|
||||
> 3. 自行编写独立的种子脚本调用 `_seed_data()`
|
||||
|
||||
### 4. 数据库迁移 (Alembic)
|
||||
|
||||
项目使用 Alembic 管理数据库结构变更,`env.py` 已导入全部 20 个 model。
|
||||
项目使用 Alembic 管理数据库结构变更。
|
||||
|
||||
```bash
|
||||
cd video-gen-api
|
||||
@@ -179,17 +197,13 @@ python -m alembic history
|
||||
python -m alembic downgrade -1
|
||||
```
|
||||
|
||||
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
||||
|
||||
**新增 model 时:** 需要在 `alembic/env.py` 中添加对应的 import。
|
||||
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
||||
|
||||
### 5. 生产运行
|
||||
|
||||
```bash
|
||||
# 方式一:直接运行(推荐 4 workers)
|
||||
# 直接运行(推荐 4 workers)
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||
|
||||
# 方式二:使用 systemd (Linux)
|
||||
```
|
||||
|
||||
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`:
|
||||
@@ -220,18 +234,72 @@ sudo systemctl start videogen-api
|
||||
|
||||
### 6. Celery Worker(可选)
|
||||
|
||||
ChatAPI 异步生成流水线需要 Celery Worker。需要 Redis 作为 Broker。
|
||||
异步任务流水线需要 Celery Worker,依赖 Redis 作为 Broker。
|
||||
|
||||
Celery 使用 **12 个队列**,按功能分离:
|
||||
|
||||
| 队列 | 用途 |
|
||||
|------|------|
|
||||
| `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) |
|
||||
| `gen_provider_poll` | 轮询火山引擎生成状态 |
|
||||
| `gen_result_download` | 下载生成的视频/图片结果 |
|
||||
| `gen_video_upscale_local` | 本地视频超分(FFmpeg) |
|
||||
| `gen_video_upscale_remote` | 远程视频超分(火山 MediaKit) |
|
||||
| `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) |
|
||||
| `gen_private_portrait` | 真人素材认证与同步 |
|
||||
| `gen_shot_analysis` | 拆镜分析 |
|
||||
| `gen_shot_split` | 拆镜切片 |
|
||||
| `gen_api_create` | **API v3 视频任务创建** |
|
||||
| `gen_api_poll` | **API v3 视频状态轮询** |
|
||||
| `gen_api_download` | **API v3 视频下载** |
|
||||
| `gen_api_upscale` | **API v3 超分处理(本地/远程)** |
|
||||
| `default` | 默认队列(用户 OAuth、清理任务等) |
|
||||
|
||||
#### 启动命令
|
||||
|
||||
```bash
|
||||
# 启动 Worker(处理 3 个队列:gen_chatapi_create, gen_provider_poll, gen_result_download)
|
||||
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
|
||||
# ── 启动 Worker(消费所有队列,单机部署)──
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_video_upscale_local,gen_video_upscale_remote,gen_recovery,gen_private_portrait,gen_shot_analysis,gen_shot_split,gen_api_create,gen_api_poll,gen_api_download,gen_api_upscale,default \
|
||||
--concurrency=4
|
||||
|
||||
# ── 按功能分离 Worker(生产环境推荐)──
|
||||
|
||||
# 业务 Worker:处理生成创建、轮询、下载
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download \
|
||||
--concurrency=4 \
|
||||
-n worker_busy@%h
|
||||
|
||||
# 超分 Worker:处理本地和远程超分(含 API v3 超分)
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale \
|
||||
--concurrency=2 \
|
||||
-n worker_upscale@%h
|
||||
|
||||
# 恢复 Worker:处理容灾恢复(低频任务)
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_recovery \
|
||||
--concurrency=1 \
|
||||
-n worker_recovery@%h
|
||||
|
||||
# 其他 Worker:真人素材、拆镜、默认
|
||||
celery -A app.tasks.celery_app worker -l info \
|
||||
-Q gen_private_portrait,gen_shot_analysis,gen_shot_split,default \
|
||||
--concurrency=2 \
|
||||
-n worker_other@%h
|
||||
|
||||
# ── 启动 Celery Beat(定时任务调度器)──
|
||||
celery -A app.tasks.celery_app beat -l info
|
||||
```
|
||||
|
||||
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`:
|
||||
#### systemd 服务文件
|
||||
|
||||
**业务 Worker** `/etc/systemd/system/videogen-worker-busy.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VideoGen Celery Worker
|
||||
Description=VideoGen Celery Worker (Busy)
|
||||
After=network.target redis.service
|
||||
|
||||
[Service]
|
||||
@@ -239,7 +307,7 @@ Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/video-gen-api
|
||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
|
||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_api_create,gen_api_poll,gen_api_download --concurrency=4 -n worker_busy@%h
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
@@ -247,25 +315,94 @@ RestartSec=5
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**超分 Worker** `/etc/systemd/system/videogen-worker-upscale.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VideoGen Celery Worker (Upscale)
|
||||
After=network.target redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/video-gen-api
|
||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_video_upscale_local,gen_video_upscale_remote,gen_api_upscale --concurrency=2 -n worker_upscale@%h
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**恢复 Worker** `/etc/systemd/system/videogen-worker-recovery.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VideoGen Celery Worker (Recovery)
|
||||
After=network.target redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/video-gen-api
|
||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app worker -l info -Q gen_recovery --concurrency=1 -n worker_recovery@%h
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
**Beat 调度器** `/etc/systemd/system/videogen-beat.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VideoGen Celery Beat
|
||||
After=network.target redis.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/video-gen-api
|
||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
||||
ExecStart=/opt/video-gen-api/.venv/bin/celery -A app.tasks.celery_app beat -l info
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启用并启动所有服务
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable videogen-worker-busy videogen-worker-upscale videogen-worker-recovery videogen-beat
|
||||
sudo systemctl start videogen-worker-busy videogen-worker-upscale videogen-worker-recovery videogen-beat
|
||||
```
|
||||
|
||||
### 7. Docker 部署(可选)
|
||||
|
||||
项目提供 `Dockerfile` 和 `docker-compose.yml`,一键启动完整环境:
|
||||
项目提供 `Dockerfile` 和 `docker-compose.yml`。
|
||||
|
||||
> ⚠️ **注意:** 默认 `Dockerfile` 只安装基础依赖(`pip install .`),生产使用需改为 `".[pg,redis,celery]"`。
|
||||
|
||||
```bash
|
||||
cd video-gen-api
|
||||
|
||||
# 注意:Dockerfile 默认只装基础依赖,需修改为安装 pg+redis+celery
|
||||
# 将 Dockerfile 中的 RUN pip install --no-cache-dir . 改为:
|
||||
# 使用前需修改 Dockerfile 第 6 行为:
|
||||
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动的服务:
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| api | 8000 | FastAPI 应用(带 `--reload`,开发模式) |
|
||||
| worker | - | Celery Worker |
|
||||
| api | 8000 | FastAPI 应用(`--reload` 开发模式) |
|
||||
| worker | - | Celery Worker(消费所有队列) |
|
||||
| postgres | 5432 | PostgreSQL 16 |
|
||||
| redis | 6379 | Redis 7 |
|
||||
|
||||
@@ -276,7 +413,7 @@ server {
|
||||
listen 80;
|
||||
server_name api.yourdomain.com;
|
||||
|
||||
# 上传文件大小限制
|
||||
# 上传文件大小限制(视频上传需要较大值)
|
||||
client_max_body_size 100M;
|
||||
|
||||
location / {
|
||||
@@ -300,6 +437,8 @@ server {
|
||||
|
||||
## 三、前台部署 (video-gen-app)
|
||||
|
||||
用户前台,面向最终用户。
|
||||
|
||||
### 1. 安装依赖 & 构建
|
||||
|
||||
```bash
|
||||
@@ -310,8 +449,8 @@ npm install
|
||||
# 配置 API 地址(创建 .env.production)
|
||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
||||
|
||||
# 如需前后端加密通信
|
||||
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
|
||||
# 如需前后端加密通信(与后端 ENCRYPTION_KEY 相同)
|
||||
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
@@ -319,7 +458,15 @@ npm run build
|
||||
|
||||
构建产物在 `dist/` 目录。
|
||||
|
||||
### 2. Nginx 配置
|
||||
### 2. 前端环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||
| `VITE_USE_MOCK` | 是否使用 mock 数据(无需后端) | `false` |
|
||||
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥(需与后端一致) | 空(不加密) |
|
||||
|
||||
### 3. Nginx 配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -328,12 +475,12 @@ server {
|
||||
root /opt/video-gen-app/dist;
|
||||
index index.html;
|
||||
|
||||
# SPA 路由
|
||||
# SPA 路由:所有页面请求回退到 index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
# 静态资源缓存(带 hash 的文件名可长期缓存)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
@@ -345,6 +492,8 @@ server {
|
||||
|
||||
## 四、后台管理部署 (video-gen-admin)
|
||||
|
||||
管理员后台,面向运营/管理人员。
|
||||
|
||||
### 1. 安装依赖 & 构建
|
||||
|
||||
```bash
|
||||
@@ -356,13 +505,21 @@ npm install
|
||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
||||
|
||||
# 如需前后端加密通信
|
||||
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
|
||||
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
|
||||
|
||||
# 构建
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 2. Nginx 配置
|
||||
### 2. 后台环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||
| `VITE_USE_MOCK` | 是否使用 mock 数据 | `false` |
|
||||
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥 | 空(不加密) |
|
||||
|
||||
### 3. Nginx 配置
|
||||
|
||||
```nginx
|
||||
server {
|
||||
@@ -456,6 +613,8 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
||||
|
||||
## 七、默认账号
|
||||
|
||||
> ⚠️ 默认账号仅在种子数据被执行后存在(见第三节第 3 点说明)。
|
||||
|
||||
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|
||||
|------|--------|--------|------|------|
|
||||
| 管理员 | admin | 13800000000 | 123456 | 10000 |
|
||||
@@ -467,32 +626,84 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
||||
|
||||
## 八、架构说明
|
||||
|
||||
### 异步任务处理
|
||||
### 后端启动时的后台任务
|
||||
|
||||
系统有两套异步任务机制:
|
||||
后端启动时(`lifespan`)会自动启动以下**进程内**后台任务:
|
||||
|
||||
| 机制 | 说明 | 依赖 |
|
||||
|------|------|------|
|
||||
| 内置 TaskQueue | asyncio 实现,运行在 uvicorn 进程内,轮询视频/图片生成状态 | 无额外依赖 |
|
||||
| Celery Worker | 处理 ChatAPI 异步生成流水线,3 个队列分优先级 | Redis 作为 Broker |
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| `task_queue` (video_queue) | 内置 asyncio 任务队列,轮询视频/图片生成状态 |
|
||||
| `upload_queue` | 上传任务队列 |
|
||||
| `material_consumption_queue` | 素材消耗队列 |
|
||||
| `token_refresh_scheduler` | 每 5 分钟检查并刷新即将过期的 token |
|
||||
| `poll_pre_test_results` | 每分钟轮询前测结果 |
|
||||
| `schedule_daily_sync` | 每天 9 点自动同步素材消耗 |
|
||||
| `_order_expiry_loop` | 每分钟同步待支付订单状态 + 自动过期订单 |
|
||||
|
||||
### 中间件栈(从外到内)
|
||||
加上 **Celery Worker**(可选,处理 ChatAPI 异步流水线)。
|
||||
|
||||
### 中间件栈(从外到内,即请求到达的顺序)
|
||||
|
||||
1. `RequestLoggingMiddleware` — 请求/响应日志记录
|
||||
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
|
||||
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
|
||||
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
|
||||
5. `CORSMiddleware` — 跨域
|
||||
5. `CORSMiddleware` — 跨域(expose `X-Encrypted` 响应头)
|
||||
|
||||
### 日志系统
|
||||
### 前后端通信加密
|
||||
|
||||
| 日志类型 | 目录 | 控制方式 |
|
||||
**机制:** AES-256-GCM 对称加密,前后端共享同一个 `ENCRYPTION_KEY`。使用 Web Crypto API(前端)和 `cryptography` 库(后端)。
|
||||
|
||||
**生效条件:** 同时满足以下两个条件才启用加密:
|
||||
- 后端 `.env` 中 `ENCRYPTION_KEY` 非空
|
||||
- 前端 `.env` 中 `VITE_ENCRYPTION_KEY` 非空
|
||||
- 运行在安全上下文(HTTPS 或 localhost)
|
||||
|
||||
**加密范围:**
|
||||
|
||||
| 请求类型 | 请求体 | 响应体 |
|
||||
|---------|--------|--------|
|
||||
| GET(无 body) | 不加密(无内容) | **加密** |
|
||||
| POST/PUT/DELETE(有 body) | **加密** | **加密** |
|
||||
|
||||
**工作流:**
|
||||
1. 前端发送 POST 请求时,将 JSON 请求体加密为 `{ data: "<密文>" }`,并加 `X-Encrypted: true` 请求头
|
||||
2. 后端中间件检测到 `X-Encrypted: true` 时解密请求体,处理完后加密响应体
|
||||
3. 支付回调接口(`/payments/alipay/callback`, `/payments/wechat/callback`)白名单跳过加密
|
||||
|
||||
> **GET 请求注意:** GET 没有请求体,但响应仍会被加密。前端会自动检测并解密。
|
||||
|
||||
### 文件日志加密(独立机制)
|
||||
|
||||
与通信加密不同,**文件日志存储**使用另一套独立的 AES-CBC-256 加密:
|
||||
|
||||
| 日志类型 | 目录 | 加密方式 |
|
||||
|---------|------|---------|
|
||||
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | 始终开启 |
|
||||
| AI 模型日志 | `log/AiModel/{日期}.log` | `AI_LOG_ENABLED` 环境变量 |
|
||||
| Python 控制台日志 | stderr | `DEBUG=true` 时输出 INFO,否则 WARNING |
|
||||
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||
| AI 模型日志 | `log/AiModel/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||
|
||||
文件日志使用 AES-CBC 加密存储,解密工具:`/internal/decrypt-data` 端点。
|
||||
日志加密密钥与 `ENCRYPTION_KEY` 无关,是代码中硬编码的值。解密工具:`/internal/decrypt-data` 页面。
|
||||
|
||||
### 内部管理端点
|
||||
|
||||
| 端点 | 用途 |
|
||||
|------|------|
|
||||
| `/internal/` | 后端入口导航页 |
|
||||
| `/internal/health` | 健康检查 (`{"status": "ok"}`) |
|
||||
| `/internal/status` | 服务状态监控页 (Celery/Redis 连接状态) |
|
||||
| `/internal/decrypt-data` | 日志数据解密工具(AES-CBC) |
|
||||
| `/internal/api-docs` | Swagger API 文档 |
|
||||
| `/internal/api-redoc` | ReDoc API 文档 |
|
||||
| `/uploads/` | 用户上传的静态文件(挂载为静态目录) |
|
||||
| `/api/decrypt` | 解密接口(POST,供解密工具调用) |
|
||||
|
||||
### 前端 Mock 模式
|
||||
|
||||
前端支持 Mock 数据模式,无需后端即可开发:
|
||||
|
||||
- 设置 `VITE_USE_MOCK=true` 时,所有 API 调用返回本地假数据
|
||||
- 适用场景:纯前端开发、演示、无后端环境
|
||||
- 注意:mock 模式下仍会调用部分真实 API(如站点信息、验证码)
|
||||
|
||||
### 存储路径
|
||||
|
||||
@@ -522,6 +733,15 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
||||
│ ├── .env # 环境变量
|
||||
│ ├── .venv/ # Python 虚拟环境
|
||||
│ ├── app/ # 应用代码
|
||||
│ │ ├── api/ # API 路由
|
||||
│ │ │ ├── v1/ # v1 版本接口(前端用户端 + 部分管理接口)
|
||||
│ │ │ └── admin/ # 管理端接口
|
||||
│ │ ├── middleware/ # 中间件
|
||||
│ │ ├── models/ # 数据模型 (41 个)
|
||||
│ │ ├── services/ # 业务服务
|
||||
│ │ ├── tasks/ # Celery 任务
|
||||
│ │ ├── enums/ # 枚举定义
|
||||
│ │ └── utils/ # 工具函数
|
||||
│ ├── alembic/ # 数据库迁移文件
|
||||
│ ├── storage/
|
||||
│ │ ├── generate/
|
||||
@@ -565,4 +785,69 @@ cd /opt/video-gen-api && python -m alembic upgrade head
|
||||
|
||||
# 生成迁移文件
|
||||
cd /opt/video-gen-api && python -m alembic revision --autogenerate -m "描述"
|
||||
|
||||
# 查看 Celery 活动任务
|
||||
celery -A app.tasks.celery_app inspect active
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、环境变量完整参考
|
||||
|
||||
### 后端 (video-gen-api/.env)
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `SECRET_KEY` | **是** | `change-me` | JWT 签名密钥 |
|
||||
| `DATABASE_URL` | **是** | sqlite | PostgreSQL 连接串 |
|
||||
| `REDIS_URL` | 否 | 空 | Redis 连接串,留空禁用限流/验证码 |
|
||||
| `JWT_EXPIRE_MINUTES` | 否 | 1440 | Token 有效期(分钟) |
|
||||
| `JWT_EXPIRE_REMEMBER_MINUTES` | 否 | 10080 | 记住登录 Token 有效期 |
|
||||
| `SEEDANCE_API_KEY` | **是** | 空 | 火山引擎 API Key |
|
||||
| `SEEDANCE_API_BASE` | 否 | 火山地址 | API 基础 URL |
|
||||
| `SEEDANCE_CALLBACK_URL` | 否 | 空 | 生成结果回调 URL |
|
||||
| `LLM_API_BASE` | 否 | OpenAI | LLM API 地址 |
|
||||
| `LLM_API_KEY` | 否 | 空 | LLM API Key |
|
||||
| `LLM_MODEL` | 否 | gpt-4o | LLM 模型名称 |
|
||||
| `LLM_MOCK` | 否 | true | 是否 mock LLM 响应 |
|
||||
| `ENCRYPTION_KEY` | 否 | 占位符 | 前后端通信加密密钥 |
|
||||
| `SMS_MOCK` | 否 | true | 是否 mock 短信 |
|
||||
| `PAYMENT_MOCK` | 否 | false | 是否 mock 支付 |
|
||||
| `CORS_ORIGINS` | 否 | `["*"]` | 允许的跨域来源 |
|
||||
| `BASE_URL` | 否 | 测试地址 | 站点基础 URL,用于回调拼接 |
|
||||
| `STORAGE_TYPE` | 否 | local | 存储类型 |
|
||||
| `UPLOAD_LOCAL_PATH` | 否 | `./storage/uploads` | 上传文件存储路径 |
|
||||
| `FFMPEG_BIN` | 否 | 空 | FFmpeg 路径(留空自动查找) |
|
||||
| `CAPTCHA_ENABLED` | 否 | true | 是否启用验证码 |
|
||||
| `CELERY_BROKER_URL` | 否 | 空 | Celery Broker(留空禁用 Celery) |
|
||||
| `CELERY_RESULT_BACKEND` | 否 | 空 | Celery 结果后端 |
|
||||
| `RATE_LIMIT_ENABLED` | 否 | true | 是否启用限流 |
|
||||
|
||||
### 前台 (video-gen-app/.env.production)
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `VITE_API_BASE` | **是** | localhost:8000 | 后端 API 地址 |
|
||||
| `VITE_USE_MOCK` | 否 | false | 是否使用 mock 数据 |
|
||||
| `VITE_ENCRYPTION_KEY` | 否 | 空 | 通信加密密钥(与后端一致) |
|
||||
|
||||
### 后台管理 (video-gen-admin/.env.production)
|
||||
|
||||
同前台。
|
||||
|
||||
---
|
||||
|
||||
## 十二、故障排查
|
||||
|
||||
| 现象 | 可能原因 | 解决方案 |
|
||||
|------|---------|---------|
|
||||
| 前端登录后 401 | Token 过期或 SECRET_KEY 不一致 | 检查后端 `SECRET_KEY` 是否变更 |
|
||||
| 上传文件失败 413 | Nginx 上传大小限制 | 增大 `client_max_body_size` |
|
||||
| 加密请求报错"解密失败" | 前后端密钥不一致 | 确保 `VITE_ENCRYPTION_KEY` = 后端 `ENCRYPTION_KEY` |
|
||||
| 非 HTTPS 环境加密无效 | Web Crypto API 需要安全上下文 | 本地开发用 localhost,生产用 HTTPS |
|
||||
| Celery 任务不执行 | Redis 未启动或地址错误 | 检查 `CELERY_BROKER_URL` 和 Redis |
|
||||
| 限流不生效 | Redis 未配置 | 检查 `REDIS_URL` |
|
||||
| 短信发送失败 | 火山配置缺失或 `SMS_MOCK=true` | 填入 `VOLC_SMS_*` 变量并设 `SMS_MOCK=false` |
|
||||
| 视频封面无法生成 | FFmpeg 未安装 | 安装 FFmpeg 或设置 `FFMPEG_BIN` |
|
||||
| HTTPS 请求支付宝/火山失败 | 缺少 CA 证书 | 安装 `ca-certificates` |
|
||||
| 数据库迁移失败 | Model 定义与迁移不一致 | 重新生成迁移文件后执行 |
|
||||
@@ -1,5 +1,7 @@
|
||||
# VITE_API_BASE=http://192.168.120.17:8000
|
||||
#VITE_API_BASE=https://apiforeign.minzhongzc.com
|
||||
VITE_API_BASE=https://ceshi.apiforeign.minzhongzc.com
|
||||
#VITE_API_BASE=http://localhost:8000
|
||||
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
|
||||
VITE_USE_MOCK=false
|
||||
# Encryption disabled for dev — enable in production
|
||||
VITE_ENCRYPTION_KEY=
|
||||
+602
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-405
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
@import "https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap";:root{--font-sans:"Outfit", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;--nav-bg:#08080c;--nav-surface:#ffffff0a;--nav-border:#ffffff0f;--nav-text:#e8e8ec;--nav-text-muted:#8b8fa3;--nav-hover:#ffffff14;--nav-active:#ffffff1f}*,:before,:after{box-sizing:border-box}html,body{font-family:var(--font-sans);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;color:#1a1a2e;letter-spacing:-.01em;background:#f8f9fc;margin:0;padding:0}#root{min-height:100vh}
|
||||
Vendored
+37
-37
@@ -1,37 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-D3ycepPx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<title>后台管理</title>
|
||||
<script>
|
||||
(function() {
|
||||
var cached = localStorage.getItem('siteInfo');
|
||||
if (cached) {
|
||||
try {
|
||||
var info = JSON.parse(cached);
|
||||
if (info.siteName) {
|
||||
document.title = info.siteName + ' - 管理后台';
|
||||
}
|
||||
if (info.siteLogo) {
|
||||
var link = document.querySelector('link[rel="icon"]');
|
||||
if (link) {
|
||||
link.href = info.siteLogo;
|
||||
link.type = 'image/png';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CTeXmoRP.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D3fwIbOp.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+251
@@ -13,7 +13,9 @@
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"xlsx-js-style": "^1.2.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1235,6 +1237,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.2.0.tgz",
|
||||
"integrity": "sha512-/vUqU/UY4MVeFsg+SsK6c+/05RZXIHZMGJA+PX5JyWI0ZRcBpupnRuPLU/NXXoFwMYCPCoxIfElM2eS+DUXCqQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"printj": "~1.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"adler32": "bin/adler32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/antd": {
|
||||
"version": "6.3.7",
|
||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.3.7.tgz",
|
||||
@@ -1299,6 +1317,28 @@
|
||||
"react-dom": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb/node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1308,6 +1348,34 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.14.0.tgz",
|
||||
"integrity": "sha512-iz3zJLhlrg37/gYRWgEPkaFTtzmnEv1h+r7NgZum2lFElYQPi0/5bnmuDfODHxfp0INEfnRqyfyeIJDbb7ahRw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"commander": "~2.14.1",
|
||||
"exit-on-epipe": "~1.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"codepage": "bin/codepage.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage/node_modules/commander": {
|
||||
"version": "2.14.1",
|
||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-2.14.1.tgz",
|
||||
"integrity": "sha512-+YR16o3rK53SmWHU3rEM3tPAh2rwb1yPcQX5irVn7mb0gXbwuCCrnkbV5+PBfETdfg1vui07nM6PCG1zndcjQw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.17.1",
|
||||
"resolved": "https://registry.npmmirror.com/commander/-/commander-2.17.1.tgz",
|
||||
"integrity": "sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compute-scroll-into-view": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||
@@ -1327,6 +1395,18 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1349,6 +1429,27 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exit-on-epipe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1367,6 +1468,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.3.11",
|
||||
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.3.11.tgz",
|
||||
"integrity": "sha512-Rr5QlUeGN1mbOHlaqcSYMKVpPbgLy0AWT/W0EHxA6NGI12yO1jpoui2zBBvU2G824ltM6Ut8BFgfHSBGfkmS0A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1658,6 +1774,25 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.clonedeep": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
@@ -1677,6 +1812,12 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/parchment": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1726,6 +1867,47 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/printj": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/printj/-/printj-1.1.2.tgz",
|
||||
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"printj": "bin/printj.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/quill": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"eventemitter3": "^5.0.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"parchment": "^3.0.0",
|
||||
"quill-delta": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=8.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/quill-delta": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-diff": "^1.3.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
@@ -1753,6 +1935,21 @@
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-quill-new": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/react-quill-new/-/react-quill-new-3.8.3.tgz",
|
||||
"integrity": "sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21",
|
||||
"quill": "~2.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"quill-delta": "^5.1.0",
|
||||
"react": "^16 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
||||
@@ -1863,6 +2060,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-convert": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
||||
@@ -2001,6 +2210,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx-js-style": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/xlsx-js-style/-/xlsx-js-style-1.2.0.tgz",
|
||||
"integrity": "sha512-DDT4FXFSWfT4DXMSok/m3TvmP1gvO3dn0Eu/c+eXHW5Kzmp7IczNkxg/iEPnImbG9X0Vb8QhROda5eatSR/97Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.2.0",
|
||||
"cfb": "^1.1.4",
|
||||
"codepage": "~1.14.0",
|
||||
"commander": "~2.17.1",
|
||||
"crc-32": "~1.2.0",
|
||||
"exit-on-epipe": "~1.0.1",
|
||||
"fflate": "^0.3.8",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.13",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-quill-new": "^3.8.3",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"xlsx-js-style": "^1.2.0",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -3,9 +3,13 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider, App as AntApp, Spin } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import AdminLayout from './pages/AdminLayout';
|
||||
import AdminAuthoriz from './pages/AdminAuthoriz';
|
||||
import AdminConsume from './pages/AdminConsume';
|
||||
import AdminLoginPage from './pages/AdminLoginPage';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AdminPlatform from './pages/AdminPlatform';
|
||||
import AdminUsers from './pages/AdminUsers';
|
||||
import AdminTeams from './pages/AdminTeams';
|
||||
import AdminModels from './pages/AdminModels';
|
||||
import AdminSettings from './pages/AdminSettings';
|
||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||
@@ -17,7 +21,9 @@ import AdminVideoEngines from './pages/AdminVideoEngines';
|
||||
import AdminImageEngines from './pages/AdminImageEngines';
|
||||
import AdminCreditRatios from './pages/AdminCreditRatios';
|
||||
import AdminMenuConfig from './pages/AdminMenuConfig';
|
||||
import AdminRechargePackages from './pages/AdminRechargePackages';
|
||||
import AdminCreditProducts from './pages/AdminCreditProducts';
|
||||
import AdminLlmBillingPolicies from './pages/AdminLlmBillingPolicies';
|
||||
import AdminLlmBillingExecutions from './pages/AdminLlmBillingExecutions';
|
||||
import AdminOperationLogs from './pages/AdminOperationLogs';
|
||||
import AdminOauthAppList from './pages/AdminOauthAppList';
|
||||
import AdminGenerationRecords from './pages/AdminGenerationRecords';
|
||||
@@ -27,6 +33,21 @@ import AdminHotOpeningReplicationDetail from './pages/AdminHotOpeningReplication
|
||||
import AdminShotReplications from './pages/AdminShotReplications';
|
||||
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
|
||||
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
||||
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
||||
import AdminVideoUpscale from './pages/AdminVideoUpscale';
|
||||
import AdminContactRequests from './pages/AdminContactRequests';
|
||||
import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
||||
import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
|
||||
import AdminOAuthList from './pages/AdminOAuthList';
|
||||
import AdminMaterialList from './pages/AdminMaterialList';
|
||||
import AdminPrivatePortraitProjects from './pages/AdminPrivatePortraitProjects';
|
||||
import AdminApiKeys from './pages/AdminApiKeys';
|
||||
import AdminApiModelPricings from './pages/AdminApiModelPricings';
|
||||
import AdminApiUsage from './pages/AdminApiUsage';
|
||||
import AdminInvoices from './pages/AdminInvoices';
|
||||
import AdminBankTransactions from './pages/AdminBankTransactions';
|
||||
import AdminScheduledTasks from './pages/AdminScheduledTasks';
|
||||
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
@@ -73,6 +94,7 @@ const App = () => {
|
||||
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="users" element={<AdminUsers />} />
|
||||
<Route path="teams" element={<AdminTeams />} />
|
||||
<Route path="credit-records" element={<AdminCreditRecords />} />
|
||||
<Route path="models" element={<AdminModels />} />
|
||||
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
||||
@@ -80,10 +102,20 @@ const App = () => {
|
||||
<Route path="image-engines" element={<AdminImageEngines />} />
|
||||
<Route path="industries" element={<AdminIndustries />} />
|
||||
<Route path="menu-configs" element={<AdminMenuConfig />} />
|
||||
<Route path="recharge-packages" element={<AdminRechargePackages />} />
|
||||
<Route path="credit-products" element={<AdminCreditProducts />} />
|
||||
<Route path="llm-billing-policies" element={<AdminLlmBillingPolicies />} />
|
||||
<Route path="llm-billing-executions" element={<AdminLlmBillingExecutions />} />
|
||||
<Route path="payment" element={<AdminPaymentConfig />} />
|
||||
<Route path="payment-stats" element={<AdminPaymentStats />} />
|
||||
<Route path="settings" element={<AdminSettings />} />
|
||||
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
|
||||
<Route path="video-upscale" element={<AdminVideoUpscale />} />
|
||||
<Route path="api-keys" element={<AdminApiKeys />} />
|
||||
<Route path="api-model-pricings" element={<AdminApiModelPricings />} />
|
||||
<Route path="api-usage" element={<AdminApiUsage />} />
|
||||
<Route path="invoices" element={<AdminInvoices />} />
|
||||
<Route path="bank-transactions" element={<AdminBankTransactions />} />
|
||||
<Route path="scheduled-tasks" element={<AdminScheduledTasks />} />
|
||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||
@@ -94,6 +126,15 @@ const App = () => {
|
||||
<Route path="shot-replications" element={<AdminShotReplications />} />
|
||||
<Route path="shot-replications/task-sets/:taskSetId" element={<AdminShotTaskSetDetail />} />
|
||||
<Route path="shot-replications/projects/:projectId" element={<AdminReplicationProjectDetail moduleType="shot_replicate" />} />
|
||||
<Route path="authoriza" element={<AdminAuthoriz />} />
|
||||
<Route path="consume" element={<AdminConsume />} />
|
||||
<Route path="platform" element={<AdminPlatform />} />
|
||||
<Route path="contact-requests" element={<AdminContactRequests />} />
|
||||
<Route path="home-materials" element={<AdminHomeMaterials />} />
|
||||
<Route path="pretest-templates" element={<AdminPreTestTemplates />} />
|
||||
<Route path="oauth-list" element={<AdminOAuthList />} />
|
||||
<Route path="material-list" element={<AdminMaterialList />} />
|
||||
<Route path="private-portrait-projects" element={<AdminPrivatePortraitProjects />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Real API client for connecting to the FastAPI backend.
|
||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
||||
* Supports both JSON bodies and multipart FormData bodies.
|
||||
*/
|
||||
|
||||
import { encrypt, decrypt } from './crypto';
|
||||
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
|
||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
|
||||
|
||||
interface RequestOptions {
|
||||
method?: string;
|
||||
@@ -24,6 +25,7 @@ function toCamel(s: string): string {
|
||||
function keysToCamel(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map(keysToCamel);
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
if (obj instanceof File || obj instanceof Blob || obj instanceof FormData) return obj;
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), keysToCamel(v)])
|
||||
);
|
||||
@@ -54,31 +56,35 @@ async function tryDecrypt(data: string): Promise<string | null> {
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
|
||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||||
|
||||
if (auth) {
|
||||
const token = getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let bodyStr: string | undefined;
|
||||
let requestBody: BodyInit | undefined;
|
||||
if (body !== undefined) {
|
||||
const json = JSON.stringify(body);
|
||||
if (encryptBody) {
|
||||
headers['X-Encrypted'] = 'true';
|
||||
bodyStr = JSON.stringify({ data: await encrypt(json) });
|
||||
if (isFormData) {
|
||||
requestBody = body as FormData;
|
||||
} else {
|
||||
bodyStr = json;
|
||||
const json = JSON.stringify(body);
|
||||
if (encryptBody) {
|
||||
headers['X-Encrypted'] = 'true';
|
||||
requestBody = JSON.stringify({ data: await encrypt(json) });
|
||||
} else {
|
||||
requestBody = json;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: bodyStr,
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
@@ -96,7 +102,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
|
||||
// If the response is encrypted, decrypt it
|
||||
const hasEncryptedData = parsed && typeof parsed.data === 'string';
|
||||
if (hasEncryptedData && encryptBody) {
|
||||
if (hasEncryptedData && encryptBody && !isFormData) {
|
||||
const decrypted = await tryDecrypt(parsed.data);
|
||||
if (decrypted !== null) {
|
||||
parsed = JSON.parse(decrypted);
|
||||
@@ -107,10 +113,15 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
|
||||
// Handle error responses
|
||||
if (!res.ok) {
|
||||
const msg = parsed?.detail || `请求失败 (${res.status})`;
|
||||
const rawMsg = parsed?.detail || parsed?.message || `请求失败 (${res.status})`;
|
||||
const msg = typeof rawMsg === 'string' ? rawMsg : JSON.stringify(rawMsg);
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
window.location.href = '/login';
|
||||
// Only redirect to login if not already on login page
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
@@ -121,7 +132,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
// Convenience methods
|
||||
export const api = {
|
||||
get: <T>(path: string, auth = true) => apiRequest<T>(path, { auth }),
|
||||
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth }),
|
||||
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth }),
|
||||
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth, encryptBody: body instanceof FormData ? false : USE_ENCRYPTION }),
|
||||
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth, encryptBody: body instanceof FormData ? false : USE_ENCRYPTION }),
|
||||
delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* AES-256-GCM encryption/decryption for API request/response.
|
||||
* Uses Web Crypto API with a shared symmetric key.
|
||||
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
|
||||
*/
|
||||
|
||||
const ALGO = 'AES-GCM';
|
||||
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
|
||||
|
||||
let cryptoKey: CryptoKey | null = null;
|
||||
|
||||
export function isCryptoAvailable(): boolean {
|
||||
return typeof window !== 'undefined' &&
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.subtle !== 'undefined';
|
||||
}
|
||||
|
||||
async function getCryptoKey(): Promise<CryptoKey> {
|
||||
if (cryptoKey) return cryptoKey;
|
||||
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
||||
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
||||
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
|
||||
}
|
||||
|
||||
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
||||
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
||||
if (keyBytes.length !== 32) {
|
||||
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
|
||||
}
|
||||
|
||||
export async function encrypt(plaintext: string): Promise<string> {
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Encryption not available in non-secure context');
|
||||
}
|
||||
const key = await getCryptoKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
@@ -38,10 +53,13 @@ export async function encrypt(plaintext: string): Promise<string> {
|
||||
}
|
||||
|
||||
export async function decrypt(cipherB64: string): Promise<string> {
|
||||
if (!isCryptoAvailable()) {
|
||||
throw new Error('Decryption not available in non-secure context');
|
||||
}
|
||||
const key = await getCryptoKey();
|
||||
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
||||
const iv = combined.slice(0, IV_LENGTH);
|
||||
const cipherBytes = combined.slice(IV_LENGTH);
|
||||
const plainBuf = await crypto.subtle.decrypt({ name: ALGO, iv, tagLength: TAG_LENGTH }, key, cipherBytes);
|
||||
return new TextDecoder().decode(plainBuf);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Popover, Tag, List, Descriptions, Typography } from 'antd';
|
||||
|
||||
interface PreResultData {
|
||||
video_id: string;
|
||||
advertiser_id: number;
|
||||
material_id: string;
|
||||
is_ad_high_quality_material: string;
|
||||
is_ecp_high_quality_material: string;
|
||||
is_inefficient_material: string;
|
||||
is_first_publish_material: string;
|
||||
is_local_high_quality_material: string;
|
||||
not_ad_high_quality_reason: string[] | null;
|
||||
not_ecp_high_quality_reason: string[] | null;
|
||||
}
|
||||
|
||||
interface PreResultDisplayProps {
|
||||
preResult: string;
|
||||
}
|
||||
|
||||
const fieldConfig = [
|
||||
{ key: 'is_ad_high_quality_material', label: 'AD优质', noLabel: 'AD非优质素材', unknownLabel: 'AD未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
|
||||
{ key: 'is_ecp_high_quality_material', label: '千川优质', noLabel: '千川非优质素材', unknownLabel: '千川未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
|
||||
{ key: 'is_local_high_quality_material', label: '本地推优质', noLabel: '本地推非优质', unknownLabel: '本地推未知', yesColor: 'green', noColor: 'red', unknownColor: 'default' },
|
||||
{ key: 'is_inefficient_material', label: '低效', noLabel: '非低效', unknownLabel: '是否低效未知', yesColor: 'red', noColor: 'green', unknownColor: 'default' },
|
||||
{ key: 'is_first_publish_material', label: '首发', noLabel: '非首发', unknownLabel: '是否首发未知', yesColor: 'blue', noColor: 'default', unknownColor: 'default' },
|
||||
];
|
||||
|
||||
const PreResultDisplay: React.FC<PreResultDisplayProps> = ({ preResult }) => {
|
||||
const [parsedData, setParsedData] = useState<PreResultData | null>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!preResult) {
|
||||
setParsedData(null);
|
||||
setHasError(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(preResult);
|
||||
setParsedData(data);
|
||||
setHasError(false);
|
||||
} catch {
|
||||
setParsedData(null);
|
||||
setHasError(true);
|
||||
}
|
||||
}, [preResult]);
|
||||
|
||||
if (!preResult || hasError || !parsedData) {
|
||||
return <span style={{ color: '#64748b' }}>-</span>;
|
||||
}
|
||||
|
||||
const getIndicatorDisplay = (key: string) => {
|
||||
const config = fieldConfig.find(c => c.key === key);
|
||||
if (!config) return null;
|
||||
const value = parsedData[key as keyof PreResultData];
|
||||
const isYes = value === 'YES';
|
||||
const isNo = value === 'NO';
|
||||
const isUnknown = value === 'UNKNOWN';
|
||||
if (isUnknown) return null;
|
||||
let displayLabel, displayColor;
|
||||
if (isYes) {
|
||||
displayLabel = config.label;
|
||||
displayColor = config.yesColor;
|
||||
} else if (isNo) {
|
||||
displayLabel = config.noLabel;
|
||||
displayColor = config.noColor;
|
||||
}
|
||||
return {
|
||||
tag: (
|
||||
<Tag
|
||||
key={key}
|
||||
color={displayColor}
|
||||
style={{ marginRight: 6, fontSize: 12, borderRadius: 4 }}
|
||||
>
|
||||
{displayLabel}
|
||||
</Tag>
|
||||
),
|
||||
detail: {
|
||||
label: config.label.replace('优质', '优质素材'),
|
||||
value: displayLabel,
|
||||
color: displayColor,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const content = (
|
||||
<div style={{ maxWidth: 500, padding: '8px 0' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, color: '#1e293b' }}>前测结果详情</Typography.Text>
|
||||
|
||||
<Descriptions column={1} size="small" style={{ marginBottom: 12 }}>
|
||||
<Descriptions.Item label="视频ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.video_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="广告主ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.advertiser_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="素材ID" labelStyle={{ fontWeight: 500, color: '#64748b' }} contentStyle={{ color: '#1e293b' }}>{parsedData.material_id}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Text strong style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 8 }}>质量评估</Typography.Text>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginBottom: 12 }}>
|
||||
{fieldConfig.map(config => {
|
||||
const value = parsedData[config.key as keyof PreResultData];
|
||||
const isYes = value === 'YES';
|
||||
const isNo = value === 'NO';
|
||||
const isUnknown = value === 'UNKNOWN';
|
||||
if (isUnknown) return null;
|
||||
let displayLabel, displayColor;
|
||||
if (isYes) {
|
||||
displayLabel = config.label;
|
||||
displayColor = config.yesColor;
|
||||
} else if (isNo) {
|
||||
displayLabel = config.noLabel;
|
||||
displayColor = config.noColor;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
key={config.key}
|
||||
color={displayColor}
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
>
|
||||
{displayLabel}
|
||||
</Tag>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{parsedData.not_ad_high_quality_reason && parsedData.not_ad_high_quality_reason.length > 0 && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
|
||||
AD非优质原因
|
||||
</Typography.Text>
|
||||
<List
|
||||
dataSource={parsedData.not_ad_high_quality_reason}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
|
||||
{index + 1}. {item}
|
||||
</List.Item>
|
||||
)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsedData.not_ecp_high_quality_reason && parsedData.not_ecp_high_quality_reason.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontSize: 12, color: '#ef4444', display: 'block', marginBottom: 8 }}>
|
||||
千川非优质原因
|
||||
</Typography.Text>
|
||||
<List
|
||||
dataSource={parsedData.not_ecp_high_quality_reason}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item key={index} style={{ padding: '4px 0', fontSize: 12, color: '#64748b' }}>
|
||||
{index + 1}. {item}
|
||||
</List.Item>
|
||||
)}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={content}
|
||||
title={null}
|
||||
trigger="hover"
|
||||
placement="topLeft"
|
||||
overlayStyle={{ borderRadius: 12, boxShadow: '0 8px 24px rgba(0,0,0,0.12)' }}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 4}}>
|
||||
{fieldConfig.map(config => {
|
||||
const display = getIndicatorDisplay(config.key);
|
||||
return display?.tag;
|
||||
})}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreResultDisplay;
|
||||
@@ -0,0 +1,192 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Modal, Radio, InputNumber, Input, Select, Space, Typography, Tag, Divider, message,
|
||||
} from 'antd';
|
||||
import { adjustApiKeyQuota } from '../api';
|
||||
|
||||
interface QuotaAdjustModalProps {
|
||||
open: boolean;
|
||||
keyId: string;
|
||||
companyName: string;
|
||||
quotaLimit: number | null;
|
||||
quotaUsed: number;
|
||||
quotaCycle: string | null;
|
||||
onCancel: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const QuotaAdjustModal: React.FC<QuotaAdjustModalProps> = ({
|
||||
open, keyId, companyName, quotaLimit, quotaUsed, quotaCycle, onCancel, onSuccess,
|
||||
}) => {
|
||||
const [action, setAction] = useState<'adjust' | 'reset_usage' | 'set_limit' | 'change_cycle'>('adjust');
|
||||
const [delta, setDelta] = useState<number>(0);
|
||||
const [newLimit, setNewLimit] = useState<number | null>(quotaLimit);
|
||||
const [newCycle, setNewCycle] = useState<string | null>(quotaCycle);
|
||||
const [reason, setReason] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const cycleLabel = (cycle: string | null) => {
|
||||
const map: Record<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
|
||||
return cycle ? map[cycle] || cycle : '无限';
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: any = { action, reason: reason || undefined };
|
||||
if (action === 'adjust') payload.quotaLimitDelta = delta;
|
||||
if (action === 'set_limit') payload.quotaLimit = newLimit;
|
||||
if (action === 'change_cycle') payload.quotaCycle = newCycle;
|
||||
|
||||
await adjustApiKeyQuota(keyId, payload);
|
||||
message.success('配额调整成功');
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || '调整失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setAction('adjust');
|
||||
setDelta(0);
|
||||
setNewLimit(quotaLimit);
|
||||
setNewCycle(quotaCycle);
|
||||
setReason('');
|
||||
onCancel();
|
||||
};
|
||||
|
||||
// 预览计算
|
||||
const previewLimit = action === 'adjust'
|
||||
? round((quotaLimit || 0) + delta)
|
||||
: action === 'set_limit'
|
||||
? newLimit
|
||||
: quotaLimit;
|
||||
|
||||
function round(n: number) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="调整配额"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okText="确认调整"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
width={480}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">公司:</Typography.Text>
|
||||
<Typography.Text strong>{companyName}</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">当前:</Typography.Text>
|
||||
<Tag color="blue">已用 {quotaUsed.toFixed(2)} 元</Tag>
|
||||
<Tag color="green">限额 {quotaLimit != null ? `${quotaLimit.toFixed(2)} 元` : '无限'}</Tag>
|
||||
<Tag color="purple">{cycleLabel(quotaCycle)}</Tag>
|
||||
</div>
|
||||
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
|
||||
<Radio.Group value={action} onChange={e => setAction(e.target.value)} style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Radio value="adjust">
|
||||
<Space>
|
||||
<Typography.Text>增加总额</Typography.Text>
|
||||
{action === 'adjust' && (
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={10}
|
||||
value={delta}
|
||||
onChange={v => setDelta(v || 0)}
|
||||
addonAfter="元"
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Radio>
|
||||
|
||||
<Radio value="reset_usage">
|
||||
<Space>
|
||||
<Typography.Text>重置已用</Typography.Text>
|
||||
{action === 'reset_usage' && (
|
||||
<Typography.Text type="secondary">
|
||||
({quotaUsed.toFixed(2)} → 0.00 元)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Radio>
|
||||
|
||||
<Radio value="set_limit">
|
||||
<Space>
|
||||
<Typography.Text>设置限额</Typography.Text>
|
||||
{action === 'set_limit' && (
|
||||
<>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={10}
|
||||
value={newLimit}
|
||||
onChange={setNewLimit}
|
||||
addonAfter="元"
|
||||
placeholder="留空=无限"
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
(当前:{quotaLimit != null ? `${quotaLimit.toFixed(2)} 元` : '无限'})
|
||||
</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
</Radio>
|
||||
|
||||
<Radio value="change_cycle">
|
||||
<Space>
|
||||
<Typography.Text>修改周期</Typography.Text>
|
||||
{action === 'change_cycle' && (
|
||||
<Select
|
||||
value={newCycle}
|
||||
onChange={setNewCycle}
|
||||
allowClear
|
||||
placeholder="选择周期"
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '每日', value: 'daily' },
|
||||
{ label: '每月', value: 'monthly' },
|
||||
{ label: '一次性', value: 'one_time' },
|
||||
{ label: '无限', value: null },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Radio>
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
|
||||
{action === 'adjust' && delta > 0 && (
|
||||
<div style={{ padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 13 }}>
|
||||
调整后总额:<strong style={{ color: '#1677ff' }}>{previewLimit != null ? `${previewLimit.toFixed(2)} 元` : '无限'}</strong>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>调整原因(可选)</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="请输入调整原因..."
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuotaAdjustModal;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { Empty, Spin, Tag, Typography } from 'antd';
|
||||
import { PlayCircleFilled } from '@ant-design/icons';
|
||||
import type { GenerationAITaskOut } from '../../types';
|
||||
import { resolveGenerationUiState } from '../../utils/generationTaskStatus';
|
||||
|
||||
interface Props {
|
||||
task: GenerationAITaskOut;
|
||||
resolveUrl: (url?: string | null) => string;
|
||||
onPreview: (url: string, type: 'image' | 'video', title: string) => void;
|
||||
}
|
||||
|
||||
const spanByCount = (count: number, index: number): number => {
|
||||
if (count <= 1) return 6;
|
||||
if (count === 2 || count === 4) return 3;
|
||||
if (count === 3) return index < 2 ? 3 : 6;
|
||||
return index < 3 ? 2 : 3;
|
||||
};
|
||||
|
||||
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, resolveUrl, onPreview }) => {
|
||||
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
|
||||
const sortedChildren = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
|
||||
const items: GenerationAITaskOut[] = sortedChildren.length
|
||||
? sortedChildren
|
||||
: (count > 1
|
||||
? Array.from({ length: count }, (_, index) => ({ ...task, id: `${task.id}-${index + 1}`, generationIndex: index + 1, childItems: [] }))
|
||||
: [task]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 430, display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0,1fr))', gridAutoRows: 'minmax(0,1fr)', gap: items.length > 1 ? 8 : 0 }}>
|
||||
{items.map((item, index) => {
|
||||
const uiState = resolveGenerationUiState(item);
|
||||
const isVideo = item.genType === 'video';
|
||||
const resultUrl = resolveUrl(isVideo ? item.videoUrl : item.imageUrl);
|
||||
const coverUrl = resolveUrl(item.videoCoverUrl);
|
||||
const active = uiState.isActive;
|
||||
return (
|
||||
<div key={item.id} style={{ gridColumn: `span ${spanByCount(items.length, index)}`, minWidth: 0, minHeight: 0, border: '1px solid #edf0f5', borderRadius: 10, overflow: 'hidden', position: 'relative', background: '#f8f9fc' }}>
|
||||
{resultUrl && uiState.isSuccess ? (
|
||||
<button type="button" onClick={() => onPreview(resultUrl, isVideo ? 'video' : 'image', `生成结果 ${item.generationIndex || index + 1}`)} style={{ width: '100%', height: '100%', padding: 0, border: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}>
|
||||
{isVideo ? (coverUrl ? <img src={coverUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : <video src={resultUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />) : <img src={resultUrl} alt="生成图片" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
|
||||
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)', color: '#fff', fontSize: 38, filter: 'drop-shadow(0 3px 8px rgba(0,0,0,.35))' }} /> : null}
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 9, padding: 12, textAlign: 'center' }}>
|
||||
{active ? <Spin size="small" /> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={null} />}
|
||||
<Tag color={uiState.color}>{uiState.label}</Tag>
|
||||
{item.errorMessage && !active ? <Typography.Text type="danger" style={{ fontSize: 11 }}>{item.errorMessage}</Typography.Text> : null}
|
||||
</div>
|
||||
)}
|
||||
{items.length > 1 ? <span style={{ position: 'absolute', top: 6, left: 6, padding: '1px 7px', borderRadius: 10, color: '#fff', background: 'rgba(17,24,39,.58)', fontSize: 11 }}>#{item.generationIndex || index + 1}</span> : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerationTaskResourceGrid;
|
||||
@@ -0,0 +1,760 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Button, Card, DatePicker, Divider, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, ApiOutlined, EyeOutlined, KeyOutlined, CopyOutlined, DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import QuotaAdjustModal from '../components/QuotaAdjustModal';
|
||||
import {
|
||||
getApiKeys, createApiKey, updateApiKey, deleteApiKey, getApiKeyUsage, getGenerationAiEngines, revealApiKey, getApiKeyUpscaleConfig, saveApiKeyUpscaleConfig,
|
||||
getApiKeyVpV3Quota, saveApiKeyVpV3Quota, getOperationLogs,
|
||||
} from '../api';
|
||||
import type { GenerationAiEngineOption } from '../types';
|
||||
|
||||
interface EngineOption {
|
||||
id: string;
|
||||
name: string;
|
||||
modelName: string;
|
||||
genType: 'video' | 'image';
|
||||
}
|
||||
|
||||
interface ApiKey {
|
||||
id: string;
|
||||
companyName: string;
|
||||
apiKeyPrefix: string;
|
||||
description: string | null;
|
||||
callableModels?: Array<{ engineId: string; engineType: string; modelName: string }>;
|
||||
quotaLimit: number | null;
|
||||
quotaCycle: string | null;
|
||||
quotaUsed: number;
|
||||
validFrom: string | null;
|
||||
validUntil: string | null;
|
||||
maxConcurrentVideoTasks: number | null;
|
||||
isActive: boolean;
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface UpscaleRule {
|
||||
targetResolution: string;
|
||||
providerGenerationResolution: string;
|
||||
processorKey: string;
|
||||
}
|
||||
|
||||
const AdminApiKeys: React.FC = () => {
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [modal, setModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null });
|
||||
const [usageModal, setUsageModal] = useState<{ open: boolean; key: ApiKey | null; usage: any }>({ open: false, key: null, usage: null });
|
||||
const [quotaModal, setQuotaModal] = useState<{ open: boolean; key: ApiKey | null }>({ open: false, key: null });
|
||||
const [quotaLogs, setQuotaLogs] = useState<{ items: any[]; total: number; page: number; loading: boolean }>({ items: [], total: 0, page: 1, loading: false });
|
||||
const [form] = Form.useForm();
|
||||
const [engines, setEngines] = useState<EngineOption[]>([]);
|
||||
const [upscaleRules, setUpscaleRules] = useState<UpscaleRule[]>([]);
|
||||
const [upscaleEnabled, setUpscaleEnabled] = useState(false);
|
||||
const [deleteSource, setDeleteSource] = useState(false);
|
||||
|
||||
// V3 虚拟素材库配额(编辑时加载)
|
||||
const [vpV3Quota, setVpV3Quota] = useState<{
|
||||
projectLimit: number; assetLimit: number; storageMbLimit: number;
|
||||
projectUsed: number; assetUsed: number; storageMbUsed: number;
|
||||
enabled: boolean; remark?: string | null;
|
||||
}>({
|
||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
||||
enabled: false, remark: null,
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [keysData, enginesData] = await Promise.all([
|
||||
getApiKeys({ limit: 100 }),
|
||||
getGenerationAiEngines(),
|
||||
]);
|
||||
setKeys(keysData?.items || keysData || []);
|
||||
setTotal(keysData?.total || (keysData?.length || 0));
|
||||
const allEngines: EngineOption[] = [
|
||||
...(enginesData?.engine?.image || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name || e.modelName,
|
||||
modelName: e.modelName,
|
||||
genType: 'image' as const,
|
||||
})),
|
||||
...(enginesData?.engine?.video || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
name: e.name || e.modelName,
|
||||
modelName: e.modelName,
|
||||
genType: 'video' as const,
|
||||
})),
|
||||
];
|
||||
setEngines(allEngines);
|
||||
} catch {
|
||||
message.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const openEdit = async (key: ApiKey | null = null) => {
|
||||
if (key) {
|
||||
// 将 callableModels 转换为引擎 ID 数组用于 Select
|
||||
const selectedEngineIds = (key.callableModels || []).map((m: any) => m.engineId || m.engine_id);
|
||||
form.setFieldsValue({
|
||||
companyName: key.companyName || '',
|
||||
description: key.description || '',
|
||||
quotaLimit: key.quotaLimit || null,
|
||||
quotaCycle: key.quotaCycle || 'monthly',
|
||||
validUntil: key.validUntil ? dayjs(key.validUntil) : null,
|
||||
maxConcurrentVideoTasks: key.maxConcurrentVideoTasks || null,
|
||||
engineIds: selectedEngineIds,
|
||||
});
|
||||
// 并行加载:超分配置 + 虚拟素材库配额
|
||||
await Promise.all([
|
||||
loadUpscaleConfig(key.id),
|
||||
(async () => {
|
||||
try {
|
||||
const quota = await getApiKeyVpV3Quota(key.id);
|
||||
setVpV3Quota({
|
||||
projectLimit: quota?.projectLimit ?? quota?.project_limit ?? 0,
|
||||
assetLimit: quota?.assetLimit ?? quota?.asset_limit ?? 0,
|
||||
storageMbLimit: quota?.storageMbLimit ?? quota?.storage_mb_limit ?? 0,
|
||||
projectUsed: quota?.projectUsed ?? quota?.project_used ?? 0,
|
||||
assetUsed: quota?.assetUsed ?? quota?.asset_used ?? 0,
|
||||
storageMbUsed: quota?.storageMbUsed ?? quota?.storage_mb_used ?? 0,
|
||||
enabled: !!quota?.enabled,
|
||||
remark: quota?.remark ?? null,
|
||||
});
|
||||
} catch {
|
||||
setVpV3Quota({
|
||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
||||
enabled: false, remark: null,
|
||||
});
|
||||
}
|
||||
})(),
|
||||
]);
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ quotaCycle: 'monthly', quotaLimit: 100, engineIds: [] });
|
||||
setUpscaleEnabled(false);
|
||||
setDeleteSource(false);
|
||||
setUpscaleRules([]);
|
||||
setVpV3Quota({
|
||||
projectLimit: 0, assetLimit: 0, storageMbLimit: 0,
|
||||
projectUsed: 0, assetUsed: 0, storageMbUsed: 0,
|
||||
enabled: false, remark: null,
|
||||
});
|
||||
}
|
||||
setModal({ open: true, key });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
// 将选中的引擎 ID 转换为 callableModels 格式
|
||||
const callableModels = (values.engineIds || []).map((id: string) => {
|
||||
const engine = engines.find(e => e.id === id);
|
||||
return {
|
||||
engineId: id,
|
||||
engineType: engine?.genType || 'video',
|
||||
modelName: engine?.modelName || '',
|
||||
};
|
||||
});
|
||||
const payload = {
|
||||
companyName: values.companyName,
|
||||
description: values.description || null,
|
||||
quotaLimit: values.quotaLimit || null,
|
||||
quotaCycle: values.quotaCycle || null,
|
||||
validUntil: values.validUntil ? (values.validUntil.toISOString ? values.validUntil.toISOString() : values.validUntil) : null,
|
||||
maxConcurrentVideoTasks: values.maxConcurrentVideoTasks || null,
|
||||
callableModels,
|
||||
};
|
||||
console.log('API Key payload:', JSON.stringify(payload, null, 2));
|
||||
if (modal.key?.id) {
|
||||
await updateApiKey(modal.key.id, payload);
|
||||
} else {
|
||||
const result = await createApiKey(payload);
|
||||
if (result?.apiKey) {
|
||||
Modal.success({
|
||||
title: 'API Key 创建成功',
|
||||
content: (
|
||||
<div>
|
||||
<p>请妥善保存以下 API Key,此信息仅显示一次:</p>
|
||||
<Typography.Paragraph copyable style={{ background: '#f5f5f5', padding: 12, borderRadius: 8, fontFamily: 'monospace' }}>
|
||||
{result.apiKey}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 保存超分配置
|
||||
if (modal.key?.id) {
|
||||
await saveUpscaleConfig(modal.key.id);
|
||||
// 保存 V3 虚拟素材库配额(编辑模式才需要,因为新建时还没有 id)
|
||||
try {
|
||||
await saveApiKeyVpV3Quota(modal.key.id, {
|
||||
projectLimit: vpV3Quota.projectLimit || 0,
|
||||
assetLimit: vpV3Quota.assetLimit || 0,
|
||||
storageMbLimit: vpV3Quota.storageMbLimit || 0,
|
||||
remark: vpV3Quota.remark ?? null,
|
||||
});
|
||||
} catch (qErr: any) {
|
||||
message.warning(qErr?.response?.data?.detail || '虚拟素材库配额保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
message.success('保存成功');
|
||||
setModal({ open: false, key: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteApiKey(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyKey = async (key: ApiKey) => {
|
||||
try {
|
||||
const result = await revealApiKey(key.id);
|
||||
const plainKey: string | undefined = result?.apiKey || result?.data?.apiKey;
|
||||
if (!plainKey) {
|
||||
message.error('获取 API Key 失败');
|
||||
return;
|
||||
}
|
||||
// 优先用 Clipboard API,不支持时回退到 execCommand
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
try {
|
||||
await navigator.clipboard.writeText(plainKey);
|
||||
} catch {
|
||||
fallbackCopy(plainKey);
|
||||
}
|
||||
} else {
|
||||
fallbackCopy(plainKey);
|
||||
}
|
||||
message.success('API Key 已复制到剪贴板');
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data?.detail || '复制失败';
|
||||
message.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
const loadUsageDetail = async (keyId: string, page = 1, pageSize = 20) => {
|
||||
try {
|
||||
const usage = await getApiKeyUsage(keyId, 30, page, pageSize);
|
||||
setUsageModal(prev => ({ ...prev, usage }));
|
||||
} catch {
|
||||
message.error('加载使用统计失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadQuotaLogs = async (keyId: string, page = 1) => {
|
||||
setQuotaLogs(prev => ({ ...prev, loading: true }));
|
||||
try {
|
||||
// 从 operation_logs 中筛选 quota_adjust:* 且 path 包含该 keyId 的记录
|
||||
const data = await getOperationLogs({ page, pageSize: 20, action: 'quota_adjust' });
|
||||
const filtered = (data?.items || []).filter((item: any) => item.path?.includes(keyId));
|
||||
setQuotaLogs({ items: filtered, total: filtered.length, page, loading: false });
|
||||
} catch {
|
||||
message.error('加载配额变更记录失败');
|
||||
setQuotaLogs(prev => ({ ...prev, loading: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const viewUsage = async (key: ApiKey) => {
|
||||
try {
|
||||
const usage = await getApiKeyUsage(key.id, 30, 1, 20);
|
||||
setUsageModal({ open: true, key, usage });
|
||||
loadQuotaLogs(key.id, 1);
|
||||
} catch {
|
||||
message.error('加载使用统计失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ── 超分配置处理 ──
|
||||
const handleAddUpscaleRule = () => {
|
||||
setUpscaleRules([...upscaleRules, { targetResolution: '1080p', providerGenerationResolution: '720p', processorKey: 'volc_large_model_v1' }]);
|
||||
};
|
||||
|
||||
const handleRemoveUpscaleRule = (idx: number) => {
|
||||
setUpscaleRules(upscaleRules.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const handleUpscaleRuleChange = (idx: number, field: keyof UpscaleRule, value: string) => {
|
||||
const newRules = [...upscaleRules];
|
||||
newRules[idx] = { ...newRules[idx], [field]: value };
|
||||
setUpscaleRules(newRules);
|
||||
};
|
||||
|
||||
const loadUpscaleConfig = async (keyId: string) => {
|
||||
try {
|
||||
const config = await getApiKeyUpscaleConfig(keyId);
|
||||
setUpscaleEnabled(config?.data?.enabled || false);
|
||||
setDeleteSource(config?.data?.deleteSourceAfterSuccess || false);
|
||||
setUpscaleRules(config?.data?.rules || []);
|
||||
} catch {
|
||||
setUpscaleEnabled(false);
|
||||
setDeleteSource(false);
|
||||
setUpscaleRules([]);
|
||||
}
|
||||
};
|
||||
|
||||
const saveUpscaleConfig = async (keyId: string) => {
|
||||
try {
|
||||
await saveApiKeyUpscaleConfig(keyId, {
|
||||
data: {
|
||||
enabled: upscaleEnabled,
|
||||
deleteSourceAfterSuccess: deleteSource,
|
||||
rules: upscaleRules,
|
||||
},
|
||||
});
|
||||
message.success('超分配置已保存');
|
||||
} catch {
|
||||
message.error('保存超分配置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const cycleLabel = (cycle: string | null) => {
|
||||
const map: Record<string, string> = { daily: '每日', monthly: '每月', one_time: '一次性' };
|
||||
return cycle ? map[cycle] || cycle : '无限';
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ApiKey> = [
|
||||
{ title: '公司', dataIndex: 'companyName', width: 120, ellipsis: true },
|
||||
{
|
||||
title: 'api-key',
|
||||
dataIndex: 'apiKeyPrefix',
|
||||
width: 180,
|
||||
render: (v: string, r: ApiKey) => (
|
||||
<Space size={4}>
|
||||
<code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}****</code>
|
||||
<Button type="link" size="small" icon={<CopyOutlined />} onClick={() => handleCopyKey(r)}></Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '配额(元)',
|
||||
dataIndex: 'quotaLimit',
|
||||
width: 130,
|
||||
render: (_v: number, r: ApiKey) => {
|
||||
if (!r.quotaLimit) return <Tag>无限</Tag>;
|
||||
const used = r.quotaUsed || 0;
|
||||
const limit = r.quotaLimit || 1;
|
||||
const pct = Math.min(100, Math.round((used / limit) * 100));
|
||||
return (
|
||||
<div style={{ width: 110 }}>
|
||||
<Progress percent={pct} size="small" format={() => `${used.toFixed(1)}/${limit}`} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '周期',
|
||||
dataIndex: 'quotaCycle',
|
||||
width: 70,
|
||||
render: (v: string | null) => <Tag>{cycleLabel(v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isActive',
|
||||
width: 70,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '有效期',
|
||||
dataIndex: 'validUntil',
|
||||
width: 100,
|
||||
render: (v: string | null) => v ? new Date(v).toLocaleDateString() : '永久',
|
||||
},
|
||||
{
|
||||
title: '最后使用',
|
||||
dataIndex: 'lastUsedAt',
|
||||
width: 150,
|
||||
render: (v: string | null) => v ? new Date(v).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 260,
|
||||
render: (_: any, r: ApiKey) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" icon={<DollarOutlined />} onClick={() => setQuotaModal({ open: true, key: r })}>配额</Button>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => viewUsage(r)}>统计</Button>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ApiOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>API Key 管理</Typography.Text>
|
||||
<Tag color="purple">{total} 个</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>创建 Key</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<Table columns={columns} dataSource={keys} rowKey="id" loading={loading} pagination={false} scroll={{ x: 1100 }} />
|
||||
</Card>
|
||||
|
||||
{/* 创建/编辑弹窗 */}
|
||||
<Modal
|
||||
title={modal.key ? '编辑 API Key' : '创建 API Key'}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, key: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={760}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="companyName" label="公司名称" rules={[{ required: true, message: '请输入公司名称' }]}>
|
||||
<Input placeholder="公司名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="备注">
|
||||
<Input.TextArea placeholder="备注信息" rows={2} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="quotaLimit" label="配额总额(元)" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={10} style={{ width: '100%' }} placeholder="留空=无限" />
|
||||
</Form.Item>
|
||||
<Form.Item name="quotaCycle" label="配额周期" style={{ flex: 1 }}>
|
||||
<Select>
|
||||
<Select.Option value="daily">每日</Select.Option>
|
||||
<Select.Option value="monthly">每月</Select.Option>
|
||||
<Select.Option value="one_time">一次性</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="validUntil" label="有效期至" style={{ flex: 1 }}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="留空=永久" />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxConcurrentVideoTasks" label="最大并发视频任务" style={{ flex: 1 }}>
|
||||
<InputNumber min={1} style={{ width: '100%' }} placeholder="留空=无限" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="engineIds" label="可调用模型">
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="选择该 Key 可调用的模型(留空=允许所有已定价模型)"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: '100%' }}
|
||||
options={(engines || []).map(e => ({
|
||||
label: `[${e.genType === 'video' ? '视频' : '图片'}] ${e.name || e.modelName || e.id}`,
|
||||
value: e.id,
|
||||
}))}
|
||||
notFoundContent={engines.length === 0 ? '暂无可用引擎' : null}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* 超分配置(仅编辑模式显示,新建时没ID) */}
|
||||
{modal.key?.id && (
|
||||
<>
|
||||
<Divider />
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>🎬 超分配置</Typography.Text>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Typography.Text>启用超分:</Typography.Text>
|
||||
<Switch checked={upscaleEnabled} onChange={setUpscaleEnabled} checkedChildren="启用" unCheckedChildren="关闭" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Typography.Text>成功后删除源文件:</Typography.Text>
|
||||
<Switch checked={deleteSource} onChange={setDeleteSource} checkedChildren="是" unCheckedChildren="否" />
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>超分规则:</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{(upscaleRules || []).map((rule, idx) => (
|
||||
<Card key={idx} size="small" style={{ background: '#f8f9fc' }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<Select
|
||||
value={rule.targetResolution}
|
||||
onChange={v => handleUpscaleRuleChange(idx, 'targetResolution', v)}
|
||||
style={{ width: 100 }}
|
||||
options={['480p', '720p', '1080p', '2K', '4K'].map(r => ({ label: r, value: r }))}
|
||||
/>
|
||||
<span>→</span>
|
||||
<Select
|
||||
value={rule.providerGenerationResolution}
|
||||
onChange={v => handleUpscaleRuleChange(idx, 'providerGenerationResolution', v)}
|
||||
style={{ width: 100 }}
|
||||
options={['480p', '720p', '1080p'].map(r => ({ label: r, value: r }))}
|
||||
/>
|
||||
<Select
|
||||
value={rule.processorKey}
|
||||
onChange={v => handleUpscaleRuleChange(idx, 'processorKey', v)}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ label: '本地FFmpeg', value: 'local_ffmpeg_crop_v1' },
|
||||
{ label: '火山标准版', value: 'volc_standard_v1' },
|
||||
{ label: '火山专业版', value: 'volc_professional_v1' },
|
||||
{ label: '火山大模型', value: 'volc_large_model_v1' },
|
||||
]}
|
||||
/>
|
||||
<Popconfirm title="确定删除此规则?" onConfirm={() => handleRemoveUpscaleRule(idx)}>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={handleAddUpscaleRule}>
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 虚拟素材库配额(仅编辑模式显示,新建时没ID) */}
|
||||
{modal.key?.id && (
|
||||
<>
|
||||
<Divider />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>🧩 V3 虚拟素材库配额</Typography.Text>
|
||||
<Tag color={vpV3Quota.enabled ? 'green' : 'default'}>
|
||||
{vpV3Quota.enabled ? '已启用' : '未启用(全0=不可用)'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px', backgroundColor: '#f6ffed', borderRadius: 8, border: '1px solid #b7eb8f' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 12 }}>
|
||||
默认 0 = 该 API Key 不可使用虚拟素材库功能。项目数或素材数任一上限 > 0 即启用(存储空间不再设置上限)。
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text strong>项目数上限</Typography.Text>
|
||||
<Tag color="blue">已使用 {vpV3Quota.projectUsed || 0} / {vpV3Quota.projectLimit || 0}</Tag>
|
||||
</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={10000}
|
||||
style={{ width: '100%' }}
|
||||
value={vpV3Quota.projectLimit}
|
||||
onChange={(v) => setVpV3Quota(q => ({ ...q, projectLimit: Number(v) || 0 }))}
|
||||
addonBefore="上限" addonAfter="个"
|
||||
/>
|
||||
<Progress
|
||||
percent={vpV3Quota.projectLimit > 0 ? Math.min(100, Math.round((vpV3Quota.projectUsed || 0) * 100 / (vpV3Quota.projectLimit || 1))) : 0}
|
||||
size="small"
|
||||
style={{ marginTop: 6 }}
|
||||
strokeColor={vpV3Quota.projectLimit > 0 && (vpV3Quota.projectUsed || 0) >= vpV3Quota.projectLimit ? '#ff4d4f' : '#1677ff'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ marginBottom: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text strong>素材数上限</Typography.Text>
|
||||
<Tag color="blue">已使用 {vpV3Quota.assetUsed || 0} / {vpV3Quota.assetLimit || 0}</Tag>
|
||||
</div>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1000000}
|
||||
style={{ width: '100%' }}
|
||||
value={vpV3Quota.assetLimit}
|
||||
onChange={(v) => setVpV3Quota(q => ({ ...q, assetLimit: Number(v) || 0 }))}
|
||||
addonBefore="上限" addonAfter="张"
|
||||
/>
|
||||
<Progress
|
||||
percent={vpV3Quota.assetLimit > 0 ? Math.min(100, Math.round((vpV3Quota.assetUsed || 0) * 100 / (vpV3Quota.assetLimit || 1))) : 0}
|
||||
size="small"
|
||||
style={{ marginTop: 6 }}
|
||||
strokeColor={vpV3Quota.assetLimit > 0 && (vpV3Quota.assetUsed || 0) >= vpV3Quota.assetLimit ? '#ff4d4f' : '#1677ff'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{vpV3Quota.storageMbUsed > 0 && (
|
||||
<div style={{ marginTop: 12, padding: '8px 12px', background: '#f0f5ff', borderRadius: 6, fontSize: 12, color: '#475569' }}>
|
||||
已使用存储空间:<strong style={{ color: '#1e40af' }}>{Number(vpV3Quota.storageMbUsed || 0).toFixed(2)} MB</strong>(无上限限制,仅供参考)
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>备注(仅后台可见):</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
placeholder="可选:配额配置说明"
|
||||
value={vpV3Quota.remark ?? ''}
|
||||
onChange={(e) => setVpV3Quota(q => ({ ...q, remark: e.target.value || null }))}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 使用统计 + 配额变更弹窗 */}
|
||||
<Modal
|
||||
title={`API Key 详情 - ${usageModal.key?.companyName || ''}`}
|
||||
open={usageModal.open}
|
||||
onCancel={() => setUsageModal({ open: false, key: null, usage: null })}
|
||||
footer={null} width={720}
|
||||
>
|
||||
<Tabs
|
||||
defaultActiveKey="usage"
|
||||
items={[
|
||||
{
|
||||
key: 'usage',
|
||||
label: '使用统计',
|
||||
children: usageModal.usage && (
|
||||
<div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 24 }}>
|
||||
<Card><Typography.Text type="secondary">总请求数</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests}</Typography.Title></Card>
|
||||
<Card><Typography.Text type="secondary">总消耗(元)</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalCreditsCost?.toFixed(2)}</Typography.Title></Card>
|
||||
<Card><Typography.Text type="secondary">成功率</Typography.Text><Typography.Title level={3} style={{ margin: 0 }}>{usageModal.usage.totalRequests ? ((usageModal.usage.successCount / usageModal.usage.totalRequests) * 100).toFixed(1) : 0}%</Typography.Title></Card>
|
||||
</div>
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
|
||||
{
|
||||
title: '类型', dataIndex: 'genType', width: 70,
|
||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
||||
},
|
||||
{ title: '模型', dataIndex: 'modelName', width: 140, ellipsis: true },
|
||||
{ title: '消耗(元)', dataIndex: 'creditsCost', width: 90, render: (v: number) => v?.toFixed(2) || '0.00' },
|
||||
{ title: '状态', dataIndex: 'status', width: 70, render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag> },
|
||||
]}
|
||||
dataSource={usageModal.usage.items || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: usageModal.usage.page || 1,
|
||||
pageSize: usageModal.usage.pageSize || 20,
|
||||
total: usageModal.usage.total || 0,
|
||||
onChange: (p, ps) => loadUsageDetail(usageModal.key?.id || '', p, ps || 20),
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
size: 'small',
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'quota',
|
||||
label: '配额变更',
|
||||
children: (
|
||||
<div>
|
||||
<Table
|
||||
columns={[
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => v ? new Date(v).toLocaleString() : '-' },
|
||||
{ title: '管理员', dataIndex: 'username', width: 100, ellipsis: true },
|
||||
{
|
||||
title: '操作', dataIndex: 'action', width: 110,
|
||||
render: (v: string) => {
|
||||
const sub = v?.split(':')[1] || v;
|
||||
const label: Record<string, string> = { adjust: '增加总额', reset_usage: '重置已用', set_limit: '设置限额', change_cycle: '修改周期' };
|
||||
return <Tag color="blue">{label[sub] || v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变更详情', dataIndex: 'detail', width: 220,
|
||||
ellipsis: true,
|
||||
render: (v: string, row: any) => {
|
||||
let detail: any = v;
|
||||
if (typeof v === 'string') {
|
||||
try { detail = JSON.parse(v); } catch { return v || '-'; }
|
||||
}
|
||||
if (!detail || typeof detail !== 'object') return '-';
|
||||
const parts: string[] = [];
|
||||
if (detail.old_limit != null || detail.new_limit != null) {
|
||||
parts.push(`限额: ${detail.old_limit != null ? detail.old_limit.toFixed(2) : '-'} → ${detail.new_limit != null ? detail.new_limit.toFixed(2) : '无限'}`);
|
||||
}
|
||||
if (detail.old_used != null && detail.new_used != null && detail.old_used !== detail.new_used) {
|
||||
parts.push(`已用: ${detail.old_used.toFixed(2)} → ${detail.new_used.toFixed(2)}`);
|
||||
}
|
||||
if (detail.old_cycle != null || detail.new_cycle != null) {
|
||||
if (detail.old_cycle !== detail.new_cycle) {
|
||||
parts.push(`周期: ${cycleLabel(detail.old_cycle)} → ${cycleLabel(detail.new_cycle)}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? <span style={{ fontSize: 12 }}>{parts.join(' | ')}</span> : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '原因', dataIndex: 'detail', width: 120,
|
||||
ellipsis: true,
|
||||
render: (v: string) => {
|
||||
let detail: any = v;
|
||||
if (typeof v === 'string') {
|
||||
try { detail = JSON.parse(v); } catch { /* */ }
|
||||
}
|
||||
return detail?.reason || '-';
|
||||
},
|
||||
},
|
||||
]}
|
||||
dataSource={quotaLogs.items}
|
||||
rowKey={(r, i) => r.id || r.createdAt || i}
|
||||
loading={quotaLogs.loading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* 配额调整弹窗 */}
|
||||
<QuotaAdjustModal
|
||||
open={quotaModal.open}
|
||||
keyId={quotaModal.key?.id || ''}
|
||||
companyName={quotaModal.key?.companyName || ''}
|
||||
quotaLimit={quotaModal.key?.quotaLimit ?? null}
|
||||
quotaUsed={quotaModal.key?.quotaUsed || 0}
|
||||
quotaCycle={quotaModal.key?.quotaCycle || null}
|
||||
onCancel={() => setQuotaModal({ open: false, key: null })}
|
||||
onSuccess={() => {
|
||||
setQuotaModal({ open: false, key: null });
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminApiKeys;
|
||||
@@ -0,0 +1,297 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PlusOutlined, EditOutlined, DeleteOutlined, DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getApiModelPricings, saveApiModelPricing, deleteApiModelPricing, getGenerationAiEngines,
|
||||
} from '../api';
|
||||
import type { GenerationAiEngineOption } from '../types';
|
||||
|
||||
type PricingGenType = 'image' | 'video';
|
||||
|
||||
interface ApiModelPricing {
|
||||
id: string;
|
||||
modelConfigId: string;
|
||||
genType: PricingGenType | string;
|
||||
resolution: string;
|
||||
priceRatio: number;
|
||||
basePrice: number;
|
||||
perSecondPrice: number;
|
||||
inputVideoRatio: number;
|
||||
inputVideoBasePrice: number;
|
||||
inputVideoPerSecondPrice: number;
|
||||
inputImageRatio: number;
|
||||
inputImageBasePrice: number;
|
||||
inputImagePerImagePrice: number;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
||||
const DEFAULT_VIDEO_RESOLUTIONS = ['480p', '720p', '1080p'];
|
||||
|
||||
const AdminApiModelPricings: React.FC = () => {
|
||||
const [pricings, setPricings] = useState<ApiModelPricing[]>([]);
|
||||
const [engines, setEngines] = useState<GenerationAiEngineOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; pricing: ApiModelPricing | null }>({ open: false, pricing: null });
|
||||
const [form] = Form.useForm();
|
||||
const genType = Form.useWatch('genType', form) || 'video';
|
||||
const selectedEngineId = Form.useWatch('modelConfigId', form);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [pricingData, enginesData] = await Promise.all([
|
||||
getApiModelPricings(),
|
||||
getGenerationAiEngines(),
|
||||
]);
|
||||
setPricings(pricingData);
|
||||
|
||||
const imageEngines: GenerationAiEngineOption[] = (enginesData?.engine?.image || []).map(engine => ({
|
||||
...engine,
|
||||
genType: 'image' as const,
|
||||
}));
|
||||
const videoEngines: GenerationAiEngineOption[] = (enginesData?.engine?.video || []).map(engine => ({
|
||||
...engine,
|
||||
genType: 'video' as const,
|
||||
}));
|
||||
setEngines([...imageEngines, ...videoEngines]);
|
||||
} catch {
|
||||
message.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const filteredEngines = engines.filter(e => e.genType === genType);
|
||||
const selectedEngine = engines.find(e => e.id === selectedEngineId);
|
||||
const resolutions: string[] = genType === 'video'
|
||||
? (selectedEngine?.supportedResolutions?.length ? selectedEngine.supportedResolutions : DEFAULT_VIDEO_RESOLUTIONS)
|
||||
: (selectedEngine?.supportedSizes?.length ? Object.keys(selectedEngine.supportedSizes) : DEFAULT_IMAGE_SIZES);
|
||||
|
||||
const openEdit = (pricing: ApiModelPricing | null = null) => {
|
||||
if (pricing) {
|
||||
form.setFieldsValue({
|
||||
modelConfigId: pricing.modelConfigId,
|
||||
genType: pricing.genType,
|
||||
resolution: pricing.resolution,
|
||||
priceRatio: pricing.priceRatio,
|
||||
basePrice: pricing.basePrice,
|
||||
perSecondPrice: pricing.perSecondPrice,
|
||||
inputVideoRatio: pricing.inputVideoRatio,
|
||||
inputVideoBasePrice: pricing.inputVideoBasePrice,
|
||||
inputVideoPerSecondPrice: pricing.inputVideoPerSecondPrice,
|
||||
inputImageRatio: pricing.inputImageRatio,
|
||||
inputImageBasePrice: pricing.inputImageBasePrice,
|
||||
inputImagePerImagePrice: pricing.inputImagePerImagePrice,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
genType: 'video',
|
||||
priceRatio: 1.0,
|
||||
basePrice: 0.0,
|
||||
perSecondPrice: 0.00,
|
||||
inputVideoRatio: 1.0,
|
||||
inputVideoBasePrice: 0,
|
||||
inputVideoPerSecondPrice: 0,
|
||||
inputImageRatio: 1.0,
|
||||
inputImageBasePrice: 0,
|
||||
inputImagePerImagePrice: 0,
|
||||
});
|
||||
}
|
||||
setModal({ open: true, pricing });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
...(modal.pricing?.id ? { id: modal.pricing.id } : {}),
|
||||
modelConfigId: values.modelConfigId,
|
||||
genType: values.genType,
|
||||
resolution: values.resolution,
|
||||
priceRatio: values.priceRatio,
|
||||
basePrice: values.basePrice,
|
||||
perSecondPrice: values.perSecondPrice || 0,
|
||||
inputVideoRatio: values.inputVideoRatio || 1.0,
|
||||
inputVideoBasePrice: values.inputVideoBasePrice || 0,
|
||||
inputVideoPerSecondPrice: values.inputVideoPerSecondPrice || 0,
|
||||
inputImageRatio: values.inputImageRatio || 1.0,
|
||||
inputImageBasePrice: values.inputImageBasePrice || 0,
|
||||
inputImagePerImagePrice: values.inputImagePerImagePrice || 0,
|
||||
};
|
||||
await saveApiModelPricing(payload);
|
||||
message.success('保存成功');
|
||||
setModal({ open: false, pricing: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e: any) {
|
||||
if (e?.errorFields) return;
|
||||
message.error('保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteApiModelPricing(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ApiModelPricing> = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'genType',
|
||||
width: 80,
|
||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '引擎',
|
||||
dataIndex: 'modelConfigId',
|
||||
width: 160,
|
||||
render: (v: string) => {
|
||||
const engine = engines.find(e => e.id === v);
|
||||
return engine?.name || v;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '分辨率',
|
||||
dataIndex: 'resolution',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '价格系数',
|
||||
dataIndex: 'priceRatio',
|
||||
width: 90,
|
||||
render: (v: number) => <span style={{ color: v >= 2 ? '#f5222d' : v >= 1.5 ? '#faad14' : '#52c41a' }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '基础价格(元)',
|
||||
dataIndex: 'basePrice',
|
||||
width: 110,
|
||||
},
|
||||
{
|
||||
title: '每秒价格(元)',
|
||||
dataIndex: 'perSecondPrice',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '传入视频(元)/每秒',
|
||||
dataIndex: 'inputVideoBasePrice',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '传入图片(元)/每张',
|
||||
dataIndex: 'inputImageBasePrice',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
fixed: 'right',
|
||||
width: 150,
|
||||
render: (_: any, r: ApiModelPricing) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<DollarOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>API 模型价格配置</Typography.Text>
|
||||
<Tag color="purple">{pricings.length} 条</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>添加价格</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pricings}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={modal.pricing ? '编辑价格' : '添加价格'}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, pricing: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={560}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="genType" label="引擎类型" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<Select onChange={() => { form.setFieldsValue({ modelConfigId: undefined, resolution: undefined }); }}>
|
||||
<Select.Option value="video">视频</Select.Option>
|
||||
<Select.Option value="image">图片</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="modelConfigId" label="引擎" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<Select placeholder="选择引擎" showSearch optionFilterProp="label">
|
||||
{filteredEngines.map(e => (
|
||||
<Select.Option key={e.id} value={e.id} label={e.name}>{e.name}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="resolution" label="分辨率" rules={[{ required: true }]}>
|
||||
<Select placeholder="选择分辨率">
|
||||
{resolutions.map(r => (
|
||||
<Select.Option key={r} value={r}>{r}</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priceRatio" label="价格系数" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<InputNumber min={0.01} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="basePrice" label="基础价格(元)" rules={[{ required: true }]} style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{genType === 'video' && (
|
||||
<Form.Item name="perSecondPrice" label="每秒价格(元)">
|
||||
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>传入媒体附加费</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
|
||||
<Form.Item name="inputVideoBasePrice" label="传入视频(元)/每秒" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputImageBasePrice" label="传入图片(元)/每张" style={{ flex: 1 }}>
|
||||
<InputNumber min={0} step={0.1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminApiModelPricings;
|
||||
@@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
TableOutlined, ReloadOutlined, SearchOutlined, ExportOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getApiUsageAll } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface UsageItem {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
companyName: string;
|
||||
apiKeyPrefix: string | null;
|
||||
taskId: string | null;
|
||||
requestType: string;
|
||||
modelName: string;
|
||||
genType: string;
|
||||
creditsCost: number;
|
||||
tokensUsed: number;
|
||||
requestDurationMs: number;
|
||||
duration: number | null;
|
||||
resolution: string | null;
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
errorCode: string | null;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
const AdminApiUsage: React.FC = () => {
|
||||
const [items, setItems] = useState<UsageItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterGenType, setFilterGenType] = useState<string | undefined>(undefined);
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = {
|
||||
skip: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
};
|
||||
if (filterGenType) params.genType = filterGenType;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (dateRange[0]) params.startDate = dateRange[0].startOf('day').toISOString();
|
||||
if (dateRange[1]) params.endDate = dateRange[1].endOf('day').toISOString();
|
||||
if (searchText.trim()) params.search = searchText.trim();
|
||||
|
||||
const data = await getApiUsageAll(params);
|
||||
setItems(data?.items || []);
|
||||
setTotal(data?.total || 0);
|
||||
} catch {
|
||||
message.error('加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, filterGenType, filterStatus, dateRange, searchText]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
load();
|
||||
};
|
||||
|
||||
// 导出 CSV
|
||||
const handleExport = () => {
|
||||
const headers = ['时间', '公司', 'api-key', '类型', '模型', '时长(秒)', '分辨率', '消耗(元)', 'Token', '耗时(ms)', '状态', '错误信息'];
|
||||
const rows = items.map(item => [
|
||||
item.createdAt ? new Date(item.createdAt).toLocaleString() : '',
|
||||
item.companyName || '',
|
||||
item.apiKeyPrefix || '',
|
||||
item.genType === 'video' ? '视频' : '图片',
|
||||
item.modelName || '',
|
||||
item.duration || '',
|
||||
item.resolution || '',
|
||||
(item.creditsCost || 0).toFixed(2),
|
||||
item.tokensUsed || '',
|
||||
item.requestDurationMs || '',
|
||||
item.status === 'success' ? '成功' : '失败',
|
||||
item.errorMessage || '',
|
||||
]);
|
||||
|
||||
const csvContent = [headers, ...rows]
|
||||
.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(','))
|
||||
.join('\n');
|
||||
|
||||
const BOM = '';
|
||||
const blob = new Blob([BOM + csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `api_usage_${dayjs().format('YYYYMMDD_HHmmss')}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
message.success('导出成功');
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 160,
|
||||
render: (v: string) => v ? new Date(v).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
title: '公司',
|
||||
dataIndex: 'companyName',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: 'api-key',
|
||||
dataIndex: 'apiKeyPrefix',
|
||||
width: 110,
|
||||
render: (v: string) => v ? <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 4 }}>{v}</code> : '-',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'genType',
|
||||
width: 70,
|
||||
render: (v: string) => <Tag color={v === 'video' ? 'blue' : 'green'}>{v === 'video' ? '视频' : '图片'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '模型',
|
||||
dataIndex: 'modelName',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '消耗(元)',
|
||||
dataIndex: 'creditsCost',
|
||||
width: 90,
|
||||
render: (v: number) => <span style={{ color: v > 0 ? '#f5222d' : '#52c41a', fontWeight: 500 }}>{v?.toFixed(2) || '0.00'}</span>,
|
||||
},
|
||||
{
|
||||
title: 'Token',
|
||||
dataIndex: 'tokensUsed',
|
||||
width: 80,
|
||||
render: (v: number) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '耗时(ms)',
|
||||
dataIndex: 'requestDurationMs',
|
||||
width: 90,
|
||||
render: (v: number) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (v: string) => <Tag color={v === 'success' ? 'green' : 'red'}>{v === 'success' ? '成功' : '失败'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '错误信息',
|
||||
dataIndex: 'errorMessage',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (v: string) => v ? <span style={{ color: '#f5222d' }}>{v}</span> : '-',
|
||||
},
|
||||
];
|
||||
|
||||
// 统计
|
||||
const totalCost = items.reduce((sum, item) => sum + (item.creditsCost || 0), 0);
|
||||
const successCount = items.filter(i => i.status === 'success').length;
|
||||
const failedCount = items.filter(i => i.status === 'failed').length;
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Space>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<TableOutlined style={{ color: '#fff', fontSize: 18 }} />
|
||||
</div>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>API 消耗列表</Typography.Text>
|
||||
<Tag color="purple">{total} 条</Tag>
|
||||
</Space>
|
||||
<Space>
|
||||
<Tag color="blue">本页消耗: {totalCost.toFixed(2)} 元</Tag>
|
||||
<Tag color="green">成功: {successCount}</Tag>
|
||||
<Tag color="red">失败: {failedCount}</Tag>
|
||||
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleSearch}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 筛选栏 */}
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="搜索公司名或 Key 前缀"
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="类型"
|
||||
value={filterGenType}
|
||||
onChange={v => { setFilterGenType(v); setPage(1); }}
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value="video">视频</Select.Option>
|
||||
<Select.Option value="image">图片</Select.Option>
|
||||
</Select>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={filterStatus}
|
||||
onChange={v => { setFilterStatus(v); setPage(1); }}
|
||||
allowClear
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value="success">成功</Select.Option>
|
||||
<Select.Option value="failed">失败</Select.Option>
|
||||
</Select>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => { setDateRange(dates as [dayjs.Dayjs | null, dayjs.Dayjs | null]); setPage(1); }}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>筛选</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12 }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps || 50); },
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 1300 }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminApiUsage;
|
||||
@@ -0,0 +1,353 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button, Card, Space, Table, Tag, Modal, Select, App, Input, Typography } from 'antd';
|
||||
import { PlusOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import { getOAuthList, requestOAuth, getOpenTypeAll } from '../api';
|
||||
|
||||
const PORT_TYPE_MAP: Record<number, string> = {
|
||||
1: '巨量',
|
||||
2: '磁力',
|
||||
3: '巨量星图',
|
||||
4: '服务单',
|
||||
5: '腾讯',
|
||||
};
|
||||
|
||||
// 格式化时间 2026-06-12T03:47:28.542988Z -> 2026-06-12 03:47:28
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
interface AuthorizationData {
|
||||
id: string;
|
||||
status: string;
|
||||
description: string;
|
||||
account_userid?: string;
|
||||
open_type?: number;
|
||||
account_id?: string;
|
||||
}
|
||||
|
||||
const AuthorizationPage: React.FC = () => {
|
||||
const { message } = App.useApp();
|
||||
const [authorizations, setAuthorizations] = useState<AuthorizationData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [selectedOpenType, setSelectedOpenType] = useState<number | undefined>(undefined);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchParams, setSearchParams] = useState({
|
||||
account_userid: '',
|
||||
open_type: undefined as number | undefined,
|
||||
account_id: '',
|
||||
});
|
||||
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
||||
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadOAuthList();
|
||||
loadOpenTypeList();
|
||||
}, []);
|
||||
|
||||
const loadOpenTypeList = async () => {
|
||||
try {
|
||||
const res = await getOpenTypeAll();
|
||||
const data = res.data || [];
|
||||
const map: Record<number, string> = {};
|
||||
const options: { value: number; label: string }[] = [];
|
||||
data.forEach(item => {
|
||||
map[item.openType] = item.typeName;
|
||||
options.push({ value: item.openType, label: item.typeName });
|
||||
});
|
||||
setOpenTypeMap(map);
|
||||
setOpenTypeOptions(options);
|
||||
} catch (error) {
|
||||
console.error('加载开户方式列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadOAuthList = async (page = 1, pageSize = 10, params = searchParams) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const response = await getOAuthList({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
account_userid: params.account_userid || undefined,
|
||||
open_type: params.open_type,
|
||||
account_id: params.account_id || undefined,
|
||||
});
|
||||
if (response) {
|
||||
if (response.data) {
|
||||
setAuthorizations(response.data.data || response.data);
|
||||
}
|
||||
if (response.pagination) {
|
||||
setTotal(response.pagination.total || 0);
|
||||
setCurrentPage(response.pagination.page || 1);
|
||||
setPageSize(response.pagination.pageSize || 10);
|
||||
}
|
||||
} else {
|
||||
setAuthorizations(response || []);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取授权列表失败');
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthorize = () => {
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedOpenType) {
|
||||
message.warning('请选择开户方式');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await requestOAuth({ open_type: selectedOpenType });
|
||||
if (response.authUrl) {
|
||||
window.open(response.authUrl, '_blank');
|
||||
} else {
|
||||
message.error('获取授权链接失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('请求授权失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowModal(false);
|
||||
setSelectedOpenType(undefined);
|
||||
}
|
||||
};
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '授权账户ID',
|
||||
dataIndex: 'accountId',
|
||||
key: 'accountId',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '授权账户名称',
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
},
|
||||
{
|
||||
title: '授权账户角色',
|
||||
dataIndex: 'accountRole',
|
||||
key: 'accountRole',
|
||||
render: (role: string) => {
|
||||
const roleMap: Record<string, string> = {
|
||||
ADVERTISER: '客户',
|
||||
CUSTOMER_ADMIN: '普通版工作台-管理员',
|
||||
CUSTOMER_OPERATOR: '普通版工作台-协作者',
|
||||
AGENT: '代理商',
|
||||
CHILD_AGENT: '二级代理商',
|
||||
PLATFORM_ROLE_STAR: '星图账户',
|
||||
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
|
||||
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
|
||||
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
|
||||
PLATFORM_ROLE_AWEME: '抖音号',
|
||||
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
|
||||
PLATFORM_ROLE_STAR_ISV: '星图服务商',
|
||||
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
|
||||
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
|
||||
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
|
||||
PLATFORM_ROLE_LIFE: '抖音来客账户',
|
||||
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
|
||||
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '授权账户用户ID',
|
||||
dataIndex: 'accountUserid',
|
||||
key: 'accountUserid',
|
||||
render: (text: string) => <span style={{ color: text ? '#1e293b' : '#94a3b8' }}>{text || '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '授权账户用户名',
|
||||
dataIndex: 'accountUsername',
|
||||
key: 'accountUsername',
|
||||
},
|
||||
{
|
||||
title: '授权应用ID',
|
||||
dataIndex: 'appid',
|
||||
key: 'appid',
|
||||
},
|
||||
{
|
||||
title: '是否敏感物料授权',
|
||||
dataIndex: 'materialAuthStatus',
|
||||
key: 'materialAuthStatus',
|
||||
width: 100,
|
||||
render: (text: boolean) => (
|
||||
<Tag color={text ? 'green' : 'default'}>
|
||||
{text ? '是' : '否'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '开户方式',
|
||||
dataIndex: 'openType',
|
||||
key: 'openType',
|
||||
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
|
||||
},
|
||||
{
|
||||
title: '平台端口',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
ellipsis: true,
|
||||
render: (text: number) => <span style={{ color: '#1e293b' }}>{PORT_TYPE_MAP[text] || text}</span>,
|
||||
},
|
||||
{
|
||||
title: '用户id',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 160,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const tableData = authorizations.map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
key: item.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<LockOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>授权管理</Typography.Text>
|
||||
</Space>
|
||||
<Button
|
||||
type="primary"
|
||||
size="medium"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAuthorize}
|
||||
loading={loading}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
点击授权
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="账号用户ID"
|
||||
value={searchParams.account_userid}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, account_userid: e.target.value }))}
|
||||
style={{ width: 180 }}
|
||||
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="开户方式"
|
||||
value={searchParams.open_type}
|
||||
onChange={(value) => setSearchParams(prev => ({ ...prev, open_type: value }))}
|
||||
style={{ width: 140 }}
|
||||
options={openTypeOptions}
|
||||
/>
|
||||
<Input
|
||||
placeholder="账号ID"
|
||||
value={searchParams.account_id}
|
||||
onChange={(e) => setSearchParams(prev => ({ ...prev, account_id: e.target.value }))}
|
||||
style={{ width: 180 }}
|
||||
onPressEnter={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="medium"
|
||||
onClick={() => { setCurrentPage(1); loadOAuthList(1, pageSize); }}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
<Button
|
||||
size="medium"
|
||||
onClick={() => {
|
||||
setSearchParams({ account_userid: '', open_type: undefined, account_id: '' });
|
||||
setCurrentPage(1);
|
||||
loadOAuthList(1, pageSize);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={listLoading}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
loadOAuthList(page, size);
|
||||
},
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="选择开户方式"
|
||||
open={showModal}
|
||||
onOk={handleConfirm}
|
||||
onCancel={() => {
|
||||
setShowModal(false);
|
||||
setSelectedOpenType(undefined);
|
||||
}}
|
||||
okText="确认授权"
|
||||
cancelText="取消"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Select
|
||||
placeholder="请选择开户方式"
|
||||
value={selectedOpenType}
|
||||
onChange={(value) => setSelectedOpenType(value)}
|
||||
style={{ width: '100%' }}
|
||||
options={openTypeOptions}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthorizationPage;
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Table, Button, Space, Typography, message, Card, DatePicker, Select } from 'antd';
|
||||
import { BankOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { queryBankTransactions, listBankAccounts } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import type { BankAccount } from '../types';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
interface TransactionRecord {
|
||||
counterAcctNo?: string;
|
||||
cnterName?: string;
|
||||
cnterBankName?: string;
|
||||
acctNo?: string;
|
||||
transAmt?: number | string;
|
||||
dcFlag?: number;
|
||||
dcFlagLabel?: string;
|
||||
transTimeStr?: string;
|
||||
digestCode?: string;
|
||||
remark?: string;
|
||||
balance?: number | string;
|
||||
tellerSeqno?: string;
|
||||
transferId?: string;
|
||||
}
|
||||
|
||||
const AdminBankTransactions: React.FC = () => {
|
||||
const [data, setData] = useState<TransactionRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
// 银行账户
|
||||
const [accounts, setAccounts] = useState<BankAccount[]>([]);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string | undefined>(undefined);
|
||||
|
||||
// 搜索条件
|
||||
const [dateRange, setDateRange] = useState<[string | null, string | null]>([null, null]);
|
||||
const [dcFlag, setDcFlag] = useState<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const res = await listBankAccounts();
|
||||
setAccounts(res.items || []);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载银行账户失败');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!selectedAccountId || !dateRange[0] || !dateRange[1]) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await queryBankTransactions({
|
||||
accountId: selectedAccountId,
|
||||
startDate: dateRange[0],
|
||||
endDate: dateRange[1],
|
||||
dcFlag,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setData(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '查询失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedAccountId, dateRange, dcFlag, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedAccountId && dateRange[0] && dateRange[1]) {
|
||||
fetchData();
|
||||
}
|
||||
}, [fetchData]);
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!selectedAccountId) {
|
||||
message.warning('请选择银行账户');
|
||||
return;
|
||||
}
|
||||
if (!dateRange[0] || !dateRange[1]) {
|
||||
message.warning('请选择交易时间区间');
|
||||
return;
|
||||
}
|
||||
setPage(1);
|
||||
fetchData();
|
||||
};
|
||||
|
||||
const selectedAccount = accounts.find(a => a.id === selectedAccountId);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '收款账号',
|
||||
dataIndex: 'counterAcctNo',
|
||||
width: 180,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '收款户名',
|
||||
dataIndex: 'cnterName',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '收款开户行',
|
||||
dataIndex: 'cnterBankName',
|
||||
width: 150,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '我方付款账号',
|
||||
dataIndex: 'acctNo',
|
||||
width: 180,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '打款金额',
|
||||
dataIndex: 'transAmt',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number | string) => v != null ? Number(v).toFixed(2) : '-',
|
||||
},
|
||||
{
|
||||
title: '借贷方向',
|
||||
dataIndex: 'dcFlagLabel',
|
||||
width: 100,
|
||||
render: (v: string, r: TransactionRecord) => v || (r.dcFlag === 0 ? '借/出金' : r.dcFlag === 1 ? '贷/入金' : '-'),
|
||||
},
|
||||
{
|
||||
title: '打款时间',
|
||||
dataIndex: 'transTimeStr',
|
||||
width: 160,
|
||||
render: (v: string) => v ? formatDate(v) : '-',
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
dataIndex: 'digestCode',
|
||||
width: 100,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '用途/备注',
|
||||
dataIndex: 'remark',
|
||||
width: 150,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '账户余额',
|
||||
dataIndex: 'balance',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number | string) => v != null ? Number(v).toFixed(2) : '-',
|
||||
},
|
||||
{
|
||||
title: '流水号',
|
||||
dataIndex: 'tellerSeqno',
|
||||
width: 150,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}>
|
||||
<BankOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>银行交易查询</Typography.Title>
|
||||
<Typography.Text type="secondary">查询银行账户交易流水记录</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="选择银行账户"
|
||||
value={selectedAccountId}
|
||||
onChange={setSelectedAccountId}
|
||||
style={{ width: 260 }}
|
||||
allowClear
|
||||
options={accounts.map(a => ({
|
||||
value: a.id,
|
||||
label: `${a.bankName} - ${a.accountNo} (${a.accountName})`,
|
||||
}))}
|
||||
/>
|
||||
<RangePicker
|
||||
onChange={(dates, dateStrings) => {
|
||||
if (Array.isArray(dateStrings)) {
|
||||
setDateRange([dateStrings[0] || null, dateStrings[1] || null]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="借贷方向"
|
||||
value={dcFlag}
|
||||
onChange={setDcFlag}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
>
|
||||
<Select.Option value={0}>借/出金</Select.Option>
|
||||
<Select.Option value={1}>贷/入金</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{selectedAccount && (
|
||||
<div style={{ padding: '8px 12px', background: '#f8fafc', borderRadius: 8, fontSize: 13, color: '#64748b' }}>
|
||||
<strong>当前查询账户:</strong>{selectedAccount.bankName} | {selectedAccount.accountNo} | {selectedAccount.accountName}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Table
|
||||
rowKey={(r, i) => `${r.tellerSeqno || ''}-${r.transferId || ''}-${i}`}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminBankTransactions;
|
||||
@@ -0,0 +1,337 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Space, Table, Button, Typography, Modal, Checkbox, Input, message, DatePicker } from 'antd';
|
||||
import { ArrowLeftOutlined, DollarOutlined, SettingOutlined, SearchOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getFields, getMaterialConsumpList } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const toCamelCase = (str: string) => str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
|
||||
interface ConsumptionRecord {
|
||||
id: string;
|
||||
advertiser_id?: string;
|
||||
user_id?: string;
|
||||
consume_date?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const ConsumePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [consumptionRecords, setConsumptionRecords] = useState<ConsumptionRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [columnsData, setColumnsData] = useState<{ name: string; description: string }[]>([]);
|
||||
const [selectedColumns, setSelectedColumns] = useState<string[]>([]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [advertiserId, setAdvertiserId] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
const [consumeDateRange, setConsumeDateRange] = useState<[string, string] | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
loadColumns();
|
||||
loadData();
|
||||
}, [page, pageSize, consumeDateRange]);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getMaterialConsumpList({
|
||||
advertiser_id: advertiserId || undefined,
|
||||
user_id: userId || undefined,
|
||||
consume_date: consumeDateRange,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
setConsumptionRecords(res.data || []);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
} catch (e: any) {
|
||||
message.error('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async () => {
|
||||
try {
|
||||
const res = await getFields();
|
||||
const fields = res.data || [];
|
||||
const formattedFields = fields.map((item: { field: string; description: string }) => ({
|
||||
name: toCamelCase(item.field),
|
||||
description: item.description,
|
||||
}));
|
||||
setColumnsData(formattedFields);
|
||||
const saved = localStorage.getItem('consumeColumns');
|
||||
if (saved) {
|
||||
setSelectedColumns(JSON.parse(saved));
|
||||
} else {
|
||||
setSelectedColumns(formattedFields.map((item: { name: string }) => item.name));
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error('加载表头字段失败');
|
||||
}
|
||||
};
|
||||
|
||||
const tableData = consumptionRecords.map((item, index) => ({
|
||||
...item,
|
||||
index: index + 1,
|
||||
key: item.id,
|
||||
}));
|
||||
|
||||
const handleBack = () => {
|
||||
navigate('/authorization');
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setAdvertiserId('');
|
||||
setUserId('');
|
||||
setConsumeDateRange(undefined);
|
||||
setPage(1);
|
||||
loadData();
|
||||
};
|
||||
|
||||
const dynamicColumns = selectedColumns
|
||||
.map(colName => columnsData.find(col => col.name === colName))
|
||||
.filter((col): col is { name: string; description: string } => !!col)
|
||||
.map(col => ({
|
||||
title: col.description,
|
||||
dataIndex: col.name,
|
||||
key: col.name,
|
||||
ellipsis: true,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: '94vh' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
{/*<Button type="text" icon={<ArrowLeftOutlined />} onClick={handleBack} />
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={handleBack}>返回</Button> */}
|
||||
<DollarOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消耗记录</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button icon={<SettingOutlined />} onClick={() => setShowModal(true)}>自定义表头</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Input
|
||||
placeholder="广告主ID"
|
||||
value={advertiserId}
|
||||
onChange={(e) => setAdvertiserId(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户ID"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<RangePicker
|
||||
value={consumeDateRange ? [dayjs(consumeDateRange[0]), dayjs(consumeDateRange[1])] : undefined}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setConsumeDateRange([dates[0].format('YYYY-MM-DD'), dates[1].format('YYYY-MM-DD')]);
|
||||
} else {
|
||||
setConsumeDateRange(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" icon={<SearchOutlined />} onClick={handleSearch}>搜索</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={dynamicColumns}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
size: 'small',
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<Modal
|
||||
title="自定义表头"
|
||||
open={showModal}
|
||||
onOk={() => {
|
||||
localStorage.setItem('consumeColumns', JSON.stringify(selectedColumns));
|
||||
message.success('表头设置已保存');
|
||||
setShowModal(false);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowModal(false);
|
||||
setSearchText('');
|
||||
}}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
width={800}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 20, height: 400 }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<Input
|
||||
placeholder="搜索指标..."
|
||||
value={searchText}
|
||||
allowClear={true}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginBottom: 12, display: 'block' }}>
|
||||
输入指标名称进行搜索
|
||||
</Typography.Text>
|
||||
<div style={{ flex: 1, overflowY: 'auto', border: '1px solid #f0f0f5', borderRadius: 8, padding: 12 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}>任务详情指标</Typography.Text>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={selectedColumns.length === columnsData.length && columnsData.length > 0}
|
||||
indeterminate={selectedColumns.length > 0 && selectedColumns.length < columnsData.length}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedColumns(columnsData.map(item => item.name));
|
||||
} else {
|
||||
setSelectedColumns([]);
|
||||
}
|
||||
}}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{columnsData
|
||||
.filter(item =>
|
||||
item.description.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
item.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
)
|
||||
.map(item => (
|
||||
<Checkbox
|
||||
key={item.name}
|
||||
checked={selectedColumns.includes(item.name)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedColumns(prev => [...prev, item.name]);
|
||||
} else {
|
||||
setSelectedColumns(prev => prev.filter(col => col !== item.name));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{item.description}
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, color: '#1e293b' }}>
|
||||
已添加({selectedColumns.length})
|
||||
</Typography.Text>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setSelectedColumns([])}
|
||||
disabled={selectedColumns.length === 0}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await getFields();
|
||||
const fields = res.data || [];
|
||||
const formattedFields = fields.map((item: { field: string; description: string }) => ({
|
||||
name: toCamelCase(item.field),
|
||||
description: item.description,
|
||||
}));
|
||||
setColumnsData(formattedFields);
|
||||
setSelectedColumns(formattedFields.map((item: { name: string }) => item.name));
|
||||
localStorage.removeItem('consumeColumns');
|
||||
message.success('表头字段已更新');
|
||||
} catch (e: any) {
|
||||
message.error('更新表头字段失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
更新表头字段
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', border: '1px solid #f0f0f5', borderRadius: 8, padding: 12 }}>
|
||||
{selectedColumns.length === 0 ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 14, textAlign: 'center', display: 'block', padding: '40px 0' }}>
|
||||
暂无已选指标
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<div>
|
||||
{selectedColumns.map((colName, index) => {
|
||||
const col = columnsData.find(c => c.name === colName);
|
||||
return (
|
||||
<div
|
||||
key={colName}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid #f0f0f5',
|
||||
cursor: 'move',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('index', String(index));
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
const fromIndex = parseInt(e.dataTransfer.getData('index'));
|
||||
const toIndex = index;
|
||||
if (fromIndex !== toIndex) {
|
||||
const newSelected = [...selectedColumns];
|
||||
const [removed] = newSelected.splice(fromIndex, 1);
|
||||
newSelected.splice(toIndex, 0, removed);
|
||||
setSelectedColumns(newSelected);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Typography.Text>{col?.description || colName}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{index + 1}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConsumePage;
|
||||
@@ -0,0 +1,266 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty } from 'antd';
|
||||
import { CheckOutlined, DeleteOutlined, EyeOutlined, FilterOutlined, MessageOutlined } from '@ant-design/icons';
|
||||
import { useAdminStore } from '../store';
|
||||
import { api } from '../api/client';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface ContactRequest {
|
||||
id: string;
|
||||
userId: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
industry: string;
|
||||
name: string;
|
||||
message: string | null;
|
||||
isHandled: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const AdminContactRequests: React.FC = () => {
|
||||
const [data, setData] = useState<ContactRequest[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [isHandledFilter, setIsHandledFilter] = useState<boolean | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<ContactRequest | null>(null);
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
|
||||
const { user } = useAdminStore();
|
||||
|
||||
const fetchData = async () => {
|
||||
// if (!user?.isAdmin) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const query = new URLSearchParams();
|
||||
query.set('page', String(page));
|
||||
query.set('page_size', String(pageSize));
|
||||
if (isHandledFilter !== null) {
|
||||
query.set('is_handled', String(isHandledFilter));
|
||||
}
|
||||
const res = await api.get<{ items: ContactRequest[]; total: number }>(`/admin/contact-requests?${query.toString()}`);
|
||||
setData(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '获取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [page, pageSize, isHandledFilter]);
|
||||
|
||||
const handleMarkHandled = async (id: string) => {
|
||||
try {
|
||||
await api.put(`/admin/contact-requests/${id}/handle`);
|
||||
message.success('已标记为处理');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await api.delete(`/admin/contact-requests/${id}`);
|
||||
message.success('已删除');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = (item: ContactRequest) => {
|
||||
setSelectedItem(item);
|
||||
setDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
key: 'phone',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '公司名称',
|
||||
dataIndex: 'companyName',
|
||||
key: 'companyName',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '行业',
|
||||
dataIndex: 'industry',
|
||||
key: 'industry',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isHandled',
|
||||
key: 'isHandled',
|
||||
width: 80,
|
||||
render: (isHandled: boolean) => (
|
||||
<Tag color={isHandled ? 'green' : 'orange'}>
|
||||
{isHandled ? '已处理' : '待处理'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (date: string) => formatDate(date),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
render: (_: unknown, record: ContactRequest) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record)}>
|
||||
查看
|
||||
</Button>
|
||||
{!record.isHandled && (
|
||||
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkHandled(record.id)}>
|
||||
标记处理
|
||||
</Button>
|
||||
)}
|
||||
<Popconfirm title="确定删除该记录?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger size="small" icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<MessageOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>联系请求管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条记录</Tag>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type={isHandledFilter === null ? 'primary' : 'default'}
|
||||
onClick={() => setIsHandledFilter(null)}
|
||||
icon={<FilterOutlined />}
|
||||
size="small"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
type={isHandledFilter === false ? 'primary' : 'default'}
|
||||
onClick={() => setIsHandledFilter(false)}
|
||||
size="small"
|
||||
>
|
||||
待处理
|
||||
</Button>
|
||||
<Button
|
||||
type={isHandledFilter === true ? 'primary' : 'default'}
|
||||
onClick={() => setIsHandledFilter(true)}
|
||||
size="small"
|
||||
>
|
||||
已处理
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : data.length === 0 ? (
|
||||
<Empty description="暂无联系请求" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><EyeOutlined />联系请求详情</Space>}
|
||||
open={detailModalOpen}
|
||||
onCancel={() => setDetailModalOpen(false)}
|
||||
footer={null}
|
||||
width={500}
|
||||
>
|
||||
{selectedItem && (
|
||||
<div style={{ padding: 8 }}>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
{selectedItem.name}
|
||||
<Tag color={selectedItem.isHandled ? 'green' : 'orange'} style={{ marginLeft: 12 }}>
|
||||
{selectedItem.isHandled ? '已处理' : '待处理'}
|
||||
</Tag>
|
||||
</Typography.Title>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
|
||||
<Typography.Text style={{ color: '#64748b' }}>手机号:</Typography.Text>
|
||||
<Typography.Text>{selectedItem.phone}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>公司名称:</Typography.Text>
|
||||
<Typography.Text>{selectedItem.companyName}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>行业:</Typography.Text>
|
||||
<Typography.Text>{selectedItem.industry}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>提交时间:</Typography.Text>
|
||||
<Typography.Text>{formatDate(selectedItem.createdAt)}</Typography.Text>
|
||||
{selectedItem.message && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>留言:</Typography.Text>
|
||||
<Typography.Text>{selectedItem.message}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
{!selectedItem.isHandled && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
handleMarkHandled(selectedItem.id);
|
||||
setDetailModalOpen(false);
|
||||
}}
|
||||
icon={<CheckOutlined />}
|
||||
>
|
||||
标记为已处理
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => setDetailModalOpen(false)}>关闭</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminContactRequests;
|
||||
@@ -0,0 +1,469 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
|
||||
Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
createCreditProduct,
|
||||
getCreditProducts,
|
||||
setCreditProductRenewal,
|
||||
setCreditProductStatus,
|
||||
softDeleteCreditProduct,
|
||||
updateCreditProduct,
|
||||
} from '../api';
|
||||
import type { CreditProduct, CreditProductType } from '../types';
|
||||
|
||||
const cycleLabel: Record<string, string> = { monthly: '月', quarterly: '季', yearly: '年' };
|
||||
|
||||
const formatMoney = (value?: number | null): string => {
|
||||
const amount = Number(value);
|
||||
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
|
||||
};
|
||||
|
||||
const isSubscriptionType = (value: CreditProductType) => value === 'subscription' || value === 'team_subscription';
|
||||
|
||||
const AdminCreditProducts: React.FC = () => {
|
||||
const [type, setType] = useState<CreditProductType>('subscription');
|
||||
const [items, setItems] = useState<CreditProduct[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editing, setEditing] = useState<CreditProduct | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [renewalEnabled, setRenewalEnabled] = useState(true);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const replaceItem = (product: CreditProduct) => {
|
||||
setItems((current) => current.map((item) => (item.id === product.id ? product : item)));
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems(await getCreditProducts(type));
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载积分商品失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [type]);
|
||||
|
||||
const openEditor = (item?: CreditProduct) => {
|
||||
const nextRenewalEnabled = item ? item.renewalEnabled === true : true;
|
||||
setEditing(item || null);
|
||||
setRenewalEnabled(nextRenewalEnabled);
|
||||
setOpen(true);
|
||||
form.resetFields();
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
productCode: item.productCode,
|
||||
name: item.name,
|
||||
description: item.description || undefined,
|
||||
featuresText: (item.features || []).join('\n'),
|
||||
tierCode: item.tierCode,
|
||||
tierRank: item.tierRank,
|
||||
billingCycle: item.billingCycle,
|
||||
monthlyGrantCredits: item.monthlyGrantCredits,
|
||||
firstPurchasePrice: item.firstPurchasePrice,
|
||||
regularPrice: item.regularPrice,
|
||||
activityPrice: item.activityPrice,
|
||||
activityRange: item.activityStartAt && item.activityEndAt
|
||||
? [dayjs(item.activityStartAt), dayjs(item.activityEndAt)]
|
||||
: undefined,
|
||||
renewalEnabled: nextRenewalEnabled,
|
||||
price: item.price,
|
||||
grantCredits: item.grantCredits,
|
||||
validityMonths: item.validityMonths ?? 1,
|
||||
sortOrder: item.sortOrder,
|
||||
isActive: item.isActive,
|
||||
});
|
||||
return;
|
||||
}
|
||||
form.setFieldsValue({
|
||||
productCode: undefined,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
featuresText: undefined,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
billingCycle: 'monthly',
|
||||
tierRank: 1,
|
||||
renewalEnabled: true,
|
||||
validityMonths: 1,
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const activityRange = values.activityRange || [];
|
||||
const payload: Record<string, unknown> = {
|
||||
name: values.name,
|
||||
description: values.description || null,
|
||||
features: String(values.featuresText || '')
|
||||
.split('\n')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
credit_level: 'general',
|
||||
currency: 'CNY',
|
||||
sort_order: values.sortOrder ?? 0,
|
||||
};
|
||||
if (!editing) {
|
||||
payload.product_code = values.productCode;
|
||||
payload.product_type = type;
|
||||
payload.is_active = values.isActive ?? true;
|
||||
}
|
||||
|
||||
if (isSubscriptionType(type)) {
|
||||
if (!editing) {
|
||||
Object.assign(payload, {
|
||||
tier_code: values.tierCode,
|
||||
tier_rank: values.tierRank,
|
||||
billing_cycle: values.billingCycle,
|
||||
});
|
||||
}
|
||||
Object.assign(payload, {
|
||||
monthly_grant_credits: values.monthlyGrantCredits,
|
||||
first_purchase_price: values.firstPurchasePrice,
|
||||
regular_price: values.regularPrice,
|
||||
activity_price: values.activityPrice ?? null,
|
||||
activity_start_at: activityRange[0]?.toISOString() || null,
|
||||
activity_end_at: activityRange[1]?.toISOString() || null,
|
||||
renewal_enabled: renewalEnabled,
|
||||
price: values.regularPrice,
|
||||
});
|
||||
} else {
|
||||
Object.assign(payload, {
|
||||
price: values.price,
|
||||
grant_credits: values.grantCredits,
|
||||
validity_months: values.validityMonths,
|
||||
});
|
||||
}
|
||||
|
||||
let savedProduct = editing
|
||||
? await updateCreditProduct(editing.id, payload)
|
||||
: await createCreditProduct(payload);
|
||||
|
||||
if (editing && savedProduct.isActive !== (values.isActive === true)) {
|
||||
savedProduct = await setCreditProductStatus(editing.id, values.isActive === true);
|
||||
}
|
||||
|
||||
if (isSubscriptionType(type) && savedProduct.renewalEnabled !== renewalEnabled) {
|
||||
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
replaceItem(savedProduct);
|
||||
} else {
|
||||
setItems((current) => [...current, savedProduct].sort((a, b) => {
|
||||
const sortDiff = Number(a.sortOrder || 0) - Number(b.sortOrder || 0);
|
||||
return sortDiff !== 0 ? sortDiff : a.id.localeCompare(b.id);
|
||||
}));
|
||||
}
|
||||
message.success(editing ? '商品已更新' : '商品已创建');
|
||||
setOpen(false);
|
||||
setEditing(null);
|
||||
setRenewalEnabled(true);
|
||||
form.resetFields();
|
||||
} catch (error: any) {
|
||||
if (error?.errorFields) return;
|
||||
message.error(error?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRenewal = async (product: CreditProduct, enabled: boolean) => {
|
||||
const previous = product.renewalEnabled === true;
|
||||
const optimistic = { ...product, renewalEnabled: enabled };
|
||||
replaceItem(optimistic);
|
||||
if (editing?.id === product.id) {
|
||||
setEditing(optimistic);
|
||||
setRenewalEnabled(enabled);
|
||||
form.setFieldValue('renewalEnabled', enabled);
|
||||
}
|
||||
|
||||
try {
|
||||
const savedProduct = await setCreditProductRenewal(product.id, enabled);
|
||||
if (savedProduct.renewalEnabled !== enabled) {
|
||||
throw new Error('续费开关保存结果与提交值不一致');
|
||||
}
|
||||
replaceItem(savedProduct);
|
||||
if (editing?.id === product.id) {
|
||||
setEditing(savedProduct);
|
||||
setRenewalEnabled(savedProduct.renewalEnabled === true);
|
||||
form.setFieldValue('renewalEnabled', savedProduct.renewalEnabled === true);
|
||||
}
|
||||
message.success(enabled ? '已开启续费' : '已关闭续费');
|
||||
} catch (error: any) {
|
||||
const reverted = { ...product, renewalEnabled: previous };
|
||||
replaceItem(reverted);
|
||||
if (editing?.id === product.id) {
|
||||
setEditing(reverted);
|
||||
setRenewalEnabled(previous);
|
||||
form.setFieldValue('renewalEnabled', previous);
|
||||
}
|
||||
message.error(error?.message || '更新续费状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = async (row: CreditProduct) => {
|
||||
try {
|
||||
const saved = await setCreditProductStatus(row.id, !row.isActive);
|
||||
replaceItem(saved);
|
||||
message.success(saved.isActive ? '已上架' : '已下架');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '商品状态更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const removeProduct = async (row: CreditProduct) => {
|
||||
try {
|
||||
await softDeleteCreditProduct(row.id);
|
||||
message.success('商品已软删除,商品编码永久保留且不能恢复');
|
||||
await load();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '商品删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => isSubscriptionType(type) ? [
|
||||
{
|
||||
title: '套餐',
|
||||
key: 'name',
|
||||
render: (_: unknown, row: CreditProduct) => <>
|
||||
<Typography.Text strong>{row.name}</Typography.Text>
|
||||
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
|
||||
</>,
|
||||
},
|
||||
{
|
||||
title: '等级',
|
||||
key: 'tier',
|
||||
render: (_: unknown, row: CreditProduct) => <Tag>{row.tierCode || '-'} / {row.tierRank ?? '-'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '周期',
|
||||
dataIndex: 'billingCycle',
|
||||
render: (value?: string | null) => value ? cycleLabel[value] || value : '-',
|
||||
},
|
||||
{
|
||||
title: '每月积分',
|
||||
dataIndex: 'monthlyGrantCredits',
|
||||
render: (value?: number) => Number(value || 0).toLocaleString(),
|
||||
},
|
||||
{
|
||||
title: '首充价',
|
||||
dataIndex: 'firstPurchasePrice',
|
||||
render: (value?: number | null) => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '原价',
|
||||
dataIndex: 'regularPrice',
|
||||
render: (value?: number | null) => formatMoney(value),
|
||||
},
|
||||
{
|
||||
title: '活动价',
|
||||
key: 'activity',
|
||||
render: (_: unknown, row: CreditProduct) => row.activityPrice == null
|
||||
? '-'
|
||||
: <Tag color="red">{formatMoney(row.activityPrice)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '允许续费',
|
||||
dataIndex: 'renewalEnabled',
|
||||
render: (enabled: boolean, row: CreditProduct) => row.isDeleted
|
||||
? <Typography.Text type="secondary">-</Typography.Text>
|
||||
: <Switch
|
||||
checked={enabled === true}
|
||||
checkedChildren="开启"
|
||||
unCheckedChildren="关闭"
|
||||
onChange={(checked) => void toggleRenewal(row, checked)}
|
||||
/>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
|
||||
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
|
||||
</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, row: CreditProduct) => row.isDeleted
|
||||
? <Typography.Text type="secondary">已软删除,不可恢复</Typography.Text>
|
||||
: <Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}>编辑</Button>
|
||||
<Popconfirm
|
||||
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
|
||||
onConfirm={() => void toggleStatus(row)}
|
||||
>
|
||||
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
|
||||
{row.isActive ? '下架' : '上架'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认软删除该商品?"
|
||||
description="软删除后不能恢复,商品编码永久保留且不能复用;历史订单和订阅不受影响。"
|
||||
onConfirm={() => void removeProduct(row)}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>,
|
||||
},
|
||||
] : [
|
||||
{
|
||||
title: '增值包',
|
||||
key: 'name',
|
||||
render: (_: unknown, row: CreditProduct) => <>
|
||||
<Typography.Text strong>{row.name}</Typography.Text>
|
||||
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
|
||||
</>,
|
||||
},
|
||||
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
|
||||
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
|
||||
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
|
||||
{
|
||||
title: '状态', key: 'status', render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
|
||||
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
|
||||
</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
render: (_: unknown, row: CreditProduct) => row.isDeleted
|
||||
? <Typography.Text type="secondary">已软删除,不可恢复</Typography.Text>
|
||||
: <Space>
|
||||
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}>编辑</Button>
|
||||
<Popconfirm
|
||||
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
|
||||
onConfirm={() => void toggleStatus(row)}
|
||||
>
|
||||
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
|
||||
{row.isActive ? '下架' : '上架'}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Popconfirm
|
||||
title="确认软删除该商品?"
|
||||
description="软删除后不能恢复,商品编码永久保留且不能复用。"
|
||||
onConfirm={() => void removeProduct(row)}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>,
|
||||
},
|
||||
], [type, items, editing]);
|
||||
|
||||
const createButtonLabel = type === 'subscription'
|
||||
? '新增个人订阅套餐'
|
||||
: type === 'team_subscription'
|
||||
? '新增团队订阅套餐'
|
||||
: '新增积分增值包';
|
||||
|
||||
return <Card
|
||||
title={<Space><ShoppingOutlined />积分产品</Space>}
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>{createButtonLabel}</Button>}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={type}
|
||||
onChange={(key) => setType(key as CreditProductType)}
|
||||
items={[
|
||||
{ key: 'subscription', label: '个人订阅套餐' },
|
||||
{ key: 'team_subscription', label: '团队订阅套餐' },
|
||||
{ key: 'credit_addon', label: '积分增值包' },
|
||||
]}
|
||||
/>
|
||||
<Table rowKey="id" loading={loading} dataSource={items} columns={columns as any} scroll={{ x: 1200 }} />
|
||||
<Modal
|
||||
open={open}
|
||||
title={editing ? '编辑积分商品' : '新增积分商品'}
|
||||
onOk={save}
|
||||
onCancel={() => { setOpen(false); setEditing(null); setRenewalEnabled(true); form.resetFields(); }}
|
||||
width={760}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Space align="start" style={{ width: '100%' }} size={16}>
|
||||
<Form.Item
|
||||
name="productCode"
|
||||
label="商品编码"
|
||||
rules={[{ required: true }]}
|
||||
extra={editing ? '商品编码创建后永久不可修改。' : undefined}
|
||||
>
|
||||
<Input disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
|
||||
<Input style={{ width: 260 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
|
||||
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Space>
|
||||
{isSubscriptionType(type) ? <>
|
||||
<Space align="start" style={{ width: '100%' }} size={16}>
|
||||
<Form.Item name="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input disabled={!!editing} /></Form.Item>
|
||||
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} disabled={!!editing} /></Form.Item>
|
||||
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
|
||||
<Select
|
||||
disabled={!!editing}
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'monthly', label: '月' },
|
||||
{ value: 'quarterly', label: '季' },
|
||||
{ value: 'yearly', label: '年' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="monthlyGrantCredits" label="每月发放积分" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space align="start" size={16} wrap>
|
||||
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="regularPrice" label="原价/续费价" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="activityPrice" label="活动价"><InputNumber min={0} precision={2} /></Form.Item>
|
||||
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
|
||||
<Form.Item
|
||||
label="允许续费"
|
||||
tooltip="关闭后,已失去对应首购资格的用户不能再次购买该套餐;不影响已创建待付款订单、已持有订阅和季/年卡内部月度发放。"
|
||||
>
|
||||
<Switch
|
||||
checked={renewalEnabled}
|
||||
checkedChildren="开启"
|
||||
unCheckedChildren="关闭"
|
||||
onChange={(checked) => {
|
||||
setRenewalEnabled(checked);
|
||||
form.setFieldValue('renewalEnabled', checked);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</> : <Space align="start" size={16}>
|
||||
<Form.Item name="grantCredits" label="积分数量" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} />
|
||||
</Form.Item>
|
||||
<Form.Item name="price" label="价格" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="validityMonths" label="有效期" rules={[{ required: true, message: '请选择有效期' }]}>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
options={Array.from({ length: 36 }, (_, index) => ({ value: index + 1, label: `${index + 1} 个月` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>}
|
||||
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
|
||||
<Form.Item name="featuresText" label="权益说明(每行一项)"><Input.TextArea rows={4} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>;
|
||||
};
|
||||
|
||||
export default AdminCreditProducts;
|
||||
@@ -3,12 +3,11 @@ import {
|
||||
Button, Card, Form, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FontSizeOutlined,
|
||||
CalculatorOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
getCreditRatios, saveCreditRatio,
|
||||
deleteCreditRatio, getSystemConfigs,
|
||||
updateSystemConfig, getGenerationAiEngines,
|
||||
deleteCreditRatio, getGenerationAiEngines,
|
||||
} from '../api';
|
||||
import type { GenerationAiEngineOption } from '../types';
|
||||
|
||||
@@ -22,6 +21,12 @@ interface CreditRatio {
|
||||
ratio: number;
|
||||
baseCredits: number;
|
||||
perSecondCredits: number;
|
||||
inputVideoRatio: number;
|
||||
inputVideoBaseCredits: number;
|
||||
inputVideoPerSecondCredits: number;
|
||||
inputImageRatio: number;
|
||||
inputImageBaseCredits: number;
|
||||
inputImagePerImageCredits: number;
|
||||
}
|
||||
|
||||
interface CreditRatioFormValues {
|
||||
@@ -31,6 +36,12 @@ interface CreditRatioFormValues {
|
||||
ratio: number;
|
||||
baseCredits: number;
|
||||
perSecondCredits?: number;
|
||||
inputVideoRatio?: number;
|
||||
inputVideoBaseCredits?: number;
|
||||
inputVideoPerSecondCredits?: number;
|
||||
inputImageRatio?: number;
|
||||
inputImageBaseCredits?: number;
|
||||
inputImagePerImageCredits?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
||||
@@ -42,18 +53,14 @@ const AdminCreditRatios: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; ratio: CreditRatio | null }>({ open: false, ratio: null });
|
||||
const [form] = Form.useForm<CreditRatioFormValues>();
|
||||
const [textRate, setTextRate] = useState<number>(10);
|
||||
const [textRateConfig, setTextRateConfig] = useState<{ id: string } | null>(null);
|
||||
const [savingTextRate, setSavingTextRate] = useState(false);
|
||||
const genType = Form.useWatch('genType', form) || 'video';
|
||||
const selectedEngineId = Form.useWatch('modelConfigId', form);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [ratioData, sysConfigs, enginesData] = await Promise.all([
|
||||
const [ratioData, enginesData] = await Promise.all([
|
||||
getCreditRatios(),
|
||||
getSystemConfigs(),
|
||||
getGenerationAiEngines(),
|
||||
]);
|
||||
|
||||
@@ -69,11 +76,6 @@ const AdminCreditRatios: React.FC = () => {
|
||||
setRatios(ratioData);
|
||||
setEngines([...videoEngines, ...imageEngines]);
|
||||
|
||||
const textCfg = sysConfigs.find((c: any) => c.key === 'text_credits_per_1000_tokens');
|
||||
if (textCfg) {
|
||||
setTextRate(Number(textCfg.value) || 10);
|
||||
setTextRateConfig({ id: textCfg.id });
|
||||
}
|
||||
} catch {
|
||||
message.error('加载积分比例失败');
|
||||
} finally {
|
||||
@@ -117,7 +119,7 @@ const AdminCreditRatios: React.FC = () => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
model_config_id: values.modelConfigId,
|
||||
gen_type: values.genType,
|
||||
resolution: values.resolution,
|
||||
@@ -125,6 +127,14 @@ const AdminCreditRatios: React.FC = () => {
|
||||
base_credits: values.baseCredits,
|
||||
per_second_credits: values.genType === 'image' ? 0 : (values.perSecondCredits || 0),
|
||||
};
|
||||
if (values.genType === 'video') {
|
||||
payload.input_video_ratio = values.inputVideoRatio ?? 1.0;
|
||||
payload.input_video_base_credits = values.inputVideoBaseCredits ?? 0;
|
||||
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 0;
|
||||
}
|
||||
payload.input_image_ratio = values.inputImageRatio ?? 1.0;
|
||||
payload.input_image_base_credits = values.inputImageBaseCredits ?? 0;
|
||||
payload.input_image_per_image_credits = values.inputImagePerImageCredits ?? 0;
|
||||
if (modal.ratio) {
|
||||
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
||||
message.success('已更新');
|
||||
@@ -151,18 +161,6 @@ const AdminCreditRatios: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveTextRate = async () => {
|
||||
if (!textRateConfig) return;
|
||||
setSavingTextRate(true);
|
||||
try {
|
||||
await updateSystemConfig(textRateConfig.id, String(textRate));
|
||||
message.success('文字积分费率已更新');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSavingTextRate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (ratio?: CreditRatio) => {
|
||||
setModal({ open: true, ratio: ratio || null });
|
||||
@@ -174,10 +172,27 @@ const AdminCreditRatios: React.FC = () => {
|
||||
ratio: ratio.ratio,
|
||||
baseCredits: ratio.baseCredits,
|
||||
perSecondCredits: ratio.perSecondCredits,
|
||||
inputVideoRatio: ratio.inputVideoRatio,
|
||||
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
|
||||
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
|
||||
inputImageRatio: ratio.inputImageRatio,
|
||||
inputImageBaseCredits: ratio.inputImageBaseCredits,
|
||||
inputImagePerImageCredits: ratio.inputImagePerImageCredits,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ genType: 'video', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 });
|
||||
form.setFieldsValue({
|
||||
genType: 'video',
|
||||
ratio: 1.0,
|
||||
baseCredits: 60,
|
||||
perSecondCredits: 2,
|
||||
inputVideoRatio: 1.0,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 0.5,
|
||||
inputImageRatio: 1.0,
|
||||
inputImageBaseCredits: 0,
|
||||
inputImagePerImageCredits: 0.5,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,13 +231,72 @@ const AdminCreditRatios: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '示例计算', key: 'example', width: 120,
|
||||
title: '传入视频',
|
||||
children: [
|
||||
{
|
||||
title: '倍率', dataIndex: 'inputVideoRatio', width: 90,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : (
|
||||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||||
)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '基础积分', dataIndex: 'inputVideoBaseCredits', width: 100,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分`}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '每秒积分', dataIndex: 'inputVideoPerSecondCredits', width: 100,
|
||||
render: (v: number, r: CreditRatio) => (
|
||||
<Typography.Text>{r.genType === 'image' ? '-' : `${v} 积分/秒`}</Typography.Text>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '传入图片',
|
||||
children: [
|
||||
{
|
||||
title: '倍率', dataIndex: 'inputImageRatio', width: 90,
|
||||
render: (v: number) => (
|
||||
<span style={{ color: v >= 2 ? '#ef4444' : v >= 1.5 ? '#f59e0b' : '#10b981' }}>x{v}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '基础积分', dataIndex: 'inputImageBaseCredits', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v} 积分</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '每张积分', dataIndex: 'inputImagePerImageCredits', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v} 积分/张</Typography.Text>,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '示例计算', key: 'example', width: 160,
|
||||
render: (_: any, r: CreditRatio) => {
|
||||
let total: number;
|
||||
if (r.genType === 'image') {
|
||||
total = Math.round(r.baseCredits * r.ratio);
|
||||
if (r.inputImageRatio && r.inputImagePerImageCredits) {
|
||||
total += Math.round(
|
||||
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
|
||||
);
|
||||
}
|
||||
} else {
|
||||
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
||||
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
|
||||
total += Math.round(
|
||||
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 15) * r.inputVideoRatio
|
||||
);
|
||||
}
|
||||
if (r.inputImageRatio && r.inputImagePerImageCredits) {
|
||||
total += Math.round(
|
||||
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
|
||||
);
|
||||
}
|
||||
}
|
||||
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} 积分</Typography.Text>;
|
||||
},
|
||||
@@ -242,40 +316,8 @@ const AdminCreditRatios: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Text Credit Rate */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>文字积分费率</Typography.Text>
|
||||
</Space>
|
||||
<Button type="primary" loading={savingTextRate} onClick={handleSaveTextRate} style={{ borderRadius: 8 }}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16, fontSize: 13 }}>
|
||||
文字积分计算公式:ceil(总token数 x 费率 / 1000),最低1积分
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<Typography.Text>每1000 token消耗积分:</Typography.Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={1000}
|
||||
step={0.01}
|
||||
value={textRate}
|
||||
onChange={(v) => setTextRate(v || 0)}
|
||||
size="large"
|
||||
style={{ width: 160 }}
|
||||
addonAfter="积分"
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
示例:1000 token = {textRate} 积分,500 token = {(500 * textRate / 1000).toFixed(4)} 积分
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Generation Credit Ratios */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
@@ -297,7 +339,7 @@ const AdminCreditRatios: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 860 }}
|
||||
scroll={{ x: 1500 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -347,6 +389,54 @@ const AdminCreditRatios: React.FC = () => {
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{genType !== 'image' && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f5f5ff',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e0e0ff',
|
||||
}}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#4f46e5' }}>
|
||||
传入视频价格
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="inputVideoRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频倍率' }]}>
|
||||
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputVideoBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频基础积分' }]}>
|
||||
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputVideoPerSecondCredits" label="每秒积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频每秒积分' }]}>
|
||||
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
marginTop: 12,
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f0fff4',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #c6f6d5',
|
||||
}}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#059669' }}>
|
||||
传入图片价格
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="inputImageRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片倍率' }]}>
|
||||
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputImageBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片基础积分' }]}>
|
||||
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputImagePerImageCredits" label="每张积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入图片每张积分' }]}>
|
||||
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,202 +1,553 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Select, Space, Table, Tag, Typography, message, Input, DatePicker,
|
||||
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined,
|
||||
ArrowDownOutlined, ArrowUpOutlined, DollarOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getCreditRecords } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||
import dayjs from 'dayjs';
|
||||
import { getCreditRecords, getTeamOptions } from '../api';
|
||||
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSubscriptionUsage, AdminTeamOption, AdminCreditRecordSummary } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface CreditRecord {
|
||||
id: string;
|
||||
username: string;
|
||||
type: string;
|
||||
amount: number;
|
||||
balanceAfter: number;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
}
|
||||
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||
|
||||
const CREDIT_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '退回', color: 'blue', icon: <RollbackOutlined /> },
|
||||
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
||||
totalRecharge: 0,
|
||||
totalConsume: 0,
|
||||
totalRefund: 0,
|
||||
totalCharge: 0,
|
||||
totalHold: 0,
|
||||
totalRefundReal: 0,
|
||||
totalHoldRelease: 0,
|
||||
netConsume: 0,
|
||||
transactionCount: 0,
|
||||
generationCount: 0,
|
||||
generationAttemptCount: 0,
|
||||
imageGenerationCount: 0,
|
||||
videoGenerationCount: 0,
|
||||
imageConsume: 0,
|
||||
videoConsume: 0,
|
||||
textConsume: 0,
|
||||
analysisConsume: 0,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
};
|
||||
|
||||
const RECORD_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
||||
refund: { text: '回退', color: 'blue', icon: <RollbackOutlined /> },
|
||||
expire: { text: '过期', color: 'orange', icon: <RollbackOutlined /> },
|
||||
revoke: { text: '撤销', color: 'volcano', icon: <RollbackOutlined /> },
|
||||
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
|
||||
};
|
||||
|
||||
|
||||
const CHARGE_ACTION_MAP: Record<string, { text: string; color: string }> = {
|
||||
charge: { text: '真实消费', color: 'red' },
|
||||
refund: { text: '真实退款', color: 'blue' },
|
||||
pre_deduct: { text: '历史固定预扣', color: 'default' },
|
||||
hold: { text: '历史预扣占用', color: 'gold' },
|
||||
hold_release: { text: '历史预扣释放', color: 'green' },
|
||||
};
|
||||
|
||||
const userScopeOptions = [
|
||||
{ value: '', label: '全部用户' },
|
||||
{ value: 'admin', label: '后台用户' },
|
||||
{ value: 'frontend_internal', label: '前台内部用户' },
|
||||
{ value: 'frontend_external', label: '前台外部用户' },
|
||||
];
|
||||
|
||||
const recordTypeOptions = [
|
||||
{ value: '', label: '全部流水' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'expire', label: '过期' },
|
||||
{ value: 'revoke', label: '撤销' },
|
||||
{ value: 'team_internal', label: '团队内部' },
|
||||
];
|
||||
|
||||
const creditSubjectOptions = [
|
||||
{ value: '', label: '全部积分类型' },
|
||||
{ value: 'media', label: '图片/视频生成积分' },
|
||||
{ value: 'text', label: '提词优化积分' },
|
||||
{ value: 'module', label: '模块功能积分' },
|
||||
{ value: 'analysis', label: '分析积分' },
|
||||
{ value: 'split', label: '切片积分' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'team_internal', label: '团队内部转移' },
|
||||
{ value: 'recharge', label: '充值积分' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
const mediaTypeOptions = [
|
||||
{ value: '', label: '全部媒体' },
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'video', label: '视频' },
|
||||
];
|
||||
|
||||
const chargeKindOptions = [
|
||||
{ value: '', label: '全部扣费子类' },
|
||||
{ value: 'media', label: '媒体生成' },
|
||||
{ value: 'text_prompt', label: '提词优化' },
|
||||
{ value: 'file_parse', label: '文件解析' },
|
||||
{ value: 'vision_input', label: '图片理解' },
|
||||
{ value: 'module_create', label: '创建模块项目' },
|
||||
{ value: 'video_analysis', label: '视频分析' },
|
||||
{ value: 'video_split', label: '视频切片' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'team_internal', label: '团队内部转移' },
|
||||
];
|
||||
|
||||
|
||||
const chargeActionOptions = [
|
||||
{ value: '', label: '全部交易动作' },
|
||||
{ value: 'charge', label: '真实消费' },
|
||||
{ value: 'refund', label: '真实退款' },
|
||||
{ value: 'pre_deduct', label: '历史固定预扣' },
|
||||
{ value: 'hold', label: '历史预扣占用' },
|
||||
{ value: 'hold_release', label: '历史预扣释放' },
|
||||
];
|
||||
|
||||
const sourceModuleOptions = [
|
||||
{ value: '', label: '全部模块' },
|
||||
{ value: 'ai_creation', label: 'AI创作' },
|
||||
{ value: 'generation_record', label: '项目记录' },
|
||||
{ value: 'hot_opening_replicate', label: '爆款开头复刻' },
|
||||
{ value: 'shot_replicate', label: '拆镜复刻' },
|
||||
{ value: 'admin', label: '后台管理' },
|
||||
{ value: 'payment', label: '支付充值' },
|
||||
{ value: 'team', label: '团队管理' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
const sourceStepOptions = [
|
||||
{ value: '', label: '全部步骤' },
|
||||
{ value: 'image_prompt_optimize', label: '图片提词优化' },
|
||||
{ value: 'image_generate', label: '图片生成' },
|
||||
{ value: 'video_prompt_optimize', label: '视频提词优化' },
|
||||
{ value: 'video_generate', label: '视频生成' },
|
||||
{ value: 'video_analysis', label: '视频分析' },
|
||||
];
|
||||
|
||||
const billingSceneOptions = [
|
||||
{ value: '', label: '全部计费场景' },
|
||||
{ value: 'ai_creation_image_generate', label: 'AI创作图片生成' },
|
||||
{ value: 'ai_creation_video_generate', label: 'AI创作视频生成' },
|
||||
{ value: 'generation_record_text_prompt_optimize', label: '项目记录提词优化' },
|
||||
{ value: 'generation_record_image_generate', label: '项目记录图片生成' },
|
||||
{ value: 'generation_record_video_generate', label: '项目记录视频生成' },
|
||||
{ value: 'generation_record_file_parse', label: '项目记录文件解析' },
|
||||
{ value: 'generation_record_vision_input', label: '项目记录图片理解' },
|
||||
{ value: 'hot_opening_project_create', label: '爆款开头复刻创建项目' },
|
||||
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻图片提词优化' },
|
||||
{ value: 'hot_opening_image_generate', label: '爆款开头复刻图片生成' },
|
||||
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻视频提词优化' },
|
||||
{ value: 'hot_opening_video_generate', label: '爆款开头复刻视频生成' },
|
||||
{ value: 'shot_video_analysis', label: '拆镜视频分析' },
|
||||
{ value: 'shot_original_video_analysis', label: '拆镜复刻原视频分析' },
|
||||
{ value: 'shot_segment_video_analysis', label: '拆镜复刻片段视频分析' },
|
||||
{ value: 'shot_video_split', label: '拆镜切片' },
|
||||
{ value: 'shot_segment_replicate_create', label: '从切片创建复刻项目' },
|
||||
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻图片提词优化' },
|
||||
{ value: 'shot_image_generate', label: '拆镜复刻图片生成' },
|
||||
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻视频提词优化' },
|
||||
{ value: 'shot_video_generate', label: '拆镜复刻视频生成' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'admin_adjust', label: '管理员调整' },
|
||||
{ value: 'refund', label: '回退' },
|
||||
{ value: 'team_internal_transfer', label: '团队内部转账' },
|
||||
{ value: 'unknown', label: '历史未知' },
|
||||
];
|
||||
|
||||
function n(value: number | undefined | null): string {
|
||||
return Number(value || 0).toLocaleString();
|
||||
}
|
||||
|
||||
function engineTypeLabel(type?: string): string {
|
||||
if (type === 'model') return '提词/分析模型';
|
||||
if (type === 'image') return '图片引擎';
|
||||
if (type === 'video') return '视频引擎';
|
||||
return '执行配置';
|
||||
}
|
||||
|
||||
function buildScope(scope: string): Pick<AdminCreditRecordQueryParams, 'userType' | 'frontendUserKind'> {
|
||||
if (scope === 'admin') return { userType: 'admin' };
|
||||
if (scope === 'frontend_internal') return { userType: 'frontend', frontendUserKind: 'internal' };
|
||||
if (scope === 'frontend_external') return { userType: 'frontend', frontendUserKind: 'external' };
|
||||
return {};
|
||||
}
|
||||
|
||||
function subscriptionTierText(item: AdminCreditRecordSubscriptionUsage): string {
|
||||
const label = item.tierLabel || item.tierCode || '未知等级';
|
||||
const code = item.tierCode && item.tierCode !== label ? `(${item.tierCode})` : '';
|
||||
const rank = item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : '';
|
||||
return `${label}${code}${rank}`;
|
||||
}
|
||||
|
||||
function subscriptionProductText(item: AdminCreditRecordSubscriptionUsage): string {
|
||||
return [
|
||||
item.productTypeLabel,
|
||||
item.productName,
|
||||
subscriptionTierText(item),
|
||||
item.billingCycleLabel,
|
||||
].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function subscriptionPeriodText(item: AdminCreditRecordSubscriptionUsage): string {
|
||||
const range = item.periodValidFrom && item.periodExpiresAt
|
||||
? `${formatDate(item.periodValidFrom)} ~ ${formatDate(item.periodExpiresAt)}`
|
||||
: '-';
|
||||
return `${item.periodLabel || '周期未知'}|${range}`;
|
||||
}
|
||||
|
||||
function semanticAllocationText(record: AdminCreditRecord): string {
|
||||
return (record.allocations || []).map((item) => {
|
||||
const tier = item.tierLabel || item.tierCode
|
||||
? `${item.tierLabel || item.tierCode}${item.tierCode && item.tierLabel !== item.tierCode ? `(${item.tierCode})` : ''}${item.tierRank !== undefined && item.tierRank !== null ? ` / ${item.tierRank}` : ''}`
|
||||
: '未知等级';
|
||||
const subscription = item.subscriptionNo
|
||||
? `${item.subscriptionNo}|${[item.productTypeLabel, item.productName, tier, item.billingCycleLabel].filter(Boolean).join('/')}`
|
||||
: '非订阅资金';
|
||||
const period = item.periodLabel
|
||||
? `${item.periodLabel}${item.periodValidFrom && item.periodExpiresAt ? ` ${formatDate(item.periodValidFrom)}~${formatDate(item.periodExpiresAt)}` : ''}`
|
||||
: '无月度周期';
|
||||
return `${item.creditScopeLabel || '其他资金域'}|${item.allocationActionLabel || '资金变动'}|${subscription}|${period}|${n(item.amount)}积分`;
|
||||
}).join(';');
|
||||
}
|
||||
|
||||
const AdminCreditRecords: React.FC = () => {
|
||||
const [records, setRecords] = useState<CreditRecord[]>([]);
|
||||
const [records, setRecords] = useState<AdminCreditRecord[]>([]);
|
||||
const [summary, setSummary] = useState<AdminCreditRecordSummary>(DEFAULT_SUMMARY);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [exportProgress, setExportProgress] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
const [userNameFilter, setUserNameFilter] = useState<string>('');
|
||||
|
||||
const [userScope, setUserScope] = useState('');
|
||||
const [teamFilter, setTeamFilter] = useState('');
|
||||
const [subscriptionNoFilter, setSubscriptionNoFilter] = useState('');
|
||||
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||||
const [recordType, setRecordType] = useState('');
|
||||
const [creditSubject, setCreditSubject] = useState('');
|
||||
const [mediaType, setMediaType] = useState('');
|
||||
const [chargeKind, setChargeKind] = useState('');
|
||||
const [chargeAction, setChargeAction] = useState('');
|
||||
const [sourceModule, setSourceModule] = useState('');
|
||||
const [sourceStepCode, setSourceStepCode] = useState('');
|
||||
const [billingScene, setBillingScene] = useState('');
|
||||
const [userNameFilter, setUserNameFilter] = useState('');
|
||||
const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||
|
||||
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
|
||||
page,
|
||||
pageSize,
|
||||
userName: userNameFilter || undefined,
|
||||
teamId: teamFilter || undefined,
|
||||
subscriptionNo: subscriptionNoFilter || undefined,
|
||||
recordType: recordType || undefined,
|
||||
creditSubject: creditSubject || undefined,
|
||||
mediaType: mediaType || undefined,
|
||||
chargeKind: chargeKind || undefined,
|
||||
chargeAction: chargeAction || undefined,
|
||||
sourceModule: sourceModule || undefined,
|
||||
sourceStepCode: sourceStepCode || undefined,
|
||||
billingScene: billingScene || undefined,
|
||||
startDate: dateRange[0]?.format('YYYY-MM-DD'),
|
||||
endDate: dateRange[1]?.format('YYYY-MM-DD'),
|
||||
...buildScope(userScope),
|
||||
}), [page, pageSize, userNameFilter, teamFilter, recordType, creditSubject, mediaType, chargeKind, chargeAction, sourceModule, sourceStepCode, billingScene, dateRange, userScope, subscriptionNoFilter]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters: { type?: string; user_name?: string; start_date?: string; end_date?: string; page?: number; page_size?: number } = {};
|
||||
if (typeFilter) filters.type = typeFilter;
|
||||
if (userNameFilter) filters.user_name = userNameFilter;
|
||||
if (dateRange[0]) filters.start_date = dateRange[0].format('YYYY-MM-DD');
|
||||
if (dateRange[1]) filters.end_date = dateRange[1].format('YYYY-MM-DD');
|
||||
filters.page = page;
|
||||
filters.page_size = pageSize;
|
||||
const res = await getCreditRecords(Object.keys(filters).length > 0 ? filters : undefined);
|
||||
const res = await getCreditRecords(query);
|
||||
setRecords(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
message.error('加载积分记录失败');
|
||||
setSummary(res.summary || DEFAULT_SUMMARY);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载积分记录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, pageSize, typeFilter, userNameFilter, dateRange]);
|
||||
useEffect(() => { load(); }, [query]);
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSearch = () => {
|
||||
load();
|
||||
};
|
||||
useEffect(() => {
|
||||
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleReset = () => {
|
||||
setTypeFilter('');
|
||||
setUserScope('');
|
||||
setTeamFilter('');
|
||||
setSubscriptionNoFilter('');
|
||||
setRecordType('');
|
||||
setCreditSubject('');
|
||||
setMediaType('');
|
||||
setChargeKind('');
|
||||
setChargeAction('');
|
||||
setSourceModule('');
|
||||
setSourceStepCode('');
|
||||
setBillingScene('');
|
||||
setUserNameFilter('');
|
||||
setDateRange([null, null]);
|
||||
load();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
|
||||
const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0);
|
||||
const exportExcel = async () => {
|
||||
setExporting(true);
|
||||
setExportProgress('准备导出...');
|
||||
try {
|
||||
const exportPageSize = 500;
|
||||
const baseQuery = { ...query, page: 1, pageSize: exportPageSize };
|
||||
const first = await getCreditRecords(baseQuery);
|
||||
const all: AdminCreditRecord[] = [...(first.items || [])];
|
||||
const exportSummary = first.summary || DEFAULT_SUMMARY;
|
||||
const totalRows = first.total || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / exportPageSize));
|
||||
setExportProgress(`正在获取 ${all.length} / ${totalRows}`);
|
||||
for (let p = 2; p <= totalPages; p += 1) {
|
||||
const res = await getCreditRecords({ ...baseQuery, page: p });
|
||||
all.push(...(res.items || []));
|
||||
setExportProgress(`正在获取 ${Math.min(all.length, totalRows)} / ${totalRows}`);
|
||||
}
|
||||
|
||||
const detailColumns: StyledExcelColumn<AdminCreditRecord>[] = [
|
||||
{ title: '时间', maxWidth: 22, render: (r) => formatDate(r.createdAt || '') },
|
||||
{ title: '用户', minWidth: 12, maxWidth: 20, render: (r) => r.username || '-' },
|
||||
{ title: '手机号', minWidth: 13, maxWidth: 18, render: (r) => r.phone || '-' },
|
||||
{ title: '用户类型', maxWidth: 16, render: (r) => r.userTypeLabel || '-' },
|
||||
{ title: '前台归类', maxWidth: 18, render: (r) => r.frontendUserKindLabel || '-' },
|
||||
{ title: '归属团队', maxWidth: 20, render: (r) => r.teamNameSnapshot || '未分配团队' },
|
||||
{ title: '资金域构成', maxWidth: 22, render: (r) => r.fundingScopeLabel || '无资金分摊' },
|
||||
{ title: '团队积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.teamAllocationAmount || 0 },
|
||||
{ title: '个人积分分摊', minWidth: 14, maxWidth: 16, align: 'right', numFmt: '#,##0.00', render: (r) => r.personalAllocationAmount || 0 },
|
||||
{ title: '订阅实例', minWidth: 20, maxWidth: 36, render: (r) => (r.subscriptionUsages || []).map((item) => item.subscriptionNo).filter((value, index, arr) => arr.indexOf(value) === index).join(';') || '-' },
|
||||
{ title: '套餐信息', minWidth: 30, maxWidth: 60, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}|${subscriptionProductText(item)}`).join(';') || '-' },
|
||||
{ title: '月度周期', minWidth: 28, maxWidth: 64, render: (r) => (r.subscriptionUsages || []).map((item) => `${item.subscriptionNo}|${subscriptionPeriodText(item)}`).join(';') || '-' },
|
||||
{ title: '流水类型', maxWidth: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
|
||||
{ title: '交易动作', maxWidth: 16, align: 'center', render: (r) => r.chargeActionLabel || (r.chargeAction ? (CHARGE_ACTION_MAP[r.chargeAction]?.text || r.chargeAction) : '-') },
|
||||
{ title: '积分类型', maxWidth: 20, render: (r) => r.creditSubjectLabel || '-' },
|
||||
{ title: '扣费子类', maxWidth: 22, render: (r) => r.chargeKindLabel || '-' },
|
||||
{ title: '模块', maxWidth: 20, render: (r) => r.sourceModuleLabel || '-' },
|
||||
{ title: '模块步骤', maxWidth: 22, render: (r) => r.sourceStepCodeLabel || '-' },
|
||||
{ title: '计费场景', maxWidth: 32, render: (r) => r.sceneNameSnapshot || r.billingSceneLabel || '-' },
|
||||
{ title: '媒体类型', maxWidth: 12, align: 'center', render: (r) => r.mediaTypeLabel || '-' },
|
||||
{ title: '变动积分', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.amount },
|
||||
{ title: '已过期积分', minWidth: 13, maxWidth: 15, align: 'right', numFmt: '#,##0.00', render: (r) => r.expiredAmount || 0 },
|
||||
{ title: '变动后余额', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
|
||||
{ title: 'LLM调用', minWidth: 12, maxWidth: 15, align: 'right', render: (r) => `${r.llmCallCount || 0}(成${r.llmSuccessCallCount || 0}/败${r.llmFailedCallCount || 0})` },
|
||||
{ title: '实际 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.totalTokens || 0 },
|
||||
{ title: '输入 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.inputTokens || 0 },
|
||||
{ title: '输出 Token', minWidth: 12, maxWidth: 14, align: 'right', numFmt: '#,##0', render: (r) => r.outputTokens || 0 },
|
||||
{ title: '执行类型', maxWidth: 18, render: (r) => engineTypeLabel(r.engineType) },
|
||||
{ title: '执行配置', maxWidth: 28, render: (r) => r.engineName || '-' },
|
||||
{ title: '供应商', maxWidth: 18, render: (r) => r.engineProvider || '-' },
|
||||
{ title: '模型版本', maxWidth: 26, render: (r) => r.engineModelName || '-' },
|
||||
{ title: '关联状态', maxWidth: 14, align: 'center', render: (r) => r.ownerDeleted ? '关联已删除' : '正常' },
|
||||
{ title: '说明', minWidth: 18, maxWidth: 42, render: (r) => r.description || '' },
|
||||
{ title: '业务归属类型', maxWidth: 18, render: (r) => r.ownerType || '' },
|
||||
{ title: '业务归属ID', maxWidth: 28, render: (r) => r.ownerId || '' },
|
||||
{ title: '资金溯源明细', minWidth: 42, maxWidth: 100, render: (r) => semanticAllocationText(r) || '-' },
|
||||
{ title: 'BizKey', maxWidth: 36, render: (r) => r.bizKey || '' },
|
||||
];
|
||||
|
||||
const filename = `积分流水_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
|
||||
exportStyledExcel<AdminCreditRecord>({
|
||||
filename,
|
||||
sheetName: '积分流水',
|
||||
title: '积分流水汇总',
|
||||
metadataRows: [
|
||||
['筛选时间', `${dateRange[0]?.format('YYYY-MM-DD') || '不限'} 至 ${dateRange[1]?.format('YYYY-MM-DD') || '不限'}`],
|
||||
['订阅实例筛选', subscriptionNoFilter || '不限'],
|
||||
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
|
||||
['导出条数', totalRows],
|
||||
],
|
||||
summaryRows: [
|
||||
['总充值', exportSummary.totalRecharge],
|
||||
['总真实消费', exportSummary.totalConsume],
|
||||
['总真实退款', exportSummary.totalRefund],
|
||||
['净消耗(总消费 − 总退款,≥ 0)', exportSummary.netConsume],
|
||||
['交易笔数', exportSummary.transactionCount],
|
||||
['生成条数', exportSummary.generationCount],
|
||||
['生成尝试次数', exportSummary.generationAttemptCount],
|
||||
['图片生成条数', exportSummary.imageGenerationCount],
|
||||
['视频生成条数', exportSummary.videoGenerationCount],
|
||||
['图片消费积分(仅真实消费)', exportSummary.imageConsume],
|
||||
['视频消费积分(仅真实消费)', exportSummary.videoConsume],
|
||||
['提词消费积分(仅真实消费)', exportSummary.textConsume],
|
||||
['视频分析积分(仅真实消费)', exportSummary.analysisConsume],
|
||||
['总 Token', exportSummary.totalTokens],
|
||||
['输入 Token', exportSummary.inputTokens],
|
||||
['输出 Token', exportSummary.outputTokens],
|
||||
],
|
||||
columns: detailColumns,
|
||||
rows: all,
|
||||
});
|
||||
message.success('Excel 已导出');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导出失败');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
setExportProgress('');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '用户', dataIndex: 'username', width: 130, fixed: 'left' as const, render: (v: string, r: AdminCreditRecord) => <div><Typography.Text strong>{v || '-'}</Typography.Text><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
|
||||
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
|
||||
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text> },
|
||||
{
|
||||
title: '用户', dataIndex: 'username', width: 120,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 100,
|
||||
render: (v: string) => {
|
||||
const cfg = CREDIT_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null };
|
||||
return (
|
||||
<Tag color={cfg.color} icon={cfg.icon}>
|
||||
{cfg.text}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: CreditRecord, b: CreditRecord) => a.amount - b.amount,
|
||||
render: (v: number) => (
|
||||
<Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444', fontSize: 15 }}>
|
||||
{v > 0 ? '+' : ''}{v.toLocaleString()}
|
||||
</Typography.Text>
|
||||
title: '资金构成', key: 'fundingScope', width: 190,
|
||||
render: (_: any, r: AdminCreditRecord) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{(r.teamAllocationAmount || 0) > 0 && <Tag color="geekblue">团队积分 {n(r.teamAllocationAmount)}</Tag>}
|
||||
{(r.personalAllocationAmount || 0) > 0 && <Tag color="purple">个人积分 {n(r.personalAllocationAmount)}</Tag>}
|
||||
{!r.teamAllocationAmount && !r.personalAllocationAmount && <Typography.Text type="secondary">无资金分摊</Typography.Text>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balanceAfter', width: 120,
|
||||
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</Typography.Text>,
|
||||
title: '订阅实例 / 套餐', key: 'subscriptionUsages', width: 330,
|
||||
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
|
||||
<div>
|
||||
{(r.subscriptionUsages || []).map((item, index) => (
|
||||
<div key={`${item.subscriptionNo}-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
|
||||
<Typography.Text strong copyable={{ text: item.subscriptionNo }}>{item.subscriptionNo}</Typography.Text>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 2 }}>{subscriptionProductText(item)}</div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>{item.creditScopeLabel || '-'} · 分摊 {n(item.amount)} 积分</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <Typography.Text type="secondary">非订阅资金</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '说明', dataIndex: 'description', ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '时间', dataIndex: 'createdAt', width: 160,
|
||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||
title: '月度周期', key: 'subscriptionPeriods', width: 300,
|
||||
render: (_: any, r: AdminCreditRecord) => (r.subscriptionUsages || []).length ? (
|
||||
<div>
|
||||
{(r.subscriptionUsages || []).map((item, index) => (
|
||||
<div key={`${item.subscriptionNo}-period-${item.subscriptionPeriodId || index}`} style={{ marginBottom: index === (r.subscriptionUsages || []).length - 1 ? 0 : 8 }}>
|
||||
<Typography.Text strong>{item.periodLabel || '-'}</Typography.Text>
|
||||
<div style={{ fontSize: 12, color: '#64748b' }}>
|
||||
{item.periodValidFrom && item.periodExpiresAt ? `${formatDate(item.periodValidFrom)} ~ ${formatDate(item.periodExpiresAt)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : '-',
|
||||
},
|
||||
{ title: '流水类型', dataIndex: 'recordType', width: 100, render: (v: string, r: AdminCreditRecord) => { const cfg = RECORD_TYPE_MAP[v] || { text: r.recordTypeLabel || v || '-', color: 'default', icon: null }; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; } },
|
||||
{ title: '交易动作', dataIndex: 'chargeAction', width: 110, render: (v: string, r: AdminCreditRecord) => { const cfg = CHARGE_ACTION_MAP[v] || { text: r.chargeActionLabel || v || '-', color: 'default' }; return v ? <Tag color={cfg.color}>{r.chargeActionLabel || cfg.text}</Tag> : <Typography.Text type="secondary">历史</Typography.Text>; } },
|
||||
{ title: '积分类型', dataIndex: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
||||
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
|
||||
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.sceneNameSnapshot || r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
|
||||
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
|
||||
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (value: number) => <Typography.Text strong style={{ color: value > 0 ? '#10b981' : value < 0 ? '#ef4444' : '#64748b' }}>{value > 0 ? '+' : ''}{n(value)}</Typography.Text> },
|
||||
{ title: '过期积分', dataIndex: 'expiredAmount', width: 110, render: (v: number) => v ? <Tag color="orange">{n(v)}</Tag> : '-' },
|
||||
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
|
||||
{ title: 'LLM调用', key: 'llmCalls', width: 130, render: (_: any, r: AdminCreditRecord) => <div>{n(r.llmCallCount || 0)} 次<div style={{ fontSize: 12, color: '#94a3b8' }}>成 {n(r.llmSuccessCallCount || 0)} / 败 {n(r.llmFailedCallCount || 0)}</div></div> },
|
||||
{ title: 'Token', key: 'tokens', width: 140, render: (_: any, r: AdminCreditRecord) => <div><b>{n(r.totalTokens)}</b><div style={{ fontSize: 12, color: '#94a3b8' }}>入 {n(r.inputTokens)} / 出 {n(r.outputTokens)}</div></div> },
|
||||
{ title: '执行配置', key: 'engine', width: 230, render: (_: any, r: AdminCreditRecord) => <div><Tag color={r.engineType === 'model' ? 'geekblue' : r.engineType === 'image' ? 'purple' : r.engineType === 'video' ? 'cyan' : 'default'}>{engineTypeLabel(r.engineType)}</Tag><div>{r.engineName || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{[r.engineProvider, r.engineModelName].filter(Boolean).join(' / ') || '-'}</div></div> },
|
||||
{ title: '关联状态', dataIndex: 'ownerDeleted', width: 100, render: (v: boolean) => <Tag color={v ? 'red' : 'green'}>{v ? '已删除' : '正常'}</Tag> },
|
||||
{ title: '说明', dataIndex: 'description', width: 240, ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(16,185,129,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#10b981',
|
||||
}}><ArrowUpOutlined /></div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Space><ArrowUpOutlined style={{ color: '#10b981', fontSize: 22 }} />
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总充值</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
|
||||
<div style={{ color: '#94a3b8' }}>总充值</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(239,68,68,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#ef4444',
|
||||
}}><ArrowDownOutlined /></div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: '#94a3b8' }}>总消费</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>仅统计真实消费流水</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: '#94a3b8' }}>总退款</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div>
|
||||
<div style={{ fontSize: 11, color: '#94a3b8', marginTop: 2 }}>仅统计真实退款流水</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: 'linear-gradient(135deg, #faf5ff 0%, #eef2ff 100%)' }}>
|
||||
<Space><DollarOutlined style={{ color: '#6366f1', fontSize: 22 }} />
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总消费</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
|
||||
<div style={{ color: '#6366f1' }}>净消耗(实际用掉)</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#4338ca' }}>{n(summary.netConsume)}</div>
|
||||
<div style={{ fontSize: 11, color: '#818cf8', marginTop: 2 }}>总消费 − 总退款(≥ 0)</div>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}><WalletOutlined /></div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} />
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>交易笔数</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{total}</div>
|
||||
<div style={{ color: '#94a3b8' }}>交易 / 生成</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||
<Card size="small" bordered={false}>图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>提词消费:{n(summary.textConsume)} 积分</Card>
|
||||
<Card size="small" bordered={false}>视频分析:{n(summary.analysisConsume)} 积分</Card>
|
||||
</div>
|
||||
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space wrap>
|
||||
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
||||
<Select
|
||||
value={typeFilter}
|
||||
onChange={(value) => setTypeFilter(value)}
|
||||
style={{ width: 120 }}
|
||||
value={teamFilter}
|
||||
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||||
style={{ width: 170 }}
|
||||
options={[
|
||||
{ value: '', label: '全部类型' },
|
||||
{ value: 'recharge', label: '充值' },
|
||||
{ value: 'consume', label: '消费' },
|
||||
{ value: 'refund', label: '退回' },
|
||||
{ value: '', label: '全部团队' },
|
||||
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||||
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户名搜索"
|
||||
value={userNameFilter}
|
||||
onChange={(e) => setUserNameFilter(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => { if (dates) setDateRange([dates[0], dates[1]]); }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Input placeholder="订阅实例号(PS/TS)" value={subscriptionNoFilter} onChange={(e) => { setPage(1); setSubscriptionNoFilter(e.target.value); }} style={{ width: 190 }} allowClear />
|
||||
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
||||
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
||||
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
||||
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
|
||||
<Select value={chargeAction} onChange={(v) => { setPage(1); setChargeAction(v); }} style={{ width: 140 }} options={chargeActionOptions} />
|
||||
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
|
||||
<Select value={sourceStepCode} onChange={(v) => { setPage(1); setSourceStepCode(v); }} style={{ width: 150 }} options={sourceStepOptions} />
|
||||
<Select value={billingScene} onChange={(v) => { setPage(1); setBillingScene(v); }} style={{ width: 220 }} options={billingSceneOptions} />
|
||||
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
|
||||
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSearch}>搜索</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}>下载 Excel</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
@@ -204,13 +555,41 @@ const AdminCreditRecords: React.FC = () => {
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
pageSize,
|
||||
total,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
expandable={{
|
||||
rowExpandable: (record) => Boolean(record.allocations?.length),
|
||||
expandedRowRender: (record) => (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
dataSource={record.allocations || []}
|
||||
columns={[
|
||||
{ title: '动作', dataIndex: 'allocationActionLabel', width: 150, render: (v: string) => v || '其他动作' },
|
||||
{ title: '资金域', dataIndex: 'creditScopeLabel', width: 110, render: (v: string) => v || '其他资金域' },
|
||||
{ title: '分摊积分', dataIndex: 'amount', width: 120, render: (v: number) => <Typography.Text strong>{n(v)}</Typography.Text> },
|
||||
{ title: '积分等级', dataIndex: 'creditLevelLabel', width: 120, render: (v: string) => v || '其他积分等级' },
|
||||
{ title: '积分来源', dataIndex: 'sourceTypeLabel', width: 160, render: (v: string) => v || '其他来源' },
|
||||
{ title: '订阅实例', dataIndex: 'subscriptionNo', width: 210, render: (v: string) => v ? <Typography.Text copyable={{ text: v }}>{v}</Typography.Text> : '非订阅资金' },
|
||||
{ title: '套餐名称', dataIndex: 'productName', width: 170, render: (v: string) => v || '-' },
|
||||
{ title: '套餐类型', dataIndex: 'productTypeLabel', width: 150, render: (v: string) => v || '-' },
|
||||
{ title: '套餐等级', key: 'tier', width: 190, render: (_: any, a: any) => a.tierLabel || a.tierCode ? `${a.tierLabel || a.tierCode}${a.tierCode && a.tierLabel !== a.tierCode ? `(${a.tierCode})` : ''}${a.tierRank !== undefined && a.tierRank !== null ? ` / ${a.tierRank}` : ''}` : '-' },
|
||||
{ title: '套餐周期', dataIndex: 'billingCycleLabel', width: 110, render: (v: string) => v || '-' },
|
||||
{ title: '月度周期', dataIndex: 'periodLabel', width: 120, render: (v: string) => v || '-' },
|
||||
{ title: '周期起止', key: 'periodRange', width: 330, render: (_: any, a: any) => a.periodValidFrom && a.periodExpiresAt ? `${formatDate(a.periodValidFrom)} ~ ${formatDate(a.periodExpiresAt)}` : '-' },
|
||||
{ title: '源资金有效期', key: 'balanceRange', width: 330, render: (_: any, a: any) => a.validFrom && a.expiresAt ? `${formatDate(a.validFrom)} ~ ${formatDate(a.expiresAt)}` : '-' },
|
||||
{ title: '内部来源ID', dataIndex: 'sourceId', width: 220, render: (v: string) => v || '-' },
|
||||
]}
|
||||
scroll={{ x: 2390 }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
scroll={{ x: 3350 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Col, Row, Typography, DatePicker, Button, Space } from 'antd';
|
||||
import { Card, Col, Row, Typography, DatePicker, Button, Space, Spin } from 'antd';
|
||||
import {
|
||||
UserOutlined,
|
||||
ProjectOutlined,
|
||||
PlayCircleOutlined,
|
||||
FileTextOutlined,
|
||||
DollarOutlined,
|
||||
WalletOutlined,
|
||||
ArrowUpOutlined,
|
||||
CalendarOutlined,
|
||||
UserOutlined, ProjectOutlined, PlayCircleOutlined, FileTextOutlined,
|
||||
DollarOutlined, WalletOutlined, ArrowUpOutlined, CalendarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminStats, getSystemConfigs } from '../api';
|
||||
import type { AdminStats, SystemConfig } from '../types';
|
||||
@@ -17,10 +11,25 @@ import 'dayjs/locale/zh-cn';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
const MODULE_LABELS: Record<string, string> = {
|
||||
'ai_creation': 'AI创作',
|
||||
'generation_record': '项目生成',
|
||||
'hot_opening_replicate': '爆款开头复刻',
|
||||
'shot_replicate': '拆镜复刻',
|
||||
'payment': '支付充值',
|
||||
'admin': '后台管理',
|
||||
'team': '团队管理',
|
||||
'unknown': '历史未知',
|
||||
'other': '其他',
|
||||
};
|
||||
|
||||
const COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#ec4899', '#14b8a6', '#f97316'];
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [startDate, setStartDate] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([dayjs().startOf('day'), dayjs()]);
|
||||
const [activeRange, setActiveRange] = useState<string>('today');
|
||||
const [siteName, setSiteName] = useState<string>('数据概览');
|
||||
|
||||
const load = async () => {
|
||||
@@ -39,377 +48,377 @@ const AdminDashboard: React.FC = () => {
|
||||
const configs = await getSystemConfigs();
|
||||
const siteConfig = configs.find((c: SystemConfig) => c.key === 'site_name');
|
||||
if (siteConfig) {
|
||||
const title = `${siteConfig.value} 管理后台`;
|
||||
setSiteName(title);
|
||||
document.title = title;
|
||||
setSiteName(`${siteConfig.value} 管理后台`);
|
||||
document.title = `${siteConfig.value} 管理后台`;
|
||||
}
|
||||
} catch {
|
||||
setSiteName('数据概览');
|
||||
document.title = '数据概览';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadSiteName();
|
||||
}, []);
|
||||
useEffect(() => { load(); loadSiteName(); }, []);
|
||||
|
||||
const handleToday = () => {
|
||||
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('day'), dayjs()];
|
||||
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs], range?: string) => {
|
||||
setStartDate(dates);
|
||||
loadWithDates(dates);
|
||||
};
|
||||
|
||||
const handleYesterday = () => {
|
||||
const yesterday = dayjs().subtract(1, 'day');
|
||||
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [yesterday.startOf('day'), yesterday.endOf('day')];
|
||||
setStartDate(dates);
|
||||
loadWithDates(dates);
|
||||
};
|
||||
|
||||
const handleWeek = () => {
|
||||
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('week'), dayjs()];
|
||||
setStartDate(dates);
|
||||
loadWithDates(dates);
|
||||
};
|
||||
|
||||
const handleMonth = () => {
|
||||
const dates: [dayjs.Dayjs, dayjs.Dayjs] = [dayjs().startOf('month'), dayjs()];
|
||||
setStartDate(dates);
|
||||
loadWithDates(dates);
|
||||
};
|
||||
|
||||
const loadWithDates = (dates: [dayjs.Dayjs, dayjs.Dayjs]) => {
|
||||
if (range) setActiveRange(range);
|
||||
const start = dates[0]?.format('YYYY-MM-DD') || undefined;
|
||||
const end = dates[1]?.format('YYYY-MM-DD') || undefined;
|
||||
setLoading(true);
|
||||
getAdminStats(start, end).then(data => {
|
||||
setStats(data);
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
getAdminStats(start, end).then(data => { setStats(data); setLoading(false); }).catch(() => setLoading(false));
|
||||
};
|
||||
|
||||
const handleToday = () => loadWithDates([dayjs().startOf('day'), dayjs()], 'today');
|
||||
const handleYesterday = () => { const y = dayjs().subtract(1, 'day'); loadWithDates([y.startOf('day'), y.endOf('day')], 'yesterday'); };
|
||||
const handleWeek = () => loadWithDates([dayjs().startOf('week'), dayjs()], 'week');
|
||||
const handleMonth = () => loadWithDates([dayjs().startOf('month'), dayjs()], 'month');
|
||||
|
||||
const handleDateChange = (dates: any) => {
|
||||
if (dates) {
|
||||
setStartDate([dates[0], dates[1]]);
|
||||
loadWithDates([dates[0], dates[1]]);
|
||||
}
|
||||
};
|
||||
|
||||
const baseStats = stats ? [
|
||||
{
|
||||
title: '用户数量',
|
||||
value: stats.totalUsers,
|
||||
lastPeriodValue: stats.lastPeriodUsers,
|
||||
icon: <UserOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
prefix: '',
|
||||
suffix: '位用户',
|
||||
description: '平台注册用户总数'
|
||||
},
|
||||
{
|
||||
title: '总项目数',
|
||||
value: stats.totalProjects,
|
||||
lastPeriodValue: stats.lastPeriodProjects,
|
||||
icon: <ProjectOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #00d4ff 0%, #0099cc 100%)',
|
||||
prefix: '',
|
||||
suffix: '个项目',
|
||||
description: '创建的项目总数'
|
||||
},
|
||||
{
|
||||
title: '项目记录',
|
||||
value: stats.totalRecords,
|
||||
lastPeriodValue: stats.lastPeriodRecords,
|
||||
icon: <FileTextOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
prefix: '',
|
||||
suffix: '条记录',
|
||||
description: '项目记录总数'
|
||||
},
|
||||
{
|
||||
title: '创作记录',
|
||||
value: stats.totalGenerations,
|
||||
lastPeriodValue: stats.lastPeriodGenerations,
|
||||
icon: <PlayCircleOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
||||
prefix: '',
|
||||
suffix: '次创作',
|
||||
description: 'AI创作记录总数'
|
||||
},
|
||||
] : [];
|
||||
|
||||
const financeStats = stats ? [
|
||||
{
|
||||
title: '支付宝收入',
|
||||
value: stats.todayAlipayRevenue,
|
||||
icon: <WalletOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||
prefix: '¥',
|
||||
suffix: '',
|
||||
description: '支付宝收款',
|
||||
tag: '支付宝'
|
||||
},
|
||||
{
|
||||
title: '微信收入',
|
||||
value: stats.todayWechatRevenue,
|
||||
icon: <DollarOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
||||
prefix: '¥',
|
||||
suffix: '',
|
||||
description: '微信收款',
|
||||
tag: '微信支付'
|
||||
},
|
||||
{
|
||||
title: '总收入',
|
||||
value: stats.totalRevenue,
|
||||
lastPeriodValue: stats.lastPeriodRevenue,
|
||||
icon: <ArrowUpOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #ec4899 0%, #be185d 100%)',
|
||||
prefix: '¥',
|
||||
suffix: '',
|
||||
description: '平台总收入',
|
||||
tag: '总收入'
|
||||
},
|
||||
{
|
||||
title: '消耗积分',
|
||||
value: stats.creditsConsumedToday,
|
||||
lastPeriodValue: stats.lastPeriodCreditsConsumed,
|
||||
icon: <DollarOutlined />,
|
||||
gradient: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)',
|
||||
prefix: '',
|
||||
suffix: '积分',
|
||||
description: '用户消耗积分',
|
||||
tag: '积分消耗'
|
||||
},
|
||||
] : [];
|
||||
|
||||
const StatCard: React.FC<{
|
||||
title: string;
|
||||
value: number;
|
||||
lastPeriodValue?: number;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
description?: string;
|
||||
tag?: string;
|
||||
}> = ({ title, value, lastPeriodValue, icon, gradient, prefix = '', suffix = '', description, tag }) => {
|
||||
const change = lastPeriodValue !== undefined && lastPeriodValue > 0
|
||||
? ((value - lastPeriodValue) / lastPeriodValue * 100).toFixed(1)
|
||||
: null;
|
||||
const isPositive = change !== null && parseFloat(change) >= 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
bordered={false}
|
||||
loading={loading}
|
||||
hoverable
|
||||
style={{
|
||||
borderRadius: 16,
|
||||
border: '1px solid rgba(0,0,0,0.04)',
|
||||
background: '#ffffff',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.05)',
|
||||
transition: 'all 0.3s ease',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '16px 0'
|
||||
}}>
|
||||
<div style={{
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
background: gradient,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 24,
|
||||
color: '#fff',
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.1)',
|
||||
}}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginBottom: 4
|
||||
}}>
|
||||
<Typography.Text style={{ color: '#64748b', fontSize: 13 }}>{title}</Typography.Text>
|
||||
{tag && (
|
||||
<span style={{
|
||||
fontSize: 10,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.1)',
|
||||
color: '#6366f1',
|
||||
fontWeight: 500
|
||||
}}>
|
||||
{tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: 28,
|
||||
fontWeight: 800,
|
||||
color: '#1e293b',
|
||||
letterSpacing: -0.5,
|
||||
marginBottom: 2
|
||||
}}>
|
||||
{prefix}{typeof value === 'number' ? value.toLocaleString() : value}{suffix}
|
||||
</div>
|
||||
{description && (
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{change !== null && lastPeriodValue !== undefined && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
paddingTop: 8,
|
||||
borderTop: '1px solid #f1f5f9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8
|
||||
}}>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 11 }}>
|
||||
上一周期: {prefix}{lastPeriodValue.toLocaleString()}{suffix}
|
||||
</Typography.Text>
|
||||
<span style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: isPositive ? '#10b981' : '#ef4444',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2
|
||||
}}>
|
||||
{isPositive ? '↑' : '↓'} {Math.abs(parseFloat(change))}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
if (dates) { setActiveRange(''); loadWithDates([dates[0], dates[1]]); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 0 }}>
|
||||
<div style={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: '24px 24px 32px',
|
||||
borderRadius: 0,
|
||||
marginBottom: -24,
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: -50,
|
||||
right: -50,
|
||||
width: 200,
|
||||
height: 200,
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
borderRadius: '50%'
|
||||
}} />
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: -30,
|
||||
left: -30,
|
||||
width: 150,
|
||||
height: 150,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
borderRadius: '50%'
|
||||
}} />
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<Typography.Title level={2} style={{ color: '#fff', marginBottom: 4, fontWeight: 700 }}>
|
||||
{siteName}
|
||||
</Typography.Title>
|
||||
<Typography.Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 14 }}>
|
||||
欢迎回来,查看平台运营数据统计
|
||||
</Typography.Text>
|
||||
<div>
|
||||
{/* 日期筛选 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 20 }}>
|
||||
<Space>
|
||||
<Button type={activeRange === 'today' ? 'primary' : 'default'} size="small" onClick={handleToday}>今日</Button>
|
||||
<Button type={activeRange === 'yesterday' ? 'primary' : 'default'} size="small" onClick={handleYesterday}>昨日</Button>
|
||||
<Button type={activeRange === 'week' ? 'primary' : 'default'} size="small" onClick={handleWeek}>本周</Button>
|
||||
<Button type={activeRange === 'month' ? 'primary' : 'default'} size="small" onClick={handleMonth}>本月</Button>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '4px 12px', borderRadius: 8 }}>
|
||||
<CalendarOutlined style={{ color: '#64748b' }} />
|
||||
<DatePicker.RangePicker value={startDate} onChange={handleDateChange} size="small" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 40 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 12, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<Button
|
||||
type={!startDate[0] || !startDate[1] || startDate[0]?.isSame(dayjs().startOf('day')) && startDate[1]?.isSame(dayjs(), 'day') ? 'primary' : 'default'}
|
||||
size="small"
|
||||
onClick={handleToday}
|
||||
>
|
||||
今日
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={handleYesterday}
|
||||
>
|
||||
昨日
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={handleWeek}
|
||||
>
|
||||
本周
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
onClick={handleMonth}
|
||||
>
|
||||
本月
|
||||
</Button>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, background: '#fff', border: '1px solid #e2e8f0', padding: '6px 12px', borderRadius: 8, boxShadow: '0 1px 2px rgba(0,0,0,0.05)' }}>
|
||||
<CalendarOutlined style={{ color: '#64748b', fontSize: 14 }} />
|
||||
<DatePicker.RangePicker
|
||||
value={startDate}
|
||||
onChange={handleDateChange}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
{/* 核心数据 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>核心数据</Typography.Text>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
|
||||
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}>核心数据</Typography.Text>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}>平台基础运营指标</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{baseStats.map((s) => (
|
||||
<Col xs={12} sm={8} lg={6} key={s.title}>
|
||||
<StatCard {...s} />
|
||||
<Row gutter={[12, 12]}>
|
||||
{[
|
||||
{ title: '新增用户数量', value: stats?.totalUsers, icon: <UserOutlined />, color: '#6366f1' },
|
||||
{ title: '总收入', value: stats?.totalRevenue, icon: <WalletOutlined />, color: '#ec4899', prefix: '¥' },
|
||||
{ title: '消耗积分', value: stats?.creditsConsumedToday, icon: <DollarOutlined />, color: '#ef4444' },
|
||||
].map(s => (
|
||||
<Col xs={12} sm={8} md={4} key={s.title}>
|
||||
<CompactStatCard {...s} loading={loading} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<div style={{ marginBottom: 16, paddingLeft: 4 }}>
|
||||
<Typography.Text strong style={{ color: '#1e293b', fontSize: 15 }}>财务统计</Typography.Text>
|
||||
<Typography.Text style={{ color: '#94a3b8', fontSize: 12, marginLeft: 8 }}>收入与消耗数据</Typography.Text>
|
||||
{/* 图表区域 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>数据统计</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
{financeStats.map((s) => (
|
||||
<Col xs={12} sm={8} lg={6} key={s.title}>
|
||||
<StatCard {...s} />
|
||||
</Col>
|
||||
))}
|
||||
<Col xs={24} lg={12}>
|
||||
<ChartCard title="每日积分消耗趋势" loading={loading}>
|
||||
<LineChart data={stats?.dailyCreditsByModule || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<ChartCard title="各模块积分占比" loading={loading}>
|
||||
<ModulePie data={stats?.periodCreditsByModule || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<ChartCard title="团队积分消耗排行" loading={loading}>
|
||||
<HorizontalBarChart data={(stats?.creditsByTeam || []).slice(0, 8)} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<ChartCard title="模型使用次数" loading={loading}>
|
||||
<HorizontalBarChart data={(stats?.modelUsage || []).slice(0, 8)} valueKey="count" />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
{/* 视频参数分布 */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>视频参数分布</Typography.Text>
|
||||
</div>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="分辨率分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoResolutionUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="画面比例分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoRatioUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<ChartCard title="时长分布" loading={loading}>
|
||||
<PieBarChart data={stats?.videoDurationUsage || []} />
|
||||
</ChartCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 图表卡片 ──
|
||||
const ChartCard: React.FC<{ title: string; loading: boolean; children: React.ReactNode }> = ({ title, loading, children }) => (
|
||||
<Card bordered={false} style={{ borderRadius: 16, border: '1px solid #f0f0f5', height: '100%', boxShadow: '0 4px 20px rgba(0,0,0,0.04)', transition: 'box-shadow 0.3s' }}
|
||||
styles={{ body: { padding: '16px' } }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>{title}</Typography.Text>
|
||||
</div>
|
||||
<Spin spinning={loading}>{children}</Spin>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// ── 核心数据小卡片 ──
|
||||
const CompactStatCard: React.FC<{ title: string; value?: number; icon: React.ReactNode; color: string; prefix?: string; loading: boolean }> = ({ title, value, icon, color, prefix = '', loading }) => (
|
||||
<Card bordered={false} loading={loading} style={{ borderRadius: 10, border: '1px solid #f0f0f5' }}
|
||||
styles={{ body: { padding: '12px 14px' } }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ width: 34, height: 34, borderRadius: 8, background: `${color}15`, display: 'flex', alignItems: 'center', justifyContent: 'center', color, fontSize: 16 }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{title}</Typography.Text>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, color: '#1e293b', lineHeight: 1.3 }}>
|
||||
{prefix}{typeof value === 'number' ? value.toLocaleString() : '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// ── 折线图(按日期+模块,固定展示选中日期往前7天)──
|
||||
const LineChart: React.FC<{ data: { date: string; module: string; credits: number }[] }> = ({ data }) => {
|
||||
// 以数据中最新日期为基准,往前推 7 天;不足 7 天按实际天数
|
||||
if (!data.length) return <EmptyChart />;
|
||||
const sortedDates = Array.from(new Set(data.map(d => d.date))).sort();
|
||||
const maxDate = sortedDates[sortedDates.length - 1];
|
||||
// 生成 [maxDate-6, maxDate] 共 7 天
|
||||
const baseDayjs = dayjs(maxDate);
|
||||
const sevenDays: string[] = [];
|
||||
for (let i = 6; i >= 0; i--) sevenDays.push(baseDayjs.subtract(i, 'day').format('YYYY-MM-DD'));
|
||||
|
||||
const dateMap = new Map<string, number>();
|
||||
data.forEach(d => { dateMap.set(d.date, (dateMap.get(d.date) || 0) + d.credits); });
|
||||
const maxVal = Math.max(...sevenDays.map(d => dateMap.get(d) || 0), 1);
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'flex-end', gap: 6, borderBottom: '1px solid #f1f5f9', paddingBottom: 4 }}>
|
||||
{sevenDays.map(d => {
|
||||
const val = dateMap.get(d) || 0;
|
||||
const pct = (val / maxVal) * 100;
|
||||
return (
|
||||
<div key={d} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%', justifyContent: 'flex-end', position: 'relative' }}
|
||||
onMouseEnter={e => {
|
||||
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
||||
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
||||
if (bar) { bar.style.transform = 'scaleY(1.08)'; bar.style.filter = 'brightness(1.15) drop-shadow(0 4px 12px rgba(99,102,241,0.4))'; }
|
||||
if (tip) { tip.style.opacity = '1'; }
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const bar = e.currentTarget.querySelector('.bar') as HTMLElement;
|
||||
const tip = e.currentTarget.querySelector('.tip') as HTMLElement;
|
||||
if (bar) { bar.style.transform = 'scaleY(1)'; bar.style.filter = 'none'; }
|
||||
if (tip) { tip.style.opacity = '0'; }
|
||||
}}
|
||||
>
|
||||
<div className="tip" style={{ position: 'absolute', bottom: '100%', marginBottom: 6, background: '#1e293b', color: '#fff', fontSize: 11, padding: '4px 10px', borderRadius: 6, whiteSpace: 'nowrap', opacity: 0, transition: 'opacity 0.2s', pointerEvents: 'none', zIndex: 10 }}>
|
||||
{val > 0 ? `${val.toFixed(0)} 积分` : '无数据'}
|
||||
</div>
|
||||
<span style={{ fontSize: 9, color: '#6366f1', fontWeight: 600, marginBottom: 2 }}>{val > 0 ? val.toFixed(0) : ''}</span>
|
||||
<div className="bar" style={{ width: '65%', maxWidth: 32, height: `${Math.max(pct, 2)}%`, background: 'linear-gradient(180deg, #818cf8 0%, #6366f1 40%, #4f46e5 100%)', borderRadius: '4px 4px 0 0', minHeight: 4, transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: '0 2px 8px rgba(99,102,241,0.25)' }} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, marginTop: 4 }}>
|
||||
{sevenDays.map(d => (
|
||||
<div key={d} style={{ flex: 1, textAlign: 'center' }}>
|
||||
<span style={{ fontSize: 9, color: '#94a3b8' }}>{d.slice(5)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 模块积分占比(饼图)──
|
||||
const ModulePie: React.FC<{ data: { module: string; credits: number }[] }> = ({ data }) => {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
const moduleMap = new Map<string, number>();
|
||||
data.forEach(d => { moduleMap.set(d.module, (moduleMap.get(d.module) || 0) + d.credits); });
|
||||
const modules = Array.from(moduleMap.entries()).sort((a, b) => b[1] - a[1]);
|
||||
const total = modules.reduce((s, [, v]) => s + v, 0) || 1;
|
||||
|
||||
// 计算饼图扇形路径
|
||||
const size = 160;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const r = 68;
|
||||
let cumAngle = -90; // 从顶部开始
|
||||
|
||||
const slices = modules.map(([mod, val], i) => {
|
||||
const pct = val / total;
|
||||
const angle = pct * 360;
|
||||
const startAngle = cumAngle;
|
||||
cumAngle += angle;
|
||||
const endAngle = cumAngle;
|
||||
const startRad = (startAngle * Math.PI) / 180;
|
||||
const endRad = (endAngle * Math.PI) / 180;
|
||||
const largeArc = angle > 180 ? 1 : 0;
|
||||
const x1 = cx + r * Math.cos(startRad);
|
||||
const y1 = cy + r * Math.sin(startRad);
|
||||
const x2 = cx + r * Math.cos(endRad);
|
||||
const y2 = cy + r * Math.sin(endRad);
|
||||
const d = `M${cx},${cy} L${x1},${y1} A${r},${r} 0 ${largeArc} 1 ${x2},${y2} Z`;
|
||||
return { d, color: COLORS[i % COLORS.length], label: MODULE_LABELS[mod] || mod, val, pct };
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ flexShrink: 0, filter: 'drop-shadow(0 4px 12px rgba(0,0,0,0.08))' }}>
|
||||
<defs>
|
||||
{slices.map((s, i) => (
|
||||
<linearGradient key={i} id={`pie-grad-${i}`} x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity={1} />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity={0.7} />
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
{slices.map((s, i) => (
|
||||
<path key={i} d={s.d} fill={`url(#pie-grad-${i})`} stroke="#fff" strokeWidth={2}
|
||||
onMouseEnter={e => {
|
||||
(e.target as SVGPathElement).style.transform = 'scale(1.06)';
|
||||
(e.target as SVGPathElement).style.filter = 'brightness(1.1) drop-shadow(0 4px 8px rgba(0,0,0,0.2))';
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
(e.target as SVGPathElement).style.transform = 'scale(1)';
|
||||
(e.target as SVGPathElement).style.filter = 'none';
|
||||
}}
|
||||
style={{ transition: 'all 0.25s cubic-bezier(0.4, 0, 0.2, 1)', transformOrigin: `${cx}px ${cy}px`, cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
<circle cx={cx} cy={cy} r={36} fill="#fff" />
|
||||
<text x={cx} y={cy - 4} textAnchor="middle" fontSize={11} fill="#64748b">总计</text>
|
||||
<text x={cx} y={cy + 12} textAnchor="middle" fontSize={13} fontWeight={700} fill="#1e293b">{total.toFixed(0)}</text>
|
||||
</svg>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{slices.map((s, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#64748b', padding: '3px 6px', borderRadius: 6, transition: 'background-color 0.2s', cursor: 'default' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#f8fafc'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: s.color, flexShrink: 0, boxShadow: `0 2px 4px ${s.color}40` }} />
|
||||
<span>{s.label}</span>
|
||||
<span style={{ fontWeight: 600, color: '#1e293b' }}>{s.val.toFixed(0)}</span>
|
||||
<span style={{ fontSize: 10 }}>{(s.pct * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 横向柱状图(团队/模型)──
|
||||
const HorizontalBarChart: React.FC<{ data: { teamName?: string; modelName?: string; credits?: number; count?: number }[]; valueKey?: string }> = ({ data, valueKey = 'credits' }) => {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
const maxVal = Math.max(...data.map(d => (d as any)[valueKey] || 0), 1);
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, display: 'flex', flexDirection: 'column', gap: 8, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{data.map((d, i) => {
|
||||
const label = d.teamName || d.modelName || '-';
|
||||
const val = (d as any)[valueKey] || 0;
|
||||
const pct = (val / maxVal) * 100;
|
||||
const c1 = COLORS[i % COLORS.length];
|
||||
const c2 = COLORS[(i + 1) % COLORS.length];
|
||||
return (
|
||||
<div key={i} style={{ padding: '4px 8px', borderRadius: 8, transition: 'background-color 0.2s, box-shadow 0.2s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.backgroundColor = '#fafbff'; e.currentTarget.style.boxShadow = '0 2px 8px rgba(99,102,241,0.08)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.backgroundColor = 'transparent'; e.currentTarget.style.boxShadow = 'none'; }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, color: '#475569', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%', fontWeight: 500 }}>{label}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#1e293b' }}>{val.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ height: 18, background: '#f1f5f9', borderRadius: 6, overflow: 'hidden', boxShadow: 'inset 0 1px 2px rgba(0,0,0,0.06)' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: `linear-gradient(90deg, ${c1}, ${c2})`, borderRadius: 6, transition: 'width 0.35s cubic-bezier(0.4, 0, 0.2, 1)', boxShadow: `0 1px 3px ${c1}40`, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, height: '50%', background: 'linear-gradient(180deg, rgba(255,255,255,0.25) 0%, transparent 100%)', borderRadius: '6px 6px 0 0' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 视频参数分布(紧凑饼图+列表)──
|
||||
const PieBarChart: React.FC<{ data: { model: string; label: string; count: number }[] }> = ({ data }) => {
|
||||
if (!data.length) return <EmptyChart />;
|
||||
// 按模型分组
|
||||
const modelMap = new Map<string, { label: string; count: number }[]>();
|
||||
data.forEach(d => {
|
||||
if (!modelMap.has(d.model)) modelMap.set(d.model, []);
|
||||
modelMap.get(d.model)!.push({ label: d.label, count: d.count });
|
||||
});
|
||||
const models = Array.from(modelMap.entries());
|
||||
const total = data.reduce((s, d) => s + d.count, 0) || 1;
|
||||
|
||||
return (
|
||||
<div style={{ height: 220, overflowY: 'auto' }}>
|
||||
{models.map(([model, items], mi) => {
|
||||
const modelTotal = items.reduce((s, it) => s + it.count, 0);
|
||||
return (
|
||||
<div key={model} style={{ marginBottom: 12 }}>
|
||||
{/* 模型名称 + 总数 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: COLORS[mi % COLORS.length] }}>{model}</span>
|
||||
<span style={{ fontSize: 10, color: '#94a2b3' }}>共 {modelTotal} 条 ({((modelTotal / total) * 100).toFixed(1)}%)</span>
|
||||
</div>
|
||||
{/* 各参数 */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{
|
||||
flex: '0 0 calc(50% - 2px)',
|
||||
padding: '4px 8px',
|
||||
background: '#f8fafc',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #f1f5f9',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'default',
|
||||
}}
|
||||
onMouseEnter={e => { Object.assign(e.currentTarget.style, { background: '#fafbff', boxShadow: '0 2px 8px rgba(99,102,241,0.1)', transform: 'translateY(-1px)' }); }}
|
||||
onMouseLeave={e => { Object.assign(e.currentTarget.style, { background: '#f8fafc', boxShadow: 'none', transform: 'translateY(0)' }); }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 11, color: '#475569', fontWeight: 500 }}>{item.label}</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: '#1e293b' }}>{item.count}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, background: '#e2e8f0', borderRadius: 2, marginTop: 3, overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${(item.count / modelTotal) * 100}%`, background: COLORS[mi % COLORS.length], borderRadius: 2, transition: 'width 0.3s' }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyChart: React.FC = () => (
|
||||
<div style={{ height: 220, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 13 }}>
|
||||
暂无数据
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AdminDashboard;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
@@ -14,19 +15,32 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
AudioOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
EyeOutlined,
|
||||
FileImageOutlined,
|
||||
LinkOutlined,
|
||||
LoadingOutlined,
|
||||
PlayCircleOutlined,
|
||||
RobotOutlined,
|
||||
SearchOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminGenerationAiTasks } from '../api';
|
||||
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
|
||||
import dayjs from 'dayjs';
|
||||
import { getAdminGenerationAiTasks, getImageEngines, getVideoEngines } from '../api';
|
||||
import type {
|
||||
GenerationAiImageEngine,
|
||||
GenerationAIMediaReference,
|
||||
GenerationAITaskOut,
|
||||
GenerationAiVideoEngine,
|
||||
} from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid';
|
||||
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
// 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。
|
||||
@@ -34,6 +48,15 @@ const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/** 从 engineSnapshot 中提取引擎展示名称。 */
|
||||
const getEngineName = (snapshot: Record<string, unknown> | null | undefined): string | null => {
|
||||
if (!snapshot) return null;
|
||||
return (snapshot.name as string)
|
||||
|| (snapshot.modelName as string)
|
||||
|| (snapshot.id as string)
|
||||
|| null;
|
||||
};
|
||||
|
||||
type ResourceState = 'empty' | 'checking' | 'valid' | 'invalid';
|
||||
|
||||
interface PreviewResourceState {
|
||||
@@ -50,27 +73,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
|
||||
references: {},
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
pending: { color: 'default', text: '待处理', icon: <ClockCircleOutlined /> },
|
||||
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
|
||||
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
|
||||
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
|
||||
};
|
||||
|
||||
const PIPELINE_STAGE_MAP: Record<string, string> = {
|
||||
timeout: '任务超时',
|
||||
queued: '已入队',
|
||||
preparing: '准备中',
|
||||
creating_provider_task: '创建任务中',
|
||||
waiting_remote: '等待生成',
|
||||
result_ready: '结果就绪',
|
||||
downloading: '下载中',
|
||||
done: '完成',
|
||||
download_failed: '下载失败',
|
||||
polling: '轮询中',
|
||||
failed: '失败',
|
||||
};
|
||||
|
||||
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
|
||||
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
|
||||
@@ -200,6 +202,10 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
|
||||
</div>
|
||||
);
|
||||
|
||||
const todayStart = () => dayjs().startOf('day');
|
||||
|
||||
const todayEnd = () => dayjs().endOf('day');
|
||||
|
||||
const AdminGenerationAiRecords: React.FC = () => {
|
||||
const [records, setRecords] = useState<GenerationAITaskOut[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -208,17 +214,31 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
|
||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||
const [filterGenType, setFilterGenType] = useState<string>('');
|
||||
const [filterEngineId, setFilterEngineId] = useState<string>('');
|
||||
const [inputUserId, setInputUserId] = useState<string>('');
|
||||
const [inputUserName, setInputUserName] = useState<string>('');
|
||||
const [queryUserId, setQueryUserId] = useState<string>('');
|
||||
const [queryUserName, setQueryUserName] = useState<string>('');
|
||||
const [queryEngineId, setQueryEngineId] = useState<string>('');
|
||||
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
||||
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [engineListLoading, setEngineListLoading] = useState(false);
|
||||
const [imageEngines, setImageEngines] = useState<GenerationAiImageEngine[]>([]);
|
||||
const [videoEngines, setVideoEngines] = useState<GenerationAiVideoEngine[]>([]);
|
||||
|
||||
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
|
||||
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
|
||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
|
||||
// 资源预览弹窗(附件 / 最终结果)
|
||||
const [resourcePreview, setResourcePreview] = useState<{
|
||||
url: string;
|
||||
type: 'image' | 'video';
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -227,6 +247,9 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
status: filterStatus || undefined,
|
||||
userId: queryUserId || undefined,
|
||||
userName: queryUserName || undefined,
|
||||
engineId: queryEngineId || undefined,
|
||||
createdStart: queryCreatedRange?.[0]?.format?.('YYYY-MM-DDTHH:mm:ss'),
|
||||
createdEnd: queryCreatedRange?.[1]?.format?.('YYYY-MM-DDTHH:mm:ss'),
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
@@ -237,12 +260,70 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterGenType, filterStatus, page, queryUserId, queryUserName]);
|
||||
}, [filterGenType, filterStatus, page, queryUserId, queryUserName, queryEngineId, queryCreatedRange]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, reloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadEngineOptions = async () => {
|
||||
setEngineListLoading(true);
|
||||
try {
|
||||
const [images, videos] = await Promise.all([
|
||||
getImageEngines({ includeDeleted: true }),
|
||||
getVideoEngines({ includeDeleted: true }),
|
||||
]);
|
||||
if (!cancelled) {
|
||||
setImageEngines(images || []);
|
||||
setVideoEngines(videos || []);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
message.error(error?.message || '加载模型引擎列表失败');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setEngineListLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadEngineOptions();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const engineOptions = useMemo(() => {
|
||||
const toOption = (
|
||||
engine: GenerationAiImageEngine | GenerationAiVideoEngine,
|
||||
type: 'image' | 'video',
|
||||
) => {
|
||||
const deleted = Boolean(engine.deletedAt);
|
||||
const typeText = type === 'image' ? '图片' : '视频';
|
||||
const deletedText = deleted ? '[已删除]' : '';
|
||||
const detailText = [engine.name, engine.modelName, engine.provider, engine.id]
|
||||
.filter(Boolean)
|
||||
.join(' / ');
|
||||
const label = `[${typeText}]${deletedText} ${detailText}`;
|
||||
return {
|
||||
value: engine.id,
|
||||
label,
|
||||
searchText: [engine.id, engine.name, engine.modelName, engine.provider, typeText, deleted ? '已删除' : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase(),
|
||||
};
|
||||
};
|
||||
|
||||
const merged = [
|
||||
...imageEngines.map((engine) => toOption(engine, 'image')),
|
||||
...videoEngines.map((engine) => toOption(engine, 'video')),
|
||||
];
|
||||
return Array.from(new Map(merged.map((option) => [option.value, option])).values());
|
||||
}, [imageEngines, videoEngines]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) {
|
||||
setResourceState(EMPTY_RESOURCE_STATE);
|
||||
@@ -274,6 +355,24 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
setPage(1);
|
||||
setQueryUserId(inputUserId.trim());
|
||||
setQueryUserName(inputUserName.trim());
|
||||
setQueryEngineId(filterEngineId);
|
||||
setQueryCreatedRange(createdRange);
|
||||
setReloadKey((v) => v + 1);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterStatus('');
|
||||
setFilterGenType('');
|
||||
setFilterEngineId('');
|
||||
setInputUserId('');
|
||||
setInputUserName('');
|
||||
setQueryUserId('');
|
||||
setQueryUserName('');
|
||||
setQueryEngineId('');
|
||||
const defaultRange = [todayStart(), todayEnd()];
|
||||
setCreatedRange(defaultRange);
|
||||
setQueryCreatedRange(defaultRange);
|
||||
setPage(1);
|
||||
setReloadKey((v) => v + 1);
|
||||
};
|
||||
|
||||
@@ -341,6 +440,21 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
window.open(apiUrl(url), '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
/** 弹窗预览资源(图片/视频) */
|
||||
const handlePreviewResource = (url: string, type: 'image' | 'video', title: string) => {
|
||||
if (isBlobUrl(url)) {
|
||||
message.warning('本地临时素材已失效,暂无法查看');
|
||||
return;
|
||||
}
|
||||
if (isUrlExpired(url)) {
|
||||
message.warning('链接已超时,请刷新列表或重新搜索后再查看');
|
||||
return;
|
||||
}
|
||||
setResourcePreview({ url: apiUrl(url), type, title });
|
||||
};
|
||||
|
||||
const handleCloseResourcePreview = () => setResourcePreview(null);
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '用户', key: 'user', width: 150,
|
||||
@@ -358,6 +472,107 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '生成数量', key: 'generationCount', width: 150,
|
||||
render: (_: any, r: GenerationAITaskOut) => {
|
||||
const count = Math.max(1, Number(r.generationCount || 1));
|
||||
if (count === 1) return <Tag>1份</Tag>;
|
||||
const children = r.childItems || [];
|
||||
const completed = children.filter((item) => resolveGenerationUiState(item).isSuccess).length;
|
||||
const failed = children.filter((item) => resolveGenerationUiState(item).isFailure).length;
|
||||
const deleted = children.filter((item) => (item.displayStatus || item.status) === 'deleted').length;
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
<Tag color="purple">{count}份</Tag>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
|
||||
{completed}完成{failed ? ` / ${failed}失败` : ''}{deleted ? ` / ${deleted}删除` : ''}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '引擎', key: 'engine', width: 160,
|
||||
render: (_: any, r: GenerationAITaskOut) => {
|
||||
const name = getEngineName(r.engineSnapshot as any);
|
||||
return (
|
||||
<Tooltip title={r.engineId || '未知引擎'} placement="topLeft">
|
||||
<Tag icon={<RobotOutlined />} color="cyan">{name || r.engineId || '-'}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '附件', key: 'references', width: 100,
|
||||
render: (_: any, r: GenerationAITaskOut) => {
|
||||
const refs = r.mediaReferences || [];
|
||||
if (refs.length === 0) return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>无</Typography.Text>;
|
||||
return (
|
||||
<Space size={2} wrap>
|
||||
{refs.map((ref, idx) => {
|
||||
const refUrl = getReferenceUrl(ref);
|
||||
const refType = getReferenceType(ref);
|
||||
const title = typeof ref.name === 'string' && ref.name ? ref.name : `附件 ${idx + 1}`;
|
||||
if (!refUrl) return null;
|
||||
const icon = refType === 'video'
|
||||
? <VideoCameraOutlined style={{ color: '#6366f1' }} />
|
||||
: refType === 'audio'
|
||||
? <AudioOutlined style={{ color: '#f59e0b' }} />
|
||||
: <FileImageOutlined style={{ color: '#8b5cf6' }} />;
|
||||
return (
|
||||
<Tooltip key={idx} title={title}>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={icon}
|
||||
style={{ width: 28, height: 28, padding: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePreviewResource(refUrl, refType === 'video' ? 'video' : 'image', title);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '结果', key: 'result', width: 90,
|
||||
render: (_: any, r: GenerationAITaskOut) => {
|
||||
if (!resolveGenerationUiState(r).isSuccess) {
|
||||
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>-</Typography.Text>;
|
||||
}
|
||||
if (r.genType === 'video' && r.videoUrl) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
type="link"
|
||||
style={{ padding: 0 }}
|
||||
onClick={(e) => { e.stopPropagation(); handlePreviewResource(r.videoUrl!, 'video', '生成视频'); }}
|
||||
>
|
||||
看视频
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (r.genType === 'image' && r.imageUrl) {
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileImageOutlined />}
|
||||
type="link"
|
||||
style={{ padding: 0 }}
|
||||
onClick={(e) => { e.stopPropagation(); handlePreviewResource(r.imageUrl!, 'image', '生成图片'); }}
|
||||
>
|
||||
看图片
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return <Typography.Text style={{ fontSize: 12, color: '#94a3b8' }}>无</Typography.Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '提示词', key: 'prompt', ellipsis: true,
|
||||
render: (_: any, r: GenerationAITaskOut) => (
|
||||
@@ -402,13 +617,14 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 100,
|
||||
render: (v: string) => {
|
||||
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
|
||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
||||
const state = resolveGenerationUiState({ status: v });
|
||||
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
|
||||
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '阶段', dataIndex: 'pipelineStage', width: 120,
|
||||
render: (v: string) => <Tag color="blue">{PIPELINE_STAGE_MAP[v] || v || '-'}</Tag>,
|
||||
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '时间', key: 'time', width: 170,
|
||||
@@ -430,10 +646,10 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
], [handleOpenPreview]);
|
||||
|
||||
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
|
||||
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
|
||||
const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null;
|
||||
|
||||
const renderResultImage = () => {
|
||||
if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null;
|
||||
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null;
|
||||
|
||||
if (!preview.imageUrl) {
|
||||
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
|
||||
@@ -573,7 +789,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
};
|
||||
|
||||
const renderResultVideo = () => {
|
||||
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
|
||||
if (!preview || preview.genType !== 'video' || !resolveGenerationUiState(preview).isSuccess) return null;
|
||||
|
||||
if (!preview.videoUrl) {
|
||||
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
|
||||
@@ -764,7 +980,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
@@ -795,10 +1011,37 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
{ value: 'video', label: '视频' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
loading={engineListLoading}
|
||||
placeholder="引擎筛选"
|
||||
value={filterEngineId || undefined}
|
||||
style={{ width: 260 }}
|
||||
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
|
||||
filterOption={(input, option: any) =>
|
||||
String(option?.searchText || option?.label || '')
|
||||
.toLowerCase()
|
||||
.includes(input.trim().toLowerCase())
|
||||
}
|
||||
options={engineOptions}
|
||||
/>
|
||||
<RangePicker
|
||||
value={createdRange}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
// 自动将结束时间补到当天 23:59:59,确保选一天也能看到整天数据
|
||||
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
|
||||
} else {
|
||||
setCreatedRange(dates);
|
||||
}
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户ID搜索"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
style={{ width: 180 }}
|
||||
style={{ width: 150 }}
|
||||
value={inputUserId}
|
||||
onChange={(e) => setInputUserId(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
@@ -807,7 +1050,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
<Input
|
||||
placeholder="用户名搜索"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
style={{ width: 160 }}
|
||||
style={{ width: 150 }}
|
||||
value={inputUserName}
|
||||
onChange={(e) => setInputUserName(e.target.value)}
|
||||
onPressEnter={handleSearch}
|
||||
@@ -816,6 +1059,9 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -824,7 +1070,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
loading={loading}
|
||||
columns={columns as any}
|
||||
dataSource={records}
|
||||
scroll={{ x: 1180 }}
|
||||
scroll={{ x: 1650 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
@@ -853,8 +1099,8 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
|
||||
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
|
||||
{preview.pipelineStage ? <Tag color="blue">{PIPELINE_STAGE_MAP[preview.pipelineStage] || preview.pipelineStage}</Tag> : null}
|
||||
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
|
||||
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
|
||||
{/*{preview.generationMode ? <Tag>{preview.generationMode}</Tag> : null}*/}
|
||||
</div>
|
||||
|
||||
@@ -862,6 +1108,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
<InfoItem label="用户名称" value={preview.userName || '未知用户'} />
|
||||
<InfoItem label="用户ID" value={preview.userId || '-'} />
|
||||
<InfoItem label="任务ID" value={preview.id} />
|
||||
<InfoItem label="生成数量" value={`${preview.generationCount || 1} 份`} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -912,16 +1159,45 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
|
||||
{renderReferences()}
|
||||
|
||||
{preview.status === 'completed' ? (
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
||||
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
||||
{/* 附件快速查看区 */}
|
||||
{(preview.mediaReferences && preview.mediaReferences.length > 0) ? (
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 8 }}>
|
||||
附件快速查看(点击新窗口打开)
|
||||
</Typography.Text>
|
||||
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
|
||||
<Space size={8} wrap>
|
||||
{preview.mediaReferences.map((ref, idx) => {
|
||||
const refUrl = getReferenceUrl(ref);
|
||||
const refType = getReferenceType(ref);
|
||||
const title = typeof ref.name === 'string' && ref.name ? ref.name : `附件 ${idx + 1}`;
|
||||
if (!refUrl) return null;
|
||||
return (
|
||||
<Button
|
||||
key={idx}
|
||||
size="small"
|
||||
icon={refType === 'video' ? <PlayCircleOutlined /> : <LinkOutlined />}
|
||||
onClick={() => handlePreviewResource(refUrl, refType === 'video' ? 'video' : 'image', title)}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{preview.status === 'failed' && preview.errorMessage ? (
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
||||
生成资源(共 {preview.generationCount || 1} 份)
|
||||
</Typography.Text>
|
||||
<GenerationTaskResourceGrid
|
||||
task={preview}
|
||||
resolveUrl={apiUrl}
|
||||
onPreview={handlePreviewResource}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{previewStatusConfig?.isFailure && preview.errorMessage ? (
|
||||
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>错误信息: {preview.errorMessage}</Typography.Text>
|
||||
</div>
|
||||
@@ -936,6 +1212,34 @@ const AdminGenerationAiRecords: React.FC = () => {
|
||||
<Empty description="暂无详情" />
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 资源预览弹窗(附件 / 最终结果) */}
|
||||
<Modal
|
||||
title={resourcePreview?.title || '资源预览'}
|
||||
open={!!resourcePreview}
|
||||
onCancel={handleCloseResourcePreview}
|
||||
footer={null}
|
||||
width={resourcePreview?.type === 'video' ? 800 : 600}
|
||||
destroyOnHidden
|
||||
>
|
||||
{resourcePreview ? (
|
||||
resourcePreview.type === 'video' ? (
|
||||
<video
|
||||
key={resourcePreview.url}
|
||||
src={resourcePreview.url}
|
||||
controls
|
||||
autoPlay
|
||||
style={{ width: '100%', maxHeight: 600, background: '#000', borderRadius: 8 }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={resourcePreview.url}
|
||||
alt={resourcePreview.title}
|
||||
style={{ width: '100%', maxHeight: 600, objectFit: 'contain', borderRadius: 8 }}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Empty,
|
||||
Input,
|
||||
message,
|
||||
@@ -22,12 +23,13 @@ import {
|
||||
CloseCircleOutlined,
|
||||
SearchOutlined,
|
||||
VideoCameraOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
FileImageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminGenerationRecords, adminUpdateGenerationStatus, adminGenerateVideo } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
import { getAdminGenerationRecords, getVideoEngines, getImageEngines } from '../api';
|
||||
import type { AdminGenerationRecord, GenerationAIMediaReference } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { getGenerationStageLabel, getGenerationStatusColor, resolveGenerationUiState } from '../utils/generationTaskStatus';
|
||||
|
||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
// 后端返回的图片/视频一般是 /images、/videos、/uploads 等相对路径。
|
||||
@@ -50,14 +52,6 @@ const EMPTY_RESOURCE_STATE: PreviewResourceState = {
|
||||
references: {},
|
||||
};
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; text: string; icon: React.ReactNode }> = {
|
||||
optimizing: { color: 'processing', text: '优化中', icon: <LoadingOutlined spin /> },
|
||||
prompt_optimized: { color: 'processing', text: '待生成', icon: <ClockCircleOutlined /> },
|
||||
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
|
||||
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
|
||||
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
|
||||
};
|
||||
|
||||
const GEN_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
||||
image: { text: '图片', color: 'purple', icon: <FileImageOutlined /> },
|
||||
video: { text: '视频', color: 'geekblue', icon: <VideoCameraOutlined /> },
|
||||
@@ -186,6 +180,9 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
|
||||
</div>
|
||||
);
|
||||
|
||||
const todayStart = () => dayjs().startOf('day');
|
||||
const todayEnd = () => dayjs().endOf('day');
|
||||
|
||||
const AdminGenerationRecords: React.FC = () => {
|
||||
const [records, setRecords] = useState<AdminGenerationRecord[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
@@ -194,13 +191,18 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
const [pageSize] = useState(20);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||
const [filterUserId, setFilterUserId] = useState<string>('');
|
||||
const [filterEngineId, setFilterEngineId] = useState<string>('');
|
||||
const [filterIncludeMedia, setFilterIncludeMedia] = useState<'' | 'true' | 'false'>('');
|
||||
const [engineOptions, setEngineOptions] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [preview, setPreview] = useState<AdminGenerationRecord | null>(null);
|
||||
const [resourceState, setResourceState] = useState<PreviewResourceState>(EMPTY_RESOURCE_STATE);
|
||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [genModal, setGenModal] = useState<{ record: AdminGenerationRecord; ratio: string; resolution: string; image_size: string } | null>(null);
|
||||
|
||||
// 时间筛选(默认当天)
|
||||
const [createdRange, setCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
||||
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([todayStart(), todayEnd()]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -208,6 +210,10 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
const res = await getAdminGenerationRecords({
|
||||
userId: filterUserId.trim() || undefined,
|
||||
status: filterStatus || undefined,
|
||||
engineId: filterEngineId || undefined,
|
||||
includeMediaReferences: filterIncludeMedia === '' ? undefined : filterIncludeMedia === 'true',
|
||||
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
|
||||
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
@@ -217,6 +223,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
username: item.username,
|
||||
projectId: item.projectId,
|
||||
projectName: item.projectName,
|
||||
industry: item.industry,
|
||||
originalPrompt: item.originalPrompt,
|
||||
optimizedPrompt: item.optimizedPrompt,
|
||||
duration: item.duration,
|
||||
@@ -239,6 +246,10 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
imageTokensUsed: item.imageTokensUsed || 0,
|
||||
imageProportion: item.imageProportion,
|
||||
imagePx: item.imagePx,
|
||||
engineId: item.engineId,
|
||||
engineName: item.engineName,
|
||||
engineSnapshot: item.engineSnapshot,
|
||||
includeMediaReferences: item.includeMediaReferences,
|
||||
})));
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
@@ -246,12 +257,32 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterStatus, filterUserId, page, pageSize]);
|
||||
}, [filterStatus, filterUserId, filterEngineId, filterIncludeMedia, page, pageSize, queryCreatedRange]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load, reloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
getImageEngines({ includeDeleted: true }),
|
||||
getVideoEngines({ includeDeleted: true }),
|
||||
])
|
||||
.then(([imageEngines, videoEngines]) => {
|
||||
const items = [...(imageEngines || []), ...(videoEngines || [])];
|
||||
const seen = new Set<string>();
|
||||
setEngineOptions(items.reduce<Array<{ value: string; label: string }>>((acc, item: any) => {
|
||||
const id = String(item?.id || '');
|
||||
if (!id || seen.has(id)) return acc;
|
||||
seen.add(id);
|
||||
const deletedSuffix = item?.deletedAt ? '(已删除)' : '';
|
||||
acc.push({ value: id, label: item?.name ? `${item.name}${deletedSuffix} (${id})` : `${id}${deletedSuffix}` });
|
||||
return acc;
|
||||
}, []));
|
||||
})
|
||||
.catch(() => setEngineOptions([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) {
|
||||
setResourceState(EMPTY_RESOURCE_STATE);
|
||||
@@ -280,6 +311,19 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
}, [preview]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
setQueryCreatedRange(createdRange);
|
||||
setReloadKey((v) => v + 1);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilterStatus('');
|
||||
setFilterUserId('');
|
||||
setFilterEngineId('');
|
||||
setFilterIncludeMedia('');
|
||||
const defaultRange = [todayStart(), todayEnd()];
|
||||
setCreatedRange(defaultRange);
|
||||
setQueryCreatedRange(defaultRange);
|
||||
setPage(1);
|
||||
setReloadKey((v) => v + 1);
|
||||
};
|
||||
@@ -348,33 +392,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleStatusUpdate = async (recordId: string, newStatus: string, videoUrl?: string) => {
|
||||
setUpdating(recordId);
|
||||
try {
|
||||
await adminUpdateGenerationStatus(recordId, newStatus, videoUrl);
|
||||
message.success('状态已更新');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '更新失败');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!genModal) return;
|
||||
setUpdating(genModal.record.id);
|
||||
try {
|
||||
await adminGenerateVideo(genModal.record.id, genModal.ratio, genModal.resolution, genModal.image_size);
|
||||
message.success(`已提交${genModal.record.genType === 'video' ? '视频' : '图片'}生成`);
|
||||
setGenModal(null);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '生成失败');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
@@ -390,6 +408,10 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
|
||||
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v || '-'}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '行业', dataIndex: 'industry', width: 100, ellipsis: true,
|
||||
render: (v: string) => <Tag color="cyan">{v || '-'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'genType', width: 90, ellipsis: true,
|
||||
render: (v: string) => {
|
||||
@@ -411,21 +433,27 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
title: '参数', key: 'params', width: 160,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
r.genType === 'video' ? (
|
||||
r.duration || r.aspectRatio || r.resolution ? (
|
||||
<Space size={4} wrap>
|
||||
{r.duration ? <Tag>{r.duration}s</Tag> : null}
|
||||
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
|
||||
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
|
||||
</Space>
|
||||
) : <Tag color="default">待配置</Tag>
|
||||
<Space size={4} wrap>
|
||||
{r.duration ? <Tag>{r.duration}s</Tag> : null}
|
||||
{r.aspectRatio ? <Tag>{r.aspectRatio}</Tag> : null}
|
||||
{r.resolution ? <Tag>{r.resolution}</Tag> : null}
|
||||
{!r.duration && !r.aspectRatio && !r.resolution ? <Tag color="default">待配置</Tag> : null}
|
||||
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
|
||||
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
|
||||
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
|
||||
</Tag>
|
||||
</Space>
|
||||
) : (
|
||||
r.imageSize || r.imageProportion || r.imagePx ? (
|
||||
<Space size={4} wrap>
|
||||
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
|
||||
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
|
||||
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
|
||||
</Space>
|
||||
) : <Tag color="default">待配置</Tag>
|
||||
<Space size={4} wrap>
|
||||
{r.imageSize ? <Tag>{r.imageSize}</Tag> : null}
|
||||
{r.imageProportion ? <Tag>{r.imageProportion}</Tag> : null}
|
||||
{r.imagePx ? <Tag>{r.imagePx}</Tag> : null}
|
||||
{!r.imageSize && !r.imageProportion && !r.imagePx ? <Tag color="default">待配置</Tag> : null}
|
||||
{r.engineName || r.engineId ? <Tag color="purple">{r.engineName || truncateId(r.engineId || '')}</Tag> : null}
|
||||
<Tag color={r.includeMediaReferences ? 'green' : 'default'}>
|
||||
{r.includeMediaReferences ? '携带附件' : '不携带附件'}{r.references?.length ? `(${r.references.length})` : ''}
|
||||
</Tag>
|
||||
</Space>
|
||||
)
|
||||
),
|
||||
},
|
||||
@@ -448,10 +476,15 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 90,
|
||||
render: (v: string) => {
|
||||
const cfg = STATUS_MAP[v] || { color: 'default', text: v || '-', icon: null };
|
||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
||||
const state = resolveGenerationUiState({ status: v });
|
||||
const icon = state.isActive ? <LoadingOutlined spin /> : (state.isSuccess ? <CheckCircleOutlined /> : (state.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />));
|
||||
return <Tag color={state.color} icon={icon}>{state.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '阶段', dataIndex: 'pipelineStage', width: 150,
|
||||
render: (v: string) => <Tag color={getGenerationStatusColor(v)}>{getGenerationStageLabel(v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '时间', key: 'time', width: 150,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
@@ -462,78 +495,20 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
title: '操作', key: 'action', width: 90, fixed: 'right' as const,
|
||||
render: (_: any, r: AdminGenerationRecord) => (
|
||||
<Space size={4} wrap>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
|
||||
详情
|
||||
</Button>
|
||||
{r.status === 'generating' ? (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={updating === r.id}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定将此记录标记为失败?',
|
||||
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
||||
});
|
||||
}}
|
||||
>
|
||||
标记失败
|
||||
</Button>
|
||||
) : null}
|
||||
{r.status === 'failed' ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
loading={updating === r.id}
|
||||
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
|
||||
>
|
||||
重试生成
|
||||
</Button>
|
||||
) : null}
|
||||
{r.status === 'prompt_optimized' ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={updating === r.id}
|
||||
onClick={() => setGenModal({ record: r, ratio: r.aspectRatio || '16:9', resolution: r.resolution || '720p', image_size: r.imageSize || '2K' })}
|
||||
style={{ background: '#6366f1', border: 'none' }}
|
||||
>
|
||||
生成{r.genType === 'video' ? '视频' : '图片'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
loading={updating === r.id}
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认操作',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定将此记录标记为失败?',
|
||||
onOk: () => handleStatusUpdate(r.id, 'failed'),
|
||||
});
|
||||
}}
|
||||
>
|
||||
标记失败
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
<Button size="small" icon={<EyeOutlined />} onClick={() => handleOpenPreview(r)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
], [handleOpenPreview, updating]);
|
||||
], [handleOpenPreview]);
|
||||
|
||||
const previewTypeConfig = preview ? (GEN_TYPE_MAP[preview.genType || ''] || { text: preview.genType || '-', color: 'default', icon: null }) : null;
|
||||
const previewStatusConfig = preview ? (STATUS_MAP[preview.status] || { color: 'default', text: preview.status || '-', icon: null }) : null;
|
||||
const previewStatusConfig = preview ? resolveGenerationUiState(preview) : null;
|
||||
|
||||
const renderResultImage = () => {
|
||||
if (!preview || preview.genType !== 'image' || preview.status !== 'completed') return null;
|
||||
if (!preview || preview.genType !== 'image' || !resolveGenerationUiState(preview).isSuccess) return null;
|
||||
|
||||
if (!preview.imageUrl) {
|
||||
return <MediaPlaceholder text="此图片任务暂无结果图片" minHeight={260} />;
|
||||
@@ -676,7 +651,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
};
|
||||
|
||||
const renderResultVideo = () => {
|
||||
if (!preview || preview.genType !== 'video' || preview.status !== 'completed') return null;
|
||||
if (!preview || preview.genType !== 'video' || !resolveGenerationUiState(preview).isSuccess) return null;
|
||||
|
||||
if (!preview.videoUrl) {
|
||||
return <MediaPlaceholder text="此视频任务暂无结果视频" minHeight={340} />;
|
||||
@@ -867,7 +842,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space>
|
||||
<VideoCameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
@@ -889,6 +864,27 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
{ value: 'failed', label: '失败' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
placeholder="引擎筛选"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 220 }}
|
||||
value={filterEngineId || undefined}
|
||||
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); }}
|
||||
options={engineOptions}
|
||||
/>
|
||||
<Select
|
||||
placeholder="附件状态"
|
||||
allowClear
|
||||
style={{ width: 130 }}
|
||||
value={filterIncludeMedia || undefined}
|
||||
onChange={(v) => { setFilterIncludeMedia((v || '') as '' | 'true' | 'false'); setPage(1); }}
|
||||
options={[
|
||||
{ value: 'true', label: '携带附件' },
|
||||
{ value: 'false', label: '不携带附件' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
placeholder="用户ID搜索"
|
||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||
@@ -898,9 +894,23 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
onPressEnter={handleSearch}
|
||||
allowClear
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={createdRange}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
|
||||
} else {
|
||||
setCreatedRange(dates);
|
||||
}
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
|
||||
搜索
|
||||
</Button>
|
||||
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
@@ -951,7 +961,9 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>类型 / 状态</Typography.Text>
|
||||
<Space size={4} wrap>
|
||||
{previewTypeConfig ? <Tag color={previewTypeConfig.color} icon={previewTypeConfig.icon}>{previewTypeConfig.text}</Tag> : null}
|
||||
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.icon}>{previewStatusConfig.text}</Tag> : null}
|
||||
{previewStatusConfig ? <Tag color={previewStatusConfig.color} icon={previewStatusConfig.isActive ? <LoadingOutlined spin /> : (previewStatusConfig.isSuccess ? <CheckCircleOutlined /> : (previewStatusConfig.isFailure ? <CloseCircleOutlined /> : <ClockCircleOutlined />))}>{previewStatusConfig.label}</Tag> : null}
|
||||
{preview.pipelineStage ? <Tag color={getGenerationStatusColor(preview.pipelineStage)}>{getGenerationStageLabel(preview.pipelineStage)}</Tag> : null}
|
||||
{preview.videoUpscaleEnabled ? <Tag color="purple">已启用超分</Tag> : null}
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1030,7 +1042,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
|
||||
{renderReferences()}
|
||||
|
||||
{preview.status === 'completed' ? (
|
||||
{resolveGenerationUiState(preview).isSuccess ? (
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
||||
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
||||
@@ -1040,7 +1052,7 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
) : null}
|
||||
|
||||
{/* Error message */}
|
||||
{preview.status === 'failed' && preview.errorMessage ? (
|
||||
{resolveGenerationUiState(preview).isFailure && preview.errorMessage ? (
|
||||
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#ef4444' }}>错误信息: {preview.errorMessage}</Typography.Text>
|
||||
</div>
|
||||
@@ -1057,73 +1069,6 @@ const AdminGenerationRecords: React.FC = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Generate modal */}
|
||||
<Modal
|
||||
title={(
|
||||
<Space>
|
||||
{genModal && (genModal.record.genType === 'video' ? <PlayCircleOutlined /> : <FileImageOutlined />)}
|
||||
{genModal && (genModal.record.genType === 'video' ? '生成视频' : '生成图片')}
|
||||
</Space>
|
||||
)}
|
||||
open={!!genModal}
|
||||
onCancel={() => setGenModal(null)}
|
||||
onOk={handleGenerate}
|
||||
okText="提交生成"
|
||||
cancelText="取消"
|
||||
confirmLoading={genModal ? updating === genModal.record.id : false}
|
||||
width={420}
|
||||
>
|
||||
{genModal ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, marginTop: 16 }}>
|
||||
{genModal.record.genType === 'video' ? (
|
||||
<>
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>时长</Typography.Text>
|
||||
<Typography.Text strong>{genModal.record.duration || 5}s</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>画面比例</Typography.Text>
|
||||
<Select
|
||||
value={genModal.ratio}
|
||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, ratio: v } : null))}
|
||||
style={{ width: '100%' }}
|
||||
options={['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'].map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>分辨率</Typography.Text>
|
||||
<Select
|
||||
value={genModal.resolution}
|
||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, resolution: v } : null))}
|
||||
style={{ width: '100%' }}
|
||||
options={['480p', '720p', '1080p'].map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>尺寸</Typography.Text>
|
||||
<Typography.Text strong>{genModal.record.imagePx || '-'}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ padding: 12, borderRadius: 10, background: '#f8f9fc' }}>
|
||||
<Typography.Text style={{ fontSize: 11, color: '#94a3b8', display: 'block' }}>比例</Typography.Text>
|
||||
<Typography.Text strong>{genModal.record.imageProportion || '-'}</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text style={{ fontSize: 12, color: '#64748b', display: 'block', marginBottom: 6 }}>分辨率</Typography.Text>
|
||||
<Select
|
||||
value={genModal.image_size}
|
||||
onChange={(v) => setGenModal((prev) => (prev ? { ...prev, image_size: v } : null))}
|
||||
style={{ width: '100%' }}
|
||||
options={['2K', '4K'].map((r) => ({ value: r, label: r }))}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Form, Input, Space, Switch, Tabs, message } from 'antd';
|
||||
import { PlusOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getHomeMaterialCategories,
|
||||
getHomeMaterialConfig,
|
||||
getHomeMaterialWatermarks,
|
||||
saveHomeMaterialConfig,
|
||||
} from '../api';
|
||||
import type { HomeMaterialCategory, HomeMaterialConfig, HomeMaterialWatermark } from '../types';
|
||||
import HomeMaterialAssetTable from './homeMaterials/HomeMaterialAssetTable';
|
||||
import HomeMaterialCategoryPanel from './homeMaterials/HomeMaterialCategoryPanel';
|
||||
import HomeMaterialUploadModal from './homeMaterials/HomeMaterialUploadModal';
|
||||
import WatermarkLibraryModal from './homeMaterials/WatermarkLibraryModal';
|
||||
|
||||
const AdminHomeMaterials: React.FC = () => {
|
||||
const [form] = Form.useForm<HomeMaterialConfig>();
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [categories, setCategories] = useState<HomeMaterialCategory[]>([]);
|
||||
const [watermarks, setWatermarks] = useState<HomeMaterialWatermark[]>([]);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const loadBase = async () => {
|
||||
const [catRes, wmRes] = await Promise.all([
|
||||
getHomeMaterialCategories({ page: 1, pageSize: 200 }),
|
||||
getHomeMaterialWatermarks({ page: 1, pageSize: 200, isActive: true }),
|
||||
]);
|
||||
setCategories(catRes.items);
|
||||
setWatermarks(wmRes.items);
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const cfg = await getHomeMaterialConfig();
|
||||
form.setFieldsValue(cfg);
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
loadBase();
|
||||
}, []);
|
||||
|
||||
const saveConfig = async () => {
|
||||
const values = await form.validateFields();
|
||||
await saveHomeMaterialConfig(values);
|
||||
message.success('配置已保存');
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await loadBase();
|
||||
setReloadKey(k => k + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }} loading={configLoading}>
|
||||
<Form form={form} layout="inline" initialValues={{ enabled: false, title: '行业素材案例', subtitle: '精选图片与视频素材展示', showOriginalInAdmin: true }}>
|
||||
<Form.Item name="enabled" label="首页展示" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}><Input style={{ width: 220 }} /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input style={{ width: 320 }} /></Form.Item>
|
||||
<Form.Item name="showOriginalInAdmin" label="后台展示原素材" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={saveConfig}>保存配置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="首页素材行业装修"
|
||||
extra={(
|
||||
<Space>
|
||||
<Button onClick={refreshAll}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'assets',
|
||||
label: '素材管理',
|
||||
children: <HomeMaterialAssetTable categories={categories} watermarks={watermarks} reloadKey={reloadKey} />,
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
label: '行业类别',
|
||||
children: <HomeMaterialCategoryPanel onChanged={refreshAll} />,
|
||||
},
|
||||
{
|
||||
key: 'watermarks',
|
||||
label: '水印库',
|
||||
children: <WatermarkLibraryModal embedded onChanged={refreshAll} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<HomeMaterialUploadModal
|
||||
open={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
categories={categories}
|
||||
watermarks={watermarks}
|
||||
onSuccess={refreshAll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminHomeMaterials;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAdminHotOpeningTasks } from '../api';
|
||||
import type { HotOpeningTaskListItemOut } from '../types';
|
||||
@@ -111,16 +111,15 @@ const AdminHotOpeningReplications: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>爆款开头复刻</Typography.Title>
|
||||
<Typography.Text type="secondary">只读排查页面:查看任务列表、素材、步骤详情和生成结果。</Typography.Text>
|
||||
</div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>爆款开头复刻</Typography.Text>
|
||||
<Tag color="purple">{total} 条记录</Tag>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space wrap>
|
||||
<Select
|
||||
@@ -208,6 +207,7 @@ const AdminHotOpeningReplications: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 130, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '流程版本', dataIndex: 'flowVersion', width: 100, render: (v: string) => <Tag color={v === 'v2' ? 'blue' : 'default'}>{String(v || 'v1').toUpperCase()}</Tag> },
|
||||
{ title: '当前步骤', dataIndex: 'currentStepCode', width: 140, render: (v: string) => STEP_MAP[v] || v || '-' },
|
||||
{ title: '图片结果', dataIndex: 'finalImageUrl', width: 90, render: (v: string) => v ? <Tag color="success">有</Tag> : <Tag>无</Tag> },
|
||||
{ title: '视频结果', dataIndex: 'finalVideoUrl', width: 90, render: (v: string) => v ? <Tag color="success">有</Tag> : <Tag>无</Tag> },
|
||||
@@ -219,14 +219,12 @@ const AdminHotOpeningReplications: React.FC = () => {
|
||||
fixed: 'right',
|
||||
width: 110,
|
||||
render: (_, record) => (
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}`)}>详情</Button>
|
||||
<Button type="link" icon={<EyeOutlined />} onClick={() => navigate(`/hot-opening-replications/${record.id}?flow_version=${record.flowVersion === 'v2' ? 'v2' : 'v1'}`)}>详情</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
@@ -17,9 +17,15 @@ interface ImageEngine {
|
||||
supportedModels: string[];
|
||||
supportedSizes: Record<string, Record<string, string>>;
|
||||
defaultSize: string;
|
||||
maxImageCount: number;
|
||||
generateUrl: string;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
multiGenerationEnabled: boolean;
|
||||
maxGenerationCount: number;
|
||||
multiImageMaxImages: number;
|
||||
maxReferenceImageCount: number;
|
||||
outputFormat: '' | 'png' | 'jpeg';
|
||||
}
|
||||
|
||||
function parseJsonArray(val: unknown): any[] {
|
||||
@@ -40,25 +46,35 @@ function parseSizes(val: unknown): Record<string, Record<string, string>> {
|
||||
|
||||
// Default size options with pixel mappings
|
||||
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
|
||||
"1K": {
|
||||
"1:1": "1024x1024",
|
||||
"4:3": "1152x864",
|
||||
"3:4": "864x1152",
|
||||
"16:9": "1312x736",
|
||||
"9:16": "736x1312",
|
||||
"3:2": "1248x832",
|
||||
"2:3": "832x1248",
|
||||
"21:9": "1568x672",
|
||||
},
|
||||
"2K": {
|
||||
"1:1": "2048×2048",
|
||||
"4:3": "2304×1728",
|
||||
"3:4": "1728×2304",
|
||||
"16:9": "2560×1440",
|
||||
"9:16": "1600×2848",
|
||||
"3:2": "2496×1664",
|
||||
"2:3": "1664×2496",
|
||||
"21:9": "3024×1296",
|
||||
"1:1": "2048x2048",
|
||||
"4:3": "2304x1728",
|
||||
"3:4": "1728x2304",
|
||||
"16:9": "2848x1600",
|
||||
"9:16": "1600x2848",
|
||||
"3:2": "2496x1664",
|
||||
"2:3": "1664x2496",
|
||||
"21:9": "3136x1344",
|
||||
},
|
||||
"4K": {
|
||||
"1:1": "4096×4096",
|
||||
"4:3": "4608×3456",
|
||||
"3:4": "3520×4704",
|
||||
"16:9": "5404×3040",
|
||||
"9:16": "3040×5504",
|
||||
"3:2": "4992×3328",
|
||||
"2:3": "3328×4992",
|
||||
"21:9": "6197×2656",
|
||||
"1:1": "4096x4096",
|
||||
"4:3": "4704x3520",
|
||||
"3:4": "3520x4704",
|
||||
"16:9": "5504x3040",
|
||||
"9:16": "3040x5504",
|
||||
"3:2": "4992x3328",
|
||||
"2:3": "3328x4992",
|
||||
"21:9": "6197x2656",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -69,6 +85,7 @@ const AdminImageEngines: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
|
||||
const [form] = Form.useForm();
|
||||
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -93,7 +110,7 @@ const AdminImageEngines: React.FC = () => {
|
||||
const values = await form.validateFields();
|
||||
// Build supportedSizes from form values
|
||||
const sizes: Record<string, Record<string, string>> = {};
|
||||
for (const tier of ["2K", "4K"]) {
|
||||
for (const tier of ["1K", "2K", "4K"]) {
|
||||
const selected: string[] = values[`size_${tier}`] || [];
|
||||
if (selected.length > 0) {
|
||||
sizes[tier] = {};
|
||||
@@ -111,9 +128,15 @@ const AdminImageEngines: React.FC = () => {
|
||||
supported_models: JSON.stringify(values.supportedModels || []),
|
||||
supported_sizes: JSON.stringify(sizes),
|
||||
default_size: values.defaultSize || '2K',
|
||||
max_image_count: values.maxImageCount ?? 0,
|
||||
generate_url: values.generateUrl || '',
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
multi_generation_enabled: values.multiGenerationEnabled ?? false,
|
||||
max_generation_count: values.maxGenerationCount ?? 1,
|
||||
multi_image_max_images: values.multiImageMaxImages ?? 15,
|
||||
max_reference_image_count: values.maxReferenceImageCount ?? 14,
|
||||
output_format: values.outputFormat ?? '',
|
||||
};
|
||||
if (modal.engine) {
|
||||
await saveImageEngine({ id: modal.engine.id, ...payload });
|
||||
@@ -145,7 +168,7 @@ const AdminImageEngines: React.FC = () => {
|
||||
setModal({ open: true, engine: engine || null });
|
||||
if (engine) {
|
||||
const sizeFields: Record<string, string[]> = {};
|
||||
for (const tier of ["2K", "4K"]) {
|
||||
for (const tier of ["1K", "2K", "4K"]) {
|
||||
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
|
||||
}
|
||||
form.setFieldsValue({
|
||||
@@ -156,8 +179,12 @@ const AdminImageEngines: React.FC = () => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
isActive: true, priority: 0,
|
||||
multiGenerationEnabled: false, maxGenerationCount: 1, multiImageMaxImages: 15,
|
||||
maxReferenceImageCount: 14, outputFormat: '',
|
||||
supportedModels: ['doubao-seedream-5-0-260128'],
|
||||
defaultSize: '2K',
|
||||
maxImageCount: 0,
|
||||
size_1K: ALL_RATIOS,
|
||||
size_2K: ALL_RATIOS,
|
||||
size_4K: ALL_RATIOS,
|
||||
});
|
||||
@@ -184,6 +211,16 @@ const AdminImageEngines: React.FC = () => {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '1K 支持比例', key: 'sizes_1k', width: 260,
|
||||
render: (_: any, r: ImageEngine) => {
|
||||
const ratios = Object.keys(r.supportedSizes?.["1K"] || {});
|
||||
if (ratios.length === 0) return <span style={{ color: '#bfbfbf' }}>-</span>;
|
||||
return <Space size={2} wrap>{ratios.map(ratio => (
|
||||
<Tag key={ratio} color="green">{ratio} {r.supportedSizes["1K"][ratio]}</Tag>
|
||||
))}</Space>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '2K 支持比例', key: 'sizes_2k', width: 260,
|
||||
render: (_: any, r: ImageEngine) => {
|
||||
@@ -204,6 +241,22 @@ const AdminImageEngines: React.FC = () => {
|
||||
))}</Space>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
|
||||
render: (v: number) => <Tag color="purple">{v} 张</Tag>,
|
||||
},
|
||||
{
|
||||
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
|
||||
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
|
||||
render: (v: number, r: ImageEngine) => (
|
||||
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
|
||||
最多 {r.multiGenerationEnabled ? (v || 1) : 1} 份
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
@@ -287,7 +340,7 @@ const AdminImageEngines: React.FC = () => {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{["2K", "4K"].map(tier => (
|
||||
{["1K", "2K", "4K"].map(tier => (
|
||||
<div key={tier} style={{
|
||||
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
||||
marginBottom: 12, border: '1px solid #f0f0f5',
|
||||
@@ -311,13 +364,44 @@ const AdminImageEngines: React.FC = () => {
|
||||
|
||||
<Form.Item name="defaultSize" label="默认尺寸档位">
|
||||
<Select size="large" options={[
|
||||
{ value: '1K', label: '1K' },
|
||||
{ value: '2K', label: '2K' },
|
||||
{ value: '4K', label: '4K' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxImageCount" label="最大图片数量">
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="generateUrl" label="生成接口地址">
|
||||
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
|
||||
<Typography.Text strong>多份生成能力</Typography.Text>
|
||||
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
|
||||
管理后台只控制是否允许客户端选择多份及最大数量。客户端本次选择 2-5 份时,后端只调用一次火山同步组图 API;失败绝不降级成多次单图请求。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
|
||||
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked">
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="multiImageMaxImages" label="组图输入输出总上限" rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={15} precision={0} size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxReferenceImageCount" label="最大参考图数量" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={14} precision={0} size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="outputFormat" label="供应商输出格式">
|
||||
<Select size="large" options={[
|
||||
{ value: '', label: '不传(兼容不支持 output_format 的模型)' },
|
||||
{ value: 'png', label: 'PNG' },
|
||||
{ value: 'jpeg', label: 'JPEG' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Select size="large" options={[
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Tag, Space, Typography, message, Modal, Card, Popconfirm, Empty, Input, DatePicker } from 'antd';
|
||||
import { CheckOutlined, CloseOutlined, EyeOutlined, FileTextOutlined, FilterOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { getAdminInvoices, getAdminInvoiceDetail, updateInvoiceStatus } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import type { InvoiceItem, InvoiceDetail } from '../types';
|
||||
|
||||
const AdminInvoices: React.FC = () => {
|
||||
const [data, setData] = useState<InvoiceItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [phoneFilter, setPhoneFilter] = useState<string>('');
|
||||
const [createdRange, setCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
|
||||
const [queryCreatedRange, setQueryCreatedRange] = useState<any>([dayjs().startOf('day'), dayjs().endOf('day')]);
|
||||
|
||||
// 详情弹窗
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [currentDetail, setCurrentDetail] = useState<InvoiceDetail | null>(null);
|
||||
|
||||
// 失败原因弹窗
|
||||
const [failModalOpen, setFailModalOpen] = useState(false);
|
||||
const [failReason, setFailReason] = useState('');
|
||||
const [failTargetId, setFailTargetId] = useState<string | null>(null);
|
||||
const [failSubmitting, setFailSubmitting] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getAdminInvoices({
|
||||
page,
|
||||
pageSize,
|
||||
status: statusFilter || undefined,
|
||||
phone: phoneFilter || undefined,
|
||||
startDate: queryCreatedRange?.[0]?.format?.('YYYY-MM-DD'),
|
||||
endDate: queryCreatedRange?.[1]?.format?.('YYYY-MM-DD'),
|
||||
});
|
||||
setData(res.items);
|
||||
setTotal(res.total);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '获取失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [page, pageSize, statusFilter, queryCreatedRange]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
setQueryCreatedRange(createdRange);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatusFilter(null);
|
||||
setPhoneFilter('');
|
||||
const defaultRange = [dayjs().startOf('day'), dayjs().endOf('day')];
|
||||
setCreatedRange(defaultRange);
|
||||
setQueryCreatedRange(defaultRange);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleViewDetail = async (id: string) => {
|
||||
setDetailLoading(true);
|
||||
setDetailModalOpen(true);
|
||||
try {
|
||||
const detail = await getAdminInvoiceDetail(id);
|
||||
setCurrentDetail(detail);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '获取详情失败');
|
||||
setDetailModalOpen(false);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkSuccess = async (id: string) => {
|
||||
try {
|
||||
await updateInvoiceStatus(id, { status: 'success' });
|
||||
message.success('已标记为开具成功');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFailModal = (id: string) => {
|
||||
setFailTargetId(id);
|
||||
setFailReason('');
|
||||
setFailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmFail = async () => {
|
||||
if (!failReason.trim()) {
|
||||
message.warning('请填写失败原因');
|
||||
return;
|
||||
}
|
||||
if (!failTargetId) return;
|
||||
setFailSubmitting(true);
|
||||
try {
|
||||
await updateInvoiceStatus(failTargetId, { status: 'failed', failureReason: failReason.trim() });
|
||||
message.success('已标记为开具失败');
|
||||
setFailModalOpen(false);
|
||||
setFailTargetId(null);
|
||||
setFailReason('');
|
||||
fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
setFailSubmitting(false);
|
||||
};
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
};
|
||||
|
||||
const statusTag = (status: string) => {
|
||||
const config: Record<string, { color: string; label: string }> = {
|
||||
processing: { color: 'blue', label: '开具中' },
|
||||
success: { color: 'green', label: '已开具' },
|
||||
failed: { color: 'red', label: '已失败' },
|
||||
};
|
||||
const c = config[status] || { color: 'default', label: status };
|
||||
return <Tag color={c.color}>{c.label}</Tag>;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '发票编号',
|
||||
dataIndex: 'invoiceNo',
|
||||
key: 'invoiceNo',
|
||||
width: 180,
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 13 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 100,
|
||||
render: (_: string, record: InvoiceItem) => (
|
||||
<div>
|
||||
<div><Typography.Text strong>{record.username}</Typography.Text></div>
|
||||
<div style={{ fontSize: 12, color: '#94a3b8' }}>{record.phone}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '抬头类型',
|
||||
dataIndex: 'headerType',
|
||||
key: 'headerType',
|
||||
width: 80,
|
||||
render: (v: string) => (v === 'company' ? '企业' : '个人'),
|
||||
},
|
||||
{
|
||||
title: '抬头名称',
|
||||
dataIndex: 'headerName',
|
||||
key: 'headerName',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '邮箱',
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '总金额',
|
||||
dataIndex: 'totalAmount',
|
||||
key: 'totalAmount',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => <span style={{ fontWeight: 600 }}>¥{v.toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
title: '订单数',
|
||||
dataIndex: 'orderCount',
|
||||
key: 'orderCount',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (v: string) => statusTag(v),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 160,
|
||||
render: (v: string) => formatDate(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 260,
|
||||
render: (_: unknown, record: InvoiceItem) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => handleViewDetail(record.id)}>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'processing' && (
|
||||
<>
|
||||
<Button type="link" size="small" icon={<CheckOutlined />} onClick={() => handleMarkSuccess(record.id)}>
|
||||
开具成功
|
||||
</Button>
|
||||
<Button type="link" size="small" danger icon={<CloseOutlined />} onClick={() => handleOpenFailModal(record.id)}>
|
||||
开具失败
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<FileTextOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>发票管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条记录</Tag>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type={statusFilter === null ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter(null); setPage(1); }}
|
||||
icon={<FilterOutlined />}
|
||||
size="small"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'processing' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('processing'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
开具中
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'success' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('success'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
已开具
|
||||
</Button>
|
||||
<Button
|
||||
type={statusFilter === 'failed' ? 'primary' : 'default'}
|
||||
onClick={() => { setStatusFilter('failed'); setPage(1); }}
|
||||
size="small"
|
||||
>
|
||||
已失败
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索栏 */}
|
||||
<div style={{ display: 'flex', gap: 12, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<Input
|
||||
placeholder="按用户手机号搜索"
|
||||
value={phoneFilter}
|
||||
onChange={(e) => setPhoneFilter(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
value={createdRange}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setCreatedRange([dates[0].startOf('day'), dates[1].endOf('day')]);
|
||||
} else {
|
||||
setCreatedRange(dates);
|
||||
}
|
||||
}}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
<Button type="primary" onClick={handleSearch}>搜索</Button>
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : data.length === 0 ? (
|
||||
<Empty description="暂无发票记录" style={{ padding: '40px 0' }} />
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
}}
|
||||
scroll={{ x: 1400 }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
title={<Space><EyeOutlined />发票详情</Space>}
|
||||
open={detailModalOpen}
|
||||
onCancel={() => { setDetailModalOpen(false); setCurrentDetail(null); }}
|
||||
footer={null}
|
||||
width={700}
|
||||
>
|
||||
{detailLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
|
||||
) : currentDetail && (
|
||||
<div style={{ padding: 8 }}>
|
||||
{/* 基本信息 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
{currentDetail.invoiceNo}
|
||||
<span style={{ marginLeft: 12 }}>{statusTag(currentDetail.status)}</span>
|
||||
</Typography.Title>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: 12 }}>
|
||||
<Typography.Text style={{ color: '#64748b' }}>用户ID:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.userId}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>抬头类型:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerType === 'company' ? '企业' : '个人'}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>抬头名称:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerName}</Typography.Text>
|
||||
{currentDetail.headerTaxNo && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>税号:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.headerTaxNo}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<Typography.Text style={{ color: '#64748b' }}>接收邮箱:</Typography.Text>
|
||||
<Typography.Text>{currentDetail.email}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>总金额:</Typography.Text>
|
||||
<Typography.Text strong style={{ color: '#ef4444' }}>¥{currentDetail.totalAmount.toFixed(2)}</Typography.Text>
|
||||
<Typography.Text style={{ color: '#64748b' }}>创建时间:</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentDetail.createdAt)}</Typography.Text>
|
||||
{currentDetail.issuedAt && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>开票时间:</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentDetail.issuedAt)}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
{currentDetail.failureReason && (
|
||||
<>
|
||||
<Typography.Text style={{ color: '#64748b' }}>失败原因:</Typography.Text>
|
||||
<Typography.Text type="danger">{currentDetail.failureReason}</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 关联订单 */}
|
||||
{currentDetail.orders && currentDetail.orders.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
关联订单({currentDetail.orders.length} 笔)
|
||||
</Typography.Text>
|
||||
<Table
|
||||
dataSource={currentDetail.orders}
|
||||
columns={[
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'orderNo',
|
||||
key: 'orderNo',
|
||||
render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${v.toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '积分',
|
||||
dataIndex: 'credits',
|
||||
key: 'credits',
|
||||
align: 'right' as const,
|
||||
},
|
||||
]}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 失败原因弹窗 */}
|
||||
<Modal
|
||||
title="开具失败"
|
||||
open={failModalOpen}
|
||||
onCancel={() => { setFailModalOpen(false); setFailTargetId(null); setFailReason(''); }}
|
||||
onOk={handleConfirmFail}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
confirmLoading={failSubmitting}
|
||||
>
|
||||
<Typography.Text style={{ display: 'block', marginBottom: 8 }}>
|
||||
请填写失败原因(用户可见):
|
||||
</Typography.Text>
|
||||
<Input.TextArea
|
||||
value={failReason}
|
||||
onChange={(e) => setFailReason(e.target.value)}
|
||||
placeholder="例如:抬头信息有误,请重新提交"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminInvoices;
|
||||
@@ -67,7 +67,7 @@ const AdminLayout: React.FC = () => {
|
||||
const logo = info.siteLogo || '';
|
||||
setSiteName(name);
|
||||
setSiteLogo(logo);
|
||||
document.title = name;
|
||||
document.title = name === '管理后台' ? name : name + ' - 管理后台';
|
||||
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
|
||||
if (logo) {
|
||||
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
|
||||
@@ -230,18 +230,18 @@ const AdminLayout: React.FC = () => {
|
||||
background: 'linear-gradient(135deg, rgba(99, 102, 241, 0.06) 0%, rgba(139, 92, 246, 0.04) 100%)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 14,
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
background: '#ffffff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 4px 16px rgba(99, 102, 241, 0.35)',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{siteLogo ? (
|
||||
<img src={siteLogo} alt="logo" style={{ width: 28, height: 28, objectFit: 'contain' }} />
|
||||
<img src={siteLogo} alt="logo" style={{ width: 36, height: 36, objectFit: 'contain' }} />
|
||||
) : (
|
||||
<ThunderboltOutlined style={{ fontSize: 20, color: '#ffffff' }} />
|
||||
)}
|
||||
@@ -287,7 +287,6 @@ const AdminLayout: React.FC = () => {
|
||||
bottom: 16,
|
||||
left: 16,
|
||||
right: 16,
|
||||
padding: 12,
|
||||
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Descriptions, Input, Select, Space, Table, Tag, Timeline, Typography } from 'antd';
|
||||
import { HistoryOutlined } from '@ant-design/icons';
|
||||
import { getLlmBillingExecutions } from '../api';
|
||||
import type { LlmBillingExecution } from '../types';
|
||||
import { formatDate, formatDatePrecise } from '../utils/formatDate';
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
charged: { label: '已消费', color: 'blue' },
|
||||
processing: { label: '处理中', color: 'processing' },
|
||||
succeeded: { label: '业务成功', color: 'success' },
|
||||
final_failed: { label: '最终失败', color: 'error' },
|
||||
refunded: { label: '已退款', color: 'purple' },
|
||||
refund_failed: { label: '退款失败', color: 'red' },
|
||||
};
|
||||
|
||||
const AdminLlmBillingExecutions: React.FC = () => {
|
||||
const [items, setItems] = useState<LlmBillingExecution[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [status, setStatus] = useState<string>();
|
||||
const [userId, setUserId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const load = async () => { setLoading(true); try { const data = await getLlmBillingExecutions({ page, pageSize: 20, status, userId: userId || undefined }); setItems(data.items); setTotal(data.total); } finally { setLoading(false); } };
|
||||
useEffect(() => { load(); }, [page, status]);
|
||||
return <Card title={<Space><HistoryOutlined />LLM调用与积分审计</Space>} extra={<Space><Input.Search allowClear placeholder="用户ID" value={userId} onChange={(e) => setUserId(e.target.value)} onSearch={() => { setPage(1); load(); }} /><Select allowClear placeholder="最终状态" value={status} onChange={(v) => { setStatus(v); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value, config]) => ({ value, label: config.label }))} /></Space>}>
|
||||
<Table rowKey="id" loading={loading} dataSource={items} pagination={{ current: page, pageSize: 20, total, onChange: setPage }} expandable={{ expandedRowRender: (record) => <div>
|
||||
<Timeline items={(record.calls || []).map((call) => ({
|
||||
color: call.status === 'succeeded' ? 'green' : call.status === 'started' ? 'blue' : 'red',
|
||||
children: <div>
|
||||
<Typography.Text strong>{formatDatePrecise(call.requestStartedAt)} 第 {call.callSequence} 次调用:{call.status === 'succeeded' ? '成功' : call.status === 'started' ? '处理中' : call.status === 'timeout' ? '超时' : '失败'}</Typography.Text>
|
||||
<div>完成时间:{call.responseReceivedAt ? formatDatePrecise(call.responseReceivedAt) : '-'}</div>
|
||||
<div>模型:{call.modelNameSnapshot || '-'} 耗时:{call.durationMs == null ? '-' : `${call.durationMs}ms`}</div>
|
||||
{call.tokenUnavailableReason
|
||||
? <div>Token:{call.tokenUnavailableReason}</div>
|
||||
: <div>输入 Token:{call.inputTokens ?? 0} 输出 Token:{call.outputTokens ?? 0} 总 Token:{call.totalTokens ?? 0}</div>}
|
||||
{call.errorMessage && <Typography.Text type="danger">错误:{call.errorMessage}</Typography.Text>}
|
||||
{call.postprocessStatus === 'succeeded' && <div>后续处理:成功</div>}
|
||||
{call.postprocessError && <div>后续处理:失败,{call.postprocessError}</div>}
|
||||
</div>,
|
||||
}))} />
|
||||
<Descriptions bordered size="small" column={4} items={[
|
||||
{ key: 'scene', label: '功能', children: record.sceneNameSnapshot },
|
||||
{ key: 'credit', label: '消费积分', children: record.chargeCredits },
|
||||
{ key: 'calls', label: '调用', children: `${record.totalCallCount}(成功 ${record.successfulCallCount} / 失败 ${record.failedCallCount})` },
|
||||
{ key: 'tokens', label: '累计 Token', children: record.totalTokens },
|
||||
{ key: 'refundA', label: '有效积分退回', children: record.refundAvailableCredits },
|
||||
{ key: 'refundE', label: '过期积分退回', children: record.refundExpiredCredits },
|
||||
{ key: 'error', label: '最终错误', children: record.finalErrorMessage || '-' },
|
||||
]} />
|
||||
</div> }} columns={[
|
||||
{ title: '功能', dataIndex: 'sceneNameSnapshot' },
|
||||
{ title: '用户', dataIndex: 'userId' },
|
||||
{ title: '业务对象', render: (_: unknown, r: LlmBillingExecution) => `${r.ownerType}:${r.ownerId}` },
|
||||
{ title: '模型', dataIndex: 'modelNameSnapshot' },
|
||||
{ title: '消费积分', dataIndex: 'chargeCredits' },
|
||||
{ title: '调用/Token', render: (_: unknown, r: LlmBillingExecution) => `${r.totalCallCount} / ${r.totalTokens}` },
|
||||
{ title: '状态', dataIndex: 'status', render: (v: string) => { const config = STATUS_LABELS[v] || { label: v, color: 'default' }; return <Tag color={config.color}>{config.label}</Tag>; } },
|
||||
{ title: '请求时间', dataIndex: 'requestTime', render: (v: string) => formatDate(v) },
|
||||
]} />
|
||||
</Card>;
|
||||
};
|
||||
export default AdminLlmBillingExecutions;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Form, InputNumber, message, Modal, Select, Space, Switch, Table, Tag } from 'antd';
|
||||
import { EditOutlined, PlusOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { createLlmBillingPolicy, getLlmBillingPolicies, updateLlmBillingPolicy } from '../api';
|
||||
import type { LlmBillingPolicy } from '../types';
|
||||
|
||||
const SCENES: Array<{ value: string; label: string }> = [
|
||||
{ value: 'generation_record_text_prompt_optimize', label: 'AI创作-提示词优化' },
|
||||
{ value: 'hot_opening_image_prompt_optimize', label: '爆款开头复刻-图片提示词优化' },
|
||||
{ value: 'hot_opening_video_prompt_optimize', label: '爆款开头复刻-视频提示词优化' },
|
||||
{ value: 'shot_image_prompt_optimize', label: '拆镜复刻-图片提示词优化' },
|
||||
{ value: 'shot_video_prompt_optimize', label: '拆镜复刻-视频提示词优化' },
|
||||
{ value: 'shot_original_video_analysis', label: '拆镜复刻-原视频AI分析' },
|
||||
{ value: 'shot_segment_video_analysis', label: '拆镜复刻-片段视频AI分析' },
|
||||
];
|
||||
|
||||
const AdminLlmBillingPolicies: React.FC = () => {
|
||||
const [items, setItems] = useState<LlmBillingPolicy[]>([]);
|
||||
const [editing, setEditing] = useState<LlmBillingPolicy | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setItems(await getLlmBillingPolicies());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const edit = (item?: LlmBillingPolicy) => {
|
||||
setEditing(item || null);
|
||||
setOpen(true);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(item
|
||||
? { scene_code: item.sceneCode, charge_credits: item.chargeCredits, is_active: item.isActive }
|
||||
: { is_active: true, charge_credits: 5 });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
if (editing) await updateLlmBillingPolicy(editing.id, values);
|
||||
else await createLlmBillingPolicy(values);
|
||||
message.success('已保存');
|
||||
setOpen(false);
|
||||
load();
|
||||
} catch (error: any) {
|
||||
if (!error?.errorFields) message.error(error?.message || '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
return <Card
|
||||
title={<Space><RobotOutlined />LLM积分配置</Space>}
|
||||
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}>新增场景</Button>}
|
||||
>
|
||||
<Table rowKey="id" loading={loading} dataSource={items} columns={[
|
||||
{ title: '功能', dataIndex: 'sceneName' },
|
||||
{ title: '场景编码', dataIndex: 'sceneCode' },
|
||||
{ title: '消费积分', dataIndex: 'chargeCredits' },
|
||||
{ title: '版本', dataIndex: 'version' },
|
||||
{ title: '状态', dataIndex: 'isActive', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag> },
|
||||
{ title: '操作', render: (_: unknown, r: LlmBillingPolicy) => <Button type="link" icon={<EditOutlined />} onClick={() => edit(r)}>编辑</Button> },
|
||||
]} />
|
||||
<Modal open={open} title={editing ? '编辑LLM积分场景' : '新增LLM积分场景'} onOk={save} onCancel={() => setOpen(false)}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="scene_code" label="业务功能" rules={[{ required: true }]}>
|
||||
<Select disabled={!!editing} options={SCENES} />
|
||||
</Form.Item>
|
||||
<Form.Item name="charge_credits" label="消费积分" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>;
|
||||
};
|
||||
|
||||
export default AdminLlmBillingPolicies;
|
||||
@@ -41,8 +41,9 @@ const AdminLoginPage = () => {
|
||||
await login(values.username, values.password, values.rememberMe);
|
||||
message.success('登录成功');
|
||||
navigate('/');
|
||||
} catch {
|
||||
message.error('登录失败');
|
||||
} catch (error: any) {
|
||||
const errorMsg = error?.message || '登录失败';
|
||||
message.error(errorMsg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -80,7 +81,7 @@ const AdminLoginPage = () => {
|
||||
bottom: -100, left: -80, filter: 'blur(50px)',
|
||||
}} />
|
||||
|
||||
<Card bordered={false} style={{
|
||||
<Card variant="outlined" style={{
|
||||
width: 440,
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Select, Table, Pagination, Tag, Typography, Tooltip } from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { getMaterialList } from '../api';
|
||||
import PreResultDisplay from '../components/PreResultDisplay';
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const AdminMaterialList: React.FC = () => {
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchMaterialId, setSearchMaterialId] = useState('');
|
||||
const [searchUploadId, setSearchUploadId] = useState('');
|
||||
const [searchResourceType, setSearchResourceType] = useState('');
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '广告主账户ID',
|
||||
dataIndex: 'advertiserId',
|
||||
key: 'advertiserId',
|
||||
},
|
||||
{
|
||||
title: '素材ID',
|
||||
dataIndex: 'materialId',
|
||||
key: 'materialId',
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
title: '上传ID',
|
||||
dataIndex: 'uploadId',
|
||||
key: 'uploadId',
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '预测试模板ID',
|
||||
dataIndex: 'preTestTemplateId',
|
||||
key: 'preTestTemplateId',
|
||||
},
|
||||
{
|
||||
title: '授权ID',
|
||||
dataIndex: 'oauthId',
|
||||
key: 'oauthId',
|
||||
},
|
||||
{
|
||||
title: '目标标ID',
|
||||
dataIndex: 'targetId',
|
||||
key: 'targetId',
|
||||
},
|
||||
{
|
||||
title: '目标表',
|
||||
dataIndex: 'targetTable',
|
||||
key: 'targetTable',
|
||||
},
|
||||
{
|
||||
title: '任务ID',
|
||||
dataIndex: 'taskId',
|
||||
key: 'taskId',
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 200,
|
||||
render: (text: string) => (
|
||||
<Input.TextArea
|
||||
value={text || ''}
|
||||
readOnly
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ color: '#94a3b8', resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
|
||||
placeholder="-"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '预测试结果',
|
||||
dataIndex: 'preResult',
|
||||
key: 'preResult',
|
||||
width: 120,
|
||||
render: (text: string) => <PreResultDisplay preResult={text} />,
|
||||
},
|
||||
{
|
||||
title: '资源类型',
|
||||
dataIndex: 'resourceType',
|
||||
key: 'resourceType',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'video' ? 'blue' : 'green'}>
|
||||
{text === 'video' ? '视频' : '图片'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'SUCCESS' ? 'green' : text === 'PENDING' ? 'orange' : 'red'}>
|
||||
{text === 'SUCCESS' ? '成功' : text === 'PENDING' ? '处理中' : '失败'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{ formatDateTime(text)}</span>,
|
||||
},
|
||||
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{ formatDateTime(text)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
const loadMaterialList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getMaterialList({
|
||||
material_id: searchMaterialId || undefined,
|
||||
upload_id: searchUploadId || undefined,
|
||||
resource_type: searchResourceType || undefined,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
const data = res.data || [];
|
||||
const tableData = data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
index: (currentPage - 1) * pageSize + index + 1,
|
||||
}));
|
||||
setTableData(tableData);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadMaterialList();
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadMaterialList();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 16 }}>素材ID列表</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="素材ID"
|
||||
value={searchMaterialId}
|
||||
onChange={(e) => setSearchMaterialId(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
placeholder="上传ID"
|
||||
value={searchUploadId}
|
||||
onChange={(e) => setSearchUploadId(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
placeholder="文件名"
|
||||
value={searchResourceType}
|
||||
onChange={(e) => setSearchResourceType(e.target.value)}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="资源类型"
|
||||
value={searchResourceType}
|
||||
onChange={(value) => setSearchResourceType(value)}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
options={[
|
||||
{ value: 'image', label: '图片' },
|
||||
{ value: 'video', label: '视频' },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条记录`}
|
||||
onChange={(page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
}}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMaterialList;
|
||||
@@ -198,7 +198,7 @@ const AdminMenuConfig: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<MenuOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -110,9 +110,8 @@ const AdminModels: React.FC = () => {
|
||||
const labelMap: Record<string, string> = {
|
||||
sdk: 'SDK模式',
|
||||
openai_compatible: 'OpenAI兼容',
|
||||
mock: 'Mock模式',
|
||||
};
|
||||
return <Tag color={v === 'mock' ? 'default' : 'blue'}>{labelMap[v] || v}</Tag>;
|
||||
return <Tag color="blue">{labelMap[v] || v}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -157,10 +156,10 @@ const AdminModels: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary">
|
||||
共 {models.length} 个模型配置,按权重进行加权随机调度
|
||||
共 {models.length} 个模型配置;任务创建时锁定模型,自动重试不会切换或降级
|
||||
</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}
|
||||
style={{ borderRadius: 8 }}>
|
||||
@@ -197,7 +196,6 @@ const AdminModels: React.FC = () => {
|
||||
<Select size="large" options={[
|
||||
{ value: 'sdk', label: 'SDK模式' },
|
||||
{ value: 'openai_compatible', label: 'OpenAI兼容' },
|
||||
{ value: 'mock', label: 'Mock模式' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="modelName" label="模型标识" style={{ flex: 1 }}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty,
|
||||
Button, Card, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, Empty, Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined,
|
||||
BellOutlined, PlusOutlined, DeleteOutlined, SendOutlined, EyeOutlined, TeamOutlined, NotificationOutlined, SaveOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers } from '../api';
|
||||
import ReactQuill from 'react-quill-new';
|
||||
import 'react-quill-new/dist/quill.snow.css';
|
||||
import { getAdminNotifications, createAdminNotification, deleteAdminNotification, getAdminUsers, getNotificationReadUsers, getSystemConfigs, updateSystemConfig, createSystemConfig, resetActivityBanner } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface NotificationRecord {
|
||||
@@ -29,6 +31,19 @@ interface ReadUser {
|
||||
readAt: string;
|
||||
}
|
||||
|
||||
// 富文本编辑器工具栏配置(含颜色选择)
|
||||
const editorModules = {
|
||||
toolbar: [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
[{ color: [] }, { background: [] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ align: [] }],
|
||||
['link', 'image'],
|
||||
['clean'],
|
||||
],
|
||||
};
|
||||
|
||||
const AdminNotificationManager: React.FC = () => {
|
||||
const [notifications, setNotifications] = useState<NotificationRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -41,6 +56,11 @@ const AdminNotificationManager: React.FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
// Banner state
|
||||
const [bannerContent, setBannerContent] = useState('');
|
||||
const [bannerConfigId, setBannerConfigId] = useState<string | null>(null);
|
||||
const [bannerSaving, setBannerSaving] = useState(false);
|
||||
const [bannerLoading, setBannerLoading] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -69,8 +89,29 @@ const AdminNotificationManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadBanner = async () => {
|
||||
setBannerLoading(true);
|
||||
try {
|
||||
const configs = await getSystemConfigs();
|
||||
const banner = configs.find((c: any) => c.key === 'site_banner');
|
||||
if (banner) {
|
||||
setBannerContent(banner.value || '');
|
||||
setBannerConfigId(banner.id);
|
||||
} else {
|
||||
setBannerContent('');
|
||||
setBannerConfigId(null);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setBannerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page, pageSize]);
|
||||
|
||||
useEffect(() => { loadBanner(); }, []);
|
||||
|
||||
const handlePageChange = (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
@@ -115,6 +156,39 @@ const AdminNotificationManager: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveBanner = async () => {
|
||||
const content = bannerContent.trim();
|
||||
if (!content) {
|
||||
message.error('请输入横幅内容');
|
||||
return;
|
||||
}
|
||||
setBannerSaving(true);
|
||||
try {
|
||||
if (bannerConfigId) {
|
||||
await updateSystemConfig(bannerConfigId, content);
|
||||
} else {
|
||||
const res = await createSystemConfig('site_banner', content, '全局活动通知横幅内容');
|
||||
setBannerConfigId(res.id);
|
||||
}
|
||||
// 内容变更后自动递增版本号,让所有用户重新看到横幅
|
||||
try { await resetActivityBanner(); } catch { /* ignore */ }
|
||||
message.success('活动横幅已保存,所有用户将重新看到该横幅');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setBannerSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetBanner = async () => {
|
||||
try {
|
||||
const res = await resetActivityBanner();
|
||||
message.success(`横幅已重新展示(版本 → ${res.siteBannerVersion})`);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'system': return 'blue';
|
||||
@@ -131,6 +205,7 @@ const AdminNotificationManager: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '内容', dataIndex: 'content', ellipsis: true,
|
||||
render: (v: string) => <div style={{ maxWidth: 300 }} dangerouslySetInnerHTML={{ __html: v }} />,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 80,
|
||||
@@ -164,37 +239,148 @@ const AdminNotificationManager: React.FC = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'notifications',
|
||||
label: (
|
||||
<span><BellOutlined style={{ marginRight: 6 }} />消息推送</span>
|
||||
),
|
||||
children: (
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消息推送管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条消息</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
|
||||
style={{ borderRadius: 8 }}>
|
||||
发送新消息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={notifications}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条消息`,
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'banner',
|
||||
label: (
|
||||
<span><NotificationOutlined style={{ marginRight: 6 }} />活动横幅</span>
|
||||
),
|
||||
children: (
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||
<div style={{
|
||||
width: 40, height: 40, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 18, color: '#6366f1',
|
||||
}}>
|
||||
<NotificationOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>全局活动横幅设置</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13, display: 'block' }}>
|
||||
设置后将在用户前台页面顶部显示活动通知横幅,支持富文本格式
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>横幅内容</Typography.Text>
|
||||
{bannerLoading ? (
|
||||
<div style={{ padding: '40px 0', textAlign: 'center', color: '#94a3b8' }}>加载中...</div>
|
||||
) : (
|
||||
<ReactQuill
|
||||
theme="snow"
|
||||
value={bannerContent}
|
||||
onChange={setBannerContent}
|
||||
modules={editorModules}
|
||||
placeholder="请输入横幅内容(支持富文本:加粗、变色、链接等)"
|
||||
style={{ height: 200, marginBottom: 48 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
|
||||
<Button
|
||||
icon={<NotificationOutlined />}
|
||||
onClick={handleResetBanner}
|
||||
size="large"
|
||||
style={{ borderRadius: 8, minWidth: 160 }}
|
||||
>
|
||||
重新展示横幅
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSaveBanner}
|
||||
loading={bannerSaving}
|
||||
size="large"
|
||||
style={{ borderRadius: 8, minWidth: 140 }}
|
||||
>
|
||||
保存横幅
|
||||
</Button>
|
||||
</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
|
||||
💡 点击「重新展示横幅」将强制所有已关闭横幅的用户再次看到;修改内容并保存也会自动重新展示。
|
||||
</Typography.Text>
|
||||
|
||||
{/* 预览区域 */}
|
||||
{bannerContent && (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>前台预览</Typography.Text>
|
||||
<div style={{
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
background: 'linear-gradient(135deg, #f3e8ff 0%, #ede9fe 50%, #e0e7ff 100%)',
|
||||
border: '1px solid rgba(139, 92, 246, 0.15)',
|
||||
padding: '10px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: 8,
|
||||
background: 'rgba(139, 92, 246, 0.12)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
<NotificationOutlined style={{ color: '#7c3aed', fontSize: 14 }} />
|
||||
</div>
|
||||
<div style={{
|
||||
color: '#5b21b6',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.5,
|
||||
flex: 1,
|
||||
}} dangerouslySetInnerHTML={{ __html: bannerContent }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>消息推送管理</Typography.Text>
|
||||
<Tag color="purple">共 {total} 条消息</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}
|
||||
style={{ borderRadius: 8 }}>
|
||||
发送新消息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={notifications}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
onChange: handlePageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条消息`,
|
||||
}}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
<Tabs items={tabItems} defaultActiveKey="notifications" />
|
||||
|
||||
{/* Send Notification Modal */}
|
||||
<Modal
|
||||
@@ -210,8 +396,8 @@ const AdminNotificationManager: React.FC = () => {
|
||||
<Input placeholder="请输入消息标题" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="消息内容"
|
||||
rules={[{ required: true, message: '请输入内容' }]}>
|
||||
<Input.TextArea rows={4} placeholder="请输入消息内容" size="large" />
|
||||
rules={[{ required: true, validator: (_, v) => v && v !== '<p><br></p>' ? Promise.resolve() : Promise.reject('请输入内容') }]}>
|
||||
<ReactQuill theme="snow" modules={editorModules} placeholder="请输入消息内容(支持富文本:加粗、斜体、颜色、链接等)" style={{ height: 180, marginBottom: 40 }} />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="type" label="消息类型" style={{ flex: 1 }}
|
||||
@@ -264,4 +450,4 @@ const AdminNotificationManager: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminNotificationManager;
|
||||
export default AdminNotificationManager;
|
||||
@@ -0,0 +1,284 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Select, Table, Pagination, Tag, Typography } from 'antd';
|
||||
import { LockOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { getOAuthAccountList, getOpenTypeAll } from '../api';
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
const { Text } = Typography;
|
||||
|
||||
const AdminOAuthList: React.FC = () => {
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchAccountId, setSearchAccountId] = useState('');
|
||||
const [searchUserId, setSearchUserId] = useState('');
|
||||
const [openTypeMap, setOpenTypeMap] = useState<Record<number, string>>({});
|
||||
const [openTypeOptions, setOpenTypeOptions] = useState<{ value: number; label: string }[]>([]);
|
||||
const [searchOpenType, setSearchOpenType] = useState<number | undefined>(undefined);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
},
|
||||
{
|
||||
title: '前台用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
},
|
||||
{
|
||||
title: '前台用户手机号',
|
||||
dataIndex: 'userPhone',
|
||||
key: 'userPhone',
|
||||
},
|
||||
{
|
||||
title: '授权应用ID',
|
||||
dataIndex: 'appid',
|
||||
key: 'appid',
|
||||
},
|
||||
{
|
||||
title: '授权绑定账户ID',
|
||||
dataIndex: 'accountId',
|
||||
key: 'accountId',
|
||||
},
|
||||
{
|
||||
title: '授权绑定账户名称',
|
||||
dataIndex: 'accountName',
|
||||
key: 'accountName',
|
||||
},
|
||||
{
|
||||
title: '授权绑定账户角色',
|
||||
dataIndex: 'accountRole',
|
||||
key: 'accountRole',
|
||||
render: (role: string) => {
|
||||
const roleMap: Record<string, string> = {
|
||||
ADVERTISER: '客户',
|
||||
CUSTOMER_ADMIN: '普通版工作台-管理员',
|
||||
CUSTOMER_OPERATOR: '普通版工作台-协作者',
|
||||
AGENT: '代理商',
|
||||
CHILD_AGENT: '二级代理商',
|
||||
PLATFORM_ROLE_STAR: '星图账户',
|
||||
PLATFORM_ROLE_SHOP_ACCOUNT: '抖音店铺账户',
|
||||
PLATFORM_ROLE_QIANCHUAN_AGENT: '千川代理商',
|
||||
PLATFORM_ROLE_STAR_AGENT: '星图代理商',
|
||||
PLATFORM_ROLE_AWEME: '抖音号',
|
||||
PLATFORM_ROLE_STAR_MCN: '星图MCN机构',
|
||||
PLATFORM_ROLE_STAR_ISV: '星图服务商',
|
||||
AGENT_SYSTEM_ACCOUNT: '代理商系统账户',
|
||||
PLATFORM_ROLE_LOCAL_AGENT: '本地推代理商',
|
||||
PLATFORM_ROLE_YUNTU_BRAND_ISV_ADMIN: '云图品牌服务商管理员',
|
||||
PLATFORM_ROLE_LIFE: '抖音来客账户',
|
||||
PLATFORM_ROLE_ENTERPRISE_BP_ADMIN: '升级版工作台管理员',
|
||||
PLATFORM_ROLE_ENTERPRISE_BP_OPERATOR: '升级版工作台协作者',
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '授权登录账户用户ID',
|
||||
dataIndex: 'accountUserid',
|
||||
key: 'accountUserid',
|
||||
},
|
||||
{
|
||||
title: '授权登录账户用户名',
|
||||
dataIndex: 'accountUsername',
|
||||
key: 'accountUsername',
|
||||
},
|
||||
{
|
||||
title: '开户方式',
|
||||
dataIndex: 'openType',
|
||||
key: 'openType',
|
||||
width: 120,
|
||||
render: (text: number) => <span style={{ color: '#1e293b' }}>{openTypeMap[text] || text}</span>,
|
||||
},
|
||||
{
|
||||
title: '平台端口',
|
||||
dataIndex: 'portType',
|
||||
key: 'portType',
|
||||
render: (role: string) => {
|
||||
const roleMap: Record<string, string> = {
|
||||
1: '巨量',
|
||||
2: '磁力',
|
||||
3: '巨量星图',
|
||||
4: '服务单',
|
||||
5: '腾讯',
|
||||
};
|
||||
return roleMap[role] || role;
|
||||
},
|
||||
// 1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯
|
||||
// render: (text: number) => <span style={{ color: '#1e293b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: '授权刷新Token',
|
||||
dataIndex: 'refreshToken',
|
||||
key: 'refreshToken',
|
||||
},
|
||||
{
|
||||
title: '刷新Token过期时间',
|
||||
dataIndex: 'refreshTokenExpired',
|
||||
key: 'refreshTokenExpired',
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '授权Token',
|
||||
dataIndex: 'accessToken',
|
||||
key: 'accessToken',
|
||||
},
|
||||
{
|
||||
title: 'Token过期时间',
|
||||
dataIndex: 'accessTokenExpired',
|
||||
key: 'accessTokenExpired',
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
];
|
||||
const loadOpenTypeList = async () => {
|
||||
try {
|
||||
const res = await getOpenTypeAll();
|
||||
const data = res.data || [];
|
||||
const map: Record<number, string> = {};
|
||||
const options: { value: number; label: string }[] = [];
|
||||
data.forEach(item => {
|
||||
map[item.openType] = item.typeName;
|
||||
options.push({ value: item.openType, label: item.typeName });
|
||||
});
|
||||
setOpenTypeMap(map);
|
||||
setOpenTypeOptions(options);
|
||||
} catch (error) {
|
||||
console.error('加载开户方式列表失败:', error);
|
||||
}
|
||||
};
|
||||
const loadOAuthList = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getOAuthAccountList({
|
||||
account_id: searchAccountId || undefined,
|
||||
account_userid: searchUserId || undefined,
|
||||
open_type: searchOpenType,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
const data = res.data || [];
|
||||
const tableData = data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
index: (currentPage - 1) * pageSize + index + 1,
|
||||
}));
|
||||
setTableData(tableData);
|
||||
setTotal(res.pagination?.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadOpenTypeList();
|
||||
loadOAuthList();
|
||||
}, [currentPage, pageSize]);
|
||||
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadOAuthList();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<LockOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 16 }}>投放平台授权列表</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="授权绑定账户ID"
|
||||
value={searchAccountId}
|
||||
onChange={(e) => setSearchAccountId(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
placeholder="授权登录账户用户ID"
|
||||
value={searchUserId}
|
||||
onChange={(e) => setSearchUserId(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
prefix={<SearchOutlined />}
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Select
|
||||
placeholder="开户方式"
|
||||
value={searchOpenType}
|
||||
onChange={(value) => setSearchOpenType(value)}
|
||||
style={{ width: 140 }}
|
||||
allowClear
|
||||
options={openTypeOptions}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ background: '#fff', borderRadius: 12, boxShadow: '0 1px 3px rgba(0,0,0,0.05)' }}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (page, size) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminOAuthList;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Space, Table, Tag, Typography, message, Modal, Form, Input, Select, InputNumber,
|
||||
Button, Card, Space, Table, Tag, Tooltip, Typography, message, Modal, Form, Input, Select, InputNumber,
|
||||
} from 'antd';
|
||||
import {
|
||||
MenuOutlined, ReloadOutlined, PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
|
||||
@@ -13,7 +13,7 @@ interface OAuthApp {
|
||||
appId: string;
|
||||
secret: string;
|
||||
status: number;
|
||||
count: number;
|
||||
maxCount: number;
|
||||
openType: number;
|
||||
authUrl?: string;
|
||||
company?: string;
|
||||
@@ -54,7 +54,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
app_id: values.app_id,
|
||||
secret: values.secret,
|
||||
open_type: values.open_type,
|
||||
count: values.count,
|
||||
max_count: values.max_count,
|
||||
auth_url: values.auth_url,
|
||||
company: values.company,
|
||||
});
|
||||
@@ -82,11 +82,11 @@ const AdminOauthAppList: React.FC = () => {
|
||||
const app = await getOauthApp(id);
|
||||
setCurrentApp(app);
|
||||
updateForm.setFieldsValue({
|
||||
app_id: app.appId,
|
||||
appId: app.appId,
|
||||
secret: app.secret,
|
||||
open_type: app.openType,
|
||||
count: app.count,
|
||||
auth_url: app.authUrl,
|
||||
openType: app.openType,
|
||||
maxCount: app.maxCount,
|
||||
authUrl: app.authUrl,
|
||||
company: app.company,
|
||||
});
|
||||
setUpdateModalVisible(true);
|
||||
@@ -137,16 +137,27 @@ const AdminOauthAppList: React.FC = () => {
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id',
|
||||
{ title: 'ID', dataIndex: 'id', width: 120,
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: '应用ID', dataIndex: 'appId',
|
||||
{ title: '应用ID', dataIndex: 'appId', width: 120,
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: '应用密钥', dataIndex: 'secret',
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
// { title: '应用密钥', dataIndex: 'secret', width: 120,
|
||||
// render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
// },
|
||||
{ title: '应用密钥', dataIndex: 'secret', width: 120,
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '开户方式', dataIndex: 'openType',
|
||||
{ title: '开户方式', dataIndex: 'openType', width: 90,
|
||||
render: (v: number) => {
|
||||
const typeMap: Record<number, string> = {
|
||||
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
|
||||
@@ -155,29 +166,40 @@ const AdminOauthAppList: React.FC = () => {
|
||||
return <Typography.Text>{typeMap[v] || v}</Typography.Text>;
|
||||
},
|
||||
},
|
||||
{ title: '归属公司', dataIndex: 'company',
|
||||
{ title: '归属公司', dataIndex: 'company', width: 120,
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
|
||||
{ title: '授权次数', dataIndex: 'count',
|
||||
{ title: '授权次数', dataIndex: 'maxCount', width: 90,
|
||||
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status',
|
||||
{ title: '状态', dataIndex: 'status', width: 80,
|
||||
render: (v: number) => <Tag color={v === 1 ? 'green' : 'red'}>{v === 1 ? '正常' : '禁用'}</Tag>,
|
||||
},
|
||||
{ title: '授权URL', dataIndex: 'authUrl',
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
// { title: '授权URL', dataIndex: 'authUrl', width: 180,
|
||||
// render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
// },
|
||||
|
||||
{ title: '授权URL', dataIndex: 'authUrl', width: 200,
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }} ellipsis>{v.length > 30 ? `${v.slice(0, 27)}...` : v}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '创建人', dataIndex: 'createBy',
|
||||
{ title: '创建人', dataIndex: 'createBy', width: 120,
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt',
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160,
|
||||
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{ title: '更新时间', dataIndex: 'updatedAt',
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 160,
|
||||
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
|
||||
},
|
||||
{ title: '操作',
|
||||
{ title: '操作', width: 120, fixed: 'right' as const,
|
||||
render: (_: any, record: OAuthApp) => (
|
||||
<Space>
|
||||
<Button
|
||||
@@ -226,7 +248,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (p) => { setPage(p); load(p); },
|
||||
}}
|
||||
scroll={{ x: 800 }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -276,7 +298,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="count"
|
||||
name="max_count"
|
||||
label="最大授权用户数"
|
||||
initialValue={100}
|
||||
>
|
||||
@@ -322,7 +344,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
return typeMap[currentApp.openType] || currentApp.openType;
|
||||
})()}</p>
|
||||
<p><strong>归属公司:</strong> {currentApp.company || '-'}</p>
|
||||
<p><strong>授权次数:</strong> {currentApp.count}</p>
|
||||
<p><strong>授权次数:</strong> {currentApp.maxCount}</p>
|
||||
<p><strong>状态:</strong> {currentApp.status === 1 ? '正常' : '禁用'}</p>
|
||||
<p><strong>授权URL:</strong> {currentApp.authUrl || '-'}</p>
|
||||
<p><strong>创建人:</strong> {currentApp.createBy}</p>
|
||||
@@ -347,7 +369,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
>
|
||||
<Form form={updateForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="app_id"
|
||||
name="appId"
|
||||
label="应用ID"
|
||||
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
|
||||
>
|
||||
@@ -361,7 +383,7 @@ const AdminOauthAppList: React.FC = () => {
|
||||
<Input placeholder="请输入应用密钥" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="open_type"
|
||||
name="openType"
|
||||
label="开户方式"
|
||||
rules={[{ required: true, message: '请选择开户方式' }]}
|
||||
>
|
||||
@@ -379,13 +401,13 @@ const AdminOauthAppList: React.FC = () => {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="count"
|
||||
name="maxCount"
|
||||
label="最大授权用户数"
|
||||
>
|
||||
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="auth_url"
|
||||
name="authUrl"
|
||||
label="应用授权链接"
|
||||
>
|
||||
<Input placeholder="请输入应用授权链接" />
|
||||
|
||||
@@ -31,7 +31,7 @@ const AdminOperationLogs: React.FC = () => {
|
||||
const load = async (p?: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getOperationLogs(p || page);
|
||||
const res = await getOperationLogs({ page: p || page });
|
||||
setLogs(res.items || []);
|
||||
setTotal(res.total || 0);
|
||||
} catch {
|
||||
@@ -68,7 +68,7 @@ const AdminOperationLogs: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Switch, Typography, InputNumber,
|
||||
Button, Card, Form, Input, message, Switch, Typography, InputNumber, Space,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, ClockCircleOutlined,
|
||||
@@ -85,7 +85,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginBottom: 24 }}>
|
||||
{/* 通用设置:订单超时时间 */}
|
||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
@@ -100,14 +100,16 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
</div>
|
||||
<Form form={form} layout="inline">
|
||||
<Form.Item name="order_timeout" style={{ margin: 0 }}>
|
||||
<InputNumber
|
||||
min={30}
|
||||
max={86400}
|
||||
placeholder="180"
|
||||
style={{ width: 140 }}
|
||||
size="middle"
|
||||
addonAfter="秒"
|
||||
/>
|
||||
<Space.Compact style={{ width: 140 }}>
|
||||
<InputNumber
|
||||
min={30}
|
||||
max={86400}
|
||||
placeholder="180"
|
||||
style={{ width: '100%' }}
|
||||
size="middle"
|
||||
/>
|
||||
<Typography.Text>秒</Typography.Text>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
@@ -116,7 +118,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
|
||||
{/* 模拟支付模式 */}
|
||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: mockMode ? '#fff7e6' : '#fafbff' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', background: mockMode ? '#fff7e6' : '#fafbff' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
@@ -139,7 +141,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
{/* 微信支付 */}
|
||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
@@ -216,7 +218,7 @@ const AdminPaymentConfig: React.FC = () => {
|
||||
|
||||
{/* 支付宝 */}
|
||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
|
||||
@@ -1,308 +1,74 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Card, Col, Row, Space, Table, Tag, Typography, Statistic, message, Select, DatePicker, Button, ConfigProvider, Popconfirm
|
||||
} from 'antd';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, Col, ConfigProvider, DatePicker, Input, Row, Select, Space, Statistic, Table, Tag, Typography } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import {
|
||||
DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined, ReloadOutlined, UndoOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { getPaymentStats, getAdminPaymentOrders, refundPaymentOrder } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { DollarOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { getAdminPaymentOrders, getPaymentStats } from '../api';
|
||||
|
||||
const { Option } = Select;
|
||||
const { RangePicker } = DatePicker;
|
||||
const SOURCE_LABELS: Record<string, string> = { online_payment: '线上支付', admin_offline: '后台线下成交' };
|
||||
const METHOD_LABELS: Record<string, string> = { alipay: '支付宝', wechat: '微信支付', bank_transfer: '银行转账', cash: '现金', other: '其他-线下收款' };
|
||||
const STATUS_LABELS: Record<string, string> = { pending: '待支付', paid: '已支付', refunded: '已退款', cancelled: '已取消', expired: '已过期', failed: '失败' };
|
||||
|
||||
const AdminPaymentStats: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [orderPage, setOrderPage] = useState(1);
|
||||
const [orderPageSize, setOrderPageSize] = useState(10);
|
||||
const [orderTotal, setOrderTotal] = useState(0);
|
||||
const [filters, setFilters] = useState<{
|
||||
paymentMethod?: string;
|
||||
status?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}>({
|
||||
startDate: dayjs().format('YYYY-MM-DD'),
|
||||
endDate: dayjs().format('YYYY-MM-DD'),
|
||||
});
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
const [orders, setOrders] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [filters, setFilters] = useState<any>({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') });
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [statsData, ordersData] = await Promise.all([
|
||||
getPaymentStats(filters),
|
||||
getAdminPaymentOrders({
|
||||
...filters,
|
||||
page: orderPage,
|
||||
pageSize: orderPageSize,
|
||||
}),
|
||||
]);
|
||||
setStats({ ...statsData, recent: ordersData.items || [] });
|
||||
setOrderTotal(ordersData.total);
|
||||
} catch {
|
||||
message.error('加载支付统计失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [s, o] = await Promise.all([
|
||||
getPaymentStats({ paymentMethod: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, startDate: filters.startDate, endDate: filters.endDate }),
|
||||
getAdminPaymentOrders({ method: filters.paymentMethod, orderSource: filters.orderSource, status: filters.status, phone: filters.phone || undefined, startDate: filters.startDate, endDate: filters.endDate, page, pageSize }),
|
||||
]);
|
||||
setStats(s); setOrders(o.items || []); setTotal(Number(o.total || 0));
|
||||
} finally { setLoading(false); }
|
||||
}, [filters, page, pageSize]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
useEffect(() => { load(); }, [filters, orderPage, orderPageSize]);
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 210, render: (v: string) => <Typography.Text copyable code>{v}</Typography.Text> },
|
||||
{ title: '用户', width: 150, render: (_: any, r: any) => <div>{r.username || '-'}<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.phone || '-'}</div></div> },
|
||||
{ title: '来源', dataIndex: 'orderSource', width: 120, render: (v: string, r: any) => <Tag color={v === 'admin_offline' ? 'purple' : 'blue'}>{r.orderSourceLabel || SOURCE_LABELS[v] || '其他来源'}</Tag> },
|
||||
{ title: '商品', dataIndex: 'productNameSnapshot', width: 170, ellipsis: true, render: (v: string, r: any) => `${v || '-'}${Number(r.quantity || 1) > 1 ? ` × ${r.quantity}` : ''}` },
|
||||
{ title: '支付方式', dataIndex: 'paymentMethod', width: 135, render: (v: string, r: any) => r.paymentMethodLabel || METHOD_LABELS[v] || '其他支付方式' },
|
||||
{ title: '系统报价', dataIndex: 'quotedAmountSnapshot', width: 110, align: 'right' as const, render: (v: number) => v == null ? '-' : `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '实际成交', dataIndex: 'amount', width: 110, align: 'right' as const, render: (v: number) => <b>¥{Number(v || 0).toFixed(2)}</b> },
|
||||
{ title: '状态', dataIndex: 'status', width: 100, render: (v: string, r: any) => <Tag>{r.statusLabel || STATUS_LABELS[v] || '其他状态'}</Tag> },
|
||||
{ title: '履约', dataIndex: 'fulfillmentStatusLabel', width: 100, render: (v: string) => v || '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-' },
|
||||
// 当前版本退款按钮明确不开放。后端退款 API 仍保留兼容入口并固定返回中文阻止提示,后续重新开放时再恢复此处按钮。
|
||||
];
|
||||
|
||||
const handleOrderPageChange = (p: number, ps: number) => {
|
||||
setOrderPage(p);
|
||||
setOrderPageSize(ps);
|
||||
};
|
||||
const reset = () => { setFilters({ paymentMethod: undefined, orderSource: undefined, status: undefined, phone: '', startDate: dayjs().startOf('month').format('YYYY-MM-DD'), endDate: dayjs().format('YYYY-MM-DD') }); setPage(1); };
|
||||
const today = stats?.today || {};
|
||||
const month = stats?.month || {};
|
||||
const bySource = stats?.bySource || {};
|
||||
|
||||
const handleReset = () => {
|
||||
setFilters({
|
||||
startDate: dayjs().format('YYYY-MM-DD'),
|
||||
endDate: dayjs().format('YYYY-MM-DD'),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRefund = async (orderNo: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await refundPaymentOrder(orderNo);
|
||||
message.success('退款成功');
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.detail || '退款失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateChange = (dates: any) => {
|
||||
if (dates && dates.length === 2) {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
startDate: dates[0].format('YYYY-MM-DD'),
|
||||
endDate: dates[1].format('YYYY-MM-DD'),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { color: string; label: string; icon: React.ReactNode }> = {
|
||||
paid: { color: 'green', label: '已支付', icon: <CheckCircleOutlined /> },
|
||||
pending: { color: 'gold', label: '待支付', icon: <ClockCircleOutlined /> },
|
||||
cancelled: { color: 'default', label: '已取消', icon: <CloseCircleOutlined /> },
|
||||
refunded: { color: 'red', label: '已退款', icon: <UndoOutlined /> },
|
||||
};
|
||||
|
||||
const methodConfig: Record<string, { color: string; label: string }> = {
|
||||
alipay: { color: 'blue', label: '支付宝' },
|
||||
wechat: { color: 'green', label: '微信' },
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', key: 'orderNo', width: 200 },
|
||||
{ title: '用户', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{
|
||||
title: '支付方式', dataIndex: 'paymentMethod', key: 'paymentMethod', width: 100,
|
||||
render: (m: string) => {
|
||||
const c = methodConfig[m] || { color: 'default', label: m };
|
||||
return <Tag color={c.color}>{c.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
|
||||
render: (a: number) => <Typography.Text strong style={{ color: '#10b981' }}>¥{a.toFixed(2)}</Typography.Text>,
|
||||
},
|
||||
{ title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 100,
|
||||
render: (s: string) => {
|
||||
const c = statusConfig[s] || { color: 'default', label: s, icon: null };
|
||||
return <Tag color={c.color} icon={c.icon}>{c.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '支付宝交易号', dataIndex: 'tradeNo', key: 'tradeNo', width: 200, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '创建时间', dataIndex: 'createdAt', key: 'createdAt', width: 160,
|
||||
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '支付时间', dataIndex: 'paidAt', key: 'paidAt', width: 160,
|
||||
render: (d: string) => <span className="date-display" style={{ color: '#94a3b8' }}>{d ? formatDate(d) : '-'}</span>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
render: (_: any, record: any) => {
|
||||
if (record.status === 'paid') {
|
||||
return (
|
||||
<Popconfirm
|
||||
title="确认要退款该订单吗?"
|
||||
description="退款后积分会扣除,金额会原路返回"
|
||||
onConfirm={() => handleRefund(record.orderNo)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button type="link" danger size="small" icon={<UndoOutlined />}>
|
||||
退款
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!stats) {
|
||||
return <div style={{ padding: 24, color: '#94a3b8' }}>加载中…</div>;
|
||||
}
|
||||
|
||||
const paidInfo = stats.byStatus?.paid || { count: 0, amount: 0 };
|
||||
const pendingInfo = stats.byStatus?.pending || { count: 0, amount: 0 };
|
||||
const cancelledInfo = stats.byStatus?.cancelled || { count: 0, amount: 0 };
|
||||
const refundedInfo = stats.byStatus?.refunded || { count: 0, amount: 0 };
|
||||
const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count + refundedInfo.count;
|
||||
const monthInfo = stats.month || { count: 0, amount: 0 };
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<div>
|
||||
{/* Summary cards */}
|
||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} lg={12}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="今日收入"
|
||||
value={stats.today.paidAmount}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined style={{ color: '#10b981' }} />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#10b981', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{stats.today.paidCount} 笔订单
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={12}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Statistic
|
||||
title="本月累计"
|
||||
value={monthInfo.paidAmount}
|
||||
precision={2}
|
||||
prefix={<DollarOutlined style={{ color: '#6366f1' }} />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#6366f1', fontWeight: 700 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{monthInfo.paidCount} 笔订单
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Status breakdown */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
|
||||
title={<Space><DollarOutlined />订单状态分布</Space>}>
|
||||
<Row gutter={16}>
|
||||
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
|
||||
const info = stats.byStatus?.[s] || { count: 0, amount: 0 };
|
||||
const c = statusConfig[s];
|
||||
const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
|
||||
return (
|
||||
<Col span={6} key={s}>
|
||||
<div style={{
|
||||
padding: 16, borderRadius: 10,
|
||||
background: '#fafbff', border: '1px solid #f0f0f5',
|
||||
}}>
|
||||
<Space>
|
||||
<Tag color={c.color} icon={c.icon}>{c.label}</Tag>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{pct}%</Typography.Text>
|
||||
</Space>
|
||||
<div style={{ marginTop: 8, fontSize: 20, fontWeight: 700 }}>
|
||||
{info.count} <span style={{ fontSize: 13, color: '#94a3b8', fontWeight: 400 }}>笔</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#64748b', marginTop: 4 }}>
|
||||
¥{info.amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Recent orders table */}
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}
|
||||
title={<Space><DollarOutlined />订单列表</Space>}>
|
||||
{/* Filters */}
|
||||
<Row gutter={[16, 16]} align="middle" style={{ marginBottom: 24 }}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<span style={{ marginRight: 8 }}>支付方式:</span>
|
||||
<Select
|
||||
placeholder="全部"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={filters.paymentMethod}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, paymentMethod: value }))}
|
||||
>
|
||||
<Option value="alipay">支付宝</Option>
|
||||
<Option value="wechat">微信</Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<span style={{ marginRight: 8 }}>状态:</span>
|
||||
<Select
|
||||
placeholder="全部"
|
||||
allowClear
|
||||
style={{ width: 150 }}
|
||||
value={filters.status}
|
||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<Option value="paid">已支付</Option>
|
||||
<Option value="pending">待支付</Option>
|
||||
<Option value="cancelled">已取消</Option>
|
||||
<Option value="refunded">已退款</Option>
|
||||
</Select>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<span style={{ marginRight: 8 }}>日期范围:</span>
|
||||
<RangePicker
|
||||
value={[
|
||||
dayjs(filters.startDate),
|
||||
dayjs(filters.endDate),
|
||||
]}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={4}>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={stats.recent || []}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: orderPage,
|
||||
pageSize: orderPageSize,
|
||||
total: orderTotal,
|
||||
onChange: handleOrderPageChange,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
return <ConfigProvider locale={zhCN}><div>
|
||||
<Row gutter={[16,16]} style={{ marginBottom: 18 }}>
|
||||
<Col xs={24} md={8}><Card><Statistic title="今日总真实收入" value={Number(today.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线上 {Number(today.onlinePaidAmount || 0).toFixed(2)} 元 · 线下 {Number(today.offlinePaidAmount || 0).toFixed(2)} 元</Typography.Text></Card></Col>
|
||||
<Col xs={24} md={8}><Card><Statistic title="本月总真实收入" value={Number(month.paidAmount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">线上 {Number(month.onlinePaidAmount || 0).toFixed(2)} 元 · 线下 {Number(month.offlinePaidAmount || 0).toFixed(2)} 元</Typography.Text></Card></Col>
|
||||
<Col xs={24} md={8}><Card><Statistic title="当前筛选总收入" value={Number(stats?.totalIncome?.amount || 0)} precision={2} suffix="元" prefix={<DollarOutlined />} /><Typography.Text type="secondary">{Number(stats?.totalIncome?.count || 0)} 笔已支付成交</Typography.Text></Card></Col>
|
||||
</Row>
|
||||
<Card title="收入来源" style={{ marginBottom: 16 }}><Row gutter={16}>{['online_payment','admin_offline'].map((source) => <Col span={12} key={source}><div style={{ padding: 14, background: '#fafafa', borderRadius: 10 }}><Tag color={source === 'online_payment' ? 'blue' : 'purple'}>{bySource[source]?.label || SOURCE_LABELS[source]}</Tag><div style={{ fontSize: 24, fontWeight: 700, marginTop: 8 }}>¥{Number(bySource[source]?.amount || 0).toFixed(2)}</div><div style={{ color: '#94a3b8' }}>{Number(bySource[source]?.count || 0)} 笔</div></div></Col>)}</Row></Card>
|
||||
<Card title="订单列表">
|
||||
<Space wrap style={{ marginBottom: 16 }}>
|
||||
<Select allowClear placeholder="订单来源" style={{ width: 160 }} value={filters.orderSource} onChange={(v) => { setFilters((x:any) => ({...x, orderSource:v})); setPage(1); }} options={Object.entries(SOURCE_LABELS).map(([value,label]) => ({value,label}))} />
|
||||
<Select allowClear placeholder="支付方式" style={{ width: 150 }} value={filters.paymentMethod} onChange={(v) => { setFilters((x:any) => ({...x, paymentMethod:v})); setPage(1); }} options={Object.entries(METHOD_LABELS).map(([value,label]) => ({value,label}))} />
|
||||
<Select allowClear placeholder="订单状态" style={{ width: 130 }} value={filters.status} onChange={(v) => { setFilters((x:any) => ({...x, status:v})); setPage(1); }} options={Object.entries(STATUS_LABELS).map(([value,label]) => ({value,label}))} />
|
||||
<Input allowClear placeholder="手机号" style={{ width: 150 }} value={filters.phone} onChange={(e) => setFilters((x:any) => ({...x, phone:e.target.value}))} onPressEnter={() => { setPage(1); void load(); }} suffix={<SearchOutlined />} />
|
||||
<RangePicker value={[dayjs(filters.startDate), dayjs(filters.endDate)]} onChange={(v) => { setFilters((x:any) => ({...x, startDate:v?.[0]?.format('YYYY-MM-DD'), endDate:v?.[1]?.format('YYYY-MM-DD')})); setPage(1); }} />
|
||||
<Button icon={<ReloadOutlined />} onClick={reset}>重置</Button>
|
||||
</Space>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={orders} scroll={{ x: 1450 }} pagination={{ current:page, pageSize, total, showSizeChanger:true, showTotal:(t)=>`共 ${t} 条`, onChange:(p,ps)=>{setPage(p);setPageSize(ps);} }} />
|
||||
</Card>
|
||||
</div></ConfigProvider>;
|
||||
};
|
||||
|
||||
export default AdminPaymentStats;
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Space, Table, Tag, Typography, message, Modal, Form, Input, InputNumber, Upload,
|
||||
} from 'antd';
|
||||
import {
|
||||
HistoryOutlined, ReloadOutlined, PlusOutlined, UploadOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getOpenTypeList, getOpenType, createOpenType, updateOpenType, deleteOpenType, uploadImage } from '../api';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
|
||||
interface OpenType {
|
||||
id: string;
|
||||
openType: number;
|
||||
typeName: string;
|
||||
description: string;
|
||||
thumb?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const normalizeUploadError = (e: unknown): Error => {
|
||||
if (e instanceof Error) {
|
||||
return e;
|
||||
}
|
||||
|
||||
if (typeof e === 'string') {
|
||||
return new Error(e);
|
||||
}
|
||||
|
||||
return new Error('上传失败');
|
||||
};
|
||||
|
||||
const assertUploadFile = (file: unknown): File => {
|
||||
if (file instanceof File) {
|
||||
return file;
|
||||
}
|
||||
|
||||
throw new Error('请选择有效图片文件');
|
||||
};
|
||||
|
||||
const AdminPlatform: React.FC = () => {
|
||||
const [openTypes, setOpenTypes] = useState<OpenType[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const [createModalVisible, setCreateModalVisible] = useState(false);
|
||||
const [detailModalVisible, setDetailModalVisible] = useState(false);
|
||||
const [updateModalVisible, setUpdateModalVisible] = useState(false);
|
||||
const [currentOpenType, setCurrentOpenType] = useState<OpenType | null>(null);
|
||||
const [createThumbUrl, setCreateThumbUrl] = useState('');
|
||||
const [updateThumbUrl, setUpdateThumbUrl] = useState('');
|
||||
|
||||
const [createForm] = Form.useForm();
|
||||
const [updateForm] = Form.useForm();
|
||||
|
||||
const load = async (p?: number, ps?: number) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getOpenTypeList({
|
||||
page: p || page,
|
||||
page_size: ps || pageSize,
|
||||
});
|
||||
setOpenTypes(res.data || []);
|
||||
setTotal(res.pagination.total || 0);
|
||||
} catch {
|
||||
message.error('加载开户方式列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
const values = await createForm.validateFields();
|
||||
const thumbUrl = createThumbUrl;
|
||||
const thumbPath = thumbUrl.startsWith('http') ? thumbUrl.replace(/^https?:\/\/[^/]+/, '') : thumbUrl;
|
||||
await createOpenType({
|
||||
open_type: values.open_type,
|
||||
type_name: values.type_name,
|
||||
description: values.description,
|
||||
thumb: thumbPath,
|
||||
});
|
||||
message.success('创建成功');
|
||||
setCreateModalVisible(false);
|
||||
createForm.resetFields();
|
||||
setCreateThumbUrl('');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDetail = async (id: string) => {
|
||||
try {
|
||||
const openType = await getOpenType(id);
|
||||
setCurrentOpenType(openType.data || {});
|
||||
setDetailModalVisible(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: string) => {
|
||||
try {
|
||||
const openType = await getOpenType(id);
|
||||
const thumb = openType.data?.thumb || '';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const fullThumbUrl = thumb.startsWith('http') ? thumb : `${baseUrl}${thumb}`;
|
||||
setCurrentOpenType(openType.data || {});
|
||||
setUpdateThumbUrl(fullThumbUrl);
|
||||
setUpdateModalVisible(true);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '获取详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveUpdate = async () => {
|
||||
if (!currentOpenType) return;
|
||||
try {
|
||||
const values = await updateForm.validateFields();
|
||||
const thumbUrl = updateThumbUrl;
|
||||
const thumbPath = thumbUrl.startsWith('http') ? thumbUrl.replace(/^https?:\/\/[^/]+/, '') : thumbUrl;
|
||||
await updateOpenType(currentOpenType.id, {
|
||||
open_type: values.open_type,
|
||||
type_name: values.type_name,
|
||||
description: values.description,
|
||||
thumb: thumbPath,
|
||||
});
|
||||
message.success('更新成功');
|
||||
setUpdateModalVisible(false);
|
||||
updateForm.resetFields();
|
||||
setCurrentOpenType(null);
|
||||
setUpdateThumbUrl('');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这个开户方式吗?',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await deleteOpenType(id);
|
||||
message.success('删除成功');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
width: 100,
|
||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '开户类型',
|
||||
dataIndex: 'openType',
|
||||
width: 100,
|
||||
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型名称',
|
||||
dataIndex: 'typeName',
|
||||
width: 150,
|
||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
width: 250,
|
||||
ellipsis: true,
|
||||
render: (v: string) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{v}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '缩略图',
|
||||
dataIndex: 'thumb',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const fullUrl = v.startsWith('http') ? v : `${baseUrl}${v}`;
|
||||
return <img src={fullUrl} alt="thumb" style={{ width: '100%', height: 48, display: 'block' }} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 160,
|
||||
render: (v: string) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDate(v)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 160,
|
||||
render: (v: string) => (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDate(v)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
render: (_: any, record: OpenType) => (
|
||||
<Space>
|
||||
<Button
|
||||
icon={<EyeOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleDetail(record.id)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleUpdate(record.id)}
|
||||
>
|
||||
更新
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => handleDelete(record.id)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>平台开户方式管理</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateModalVisible(true)}>
|
||||
新增
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load()}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={openTypes}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showTotal: (t) => `共 ${t} 条记录`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
load(p, ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title="新增开户方式"
|
||||
open={createModalVisible}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => {
|
||||
setCreateModalVisible(false);
|
||||
createForm.resetFields();
|
||||
setCreateThumbUrl('');
|
||||
}}
|
||||
mask={{ closable: false }}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="open_type"
|
||||
label="开户方式Id"
|
||||
style={{ flex: 1 }}
|
||||
rules={[{ required: true, message: '请输入开户方式Id' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={1} placeholder="请输入开户方式Id" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="type_name"
|
||||
label="类型名称"
|
||||
style={{ flex: 1 }}
|
||||
rules={[
|
||||
{ required: true, message: '请输入类型名称' },
|
||||
{ max: 100, message: '类型名称不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
<Input style={{ width: '100%' }} placeholder="请输入类型名称" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
rules={[{ required: true, message: '请输入描述' }]}
|
||||
>
|
||||
<Input.TextArea placeholder="请输入描述" rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item label="缩略图">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={createThumbUrl ? [{ uid: '1', name: 'thumb', status: 'done', url: createThumbUrl }] : []}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const uploadFile = assertUploadFile(file);
|
||||
const res = await uploadImage(uploadFile);
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const fullUrl = res.url.startsWith('http') ? res.url : `${baseUrl}${res.url}`;
|
||||
setCreateThumbUrl(fullUrl);
|
||||
onSuccess?.({ url: fullUrl });
|
||||
} catch (e) {
|
||||
onError?.(normalizeUploadError(e));
|
||||
}
|
||||
}}
|
||||
onRemove={() => setCreateThumbUrl('')}
|
||||
>
|
||||
{!createThumbUrl && (
|
||||
<div>
|
||||
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
|
||||
<div style={{ marginTop: 8 }}>上传图片</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="开户方式详情"
|
||||
open={detailModalVisible}
|
||||
onOk={() => setDetailModalVisible(false)}
|
||||
onCancel={() => {
|
||||
setDetailModalVisible(false);
|
||||
setCurrentOpenType(null);
|
||||
}}
|
||||
mask={{ closable: false }}
|
||||
okText="关闭"
|
||||
cancelText="取消"
|
||||
width={520}
|
||||
>
|
||||
{currentOpenType && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 16, background: '#f8fafc', borderRadius: 8 }}>
|
||||
<div style={{
|
||||
width: 80, height: 48, borderRadius: 6, overflow: 'hidden',
|
||||
background: currentOpenType.thumb ? 'transparent' : '#e2e8f0',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{currentOpenType.thumb ? (
|
||||
<img
|
||||
src={currentOpenType.thumb.startsWith('http') ? currentOpenType.thumb : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${currentOpenType.thumb}`}
|
||||
alt="thumb"
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>暂无图片</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 16, fontWeight: 600 }}>{currentOpenType.typeName}</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>开户类型: {currentOpenType.openType}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>ID</Typography.Text>
|
||||
<Typography.Text>{currentOpenType.id}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>开户类型</Typography.Text>
|
||||
<Typography.Text>{currentOpenType.openType}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>类型名称</Typography.Text>
|
||||
<Typography.Text>{currentOpenType.typeName}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80 }}>描述</Typography.Text>
|
||||
<div style={{ padding: 12, background: '#f8fafc', borderRadius: 6, fontSize: 13, lineHeight: 1.6 }}>
|
||||
{currentOpenType.description || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>创建时间</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentOpenType.createdAt)}</Typography.Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<Typography.Text type="secondary" style={{ width: 80, flexShrink: 0 }}>更新时间</Typography.Text>
|
||||
<Typography.Text>{formatDate(currentOpenType.updatedAt)}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="更新开户方式"
|
||||
open={updateModalVisible}
|
||||
onOk={handleSaveUpdate}
|
||||
onCancel={() => {
|
||||
setUpdateModalVisible(false);
|
||||
updateForm.resetFields();
|
||||
setCurrentOpenType(null);
|
||||
}}
|
||||
afterOpenChange={(open) => {
|
||||
if (open && currentOpenType) {
|
||||
updateForm.setFieldsValue({
|
||||
open_type: currentOpenType.openType,
|
||||
type_name: currentOpenType.typeName,
|
||||
description: currentOpenType.description,
|
||||
});
|
||||
}
|
||||
}}
|
||||
mask={{ closable: false }}
|
||||
okText="更新"
|
||||
cancelText="取消"
|
||||
width={600}
|
||||
>
|
||||
<Form form={updateForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="open_type"
|
||||
label="开户类型"
|
||||
rules={[{ required: true, message: '请输入开户类型' }]}
|
||||
>
|
||||
<InputNumber min={1} placeholder="请输入开户类型编号" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="type_name"
|
||||
label="类型名称"
|
||||
rules={[
|
||||
{ required: true, message: '请输入类型名称' },
|
||||
{ max: 100, message: '类型名称不能超过100个字符' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入类型名称" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
rules={[{ required: true, message: '请输入描述' }]}
|
||||
>
|
||||
<Input.TextArea placeholder="请输入描述" rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item label="缩略图">
|
||||
<Upload
|
||||
listType="picture-card"
|
||||
fileList={updateThumbUrl ? [{ uid: '1', name: 'thumb', status: 'done', url: updateThumbUrl }] : []}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
try {
|
||||
const uploadFile = assertUploadFile(file);
|
||||
const res = await uploadImage(uploadFile);
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const fullUrl = res.url.startsWith('http') ? res.url : `${baseUrl}${res.url}`;
|
||||
setUpdateThumbUrl(fullUrl);
|
||||
onSuccess?.({ url: fullUrl });
|
||||
} catch (e) {
|
||||
onError?.(normalizeUploadError(e));
|
||||
}
|
||||
}}
|
||||
onRemove={() => setUpdateThumbUrl('')}
|
||||
>
|
||||
{!updateThumbUrl && (
|
||||
<div>
|
||||
<UploadOutlined style={{ fontSize: 24, color: '#999' }} />
|
||||
<div style={{ marginTop: 8 }}>上传图片</div>
|
||||
</div>
|
||||
)}
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPlatform;
|
||||
@@ -0,0 +1,502 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Input, Table, Pagination, Tag, Typography, DatePicker, message, Tooltip } from 'antd';
|
||||
import { FileTextOutlined } from '@ant-design/icons';
|
||||
import { getPreTestTemplateList, getArea } from '../api';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface PreTestField {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PRETEST_FIELDS: PreTestField[] = [
|
||||
{ id: 'AD_APP_ACTIVATE', name: 'AD_APP_ACTIVATE', label: '应用-激活', description: '应用激活行为' },
|
||||
{ id: 'AD_APP_AUTH', name: 'AD_APP_AUTH', label: '应用-授信', description: '应用授信行为' },
|
||||
{ id: 'AD_APP_BOOK', name: 'AD_APP_BOOK', label: '应用-预约表单', description: '应用预约表单' },
|
||||
{ id: 'AD_APP_BUY', name: 'AD_APP_BUY', label: '应用-APP内付费', description: 'APP内付费行为' },
|
||||
{ id: 'AD_APP_CLICKS', name: 'AD_APP_CLICKS', label: '应用-点击量', description: '应用点击量' },
|
||||
{ id: 'AD_APP_DETAIL', name: 'AD_APP_DETAIL', label: '应用-APP内详情页到站UV', description: 'APP内详情页到站UV' },
|
||||
{ id: 'AD_APP_DOWNLOADED', name: 'AD_APP_DOWNLOADED', label: '应用-下载完成', description: '应用下载完成' },
|
||||
{ id: 'AD_APP_INSTALLED', name: 'AD_APP_INSTALLED', label: '应用-安装完成', description: '应用安装完成' },
|
||||
{ id: 'AD_APP_KEY_BEHAVIOR', name: 'AD_APP_KEY_BEHAVIOR', label: '应用-关键行为', description: '应用关键行为' },
|
||||
{ id: 'AD_APP_ORDER', name: 'AD_APP_ORDER', label: '应用-APP内下单', description: 'APP内下单行为' },
|
||||
{ id: 'AD_APP_PAY', name: 'AD_APP_PAY', label: '应用-付费', description: '应用付费行为' },
|
||||
{ id: 'AD_APP_PRE_AUTH', name: 'AD_APP_PRE_AUTH', label: '应用-预授信', description: '应用预授信行为' },
|
||||
{ id: 'AD_APP_PRE_DOWNLOAD', name: 'AD_APP_PRE_DOWNLOAD', label: '应用-预约下载', description: '应用预约下载' },
|
||||
{ id: 'AD_APP_PUSH_ORDER', name: 'AD_APP_PUSH_ORDER', label: '应用-首次发单(乘客)', description: '首次发单行为' },
|
||||
{ id: 'AD_APP_REGISTER', name: 'AD_APP_REGISTER', label: '应用-注册', description: '应用注册行为' },
|
||||
{ id: 'AD_APP_SHOW', name: 'AD_APP_SHOW', label: '应用-展示量', description: '应用展示量' },
|
||||
{ id: 'AD_APP_SUBMIT', name: 'AD_APP_SUBMIT', label: '应用-提交认证', description: '应用提交认证' },
|
||||
{ id: 'AD_APP_VIEW', name: 'AD_APP_VIEW', label: '应用-APP内访问', description: 'APP内访问行为' },
|
||||
{ id: 'AD_CLUE_AUTH', name: 'AD_CLUE_AUTH', label: '销售线索收集-授信', description: '销售线索授信' },
|
||||
{ id: 'AD_CLUE_BUTTON', name: 'AD_CLUE_BUTTON', label: '销售线索收集-按钮跳转', description: '按钮跳转' },
|
||||
{ id: 'AD_CLUE_CLICKS', name: 'AD_CLUE_CLICKS', label: '销售线索收集-点击量', description: '点击量' },
|
||||
{ id: 'AD_CLUE_CONFIRM', name: 'AD_CLUE_CONFIRM', label: '销售线索收集-回访-信息确认', description: '回访信息确认' },
|
||||
{ id: 'AD_CLUE_CONSULT', name: 'AD_CLUE_CONSULT', label: '销售线索收集-有效咨询', description: '有效咨询' },
|
||||
{ id: 'AD_CLUE_CONSULT_MSG', name: 'AD_CLUE_CONSULT_MSG', label: '销售线索收集-留资咨询', description: '留资咨询' },
|
||||
{ id: 'AD_CLUE_COUPON', name: 'AD_CLUE_COUPON', label: '销售线索收集-卡券领取', description: '卡券领取' },
|
||||
{ id: 'AD_CLUE_CUSTOMER', name: 'AD_CLUE_CUSTOMER', label: '销售线索收集-有效获客', description: '有效获客' },
|
||||
{ id: 'AD_CLUE_CVT', name: 'AD_CLUE_CVT', label: '销售线索收集-多转化', description: '多转化' },
|
||||
{ id: 'AD_CLUE_DONE', name: 'AD_CLUE_DONE', label: '销售线索收集-完件', description: '完件' },
|
||||
{ id: 'AD_CLUE_FORM', name: 'AD_CLUE_FORM', label: '销售线索收集-表单提交', description: '表单提交' },
|
||||
{ id: 'AD_CLUE_FRIENDS', name: 'AD_CLUE_FRIENDS', label: '销售线索收集-回访-加为好友', description: '回访加为好友' },
|
||||
{ id: 'AD_CLUE_INSURANCE', name: 'AD_CLUE_INSURANCE', label: '销售线索收集-保险支付', description: '保险支付' },
|
||||
{ id: 'AD_CLUE_INTENTION', name: 'AD_CLUE_INTENTION', label: '销售线索收集-存在意向', description: '存在意向' },
|
||||
{ id: 'AD_CLUE_INTENTION_FORM', name: 'AD_CLUE_INTENTION_FORM', label: '销售线索收集-意向表单', description: '意向表单' },
|
||||
{ id: 'AD_CLUE_INTENTION_TEL', name: 'AD_CLUE_INTENTION_TEL', label: '销售线索收集-意向话单', description: '意向话单' },
|
||||
{ id: 'AD_CLUE_MESSAGE', name: 'AD_CLUE_MESSAGE', label: '销售线索收集-私信消息', description: '私信消息' },
|
||||
{ id: 'AD_CLUE_MONEY', name: 'AD_CLUE_MONEY', label: '销售线索收集-放款', description: '放款' },
|
||||
{ id: 'AD_CLUE_MSG', name: 'AD_CLUE_MSG', label: '销售线索收集-私信留资', description: '私信留资' },
|
||||
{ id: 'AD_CLUE_PAGE', name: 'AD_CLUE_PAGE', label: '销售线索收集-访问目标页面', description: '访问目标页面' },
|
||||
{ id: 'AD_CLUE_PAY', name: 'AD_CLUE_PAY', label: '销售线索收集-付费', description: '付费' },
|
||||
{ id: 'AD_CLUE_PRE_AUTH', name: 'AD_CLUE_PRE_AUTH', label: '销售线索收集-预授信', description: '预授信' },
|
||||
{ id: 'AD_CLUE_PROTENTIAL_DEAL', name: 'AD_CLUE_PROTENTIAL_DEAL', label: '销售线索收集-回访-高潜成交', description: '回访高潜成交' },
|
||||
{ id: 'AD_CLUE_PUSH_ORDER', name: 'AD_CLUE_PUSH_ORDER', label: '销售线索收集-首次发单(乘客)', description: '首次发单' },
|
||||
{ id: 'AD_CLUE_REGISTER', name: 'AD_CLUE_REGISTER', label: '销售线索收集-注册', description: '注册' },
|
||||
{ id: 'AD_CLUE_SHOW', name: 'AD_CLUE_SHOW', label: '销售线索收集-展示量', description: '展示量' },
|
||||
{ id: 'AD_CLUE_SUBMIT', name: 'AD_CLUE_SUBMIT', label: '销售线索收集-提交认证', description: '提交认证' },
|
||||
{ id: 'AD_CLUE_TEL', name: 'AD_CLUE_TEL', label: '销售线索收集-智能电话确认接通', description: '智能电话确认接通' },
|
||||
{ id: 'AD_CLUE_TEL_CALL', name: 'AD_CLUE_TEL_CALL', label: '销售线索收集-电话接通', description: '电话接通' },
|
||||
{ id: 'AD_CLUE_WX_ADD', name: 'AD_CLUE_WX_ADD', label: '销售线索收集-微信-添加企业微信', description: '添加企业微信' },
|
||||
{ id: 'AD_CLUE_WX_COPY', name: 'AD_CLUE_WX_COPY', label: '销售线索收集-微信复制', description: '微信复制' },
|
||||
{ id: 'AD_CLUE_WX_MSG', name: 'AD_CLUE_WX_MSG', label: '销售线索收集-微信-用户首次消息', description: '用户首次消息' },
|
||||
{ id: 'AD_ECP_APP_BUY', name: 'AD_ECP_APP_BUY', label: '电商-app内下单', description: '电商app内下单' },
|
||||
{ id: 'AD_ECP_APP_DETAIL', name: 'AD_ECP_APP_DETAIL', label: '电商-app内详情页到站uv', description: '电商app内详情页到站uv' },
|
||||
{ id: 'AD_ECP_APP_VIEW', name: 'AD_ECP_APP_VIEW', label: '电商-app内访问', description: '电商app内访问' },
|
||||
{ id: 'AD_ECP_BUTTON', name: 'AD_ECP_BUTTON', label: '电商-按钮跳转', description: '电商按钮跳转' },
|
||||
{ id: 'AD_ECP_INTEREST', name: 'AD_ECP_INTEREST', label: '电商-引流电商种草', description: '引流电商种草' },
|
||||
{ id: 'AD_ECP_SHOP', name: 'AD_ECP_SHOP', label: '电商-调起店铺', description: '调起店铺' },
|
||||
{ id: 'AD_ECP_SHOP_STAY', name: 'AD_ECP_SHOP_STAY', label: '电商-店铺停留', description: '店铺停留' },
|
||||
{ id: 'AD_MINIAPP_ACTIVATE', name: 'AD_MINIAPP_ACTIVATE', label: '快应用-激活', description: '快应用激活' },
|
||||
{ id: 'AD_MINIAPP_KEY_BEHAVIOR', name: 'AD_MINIAPP_KEY_BEHAVIOR', label: '快应用-关键行为', description: '快应用关键行为' },
|
||||
{ id: 'AD_MINIAPP_PAY', name: 'AD_MINIAPP_PAY', label: '快应用-付费', description: '快应用付费' },
|
||||
{ id: 'AD_MINIAPP_REGISTER', name: 'AD_MINIAPP_REGISTER', label: '快应用-注册', description: '快应用注册' },
|
||||
{ id: 'AD_NATIVE_ACTIVATE', name: 'AD_NATIVE_ACTIVATE', label: '原声互动-激活', description: '原声互动激活' },
|
||||
{ id: 'AD_NATIVE_CLICKS', name: 'AD_NATIVE_CLICKS', label: '原声互动-组件点击', description: '组件点击' },
|
||||
{ id: 'AD_NATIVE_FANS_GROUP', name: 'AD_NATIVE_FANS_GROUP', label: '原声互动-粉丝入群', description: '粉丝入群' },
|
||||
{ id: 'AD_NATIVE_FOLLOW', name: 'AD_NATIVE_FOLLOW', label: '原声互动-帐号关注', description: '帐号关注' },
|
||||
{ id: 'AD_NATIVE_INTERACTIVE', name: 'AD_NATIVE_INTERACTIVE', label: '原声互动-互动', description: '互动' },
|
||||
{ id: 'AD_NATIVE_LIVE', name: 'AD_NATIVE_LIVE', label: '原声互动-预约直播', description: '预约直播' },
|
||||
{ id: 'AD_NATIVE_LIVE_DONATE', name: 'AD_NATIVE_LIVE_DONATE', label: '原声互动-直播间营销捐赠', description: '直播间营销捐赠' },
|
||||
{ id: 'AD_NATIVE_LIVE_PAY', name: 'AD_NATIVE_LIVE_PAY', label: '原声互动-直播间打赏', description: '直播间打赏' },
|
||||
{ id: 'AD_NATIVE_LIVE_STAY', name: 'AD_NATIVE_LIVE_STAY', label: '原声互动-直播间停留', description: '直播间停留' },
|
||||
{ id: 'AD_NATIVE_LIVE_VIEW', name: 'AD_NATIVE_LIVE_VIEW', label: '原声互动-直播间观看', description: '直播间观看' },
|
||||
{ id: 'AD_NATIVE_PAY', name: 'AD_NATIVE_PAY', label: '原声互动-付费', description: '原声互动付费' },
|
||||
{ id: 'AD_PRODUCT_ACTIVATE', name: 'AD_PRODUCT_ACTIVATE', label: '商品-激活', description: '商品激活' },
|
||||
{ id: 'AD_PRODUCT_APP_BUY', name: 'AD_PRODUCT_APP_BUY', label: '商品-app内下单', description: '商品app内下单' },
|
||||
{ id: 'AD_PRODUCT_APP_DETAIL', name: 'AD_PRODUCT_APP_DETAIL', label: '商品-app内详情页到站uv', description: '商品app内详情页到站uv' },
|
||||
{ id: 'AD_PRODUCT_APP_PAY', name: 'AD_PRODUCT_APP_PAY', label: '商品-app内付费', description: '商品app内付费' },
|
||||
{ id: 'AD_PRODUCT_APP_VIEW', name: 'AD_PRODUCT_APP_VIEW', label: '商品-app内访问', description: '商品app内访问' },
|
||||
{ id: 'AD_PRODUCT_FORM', name: 'AD_PRODUCT_FORM', label: '商品-表单提交', description: '商品表单提交' },
|
||||
{ id: 'AD_PRODUCT_KEY_BEHAVIOR', name: 'AD_PRODUCT_KEY_BEHAVIOR', label: '商品-关键行为', description: '商品关键行为' },
|
||||
{ id: 'AD_PRODUCT_PAY', name: 'AD_PRODUCT_PAY', label: '商品-付费', description: '商品付费' },
|
||||
{ id: 'AD_TINYAPP_ACTIVATE', name: 'AD_TINYAPP_ACTIVATE', label: '小程序-激活', description: '小程序激活' },
|
||||
{ id: 'AD_TINYAPP_KEY_BEHAVIOR', name: 'AD_TINYAPP_KEY_BEHAVIOR', label: '小程序-关键行为', description: '小程序关键行为' },
|
||||
{ id: 'AD_TINYAPP_PAY', name: 'AD_TINYAPP_PAY', label: '小程序-付费', description: '小程序付费' },
|
||||
{ id: 'QC_LIVE_BUY', name: 'QC_LIVE_BUY', label: '直播投放-直播间下单', description: '直播间下单' },
|
||||
{ id: 'QC_LIVE_CHECK', name: 'QC_LIVE_CHECK', label: '直播投放-直播间结算', description: '直播间结算' },
|
||||
{ id: 'QC_LIVE_COMMENTS', name: 'QC_LIVE_COMMENTS', label: '直播投放-直播间评论', description: '直播间评论' },
|
||||
{ id: 'QC_LIVE_DEAL', name: 'QC_LIVE_DEAL', label: '直播投放-直播间成交', description: '直播间成交' },
|
||||
{ id: 'QC_LIVE_ENTRY', name: 'QC_LIVE_ENTRY', label: '直播投放-进入直播间', description: '进入直播间' },
|
||||
{ id: 'QC_LIVE_FANS', name: 'QC_LIVE_FANS', label: '直播投放-直播间粉丝提升', description: '直播间粉丝提升' },
|
||||
{ id: 'QC_LIVE_HIT', name: 'QC_LIVE_HIT', label: '直播投放-直播加热', description: '直播加热' },
|
||||
{ id: 'QC_LIVE_PRODUCT_CLICKS', name: 'QC_LIVE_PRODUCT_CLICKS', label: '直播投放-直播间商品点击', description: '直播间商品点击' },
|
||||
{ id: 'QC_LIVE_ROI_CHECK', name: 'QC_LIVE_ROI_CHECK', label: '直播投放-结算roi', description: '结算roi' },
|
||||
{ id: 'QC_LIVE_ROI_DEAL', name: 'QC_LIVE_ROI_DEAL', label: '直播投放-支付roi-直播间成交', description: '支付roi直播间成交' },
|
||||
{ id: 'QC_LIVE_ROI_QC', name: 'QC_LIVE_ROI_QC', label: '直播投放-支付roi-千川直接+间接订单', description: '支付roi千川订单' },
|
||||
{ id: 'QC_PRODUCT_BUY', name: 'QC_PRODUCT_BUY', label: '商品投放-商品购买', description: '商品购买' },
|
||||
{ id: 'QC_PRODUCT_COMMENTS', name: 'QC_PRODUCT_COMMENTS', label: '商品投放-点赞评论', description: '点赞评论' },
|
||||
{ id: 'QC_PRODUCT_FANS', name: 'QC_PRODUCT_FANS', label: '商品投放-粉丝提升', description: '粉丝提升' },
|
||||
{ id: 'QC_PRODUCT_INTEREST', name: 'QC_PRODUCT_INTEREST', label: '商品投放-人群种草', description: '人群种草' },
|
||||
{ id: 'QC_PRODUCT_QC', name: 'QC_PRODUCT_QC', label: '商品投放-千川直接+间接订单', description: '千川订单' },
|
||||
{ id: 'QC_PRODUCT_ROI', name: 'QC_PRODUCT_ROI', label: '商品投放-商品支付roi', description: '商品支付roi' },
|
||||
];
|
||||
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
const { Text } = Typography;
|
||||
const AdminPreTestTemplates: React.FC = () => {
|
||||
const [tableData, setTableData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [searchId, setSearchId] = useState('');
|
||||
const [searchPhone, setSearchPhone] = useState('');
|
||||
const [searchCreatedAt, setSearchCreatedAt] = useState<[dayjs.Dayjs, dayjs.Dayjs] | undefined>();
|
||||
const [cityCodeMap, setCityCodeMap] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
loadProvinceOptions();
|
||||
}, []);
|
||||
|
||||
const loadProvinceOptions = async () => {
|
||||
try {
|
||||
const cachedData = localStorage.getItem('preTestAreaData');
|
||||
if (cachedData) {
|
||||
const parsed = JSON.parse(cachedData);
|
||||
setCityCodeMap(parsed.cityCodeMap);
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await getArea({ level: 'ONE_LEVEL' });
|
||||
const provinces = res.data || [];
|
||||
const map: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
provinces.map(async (province: any) => {
|
||||
let cities: any[] = [];
|
||||
try {
|
||||
const cityRes = await getArea({ level: 'TWO_LEVEL', parent_code: province.code });
|
||||
cities = cityRes.data || [];
|
||||
cities.forEach((city: any) => {
|
||||
map[city.code] = city.name;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`获取省份 ${province.name} 的城市失败`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const cacheData = { cityCodeMap: map };
|
||||
localStorage.setItem('preTestAreaData', JSON.stringify(cacheData));
|
||||
setCityCodeMap(map);
|
||||
} catch (error) {
|
||||
console.error('加载区域数据失败:', error);
|
||||
message.error('加载区域数据失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '序号',
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
render: (text: number) => <span style={{ color: '#64748b' }}>{text}</span>,
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// title: '手机号',
|
||||
// dataIndex: 'phone',
|
||||
// key: 'phone',
|
||||
// width: 150,
|
||||
// },
|
||||
{
|
||||
title: '投放平台',
|
||||
dataIndex: 'platform',
|
||||
key: 'platform',
|
||||
width: 100,
|
||||
render: (text: string) => (
|
||||
<Tag color={text === 'AD' ? 'blue' : text === 'QIANCHUAN' ? 'green' : 'orange'} style={{ borderRadius: 6, fontSize: 12 }}>
|
||||
{text === 'AD' ? 'AD' : text === 'QIANCHUAN' ? '千川' : '本地推'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '优化目标',
|
||||
dataIndex: 'externalAction',
|
||||
key: 'externalAction',
|
||||
ellipsis: true,
|
||||
width: 200,
|
||||
render: (text: string) => {
|
||||
if (!text) return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
|
||||
const field = PRETEST_FIELDS.find(f => f.name === text.trim());
|
||||
return (
|
||||
<span style={{ color: '#1e293b', fontSize: 13 }}>{field ? field.label : text}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '目标转化成本',
|
||||
dataIndex: 'cpaBid',
|
||||
key: 'cpaBid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '受众年龄',
|
||||
dataIndex: 'audienceAge',
|
||||
key: 'audienceAge',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '受众性别',
|
||||
dataIndex: 'audienceGender',
|
||||
key: 'audienceGender',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '受众网络',
|
||||
dataIndex: 'audienceNetwork',
|
||||
key: 'audienceNetwork',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '受众区域',
|
||||
dataIndex: 'audienceRegion',
|
||||
key: 'audienceRegion',
|
||||
ellipsis: true,
|
||||
width: 300,
|
||||
render: (text: string) => {
|
||||
const arr = JSON.parse(text);
|
||||
if (!Array.isArray(arr) || arr.length === 0) {
|
||||
return <span style={{ color: '#94a3b8', fontSize: 13 }}>-</span>;
|
||||
}
|
||||
const cityNames = arr.map(code => {
|
||||
const paddedCode = String(code).padEnd(6, '0');
|
||||
return cityCodeMap[paddedCode] || paddedCode;
|
||||
});
|
||||
return (
|
||||
<Input.TextArea
|
||||
value={cityNames.join(', ') || '-'}
|
||||
readOnly
|
||||
autoSize={{ minRows: 1, maxRows: 4 }}
|
||||
style={{ resize: 'none', border: 'none', background: 'transparent', padding: 0 }}
|
||||
placeholder="-"
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '客户主体名称',
|
||||
dataIndex: 'cusName',
|
||||
key: 'cusName',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '计费方式',
|
||||
dataIndex: 'pricingType',
|
||||
key: 'pricingType',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '是否最优成本出价',
|
||||
dataIndex: 'costCap',
|
||||
key: 'costCap',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '是否稳定成本出价',
|
||||
dataIndex: 'targetCost',
|
||||
key: 'targetCost',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '是否最大转化出价',
|
||||
dataIndex: 'nobid',
|
||||
key: 'nobid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '目标点击成本',
|
||||
dataIndex: 'cpcBid',
|
||||
key: 'cpcBid',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '预算金额',
|
||||
dataIndex: 'budget',
|
||||
key: 'budget',
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'note',
|
||||
key: 'note',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '用户ID',
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
if (!v) return '-';
|
||||
const short = v.length > 12 ? `${v.slice(0, 6)}...${v.slice(-4)}` : v;
|
||||
return (
|
||||
<Tooltip title={v} placement="topLeft">
|
||||
<Typography.Text style={{ fontSize: 12 }}>{short}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '用户手机号',
|
||||
dataIndex: 'userPhone',
|
||||
key: 'userPhone',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: '是否默认',
|
||||
dataIndex: 'isDefault',
|
||||
key: 'isDefault',
|
||||
width: 100,
|
||||
render: (text: boolean) => <Tag color={text ? 'green' : 'red'}>{text ? '是' : '否'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
dataIndex: 'updatedAt',
|
||||
key: 'updatedAt',
|
||||
width: 180,
|
||||
render: (text: string) => <span style={{ color: '#64748b' }}>{formatDateTime(text)}</span>,
|
||||
},
|
||||
];
|
||||
const loadRecords = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getPreTestTemplateList({
|
||||
id: searchId || undefined,
|
||||
phone: searchPhone || undefined,
|
||||
created_at: searchCreatedAt ? [searchCreatedAt[0].format('YYYY-MM-DD'), searchCreatedAt[1].format('YYYY-MM-DD')] : undefined,
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
});
|
||||
const data = res.data || [];
|
||||
const tableData = data.map((item: any, index: number) => ({
|
||||
...item,
|
||||
index: (currentPage - 1) * pageSize + index + 1,
|
||||
}));
|
||||
setTableData(tableData);
|
||||
setTotal(res.pagination.total || 0);
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
loadRecords();
|
||||
}, [currentPage, pageSize]);
|
||||
const handlePageChange = (page: number, size: number) => {
|
||||
setCurrentPage(page);
|
||||
setPageSize(size);
|
||||
};
|
||||
const handleSearch = () => {
|
||||
setCurrentPage(1);
|
||||
loadRecords();
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
||||
<FileTextOutlined style={{ color: '#6366f1', fontSize: 16 }} />
|
||||
<Text strong style={{ fontSize: 16 }}>素材前测模板</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Input
|
||||
placeholder="ID"
|
||||
value={searchId}
|
||||
onChange={(e) => setSearchId(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<Input
|
||||
placeholder="手机号"
|
||||
value={searchPhone}
|
||||
onChange={(e) => setSearchPhone(e.target.value)}
|
||||
style={{ width: 160 }}
|
||||
allowClear
|
||||
onPressEnter={handleSearch}
|
||||
/>
|
||||
<DatePicker.RangePicker
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
value={searchCreatedAt}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setSearchCreatedAt([dates[0], dates[1]]);
|
||||
} else {
|
||||
setSearchCreatedAt(undefined);
|
||||
}
|
||||
}}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSearch}
|
||||
>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
background: '#fff',
|
||||
borderRadius: 16,
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.04)',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Table
|
||||
dataSource={tableData}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
rowKey="id"
|
||||
bordered={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={() => ({
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
},
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = '#f8fafc';
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
(e.currentTarget as HTMLElement).style.backgroundColor = 'transparent';
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<div style={{ padding: '16px', textAlign: 'right' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
showTotal={(total) => `共 ${total} 条记录`}
|
||||
pageSizeOptions={['10', '20', '50', '100']}
|
||||
onChange={handlePageChange}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default AdminPreTestTemplates;
|
||||
@@ -0,0 +1,264 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Button, Card, Image, Input, Select, Space, Statistic, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { CopyOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { adminGetPrivatePortraitAssets, adminGetPrivatePortraitProjects } from '../api';
|
||||
import type { PrivatePortraitAsset, PrivatePortraitProject } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { apiUrl } from '../utils/resourceUrl';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const LIBRARY_OPTIONS = [
|
||||
{ label: '全部素材库', value: '' },
|
||||
{ label: '真人认证', value: 'real_person' },
|
||||
{ label: '虚拟人像', value: 'aigc_virtual' },
|
||||
];
|
||||
|
||||
const ASSET_TYPE_OPTIONS = [
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '图片', value: 'Image' },
|
||||
{ label: '视频', value: 'Video' },
|
||||
];
|
||||
|
||||
const PROJECT_STATUS_OPTIONS = [
|
||||
{ label: '全部项目状态', value: '' },
|
||||
{ label: '可用', value: 'active' },
|
||||
{ label: '认证中', value: 'validating' },
|
||||
{ label: '认证失败', value: 'validate_failed' },
|
||||
{ label: '创建远端组中', value: 'creating_remote_group' },
|
||||
{ label: '创建远端组失败', value: 'create_group_failed' },
|
||||
{ label: '已删除', value: 'deleted' },
|
||||
];
|
||||
|
||||
const ASSET_STATUS_OPTIONS = [
|
||||
{ label: '全部素材状态', value: '' },
|
||||
{ label: '创建中', value: 'creating' },
|
||||
{ label: '处理中', value: 'Processing' },
|
||||
{ label: '可用', value: 'Active' },
|
||||
{ label: '失败', value: 'Failed' },
|
||||
{ label: '本地已删', value: 'local_deleted' },
|
||||
{ label: '远端已删', value: 'remote_deleted' },
|
||||
{ label: '删除失败', value: 'delete_failed' },
|
||||
];
|
||||
|
||||
const libraryLabel = (value?: string | null) => {
|
||||
if (value === 'real_person') return '真人认证';
|
||||
if (value === 'aigc_virtual') return '虚拟人像';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const libraryColor = (value?: string | null) => {
|
||||
if (value === 'real_person') return 'purple';
|
||||
if (value === 'aigc_virtual') return 'cyan';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const statusColor = (status?: string) => {
|
||||
if (status === 'Active' || status === 'active') return 'green';
|
||||
if (status === 'Processing' || status === 'creating_remote_group' || status === 'validating' || status === 'creating') return 'blue';
|
||||
if (status === 'Failed' || status === 'failed' || status === 'delete_failed' || status === 'validate_failed' || status === 'create_group_failed') return 'red';
|
||||
if (status?.includes('deleted')) return 'default';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const assetTypeLabel = (value?: string | null) => {
|
||||
if (value === 'Image') return '图片';
|
||||
if (value === 'Video') return '视频';
|
||||
return value || '-';
|
||||
};
|
||||
|
||||
const shortId = (value?: string | null, keep = 10) => {
|
||||
if (!value) return '-';
|
||||
if (value.length <= keep * 2 + 3) return value;
|
||||
return `${value.slice(0, keep)}...${value.slice(-keep)}`;
|
||||
};
|
||||
|
||||
const copyText = async (value?: string | null) => {
|
||||
if (!value) return;
|
||||
await navigator.clipboard?.writeText(value);
|
||||
message.success('已复制');
|
||||
};
|
||||
|
||||
const toPreviewUrl = (value?: string | null) => {
|
||||
if (!value) return '';
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed || trimmed.startsWith('asset://')) return '';
|
||||
return apiUrl(trimmed);
|
||||
};
|
||||
|
||||
const PreviewCell: React.FC<{ asset: PrivatePortraitAsset }> = ({ asset }) => {
|
||||
const url = toPreviewUrl(asset.displayUrl || asset.previewUrl || asset.remoteUrl || asset.sourceUrl);
|
||||
const cover = toPreviewUrl(asset.videoCoverUrl || asset.previewUrl || asset.displayUrl || asset.sourceUrl);
|
||||
if (asset.assetType === 'Video') {
|
||||
return (
|
||||
<div style={{ width: 72, height: 52, borderRadius: 10, overflow: 'hidden', background: '#f5f3ff', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
|
||||
{cover ? <img src={cover} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : <PlayCircleOutlined style={{ fontSize: 24, color: '#8b5cf6' }} />}
|
||||
{url ? <a href={url} target="_blank" rel="noreferrer" style={{ position: 'absolute', inset: 0 }} /> : null}
|
||||
<PlayCircleOutlined style={{ position: 'absolute', color: '#fff', fontSize: 22, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.45))' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (url) return <Image width={72} height={52} src={url} style={{ objectFit: 'cover', borderRadius: 10 }} />;
|
||||
return <div style={{ width: 72, height: 52, borderRadius: 10, background: '#f5f5f5' }} />;
|
||||
};
|
||||
|
||||
const AdminPrivatePortraitProjects: React.FC = () => {
|
||||
const [projects, setProjects] = useState<PrivatePortraitProject[]>([]);
|
||||
const [assets, setAssets] = useState<PrivatePortraitAsset[]>([]);
|
||||
const [projectTotal, setProjectTotal] = useState(0);
|
||||
const [assetTotal, setAssetTotal] = useState(0);
|
||||
const [loadingProjects, setLoadingProjects] = useState(false);
|
||||
const [loadingAssets, setLoadingAssets] = useState(false);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [libraryType, setLibraryType] = useState('');
|
||||
const [assetType, setAssetType] = useState('');
|
||||
const [projectStatus, setProjectStatus] = useState('');
|
||||
const [assetStatus, setAssetStatus] = useState('');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>();
|
||||
const [projectPage, setProjectPage] = useState(1);
|
||||
const [assetPage, setAssetPage] = useState(1);
|
||||
|
||||
const filters = useMemo(() => ({
|
||||
keyword: keyword.trim() || undefined,
|
||||
libraryType: libraryType || undefined,
|
||||
assetType: assetType || undefined,
|
||||
projectStatus: projectStatus || undefined,
|
||||
assetStatus: assetStatus || undefined,
|
||||
}), [keyword, libraryType, assetType, projectStatus, assetStatus]);
|
||||
|
||||
const loadProjects = async () => {
|
||||
setLoadingProjects(true);
|
||||
try {
|
||||
const res = await adminGetPrivatePortraitProjects({
|
||||
keyword: filters.keyword,
|
||||
libraryType: filters.libraryType,
|
||||
status: filters.projectStatus,
|
||||
page: projectPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
setProjects(res.items || []);
|
||||
setProjectTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载私域人像素材项目失败');
|
||||
} finally {
|
||||
setLoadingProjects(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadAssets = async () => {
|
||||
setLoadingAssets(true);
|
||||
try {
|
||||
const res = await adminGetPrivatePortraitAssets({
|
||||
projectId: selectedProjectId,
|
||||
keyword: filters.keyword,
|
||||
libraryType: filters.libraryType,
|
||||
assetType: filters.assetType,
|
||||
status: filters.assetStatus,
|
||||
page: assetPage,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
setAssets(res.items || []);
|
||||
setAssetTotal(res.total || 0);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载私域人像素材失败');
|
||||
} finally {
|
||||
setLoadingAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { loadProjects(); }, [projectPage, filters.libraryType, filters.projectStatus]);
|
||||
useEffect(() => { loadAssets(); }, [selectedProjectId, assetPage, filters.libraryType, filters.assetType, filters.assetStatus]);
|
||||
|
||||
const searchAll = () => {
|
||||
setProjectPage(1);
|
||||
setAssetPage(1);
|
||||
setTimeout(() => { loadProjects(); loadAssets(); }, 0);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setKeyword('');
|
||||
setLibraryType('');
|
||||
setAssetType('');
|
||||
setProjectStatus('');
|
||||
setAssetStatus('');
|
||||
setSelectedProjectId(undefined);
|
||||
setProjectPage(1);
|
||||
setAssetPage(1);
|
||||
};
|
||||
|
||||
const projectSummary = useMemo(() => {
|
||||
return projects.reduce((acc, item) => {
|
||||
acc.asset += item.assetCount || 0;
|
||||
acc.image += item.imageAssetCount || 0;
|
||||
acc.video += item.videoAssetCount || 0;
|
||||
acc.active += item.activeAssetCount || 0;
|
||||
return acc;
|
||||
}, { asset: 0, image: 0, video: 0, active: 0 });
|
||||
}, [projects]);
|
||||
|
||||
const projectColumns: ColumnsType<PrivatePortraitProject> = [
|
||||
{ title: '项目名称', dataIndex: 'name', width: 200, fixed: 'left', render: (v, r) => <Button type="link" onClick={() => { setSelectedProjectId(r.id); setAssetPage(1); }}>{v}</Button> },
|
||||
{ title: '类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '状态', dataIndex: 'status', width: 130, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: '素材', width: 170, render: (_, r) => <Space size={4}><Tag>总 {r.assetCount || 0}</Tag><Tag color="blue">图 {r.imageAssetCount || 0}</Tag><Tag color="geekblue">视 {r.videoAssetCount || 0}</Tag></Space> },
|
||||
{ title: 'Active', dataIndex: 'activeAssetCount', width: 90 },
|
||||
{ title: 'ProjectName', dataIndex: 'remoteProjectName', width: 180, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
|
||||
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: formatDate },
|
||||
];
|
||||
|
||||
const assetColumns: ColumnsType<PrivatePortraitAsset> = [
|
||||
{ title: '预览', width: 100, fixed: 'left', render: (_, r) => <PreviewCell asset={r} /> },
|
||||
{ title: '素材名称', dataIndex: 'name', width: 180, render: (v, r) => <div><Text strong>{v || '未命名素材'}</Text><br /><Text type="secondary">{assetTypeLabel(r.assetType)}</Text></div> },
|
||||
{ title: '库类型', dataIndex: 'libraryType', width: 110, render: (v) => <Tag color={libraryColor(v)}>{libraryLabel(v)}</Tag> },
|
||||
{ title: '用户ID', dataIndex: 'userId', width: 170, render: (v) => <Text code copyable={!!v}>{v || '-'}</Text> },
|
||||
{ title: '本地项目', dataIndex: 'projectName', width: 160, render: (v) => v || '-' },
|
||||
{ title: '状态', dataIndex: 'status', width: 120, render: (v) => <Tag color={statusColor(v)}>{v}</Tag> },
|
||||
{ title: 'AssetId', dataIndex: 'remoteAssetId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
|
||||
{ title: 'GroupId', dataIndex: 'remoteGroupId', width: 190, render: (v) => <Space><Text code>{shortId(v)}</Text>{v ? <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(v)} /> : null}</Space> },
|
||||
{ title: '轮询', width: 160, render: (_, r) => <div><Text>次数:{r.pollCount || 0}</Text><br /><Text type="secondary">下次:{formatDate((r as any).nextPollAt)}</Text></div> },
|
||||
{ title: '错误', dataIndex: 'errorMessage', width: 240, ellipsis: true, render: (v) => v ? <Tooltip title={v}><Text type="danger">{v}</Text></Tooltip> : '-' },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: formatDate },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, background: '#f7f7fb', minHeight: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }} align="start">
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>私域人像素材库</Title>
|
||||
<Text type="secondary">统一查看真人认证素材与虚拟人像素材,支持图片/视频资产状态排查。</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Input allowClear prefix={<SearchOutlined />} placeholder="搜索项目/素材" value={keyword} onChange={(e) => setKeyword(e.target.value)} onPressEnter={searchAll} style={{ width: 240 }} />
|
||||
<Select value={libraryType} onChange={(v) => { setLibraryType(v); setProjectPage(1); setAssetPage(1); }} options={LIBRARY_OPTIONS} style={{ width: 150 }} />
|
||||
<Select value={assetType} onChange={(v) => { setAssetType(v); setAssetPage(1); }} options={ASSET_TYPE_OPTIONS} style={{ width: 130 }} />
|
||||
<Button icon={<SearchOutlined />} type="primary" onClick={searchAll}>查询</Button>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => { loadProjects(); loadAssets(); }}>刷新</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Space size={16} wrap style={{ marginBottom: 16 }}>
|
||||
<Card style={{ width: 180 }}><Statistic title="项目数" value={projectTotal} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="当前页素材" value={projectSummary.asset} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="图片" value={projectSummary.image} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="视频" value={projectSummary.video} /></Card>
|
||||
<Card style={{ width: 180 }}><Statistic title="Active" value={projectSummary.active} /></Card>
|
||||
</Space>
|
||||
|
||||
<Card title="项目组" style={{ marginBottom: 16, borderRadius: 14 }} extra={<Select value={projectStatus} onChange={(v) => { setProjectStatus(v); setProjectPage(1); }} options={PROJECT_STATUS_OPTIONS} style={{ width: 180 }} />}>
|
||||
<Table rowKey="id" columns={projectColumns} dataSource={projects} loading={loadingProjects} pagination={{ current: projectPage, pageSize: PAGE_SIZE, total: projectTotal, onChange: setProjectPage }} size="small" scroll={{ x: 1350 }} />
|
||||
</Card>
|
||||
|
||||
<Card title={selectedProjectId ? '项目素材明细' : '全部素材明细'} style={{ borderRadius: 14 }} extra={<Space><Select value={assetStatus} onChange={(v) => { setAssetStatus(v); setAssetPage(1); }} options={ASSET_STATUS_OPTIONS} style={{ width: 170 }} />{selectedProjectId ? <Button size="small" onClick={() => setSelectedProjectId(undefined)}>查看全部</Button> : null}</Space>}>
|
||||
<Table rowKey="id" columns={assetColumns} dataSource={assets} loading={loadingAssets} pagination={{ current: assetPage, pageSize: PAGE_SIZE, total: assetTotal, onChange: setAssetPage }} size="small" scroll={{ x: 1700 }} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPrivatePortraitProjects;
|
||||
@@ -1,253 +1 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
GiftOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getRechargePackages, saveRechargePackage, deleteRechargePackage } from '../api';
|
||||
|
||||
interface PackageItem {
|
||||
id: string;
|
||||
name: string;
|
||||
credits: number;
|
||||
price: number;
|
||||
bonusCredits: number;
|
||||
totalCredits: number;
|
||||
description: string | null;
|
||||
packageType: string;
|
||||
isGift: boolean;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
normal: 'blue',
|
||||
gift: 'green',
|
||||
promo: 'purple',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
normal: '常规',
|
||||
gift: '赠送',
|
||||
promo: '促销',
|
||||
};
|
||||
|
||||
const AdminRechargePackages: React.FC = () => {
|
||||
const [packages, setPackages] = useState<PackageItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; item: PackageItem | null }>({ open: false, item: null });
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getRechargePackages();
|
||||
setPackages(data.map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
credits: item.credits,
|
||||
price: item.price,
|
||||
bonusCredits: item.bonus_credits ?? item.bonusCredits ?? 0,
|
||||
totalCredits: item.total_credits ?? item.totalCredits ?? item.credits,
|
||||
description: item.description,
|
||||
packageType: item.package_type ?? item.packageType ?? 'normal',
|
||||
isGift: item.is_gift ?? item.isGift ?? false,
|
||||
isActive: item.is_active ?? item.isActive ?? true,
|
||||
sortOrder: item.sort_order ?? item.sortOrder ?? 0,
|
||||
})));
|
||||
} catch {
|
||||
message.error('加载充值套餐失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
name: values.name,
|
||||
credits: values.credits,
|
||||
price: values.price,
|
||||
bonus_credits: values.bonusCredits || 0,
|
||||
description: values.description || null,
|
||||
package_type: values.packageType || 'normal',
|
||||
is_gift: values.isGift || false,
|
||||
is_active: values.isActive ?? true,
|
||||
sort_order: values.sortOrder ?? 0,
|
||||
};
|
||||
if (modal.item?.id) {
|
||||
await saveRechargePackage({ id: modal.item.id, ...payload });
|
||||
message.success('已更新');
|
||||
} else {
|
||||
await saveRechargePackage(payload);
|
||||
message.success('已添加');
|
||||
}
|
||||
setModal({ open: false, item: null });
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch { /* validation */ }
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteRechargePackage(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (item?: PackageItem) => {
|
||||
setModal({ open: true, item: item || null });
|
||||
if (item) {
|
||||
form.setFieldsValue({
|
||||
name: item.name,
|
||||
credits: item.credits,
|
||||
price: item.price,
|
||||
bonusCredits: item.bonusCredits,
|
||||
description: item.description,
|
||||
packageType: item.packageType,
|
||||
isGift: item.isGift,
|
||||
isActive: item.isActive,
|
||||
sortOrder: item.sortOrder,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ isActive: true, sortOrder: 0, packageType: 'normal', bonusCredits: 0, isGift: false });
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '套餐名称', key: 'name', width: 160,
|
||||
render: (_: any, r: PackageItem) => (
|
||||
<div>
|
||||
<Typography.Text strong>{r.name}</Typography.Text>
|
||||
{r.description && <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.description}</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '基础积分', dataIndex: 'credits', width: 100,
|
||||
render: (v: number) => <Typography.Text>{v.toLocaleString()}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '赠送积分', dataIndex: 'bonusCredits', width: 100,
|
||||
render: (v: number) => v > 0
|
||||
? <Tag color="green">+{v.toLocaleString()}</Tag>
|
||||
: <Typography.Text type="secondary">-</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '总积分', key: 'total', width: 100,
|
||||
render: (_: any, r: PackageItem) => (
|
||||
<Typography.Text strong style={{ color: '#6366f1' }}>
|
||||
{(r.credits + r.bonusCredits).toLocaleString()}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '价格(元)', dataIndex: 'price', width: 100,
|
||||
render: (v: number) => <Typography.Text strong>¥{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'packageType', width: 80,
|
||||
render: (v: string) => <Tag color={TYPE_COLORS[v] || 'default'}>{TYPE_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作', key: 'action', width: 150, fixed: 'right' as const,
|
||||
render: (_: any, r: PackageItem) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>充值套餐管理</Typography.Text>
|
||||
<Tag color="purple">{packages.length} 个套餐</Tag>
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()} style={{ borderRadius: 8 }}>
|
||||
添加套餐
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={packages}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={<Space><GiftOutlined />{modal.item ? '编辑套餐' : '添加套餐'}</Space>}
|
||||
open={modal.open}
|
||||
onOk={handleSave}
|
||||
onCancel={() => { setModal({ open: false, item: null }); form.resetFields(); }}
|
||||
okText="保存" cancelText="取消" width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="name" label="套餐名称" rules={[{ required: true, message: '请输入套餐名称' }]}>
|
||||
<Input placeholder="例如:进阶包" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="credits" label="基础积分" rules={[{ required: true, message: '请输入积分' }]} style={{ flex: 1 }}>
|
||||
<InputNumber min={1} placeholder="2000" size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="price" label="价格(元)" rules={[{ required: true, message: '请输入价格' }]} style={{ flex: 1 }}>
|
||||
<InputNumber min={0.01} step={1} placeholder="168" size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="bonusCredits" label="赠送积分" initialValue={0} style={{ flex: 1 }}>
|
||||
<InputNumber min={0} placeholder="0" size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="packageType" label="套餐类型" initialValue="normal" style={{ flex: 1 }}>
|
||||
<Select size="large" options={[
|
||||
{ value: 'normal', label: '常规' },
|
||||
{ value: 'gift', label: '赠送' },
|
||||
{ value: 'promo', label: '促销' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input placeholder="套餐描述(可选)" size="large" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" initialValue={true} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="isGift" label="是否赠送" valuePropName="checked" initialValue={false} style={{ flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序" initialValue={0} style={{ flex: 1 }}>
|
||||
<InputNumber size="large" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminRechargePackages;
|
||||
export { default } from './AdminCreditProducts';
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
PlayCircleOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { getAdminHotOpeningTaskDetail, getAdminShotProjectDetail } from '../api';
|
||||
import type { ReplicationProjectDetailOut, ReplicationStepOut } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
@@ -36,7 +36,7 @@ interface AdminReplicationProjectDetailProps {
|
||||
moduleType?: ReplicationModuleType;
|
||||
}
|
||||
|
||||
const STEP_ORDER = [
|
||||
const V1_STEP_ORDER = [
|
||||
'material_input',
|
||||
'image_prompt_optimize',
|
||||
'image_generate',
|
||||
@@ -44,6 +44,12 @@ const STEP_ORDER = [
|
||||
'video_generate',
|
||||
];
|
||||
|
||||
const V2_STEP_ORDER = [
|
||||
'material_input',
|
||||
'video_prompt_optimize',
|
||||
'video_generate',
|
||||
];
|
||||
|
||||
const STEP_DESCRIPTIONS: Record<string, string> = {
|
||||
material_input: '参考素材、项目名称和核心内容点',
|
||||
image_prompt_optimize: '图片 AI 提词优化结果',
|
||||
@@ -104,6 +110,8 @@ const renderPromptText = (value?: string | null, empty = '暂无提词') => {
|
||||
const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps> = ({ moduleType = 'shot_replicate' }) => {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const flowVersion: 'v1' | 'v2' = searchParams.get('flow_version') === 'v2' ? 'v2' : 'v1';
|
||||
const [detail, setDetail] = useState<ReplicationProjectDetailOut | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -112,15 +120,15 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = moduleType === 'hot_opening_replicate'
|
||||
? await getAdminHotOpeningTaskDetail(projectId)
|
||||
: await getAdminShotProjectDetail(projectId);
|
||||
? await getAdminHotOpeningTaskDetail(projectId, flowVersion)
|
||||
: await getAdminShotProjectDetail(projectId, flowVersion);
|
||||
setDetail(res);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载复刻项目详情失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [moduleType, projectId]);
|
||||
}, [flowVersion, moduleType, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -134,8 +142,10 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
|
||||
const moduleValue = detail?.module || moduleType;
|
||||
const moduleName = getModuleLabel(moduleValue);
|
||||
const isV2 = detail?.flowVersion === 'v2';
|
||||
const effectiveStepOrder = isV2 ? V2_STEP_ORDER : V1_STEP_ORDER;
|
||||
|
||||
const stepItems = useMemo(() => STEP_ORDER.map(code => {
|
||||
const stepItems = useMemo(() => effectiveStepOrder.map(code => {
|
||||
const step = stepsByCode[code];
|
||||
let status: 'wait' | 'process' | 'finish' | 'error' = 'wait';
|
||||
if (step?.status === 'completed') status = 'finish';
|
||||
@@ -146,7 +156,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
description: step ? <StatusTag status={step.status} /> : '未创建',
|
||||
status,
|
||||
};
|
||||
}), [stepsByCode]);
|
||||
}), [effectiveStepOrder, stepsByCode]);
|
||||
|
||||
const defaultActiveKeys = useMemo(() => buildDefaultActiveKeys(detail, stepsByCode), [detail, stepsByCode]);
|
||||
|
||||
@@ -242,7 +252,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
},
|
||||
{
|
||||
key: 'video_prompt_optimize',
|
||||
label: <StepHeader index={4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
|
||||
label: <StepHeader index={isV2 ? 2 : 4} stepCode="video_prompt_optimize" step={videoPromptStep} current={detail.currentStepCode === 'video_prompt_optimize'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
@@ -251,14 +261,21 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
|
||||
</Descriptions>
|
||||
{renderErrorAlert(videoPromptStep?.errorMessage || detail.videoGeneration?.errorMessage)}
|
||||
<VideoPromptSchemaViewer schema={detail.videoGeneration?.promptSchema} finalPrompt={detail.videoGeneration?.finalPrompt} />
|
||||
<VideoPromptSchemaViewer
|
||||
schema={detail.videoGeneration?.promptSchema}
|
||||
finalPrompt={detail.videoGeneration?.finalPrompt}
|
||||
schemaConfigSnapshot={detail.videoGeneration?.schemaConfigSnapshot}
|
||||
schemaConfigSource={detail.videoGeneration?.schemaConfigSource}
|
||||
schemaConfigVersion={detail.videoGeneration?.schemaConfigVersion}
|
||||
schemaConfigIsFallback={detail.videoGeneration?.schemaConfigIsFallback}
|
||||
/>
|
||||
<StepRawJson step={videoPromptStep} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'video_generate',
|
||||
label: <StepHeader index={5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
|
||||
label: <StepHeader index={isV2 ? 3 : 5} stepCode="video_generate" step={videoGenerateStep} current={detail.currentStepCode === 'video_generate'} />,
|
||||
children: (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Descriptions column={3} bordered size="small">
|
||||
@@ -293,6 +310,9 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
),
|
||||
},
|
||||
];
|
||||
const visibleCollapseItems = isV2
|
||||
? collapseItems.filter(item => !['image_prompt_optimize', 'image_generate'].includes(String(item.key)))
|
||||
: collapseItems;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
@@ -315,6 +335,8 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前步骤"><Tooltip title={detail.currentStepCode || ''}>{getStepCodeLabel(detail.currentStepCode)}</Tooltip></Descriptions.Item>
|
||||
<Descriptions.Item label="流程版本"><Tag color={isV2 ? 'blue' : 'default'}>{String(detail.flowVersion || 'v1').toUpperCase()}</Tag></Descriptions.Item>
|
||||
<Descriptions.Item label="步骤数量">{detail.stepCount || effectiveStepOrder.length}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态"><StatusTag status={detail.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="更新时间">{safeDate(detail.updatedAt)}</Descriptions.Item>
|
||||
@@ -329,9 +351,11 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
|
||||
<Card title="最终结果预览">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 16 }}>
|
||||
<Card size="small" title="最终图片">
|
||||
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
|
||||
</Card>
|
||||
{!isV2 ? (
|
||||
<Card size="small" title="最终图片">
|
||||
<MediaPreview type="image" url={detail.finalImageUrl || detail.imageGeneration?.resultImageUrl} height={220} emptyDescription="暂无最终图片" />
|
||||
</Card>
|
||||
) : null}
|
||||
<Card size="small" title="最终视频封面">
|
||||
<MediaPreview type="image" url={detail.finalVideoCoverUrl || detail.videoGeneration?.resultVideoCoverUrl} height={220} emptyDescription="暂无最终视频封面" />
|
||||
</Card>
|
||||
@@ -341,7 +365,7 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Collapse defaultActiveKey={defaultActiveKeys} items={collapseItems} />
|
||||
<Collapse defaultActiveKey={defaultActiveKeys} items={visibleCollapseItems} />
|
||||
|
||||
<Collapse
|
||||
items={[
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Space, Typography, message, Card, Modal, Form, Input, Select, Switch, Tag, Popconfirm, Tabs,
|
||||
} from 'antd';
|
||||
import {
|
||||
ClockCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PlayCircleOutlined, StopOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
listScheduledTasks,
|
||||
createScheduledTask,
|
||||
updateScheduledTask,
|
||||
deleteScheduledTask,
|
||||
runScheduledTask,
|
||||
toggleScheduledTask,
|
||||
} from '../api';
|
||||
import type { ScheduledTask } from '../types';
|
||||
|
||||
const { TextArea } = Input;
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS = [
|
||||
{ value: 'internal_method', label: '内部方法执行' },
|
||||
];
|
||||
|
||||
const TASK_TYPE_LABELS: Record<string, string> = {
|
||||
internal_method: '内部方法',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
success: { label: '成功', color: 'green' },
|
||||
error: { label: '失败', color: 'red' },
|
||||
};
|
||||
|
||||
const AdminScheduledTasks: React.FC = () => {
|
||||
const [data, setData] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState(false);
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'config'>('basic');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await listScheduledTasks();
|
||||
setData(res.items || []);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const openModal = (task?: ScheduledTask) => {
|
||||
if (task) {
|
||||
setEditing(task);
|
||||
let configStr = '';
|
||||
if (task.config) {
|
||||
try {
|
||||
configStr = typeof task.config === 'string' ? JSON.stringify(JSON.parse(task.config), null, 2) : JSON.stringify(task.config, null, 2);
|
||||
} catch {
|
||||
configStr = task.config;
|
||||
}
|
||||
}
|
||||
form.setFieldsValue({
|
||||
name: task.name,
|
||||
task_type: task.taskType,
|
||||
schedule: task.schedule,
|
||||
config: configStr,
|
||||
is_active: task.isActive,
|
||||
});
|
||||
} else {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ is_active: true, task_type: 'internal_method' });
|
||||
}
|
||||
setActiveTab('basic');
|
||||
setModal(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
let configVal = values.config;
|
||||
if (configVal && typeof configVal === 'string') {
|
||||
try {
|
||||
configVal = JSON.stringify(JSON.parse(configVal));
|
||||
} catch {
|
||||
message.warning('配置 JSON 格式不合法,将按原样保存');
|
||||
}
|
||||
}
|
||||
setSaving(true);
|
||||
if (editing) {
|
||||
await updateScheduledTask(editing.id, { ...values, config: configVal });
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createScheduledTask({ ...values, config: configVal });
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModal(false);
|
||||
await fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (taskId: string) => {
|
||||
try {
|
||||
await deleteScheduledTask(taskId);
|
||||
message.success('删除成功');
|
||||
await fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRun = async (taskId: string) => {
|
||||
try {
|
||||
await runScheduledTask(taskId);
|
||||
message.success('任务已提交执行');
|
||||
setTimeout(fetchData, 1500);
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '执行失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (task: ScheduledTask) => {
|
||||
try {
|
||||
const res = await toggleScheduledTask(task.id);
|
||||
message.success(res.message);
|
||||
await fetchData();
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '任务名称', dataIndex: 'name', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'taskType',
|
||||
width: 100,
|
||||
render: (v: string) => <Tag color="purple">{TASK_TYPE_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{ title: '调度表达式', dataIndex: 'schedule', width: 140, render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code> },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isActive',
|
||||
width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最后执行',
|
||||
dataIndex: 'lastRunAt',
|
||||
width: 160,
|
||||
render: (v: string, r: ScheduledTask) => {
|
||||
if (!v) return '-';
|
||||
const status = r.lastStatus ? STATUS_LABELS[r.lastStatus] : null;
|
||||
return (
|
||||
<span>
|
||||
{v.includes('T') ? v.replace('T', ' ').slice(0, 19) : v}
|
||||
{status && <Tag color={status.color} style={{ marginLeft: 6 }}>{status.label}</Tag>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_: any, record: ScheduledTask) => (
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" icon={<PlayCircleOutlined />} onClick={() => handleRun(record.id)}>执行</Button>
|
||||
<Button type="link" size="small" icon={record.isActive ? <StopOutlined /> : <CheckCircleOutlined />} onClick={() => handleToggle(record)}>
|
||||
{record.isActive ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openModal(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除该任务?" onConfirm={() => handleDelete(record.id)} okText="确定" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const scheduleHelp = (
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
|
||||
<div>• 纯数字:间隔秒数(如 3600 = 每小时执行一次)</div>
|
||||
<div>• Cron 表达式(5 字段):分 时 日 月 周</div>
|
||||
<div>• 示例:<code>0 * * * *</code> = 每小时 | <code>0 2 * * *</code> = 每天凌晨2点</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}>
|
||||
<ClockCircleOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>定时任务管理</Typography.Title>
|
||||
<Typography.Text type="secondary">自定义定时执行外部接口调用或内部方法</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openModal()}>
|
||||
新增任务
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* 新增/编辑弹窗 */}
|
||||
<Modal
|
||||
title={editing ? '编辑定时任务' : '新增定时任务'}
|
||||
open={modal}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModal(false)}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(k) => setActiveTab(k as 'basic' | 'config')}
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基本设置',
|
||||
children: (
|
||||
<>
|
||||
<Form.Item name="name" label="任务名称" rules={[{ required: true, message: '请输入任务名称' }]}>
|
||||
<Input placeholder="例如:每小时同步银行交易" />
|
||||
</Form.Item>
|
||||
<Form.Item name="task_type" label="任务类型" rules={[{ required: true, message: '请选择任务类型' }]}>
|
||||
<Select options={SCHEDULE_TYPE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="schedule"
|
||||
label="调度表达式"
|
||||
rules={[{ required: true, message: '请输入调度表达式' }]}
|
||||
extra={scheduleHelp}
|
||||
>
|
||||
<Input placeholder="纯数字(秒)或 Cron 表达式:* * * * *" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
label: '任务配置',
|
||||
children: (
|
||||
<Form.Item
|
||||
name="config"
|
||||
label="配置 JSON"
|
||||
extra='module 和 function 指定调用的函数,其余字段作为参数传入。服务会自动翻页拉取全部流水:'
|
||||
>
|
||||
<TextArea
|
||||
rows={10}
|
||||
placeholder={'{\n "module": "app.services.bank.sync_service",\n "function": "sync_bank_transactions",\n "url": "http://ceshi.web.minzhong.cn/api/api/v1/internal/get-blank-transfer-acct-time",\n "api_key": "jixekCxm8piLFi0AlfA24bDtCKJ82bfu",\n "acct_no": "110972289710001",\n "start_date": "2026-08-01",\n "end_date": "2026-08-14"\n}'}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminScheduledTasks;
|
||||
@@ -1,12 +1,31 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Space, Switch, Typography, Upload,
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Select, Space, Switch, Tabs, Typography, Upload, Table, Tag, Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined,
|
||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined, BankOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { getSystemConfigs, updateSystemConfig, uploadPdf, uploadLogo } from '../api';
|
||||
import type { SystemConfig } from '../types';
|
||||
import {
|
||||
createSystemConfig,
|
||||
getGlobalResourceCapacity,
|
||||
getSystemConfigs,
|
||||
saveGlobalResourceCapacity,
|
||||
updateSystemConfig,
|
||||
uploadLogo,
|
||||
uploadPdf,
|
||||
uploadLoginVideo,
|
||||
listBankAccounts,
|
||||
createBankAccount,
|
||||
updateBankAccount,
|
||||
deleteBankAccount,
|
||||
} from '../api';
|
||||
import type { ResourceCapacityUnit, SystemConfig, BankAccount } from '../types';
|
||||
|
||||
const capacityUnitOptions: { value: ResourceCapacityUnit; label: string }[] = [
|
||||
{ value: 'MB', label: 'MB(1024 × 1024 字节)' },
|
||||
{ value: 'GB', label: 'GB(1024 × 1024 × 1024 字节)' },
|
||||
{ value: 'TB', label: 'TB(1024 × 1024 × 1024 × 1024 字节)' },
|
||||
];
|
||||
|
||||
const AdminSettings: React.FC = () => {
|
||||
const [configs, setConfigs] = useState<SystemConfig[]>([]);
|
||||
@@ -14,39 +33,168 @@ const AdminSettings: React.FC = () => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState('');
|
||||
const [form] = Form.useForm();
|
||||
const [logoPreview, setLogoPreview] = useState('');
|
||||
|
||||
// 银行账户管理
|
||||
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
|
||||
const [loadingAccounts, setLoadingAccounts] = useState(false);
|
||||
const [accountModal, setAccountModal] = useState(false);
|
||||
const [accountEditing, setAccountEditing] = useState<BankAccount | null>(null);
|
||||
const [accountForm] = Form.useForm();
|
||||
const [accountSaving, setAccountSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadBankAccounts();
|
||||
}, []);
|
||||
|
||||
const loadBankAccounts = async () => {
|
||||
setLoadingAccounts(true);
|
||||
try {
|
||||
const res = await listBankAccounts();
|
||||
setBankAccounts(res.items || []);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载银行账户失败');
|
||||
} finally {
|
||||
setLoadingAccounts(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openAccountModal = (account?: BankAccount) => {
|
||||
if (account) {
|
||||
setAccountEditing(account);
|
||||
accountForm.setFieldsValue({
|
||||
account_name: account.accountName,
|
||||
bank_name: account.bankName,
|
||||
account_no: account.accountNo,
|
||||
is_active: account.isActive,
|
||||
is_default: account.isDefault,
|
||||
description: account.description,
|
||||
});
|
||||
} else {
|
||||
setAccountEditing(null);
|
||||
accountForm.resetFields();
|
||||
accountForm.setFieldsValue({ is_active: true, is_default: false });
|
||||
}
|
||||
setAccountModal(true);
|
||||
};
|
||||
|
||||
const handleAccountSave = async () => {
|
||||
try {
|
||||
const values = await accountForm.validateFields();
|
||||
setAccountSaving(true);
|
||||
if (accountEditing) {
|
||||
await updateBankAccount(accountEditing.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await createBankAccount(values);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setAccountModal(false);
|
||||
await loadBankAccounts();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setAccountSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountDelete = async (accountId: string) => {
|
||||
try {
|
||||
await deleteBankAccount(accountId);
|
||||
message.success('删除成功');
|
||||
await loadBankAccounts();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const accountColumns = [
|
||||
{ title: '账户名称', dataIndex: 'accountName', width: 150 },
|
||||
{ title: '开户银行', dataIndex: 'bankName', width: 150 },
|
||||
{
|
||||
title: '银行账号',
|
||||
dataIndex: 'accountNo',
|
||||
width: 180,
|
||||
render: (v: string) => <code style={{ background: '#f1f5f9', padding: '2px 6px', borderRadius: 4 }}>{v}</code>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isActive',
|
||||
width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '默认',
|
||||
dataIndex: 'isDefault',
|
||||
width: 80,
|
||||
render: (v: boolean) => v && <Tag color="blue">默认</Tag>,
|
||||
},
|
||||
{ title: '备注', dataIndex: 'description', ellipsis: true, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: any, record: BankAccount) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openAccountModal(record)}>编辑</Button>
|
||||
<Popconfirm title="确定删除该账户?" onConfirm={() => handleAccountDelete(record.id)} okText="确定" cancelText="取消">
|
||||
<Button type="link" size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const data = await getSystemConfigs();
|
||||
setConfigs(data);
|
||||
const formValues: Record<string, string> = {};
|
||||
data.forEach(c => { formValues[c.key] = c.value; });
|
||||
form.setFieldsValue(formValues);
|
||||
setLoading(false);
|
||||
try {
|
||||
const [data, capacity] = await Promise.all([
|
||||
getSystemConfigs(),
|
||||
getGlobalResourceCapacity(),
|
||||
]);
|
||||
// 确保 llm_media_as_base64 配置存在
|
||||
if (!data.some(c => c.key === 'llm_media_as_base64')) {
|
||||
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
|
||||
}
|
||||
// 确保 single_device_login_enabled 配置存在
|
||||
if (!data.some(c => c.key === 'single_device_login_enabled')) {
|
||||
data.push({ id: 'cfg_single_device_login_enabled', key: 'single_device_login_enabled', value: 'false', description: '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话' });
|
||||
}
|
||||
setConfigs(data);
|
||||
const formValues: Record<string, any> = {};
|
||||
data.forEach(c => { formValues[c.key] = c.value; });
|
||||
formValues.resource_capacity_enabled = capacity.enabled;
|
||||
formValues.resource_capacity_limit_value = capacity.limitValue || '1.000';
|
||||
formValues.resource_capacity_limit_unit = capacity.limitUnit || 'GB';
|
||||
form.setFieldsValue(formValues);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
setSaving(true);
|
||||
for (const config of configs) {
|
||||
const newVal = values[config.key];
|
||||
if (newVal !== undefined && newVal !== config.value) {
|
||||
await updateSystemConfig(config.id, newVal ?? '');
|
||||
if (newVal !== undefined && String(newVal) !== config.value) {
|
||||
await updateSystemConfig(config.id, String(newVal ?? ''));
|
||||
}
|
||||
}
|
||||
await saveGlobalResourceCapacity({
|
||||
enabled: !!values.resource_capacity_enabled,
|
||||
limitValue: String(values.resource_capacity_limit_value ?? '1.000'),
|
||||
limitUnit: values.resource_capacity_limit_unit || 'GB',
|
||||
});
|
||||
message.success('系统配置已保存');
|
||||
const data = await getSystemConfigs();
|
||||
setConfigs(data);
|
||||
setSaving(false);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setSaving(false);
|
||||
message.error(e?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -54,89 +202,143 @@ const AdminSettings: React.FC = () => {
|
||||
setUploading(configKey);
|
||||
try {
|
||||
const res = await uploadPdf(file, configKey);
|
||||
// Update local state
|
||||
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
|
||||
form.setFieldsValue({ [configKey]: res.url });
|
||||
message.success('PDF上传成功');
|
||||
|
||||
// Find the config and update to database
|
||||
const config = configs.find(c => c.key === configKey);
|
||||
if (config) {
|
||||
await updateSystemConfig(config.id, res.url);
|
||||
}
|
||||
|
||||
message.success('PDF上传成功并已保存');
|
||||
} catch {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading('');
|
||||
}
|
||||
return false; // prevent default upload
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLogoUpload = async (file: File) => {
|
||||
setUploading('site_logo');
|
||||
try {
|
||||
const res = await uploadLogo(file);
|
||||
// Update local state
|
||||
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
||||
form.setFieldsValue({ site_logo: res.url });
|
||||
setLogoPreview(res.url);
|
||||
message.success('Logo上传成功');
|
||||
const config = configs.find(c => c.key === 'site_logo');
|
||||
if (config) {
|
||||
await updateSystemConfig(config.id, res.url);
|
||||
}
|
||||
message.success('Logo上传成功并已保存');
|
||||
} catch {
|
||||
message.error('上传失败');
|
||||
} finally {
|
||||
setUploading('');
|
||||
}
|
||||
return false; // prevent default upload
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLoginVideoUpload = async (file: File) => {
|
||||
setUploading('login_bg_video');
|
||||
try {
|
||||
const res = await uploadLoginVideo(file);
|
||||
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: res.url } : c));
|
||||
form.setFieldsValue({ login_bg_video: res.url });
|
||||
const config = configs.find(c => c.key === 'login_bg_video');
|
||||
if (config) {
|
||||
await updateSystemConfig(config.id, res.url);
|
||||
}
|
||||
message.success('登录背景视频上传成功并已保存');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '上传失败');
|
||||
} finally {
|
||||
setUploading('');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveLoginVideo = async () => {
|
||||
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: '' } : c));
|
||||
form.setFieldsValue({ login_bg_video: '' });
|
||||
const config = configs.find(c => c.key === 'login_bg_video');
|
||||
if (config) {
|
||||
await updateSystemConfig(config.id, '');
|
||||
}
|
||||
message.success('已移除登录背景视频');
|
||||
};
|
||||
|
||||
const handleToggleSingleDevice = async (checked: boolean) => {
|
||||
try {
|
||||
let config = configs.find(c => c.key === 'single_device_login_enabled');
|
||||
if (config && config.id && !config.id.startsWith('cfg_')) {
|
||||
await updateSystemConfig(config.id, checked ? 'true' : 'false');
|
||||
} else {
|
||||
const res = await createSystemConfig('single_device_login_enabled', checked ? 'true' : 'false', '启用单设备登录(同端互斥):同一设备类型只允许一个登录会话');
|
||||
config = res;
|
||||
}
|
||||
setConfigs(prev => {
|
||||
const exists = prev.some(c => c.key === 'single_device_login_enabled');
|
||||
if (exists) return prev.map(c => c.key === 'single_device_login_enabled' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
|
||||
return [...prev, config!];
|
||||
});
|
||||
message.success(`已${checked ? '开启' : '关闭'}单设备登录限制`);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleBase64 = async (checked: boolean) => {
|
||||
try {
|
||||
let config = configs.find(c => c.key === 'llm_media_as_base64');
|
||||
if (config && config.id && !config.id.startsWith('cfg_')) {
|
||||
await updateSystemConfig(config.id, checked ? 'true' : 'false');
|
||||
} else {
|
||||
const res = await createSystemConfig('llm_media_as_base64', checked ? 'true' : 'false', '文字模型请求时图片/视频使用 base64 编码');
|
||||
config = res;
|
||||
}
|
||||
setConfigs(prev => {
|
||||
const exists = prev.some(c => c.key === 'llm_media_as_base64');
|
||||
if (exists) return prev.map(c => c.key === 'llm_media_as_base64' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
|
||||
return [...prev, config!];
|
||||
});
|
||||
message.success(`已${checked ? '开启' : '关闭'}文字模型媒体 base64 编码`);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
||||
'协议配置': configs.filter(c => c.key === 'user_agreement_url' || c.key === 'privacy_policy_url'),
|
||||
'站点信息': configs.filter(c => c.key.startsWith('site_') && c.key !== 'site_banner'),
|
||||
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
|
||||
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
||||
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
||||
'收款银行': [],
|
||||
'其他配置': configs.filter(c => c.key === 'operation_manual'),
|
||||
};
|
||||
|
||||
const getFieldDescription = (config: SystemConfig): string => {
|
||||
const descMap: Record<string, string> = {
|
||||
site_name: '平台显示名称,将展示在页面标题和导航栏',
|
||||
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
|
||||
user_agreement_url: '用户注册/登录时需同意的用户协议PDF文件',
|
||||
privacy_policy_url: '用户注册/登录时需同意的隐私政策PDF文件',
|
||||
site_copyright: '显示在前台登录页底部的版权信息,例如:© 2024 民众智创 版权所有',
|
||||
user_agreement_privacy_url: '用户登录时需同意的用户协议及隐私政策PDF文件',
|
||||
seo_title: '搜索引擎结果中显示的标题',
|
||||
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
|
||||
seo_keywords: '用逗号分隔的关键词列表',
|
||||
user_register_credits: '用户注册时赠送的初始积分,默认100',
|
||||
user_login_credits: '用户每日登录赠送的积分数量',
|
||||
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
|
||||
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
|
||||
};
|
||||
return descMap[config.key] || config.description || '';
|
||||
};
|
||||
|
||||
const getFieldComponent = (config: SystemConfig) => {
|
||||
if (config.key === 'site_logo') {
|
||||
return <LogoUploadField config={config} />;
|
||||
}
|
||||
if (config.key === 'seo_description') {
|
||||
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
|
||||
}
|
||||
if (config.key === 'seo_keywords') {
|
||||
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
|
||||
}
|
||||
if (config.key === 'user_login_credits_enabled') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Switch defaultChecked={config.value === 'true'} />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{config.value === 'true' ? '已启用' : '已禁用'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
|
||||
return <Input type="number" min={0} placeholder={config.description} size="large" />;
|
||||
}
|
||||
return <Input placeholder={config.description} size="large" />;
|
||||
};
|
||||
|
||||
const LogoUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
|
||||
const hasLogo = config.value && config.value.startsWith('/uploads/');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const logoUrl = hasLogo ? `${baseUrl}${config.value}` : '';
|
||||
|
||||
|
||||
const handleRemove = () => {
|
||||
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: '' } : c));
|
||||
form.setFieldsValue({ site_logo: '' });
|
||||
@@ -172,17 +374,17 @@ const AdminSettings: React.FC = () => {
|
||||
</div>
|
||||
{hasLogo ? (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Logo预览"
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
maxHeight: 80,
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Logo预览"
|
||||
style={{
|
||||
maxWidth: 200,
|
||||
maxHeight: 80,
|
||||
objectFit: 'contain',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
}}
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
|
||||
建议尺寸:200x40px,支持 PNG、JPG 格式
|
||||
@@ -198,8 +400,9 @@ const AdminSettings: React.FC = () => {
|
||||
};
|
||||
|
||||
const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
|
||||
const label = config.key === 'user_agreement_url' ? '用户协议' : '隐私政策';
|
||||
const label = config.key === 'user_agreement_privacy_url' ? '用户协议及隐私政策' : '协议文件';
|
||||
const hasFile = config.value && config.value.startsWith('/uploads/');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px', borderRadius: 10,
|
||||
@@ -214,7 +417,7 @@ const AdminSettings: React.FC = () => {
|
||||
<Space>
|
||||
{hasFile && (
|
||||
<Button size="small" icon={<EyeOutlined />}
|
||||
onClick={() => window.open(`http://localhost:8000${config.value}`, '_blank')}>
|
||||
onClick={() => window.open(`${baseUrl}${config.value}`, '_blank')}>
|
||||
预览
|
||||
</Button>
|
||||
)}
|
||||
@@ -236,40 +439,53 @@ const AdminSettings: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getFieldComponent = (config: SystemConfig) => {
|
||||
if (config.key === 'site_logo') {
|
||||
return <LogoUploadField config={config} />;
|
||||
}
|
||||
if (config.key === 'seo_description') {
|
||||
return <Input.TextArea rows={3} placeholder={config.description} size="large" />;
|
||||
}
|
||||
if (config.key === 'seo_keywords') {
|
||||
return <Input placeholder="关键词1, 关键词2, 关键词3" size="large" />;
|
||||
}
|
||||
if (config.key === 'user_login_credits_enabled') {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<Switch defaultChecked={config.value === 'true'} />
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{config.value === 'true' ? '已启用' : '已禁用'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (config.key === 'user_register_credits' || config.key === 'user_login_credits') {
|
||||
return <Input type="number" min={1} placeholder={config.description} size="large" />;
|
||||
}
|
||||
return <Input placeholder={config.description} size="large" />;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Card loading bordered={false} style={{ borderRadius: 12 }} />;
|
||||
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}>
|
||||
<SettingOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
|
||||
<Typography.Text type="secondary">管理站点基础信息、协议文件和SEO配置</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'basic',
|
||||
label: '网站基础设置',
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
{Object.entries(groupedConfigs).map(([group, items]) => (
|
||||
{['站点信息', '协议配置', 'SEO 设置'].map(group => (
|
||||
<div key={group} style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
{group}
|
||||
</Typography.Text>
|
||||
{group === '协议配置' ? (
|
||||
items.map(config => (
|
||||
groupedConfigs[group]?.map(config => (
|
||||
<PdfUploadField key={config.id} config={config} />
|
||||
))
|
||||
) : (
|
||||
items.map(config => (
|
||||
groupedConfigs[group]?.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
@@ -283,6 +499,213 @@ const AdminSettings: React.FC = () => {
|
||||
</div>
|
||||
))}
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'credits',
|
||||
label: '用户积分配置',
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
用户积分配置
|
||||
</Typography.Text>
|
||||
{groupedConfigs['用户积分配置']?.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||
extra={getFieldDescription(config)}
|
||||
>
|
||||
{getFieldComponent(config)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'integration',
|
||||
label: '收款银行',
|
||||
children: (
|
||||
<div>
|
||||
{/* 银行账户管理 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>
|
||||
<BankOutlined style={{ marginRight: 8, color: '#6366f1' }} />
|
||||
收款银行管理
|
||||
</Typography.Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} size="small" onClick={() => openAccountModal()}>
|
||||
新增账户
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
dataSource={bankAccounts}
|
||||
loading={loadingAccounts}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
columns={accountColumns}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'other',
|
||||
label: '其他配置',
|
||||
children: (
|
||||
<Form form={form} layout="vertical">
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
其他配置
|
||||
</Typography.Text>
|
||||
{groupedConfigs['其他配置']?.map(config => (
|
||||
<Form.Item
|
||||
key={config.id}
|
||||
name={config.key}
|
||||
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
|
||||
extra={getFieldDescription(config)}
|
||||
>
|
||||
{getFieldComponent(config)}
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 登录背景视频 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
登录背景视频
|
||||
</Typography.Text>
|
||||
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Space>
|
||||
<VideoCameraOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||
<Typography.Text strong>背景视频</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
{form.getFieldValue('login_bg_video') && (
|
||||
<Button size="small" danger onClick={handleRemoveLoginVideo}>
|
||||
移除
|
||||
</Button>
|
||||
)}
|
||||
<Upload
|
||||
accept="video/*,image/gif,image/webp"
|
||||
showUploadList={false}
|
||||
beforeUpload={handleLoginVideoUpload}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'login_bg_video'}>
|
||||
上传视频
|
||||
</Button>
|
||||
</Upload>
|
||||
</Space>
|
||||
</div>
|
||||
{(() => {
|
||||
const url = form.getFieldValue('login_bg_video');
|
||||
if (!url) {
|
||||
return (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
未设置,前台将默认使用 backimage.png
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${url}`;
|
||||
const isGif = url.toLowerCase().endsWith('.gif');
|
||||
return isGif ? (
|
||||
<img src={fullUrl} alt="预览" style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#f0f0f5', objectFit: 'contain' }} />
|
||||
) : (
|
||||
<video
|
||||
src={fullUrl}
|
||||
controls
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#000' }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
|
||||
支持 MP4、WebM、MOV、GIF、WebP,最大 50MB
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文字模型媒体编码 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
文字模型媒体编码
|
||||
</Typography.Text>
|
||||
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||
<div>
|
||||
<Typography.Text strong>图片/视频 base64 编码</Typography.Text>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
开启后文字模型请求时将媒体转 base64 发送,而非 URL 链接
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
<Switch
|
||||
checked={(configs.find(c => c.key === 'llm_media_as_base64') || {}).value === 'true'}
|
||||
onChange={handleToggleBase64}
|
||||
checkedChildren="base64"
|
||||
unCheckedChildren="链接"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 单设备登录限制 */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||
登录安全
|
||||
</Typography.Text>
|
||||
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
|
||||
<div>
|
||||
<Typography.Text strong>单设备登录限制</Typography.Text>
|
||||
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
|
||||
开启后同一账号仅允许在一台同类型设备登录(手机和电脑可同时登录)
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
<Switch
|
||||
checked={(configs.find(c => c.key === 'single_device_login_enabled') || {}).value === 'true'}
|
||||
onChange={handleToggleSingleDevice}
|
||||
checkedChildren="已启用"
|
||||
unCheckedChildren="已禁用"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1080 }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||
<div style={{
|
||||
width: 44, height: 44, borderRadius: 10,
|
||||
background: 'rgba(99,102,241,0.08)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 20, color: '#6366f1',
|
||||
}}>
|
||||
<SettingOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>系统设置</Typography.Title>
|
||||
<Typography.Text type="secondary">管理站点基础信息、用户积分和系统配置</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs items={tabItems} defaultActiveKey="basic" />
|
||||
</Card>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
@@ -291,6 +714,41 @@ const AdminSettings: React.FC = () => {
|
||||
保存配置
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 银行账户新增/编辑弹窗 */}
|
||||
<Modal
|
||||
title={accountEditing ? '编辑银行账户' : '新增银行账户'}
|
||||
open={accountModal}
|
||||
onOk={handleAccountSave}
|
||||
onCancel={() => setAccountModal(false)}
|
||||
confirmLoading={accountSaving}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={accountForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="account_name" label="账户名称" rules={[{ required: true, message: '请输入账户名称' }]}>
|
||||
<Input placeholder="例如:民众普康科技有限公司" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bank_name" label="开户银行" rules={[{ required: true, message: '请输入开户银行' }]}>
|
||||
<Input placeholder="例如:中国工商银行北京分行" />
|
||||
</Form.Item>
|
||||
<Form.Item name="account_no" label="银行账号" rules={[{ required: true, message: '请输入银行账号' }]}>
|
||||
<Input placeholder="银行账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选备注信息" />
|
||||
</Form.Item>
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
<Form.Item name="is_default" label="设为默认" valuePropName="checked">
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { CameraOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getAdminShotTaskSets } from '../api';
|
||||
import type { ShotTaskSetOut } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { getShotAnalysisStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@@ -35,28 +36,14 @@ const SPLIT_STATUS_OPTIONS = [
|
||||
{ value: 'retry_waiting', label: '等待重试' },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
pending_analysis: { color: 'default', text: '等待分析' },
|
||||
analyzing: { color: 'processing', text: '分析中' },
|
||||
analysis_completed: { color: 'success', text: '分析完成' },
|
||||
analysis_failed: { color: 'error', text: '分析失败' },
|
||||
splitting: { color: 'warning', text: '拆镜中' },
|
||||
split_completed: { color: 'success', text: '拆镜完成' },
|
||||
partial_failed: { color: 'orange', text: '部分失败' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
none: { color: 'default', text: '未拆镜' },
|
||||
pending: { color: 'default', text: '待处理' },
|
||||
processing: { color: 'warning', text: '处理中' },
|
||||
completed: { color: 'success', text: '完成' },
|
||||
retry_waiting: { color: 'orange', text: '等待重试' },
|
||||
};
|
||||
|
||||
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
||||
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
||||
|
||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
||||
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' }> = ({ status, kind = 'task' }) => {
|
||||
if (!status) return <Tag>-</Tag>;
|
||||
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
|
||||
const meta = kind === 'analysis'
|
||||
? getShotAnalysisStatusMeta(status)
|
||||
: (kind === 'split' ? getShotSplitStatusMeta(status) : getShotTaskStatusMeta(status));
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||
};
|
||||
|
||||
@@ -109,6 +96,25 @@ const AdminShotReplications: React.FC = () => {
|
||||
load();
|
||||
}, [load, reloadKey]);
|
||||
|
||||
const hasActiveTasks = items.some((item) => (
|
||||
getShotTaskStatusMeta(item.status).active
|
||||
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|
||||
|| getShotSplitStatusMeta(item.splitStatus).active
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveTasks) return undefined;
|
||||
const refresh = () => {
|
||||
if (document.visibilityState === 'visible') void load();
|
||||
};
|
||||
const timer = window.setInterval(refresh, 30000);
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', refresh);
|
||||
};
|
||||
}, [hasActiveTasks, load]);
|
||||
|
||||
const doSearch = () => {
|
||||
setQueryKeyword(inputKeyword.trim());
|
||||
setQueryUserId(inputUserId.trim());
|
||||
@@ -134,16 +140,15 @@ const AdminShotReplications: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>拆镜复刻</Typography.Title>
|
||||
<Typography.Text type="secondary">只读排查页面:查看拆镜总任务、AI 分析结果、切片列表和关联复刻项目。</Typography.Text>
|
||||
</div>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||
<Space>
|
||||
<CameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
<Typography.Text strong style={{ fontSize: 16 }}>拆镜复刻</Typography.Text>
|
||||
<Tag color="purple">{total} 条记录</Tag>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Space wrap>
|
||||
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
|
||||
@@ -186,9 +191,9 @@ const AdminShotReplications: React.FC = () => {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '总状态', dataIndex: 'status', width: 120, render: (v: string) => <StatusTag status={v} kind="task" /> },
|
||||
{ title: '分析状态', dataIndex: 'analysisStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
|
||||
{ title: '拆镜状态', dataIndex: 'splitStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="split" /> },
|
||||
{
|
||||
title: '切片进度',
|
||||
width: 180,
|
||||
@@ -211,9 +216,7 @@ const AdminShotReplications: React.FC = () => {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
@@ -23,6 +22,7 @@ import { getAdminShotSegmentDetail, getAdminShotSegments, getAdminShotTaskSetDet
|
||||
import type { ShotAiSuggestionOut, ShotSegmentDetailOut, ShotSegmentOut, ShotTaskSetDetailOut } from '../types';
|
||||
import { formatDate } from '../utils/formatDate';
|
||||
import { getStepCodeLabel } from './adminReplication/components/StatusTag';
|
||||
import { getShotAnalysisStatusMeta, getShotReplicateStatusMeta, getShotSplitStatusMeta, getShotTaskStatusMeta } from '../utils/shotReplicateStatus';
|
||||
|
||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||
@@ -55,25 +55,6 @@ const REPLICATE_STATUS_OPTIONS = [
|
||||
{ value: 'failed', label: '复刻失败' },
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
pending_analysis: { color: 'default', text: '等待分析' },
|
||||
analyzing: { color: 'processing', text: '分析中' },
|
||||
analysis_completed: { color: 'success', text: '分析完成' },
|
||||
analysis_failed: { color: 'error', text: '分析失败' },
|
||||
splitting: { color: 'warning', text: '拆镜中' },
|
||||
split_completed: { color: 'success', text: '拆镜完成' },
|
||||
partial_failed: { color: 'orange', text: '部分失败' },
|
||||
failed: { color: 'error', text: '失败' },
|
||||
none: { color: 'default', text: '未拆镜' },
|
||||
pending: { color: 'default', text: '待处理' },
|
||||
processing: { color: 'warning', text: '处理中' },
|
||||
completed: { color: 'success', text: '完成' },
|
||||
retry_waiting: { color: 'orange', text: '等待重试' },
|
||||
not_required: { color: 'default', text: '无需分析' },
|
||||
not_started: { color: 'default', text: '未复刻' },
|
||||
project_created: { color: 'processing', text: '已创建项目' },
|
||||
};
|
||||
|
||||
const apiUrl = (url?: string | null): string => {
|
||||
if (!url) return '';
|
||||
const value = String(url).trim();
|
||||
@@ -85,9 +66,13 @@ const apiUrl = (url?: string | null): string => {
|
||||
const safeDate = (value?: string | null): string => (value ? formatDate(value) : '-');
|
||||
const shortId = (value?: string | null): string => (!value ? '-' : value.length > 16 ? `${value.slice(0, 10)}...` : value);
|
||||
|
||||
const StatusTag: React.FC<{ status?: string | null }> = ({ status }) => {
|
||||
const StatusTag: React.FC<{ status?: string | null; kind?: 'task' | 'analysis' | 'split' | 'replicate' }> = ({ status, kind = 'task' }) => {
|
||||
if (!status) return <Tag>-</Tag>;
|
||||
const meta = STATUS_MAP[status] || { color: 'blue', text: status };
|
||||
const meta = kind === 'analysis'
|
||||
? getShotAnalysisStatusMeta(status)
|
||||
: (kind === 'split'
|
||||
? getShotSplitStatusMeta(status)
|
||||
: (kind === 'replicate' ? getShotReplicateStatusMeta(status) : getShotTaskStatusMeta(status)));
|
||||
return <Tag color={meta.color}>{meta.text}</Tag>;
|
||||
};
|
||||
|
||||
@@ -177,6 +162,32 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
||||
useEffect(() => { loadDetail(); }, [loadDetail, reloadKey]);
|
||||
useEffect(() => { loadSegments(); }, [loadSegments, reloadKey]);
|
||||
|
||||
const hasActiveTasks = Boolean(detail && (
|
||||
getShotTaskStatusMeta(detail.status).active
|
||||
|| getShotAnalysisStatusMeta(detail.analysisStatus).active
|
||||
|| getShotSplitStatusMeta(detail.splitStatus).active
|
||||
|| segments.some((item) => (
|
||||
getShotSplitStatusMeta(item.splitStatus).active
|
||||
|| getShotAnalysisStatusMeta(item.analysisStatus).active
|
||||
|| getShotReplicateStatusMeta(item.replicateStatus).active
|
||||
))
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasActiveTasks) return undefined;
|
||||
const refresh = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void loadDetail();
|
||||
void loadSegments();
|
||||
};
|
||||
const timer = window.setInterval(refresh, 20000);
|
||||
document.addEventListener('visibilitychange', refresh);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', refresh);
|
||||
};
|
||||
}, [hasActiveTasks, loadDetail, loadSegments]);
|
||||
|
||||
const openSegmentDetail = async (segmentId: string) => {
|
||||
setDrawerOpen(true);
|
||||
setSegmentDetail(null);
|
||||
@@ -224,9 +235,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
||||
<Descriptions.Item label="用户名">{detail.userName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="标题">{detail.title || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="视频时长">{Number(detail.videoDurationSeconds || 0).toFixed(2)}s</Descriptions.Item>
|
||||
<Descriptions.Item label="总状态"><StatusTag status={detail.status} /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="总状态"><StatusTag status={detail.status} kind="task" /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={detail.analysisStatus} kind="analysis" /></Descriptions.Item>
|
||||
<Descriptions.Item label="拆镜状态"><StatusTag status={detail.splitStatus} kind="split" /></Descriptions.Item>
|
||||
<Descriptions.Item label="片段数量">{detail.completedSegmentCount}/{detail.segmentCount},失败 {detail.failedSegmentCount}</Descriptions.Item>
|
||||
<Descriptions.Item label="原视频分类">{detail.originalVideoCategory || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{safeDate(detail.createdAt)}</Descriptions.Item>
|
||||
@@ -274,9 +285,9 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
||||
{ title: '来源', dataIndex: 'sourceMode', width: 100, render: (v: string) => v === 'ai_suggestion' ? <Tag color="purple">AI建议</Tag> : <Tag color="cyan">自定义</Tag> },
|
||||
{ title: '时间节点', dataIndex: 'timeNode', width: 130 },
|
||||
{ title: '时长', dataIndex: 'durationSeconds', width: 90, render: (v: number) => `${Number(v || 0).toFixed(2)}s` },
|
||||
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} /> },
|
||||
{ title: '切割', dataIndex: 'splitStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="split" /> },
|
||||
{ title: '分析', dataIndex: 'analysisStatus', width: 100, render: (v: string) => <StatusTag status={v} kind="analysis" /> },
|
||||
{ title: '复刻', dataIndex: 'replicateStatus', width: 110, render: (v: string) => <StatusTag status={v} kind="replicate" /> },
|
||||
{ title: '片段内容', dataIndex: 'segmentContent', width: 260, ellipsis: true, render: (v: string) => v || '-' },
|
||||
{ title: '分类', dataIndex: 'segmentCategory', width: 120, render: (v: string) => v || '-' },
|
||||
{
|
||||
@@ -284,9 +295,14 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
||||
width: 220,
|
||||
render: (_, record) => record.moduleProjectId ? (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}`)}>{shortId(record.moduleProjectId)}</Button>
|
||||
<Button type="link" style={{ padding: 0 }} onClick={() => navigate(`/shot-replications/projects/${record.moduleProjectId}?flow_version=${record.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{shortId(record.moduleProjectId)}</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{record.moduleProjectTitle || getStepCodeLabel(record.moduleProjectCurrentStepCode)}</Typography.Text>
|
||||
<StatusTag status={record.moduleProjectStatus} />
|
||||
<Space size={4}>
|
||||
<StatusTag status={record.moduleProjectStatus} kind="replicate" />
|
||||
<Tag color={record.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>
|
||||
{String(record.moduleProjectFlowVersion || 'v1').toUpperCase()}
|
||||
</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
) : <Tag>未创建</Tag>,
|
||||
},
|
||||
@@ -310,10 +326,11 @@ const AdminShotTaskSetDetail: React.FC = () => {
|
||||
<Descriptions.Item label="片段ID" span={2}>{segmentDetail.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间节点">{segmentDetail.timeNode}</Descriptions.Item>
|
||||
<Descriptions.Item label="时长">{Number(segmentDetail.durationSeconds || 0).toFixed(2)}s</Descriptions.Item>
|
||||
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} /></Descriptions.Item>
|
||||
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="切割状态"><StatusTag status={segmentDetail.splitStatus} kind="split" /></Descriptions.Item>
|
||||
<Descriptions.Item label="分析状态"><StatusTag status={segmentDetail.analysisStatus} kind="analysis" /></Descriptions.Item>
|
||||
<Descriptions.Item label="复刻状态"><StatusTag status={segmentDetail.replicateStatus} kind="replicate" /></Descriptions.Item>
|
||||
<Descriptions.Item label="关联项目">{segmentDetail.moduleProjectId ? <Button type="link" onClick={() => navigate(`/shot-replications/projects/${segmentDetail.moduleProjectId}?flow_version=${segmentDetail.moduleProjectFlowVersion === 'v2' ? 'v2' : 'v1'}`)}>{segmentDetail.moduleProjectId}</Button> : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目流程版本">{segmentDetail.moduleProjectId ? <Tag color={segmentDetail.moduleProjectFlowVersion === 'v2' ? 'blue' : 'default'}>{String(segmentDetail.moduleProjectFlowVersion || 'v1').toUpperCase()}</Tag> : '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段内容" span={2}>{segmentDetail.segmentContent || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段分类">{segmentDetail.segmentCategory || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="片段受众">{segmentDetail.segmentAudience || '-'}</Descriptions.Item>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, TeamOutlined, UserSwitchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
deleteAdminTeam, getAdminTeamManagerHistory, getAdminTeamMemberUsage, getAdminTeams, getAdminTeamSubscriptions,
|
||||
getTeamMembersForAdmin, saveAdminTeam, setTeamManager,
|
||||
} from '../api';
|
||||
import type { AdminTeam, AdminUser } from '../types';
|
||||
|
||||
const STATUS = { active: '启用', disabled: '禁用' } as Record<string,string>;
|
||||
const fmt = (v?: string | null) => v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-';
|
||||
|
||||
const AdminTeams: React.FC = () => {
|
||||
const [items, setItems] = useState<AdminTeam[]>([]); const [loading,setLoading]=useState(false); const [page,setPage]=useState(1); const [pageSize,setPageSize]=useState(20); const [total,setTotal]=useState(0);
|
||||
const [keyword,setKeyword]=useState(''); const [status,setStatus]=useState<string>(); const [form]=Form.useForm(); const [edit,setEdit]=useState<AdminTeam|null>(null); const [editOpen,setEditOpen]=useState(false);
|
||||
const [detail,setDetail]=useState<AdminTeam|null>(null); const [members,setMembers]=useState<AdminUser[]>([]); const [subscriptions,setSubscriptions]=useState<any[]>([]); const [usage,setUsage]=useState<any[]>([]); const [history,setHistory]=useState<any[]>([]); const [detailLoading,setDetailLoading]=useState(false);
|
||||
const query=useMemo(()=>({page,pageSize,keyword:keyword||undefined,status}),[page,pageSize,keyword,status]);
|
||||
const load=useCallback(async()=>{setLoading(true);try{const r=await getAdminTeams(query);setItems(r.items||[]);setTotal(Number(r.total||0));}catch(e:any){message.error(e?.message||'团队列表加载失败');}finally{setLoading(false);}},[query]);
|
||||
useEffect(()=>{void load();},[load]);
|
||||
|
||||
const openEdit=(team?:AdminTeam)=>{const item=team||null;setEdit(item);form.resetFields();form.setFieldsValue(item?{name:item.name,code:item.code,description:item.description,status:item.status,sortOrder:item.sortOrder}:{status:'active',sortOrder:0});setEditOpen(true);};
|
||||
const save=async()=>{try{const v=await form.validateFields();await saveAdminTeam({id:edit?.id,name:v.name,code:v.code||null,description:v.description||null,status:v.status,sort_order:Number(v.sortOrder||0)});message.success(edit?'团队已更新':'团队已创建');setEditOpen(false);await load();}catch(e:any){if(!e?.errorFields)message.error(e?.message||'保存失败');}};
|
||||
const openDetail=async(team:AdminTeam)=>{setDetail(team);setDetailLoading(true);try{const [m,s,u,h]=await Promise.all([getTeamMembersForAdmin(team.id,1,500),getAdminTeamSubscriptions(team.id),getAdminTeamMemberUsage(team.id),getAdminTeamManagerHistory(team.id)]);setMembers(m.items||[]);setSubscriptions(s||[]);setUsage(u||[]);setHistory(h||[]);}catch(e:any){message.error(e?.message||'团队详情加载失败');}finally{setDetailLoading(false);}};
|
||||
|
||||
const columns=[
|
||||
{title:'团队',dataIndex:'name',width:220,render:(v:string,r:AdminTeam)=><Space><TeamOutlined/><div><b>{v}</b><div style={{fontSize:12,color:'#94a3b8'}}>{r.code||'-'}</div></div></Space>},
|
||||
{title:'状态',dataIndex:'status',width:100,render:(v:string,r:AdminTeam)=><Tag color={v==='active'?'green':'red'}>{r.statusLabel||STATUS[v]||'其他状态'}</Tag>},
|
||||
{title:'成员数',dataIndex:'memberCount',width:90,align:'right' as const},
|
||||
{title:'当前队长',dataIndex:'managerName',width:140,render:(v:string)=>v||'-'},
|
||||
{title:'团队首购时间',dataIndex:'firstSubscriptionPaidAt',width:180,render:(v:string)=>fmt(v)},
|
||||
{title:'排序',dataIndex:'sortOrder',width:80},
|
||||
{title:'备注',dataIndex:'description',ellipsis:true,render:(v:string)=>v||'-'},
|
||||
{title:'操作',fixed:'right' as const,width:220,render:(_:any,r:AdminTeam)=><Space><Button size="small" icon={<EyeOutlined/>} onClick={()=>openDetail(r)}>详情</Button><Button size="small" icon={<EditOutlined/>} onClick={()=>openEdit(r)}>{r.status==='disabled'?'重新启用':'编辑'}</Button><Popconfirm title="确认软删除团队?" description="仅在无有效团队订阅、无未完成团队订单且无成员时允许删除。" onConfirm={async()=>{try{await deleteAdminTeam(r.id);message.success('团队已删除');await load();}catch(e:any){message.error(e?.message||'删除失败');}}}><Button size="small" danger icon={<DeleteOutlined/>}>删除</Button></Popconfirm></Space>},
|
||||
];
|
||||
|
||||
const subColumns=[
|
||||
{title:'订阅实例',width:190,render:(_:any,r:any)=><Typography.Text copyable={r.subscription?.subscriptionNo?{text:r.subscription.subscriptionNo}:false}>{r.subscription?.subscriptionNo||'-'}</Typography.Text>},
|
||||
{title:'套餐',render:(_:any,r:any)=>r.subscription?.productNameSnapshot||'-'}, {title:'状态',render:(_:any,r:any)=><Tag>{r.subscription?.statusLabel||'其他状态'}</Tag>},
|
||||
{title:'数量N',render:(_:any,r:any)=>r.subscription?.quantitySnapshot||1}, {title:'席位',render:(_:any,r:any)=>`${r.activeSeatCount||0}/${r.seatLimit||0}`},
|
||||
{title:'本期总积分',dataIndex:'periodTotalCredits',align:'right' as const},{title:'本期剩余',dataIndex:'periodUnspentCredits',align:'right' as const},{title:'未分配',dataIndex:'periodUnallocatedCredits',align:'right' as const},
|
||||
{title:'订阅到期',render:(_:any,r:any)=>fmt(r.subscription?.expiresAt)},
|
||||
];
|
||||
const memberColumns=[{title:'用户名',dataIndex:'username'},{title:'手机号',dataIndex:'phone',render:(v:string)=>v||'-'},{title:'个人积分',dataIndex:'personalCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队可用',dataIndex:'teamAvailableCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'团队冻结',dataIndex:'teamFrozenCredits',align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},{title:'设为队长',render:(_:any,r:AdminUser)=>r.id===detail?.managerId? <Tag color="purple">当前队长</Tag>:<Popconfirm title="确认更换队长?" description="只有团队启用、所有团队订阅结束且没有未完成团队订单时才能更换。" onConfirm={async()=>{if(!detail)return;try{await setTeamManager(detail.id,r.id);message.success('队长已更换');await openDetail({...detail,managerId:r.id,managerName:r.username});await load();}catch(e:any){message.error(e?.message||'更换队长失败');}}}><Button size="small" disabled={detail?.status==='disabled'} icon={<UserSwitchOutlined/>}>设为队长</Button></Popconfirm>}];
|
||||
|
||||
return <div><Card><Space wrap style={{marginBottom:16}}><Input allowClear value={keyword} onChange={e=>{setKeyword(e.target.value);setPage(1);}} placeholder="团队名称/编码/备注" prefix={<SearchOutlined/>} style={{width:260}}/><Select allowClear value={status} onChange={v=>{setStatus(v);setPage(1);}} placeholder="全部状态" style={{width:130}} options={[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/><Button icon={<ReloadOutlined/>} onClick={load}>刷新</Button><Button type="primary" icon={<PlusOutlined/>} onClick={()=>openEdit()}>新增团队</Button></Space><Table rowKey="id" loading={loading} columns={columns} dataSource={items} scroll={{x:1250}} pagination={{current:page,pageSize,total,showSizeChanger:true,onChange:(p,ps)=>{setPage(p);setPageSize(ps);}}}/></Card>
|
||||
|
||||
<Modal width={560} title={edit?'编辑团队':'新增团队'} open={editOpen} onCancel={()=>setEditOpen(false)} onOk={save} okText={edit?.status==='disabled'?'重新启用':'保存'} cancelText="取消">
|
||||
{edit?.status==='disabled'&&<Alert style={{marginBottom:14}} type="warning" showIcon message="禁用团队当前仅允许重新启用" description="团队禁用期间所有团队业务保持只读;Subscription、Period、Seat 和积分仍正常滚期、发放与过期。"/>}
|
||||
<Form form={form} layout="vertical"><Form.Item name="name" label="团队名称" rules={[{required:true}]}><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="code" label="团队编码"><Input disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="description" label="备注"><Input.TextArea rows={3} disabled={edit?.status==='disabled'}/></Form.Item><Form.Item name="status" label="状态" rules={[{required:true}]}><Select options={edit?.status==='disabled'?[{value:'active',label:'重新启用'}]:[{value:'active',label:'启用'},{value:'disabled',label:'禁用'}]}/></Form.Item><Form.Item name="sortOrder" label="排序"><InputNumber min={0} max={999999} disabled={edit?.status==='disabled'} style={{width:'100%'}}/></Form.Item></Form>
|
||||
</Modal>
|
||||
|
||||
<Modal width={1100} footer={null} title={detail?`团队详情:${detail.name}`:'团队详情'} open={!!detail} onCancel={()=>setDetail(null)}>
|
||||
{detail?.status==='disabled'&&<Alert type="warning" showIcon style={{marginBottom:14}} message="团队已禁用:当前仅允许只读查看" description="团队积分处于冻结状态,但团队订阅仍继续正常发放和到期。"/>}
|
||||
<Tabs items={[
|
||||
{key:'subscriptions',label:'订阅与席位',children:<Table loading={detailLoading} rowKey={(r:any)=>r.subscription?.id} dataSource={subscriptions} columns={subColumns} pagination={false} scroll={{x:1120}} expandable={{expandedRowRender:(r:any)=><Table rowKey="id" size="small" pagination={false} dataSource={r.seats||[]} columns={[{title:'席位用户',dataIndex:'username'},{title:'月额度',dataIndex:'monthlyAllocatedCredits',align:'right' as const},{title:'本期净消耗',dataIndex:'currentPeriodUsedCredits',align:'right' as const},{title:'本期剩余',dataIndex:'currentPeriodRemainingCredits',align:'right' as const},{title:'状态',dataIndex:'statusLabel'}]}/>}}/>},
|
||||
{key:'members',label:'成员与队长',children:<Table loading={detailLoading} rowKey="id" dataSource={members} columns={memberColumns} pagination={false} scroll={{x:850}}/>},
|
||||
{key:'usage',label:'成员月净消耗',children:<Table
|
||||
loading={detailLoading}
|
||||
rowKey={(r:any)=>`${r.userId}-${r.subscriptionPeriodId}`}
|
||||
dataSource={usage}
|
||||
pagination={false}
|
||||
scroll={{x:1400}}
|
||||
columns={[
|
||||
{title:'成员',dataIndex:'username',width:120},
|
||||
{title:'订阅实例',dataIndex:'subscriptionNo',width:190,render:(v:string)=><Typography.Text copyable={v&&v!=='历史订阅'?{text:v}:false}>{v||'历史订阅'}</Typography.Text>},
|
||||
{title:'套餐名称',dataIndex:'subscriptionName',width:180,render:(v:string)=><Typography.Text strong>{v||'历史团队订阅'}</Typography.Text>},
|
||||
{title:'等级编码',width:190,render:(_:any,r:any)=><span>{r.tierLabel||'未知等级'}{r.tierCode?`(${r.tierCode})`:''}{r.tierRank?` / ${r.tierRank}`:''}</span>},
|
||||
{title:'套餐周期',dataIndex:'billingCycleLabel',width:110,render:(v:string)=><Tag>{v||'未知周期'}</Tag>},
|
||||
{title:'月度周期',dataIndex:'periodLabel',width:110,render:(v:string,r:any)=>v||(Number(r.periodSequence)>0?`第${r.periodSequence}个月`:'历史周期')},
|
||||
{title:'周期时间',width:320,render:(_:any,r:any)=>`${fmt(r.periodStartAt)} ~ ${fmt(r.periodExpiresAt)}`},
|
||||
{title:'本期净消耗',dataIndex:'consumedCredits',width:130,align:'right' as const,render:(v:number)=>Number(v||0).toLocaleString()},
|
||||
]}
|
||||
/>},
|
||||
{key:'history',label:'队长任期历史',children:<Table loading={detailLoading} rowKey="id" dataSource={history} pagination={false} columns={[{title:'队长',dataIndex:'managerName'},{title:'开始',dataIndex:'startedAt',render:(v:string)=>fmt(v)},{title:'结束',dataIndex:'endedAt',render:(v:string)=>v?fmt(v):<Tag color="green">当前任期</Tag>}]}/>},
|
||||
]}/>
|
||||
</Modal>
|
||||
</div>;
|
||||
};
|
||||
export default AdminTeams;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
|
||||
@@ -17,8 +17,16 @@ interface VideoEngine {
|
||||
supportedRatios: string[];
|
||||
supportedResolutions: string[];
|
||||
supportedDurations: number[];
|
||||
maxDuration: number;
|
||||
maxImageCount: number;
|
||||
maxVideoCount: number;
|
||||
maxAudioCount: number;
|
||||
supportsFirstLastFrame: boolean;
|
||||
supportsUniversalReference: boolean;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
multiGenerationEnabled: boolean;
|
||||
maxGenerationCount: number;
|
||||
}
|
||||
|
||||
function parseJsonArray(val: unknown): any[] {
|
||||
@@ -34,6 +42,7 @@ const AdminVideoEngines: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
|
||||
const [form] = Form.useForm();
|
||||
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
@@ -66,8 +75,16 @@ const AdminVideoEngines: React.FC = () => {
|
||||
supported_ratios: JSON.stringify(values.supportedRatios || []),
|
||||
supported_resolutions: JSON.stringify(values.supportedResolutions || []),
|
||||
supported_durations: JSON.stringify(values.supportedDurations || []),
|
||||
max_duration: values.maxDuration ?? 15,
|
||||
max_image_count: values.maxImageCount ?? 2,
|
||||
max_video_count: values.maxVideoCount ?? 0,
|
||||
max_audio_count: values.maxAudioCount ?? 0,
|
||||
supports_first_last_frame: values.supportsFirstLastFrame ?? false,
|
||||
supports_universal_reference: values.supportsUniversalReference ?? true,
|
||||
is_active: values.isActive ?? true,
|
||||
priority: values.priority ?? 0,
|
||||
multi_generation_enabled: values.multiGenerationEnabled ?? false,
|
||||
max_generation_count: values.maxGenerationCount ?? 1,
|
||||
};
|
||||
if (modal.engine) {
|
||||
await saveVideoEngine({ id: modal.engine.id, ...payload });
|
||||
@@ -102,11 +119,18 @@ const AdminVideoEngines: React.FC = () => {
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
isActive: true, priority: 0,
|
||||
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
supportedResolutions: ['480p', '720p', '1080p'],
|
||||
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||
});
|
||||
isActive: true, priority: 0,
|
||||
multiGenerationEnabled: false, maxGenerationCount: 1,
|
||||
maxDuration: 15,
|
||||
maxImageCount: 2,
|
||||
maxVideoCount: 0,
|
||||
maxAudioCount: 0,
|
||||
supportsFirstLastFrame: false,
|
||||
supportsUniversalReference: true,
|
||||
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
||||
supportedResolutions: ['480p', '720p', '1080p'],
|
||||
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,6 +166,38 @@ const AdminVideoEngines: React.FC = () => {
|
||||
title: '支持时长', dataIndex: 'supportedDurations', width: 120,
|
||||
render: (d: number[]) => <Tag color="orange">{d?.length ? `${Math.min(...d)}-${Math.max(...d)}s` : '-'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
|
||||
render: (v: number) => <Tag color="purple">{v} 张</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最大视频', dataIndex: 'maxVideoCount', width: 100,
|
||||
render: (v: number) => <Tag color="cyan">{v} 个</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最大音频', dataIndex: 'maxAudioCount', width: 100,
|
||||
render: (v: number) => <Tag color={v > 0 ? 'geekblue' : 'default'}>{v || 0} 段</Tag>,
|
||||
},
|
||||
{
|
||||
title: '首尾帧', dataIndex: 'supportsFirstLastFrame', width: 90,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
|
||||
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
|
||||
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
|
||||
render: (v: number, r: VideoEngine) => (
|
||||
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
|
||||
最多 {r.multiGenerationEnabled ? (v || 1) : 1} 份
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'isActive', width: 80,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||
@@ -161,7 +217,7 @@ const AdminVideoEngines: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||
@@ -179,7 +235,7 @@ const AdminVideoEngines: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 900 }}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -230,12 +286,67 @@ const AdminVideoEngines: React.FC = () => {
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedDurations" label="支持时长(秒)" style={{ flex: 1 }}>
|
||||
<Select mode="multiple" size="large" options={
|
||||
Array.from({ length: 12 }, (_, i) => ({ value: i + 4, label: `${i + 4}秒` }))
|
||||
Array.from({ length: 27 }, (_, i) => ({ value: i + 4, label: `${i + 4}秒` }))
|
||||
} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="priority" label="优先级">
|
||||
<Form.Item name="maxDuration" label="最大时长(秒)" style={{ flex: 1 }}>
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxImageCount" label="最大图片数量" style={{ flex: 1 }}>
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxVideoCount" label="最大视频数量" style={{ flex: 1 }}>
|
||||
<Input type="number" size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item
|
||||
name="maxAudioCount"
|
||||
label="最大参考音频数"
|
||||
style={{ flex: 1 }}
|
||||
extra="0 表示不支持音频参考"
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const n = Number(value ?? 0);
|
||||
// if (!Number.isInteger(n) || n < 0 || n > 3) {
|
||||
// return Promise.reject(new Error('最大参考音频数必须为 0-3 的整数'));
|
||||
// }
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input type="number" size="large" min={0} max={3} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="supportsFirstLastFrame" label="首尾帧模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportsUniversalReference" label="全能参考模式" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch checkedChildren="支持" unCheckedChildren="不支持" />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30, flex: 1 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
|
||||
<Typography.Text strong>多份生成能力</Typography.Text>
|
||||
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
|
||||
管理后台只控制是否允许客户端选择多份及最大数量;客户端每次可在 1 到上限之间选择。
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked" style={{ flex: 1 }}>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" style={{ flex: 1 }} rules={[{ required: true }]}>
|
||||
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
|
||||
</Form.Item>
|
||||
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
|
||||
<Select size="large" options={[
|
||||
{ value: 0, label: '0 (默认)' },
|
||||
{ value: 1, label: '1' },
|
||||
@@ -245,9 +356,6 @@ const AdminVideoEngines: React.FC = () => {
|
||||
{ value: 10, label: '10 (最高)' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Collapse,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EyeOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
exportVideoPromptSchemaConfig,
|
||||
getVideoPromptSchemaConfig,
|
||||
importVideoPromptSchemaConfig,
|
||||
previewVideoPromptSchemaConfig,
|
||||
resetVideoPromptSchemaConfig,
|
||||
saveVideoPromptSchemaConfig,
|
||||
} from '../api';
|
||||
import type {
|
||||
VideoPromptFlowItemField,
|
||||
VideoPromptSchemaConfigData,
|
||||
VideoPromptSchemaNode,
|
||||
VideoPromptSchemaNodeType,
|
||||
VideoPromptTimePlanRule,
|
||||
} from '../types';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const NODE_TYPES: { label: string; value: VideoPromptSchemaNodeType }[] = [
|
||||
{ label: '对象', value: 'object' },
|
||||
{ label: '数组', value: 'array' },
|
||||
{ label: '字符串', value: 'string' },
|
||||
{ label: '数字', value: 'number' },
|
||||
{ label: '布尔', value: 'boolean' },
|
||||
{ label: '流程数组', value: 'flow' },
|
||||
];
|
||||
|
||||
const VIDEO_SPEC_SECTION_KEY = '画面属性';
|
||||
const VIDEO_SPEC_LOCKED_FIELDS = new Set(['视频时长', '视频比例', '清晰度', '帧率', '推荐分辨率']);
|
||||
|
||||
function isLockedVideoSpecField(sectionKey: string | undefined, fieldKey: string | undefined): boolean {
|
||||
return sectionKey === VIDEO_SPEC_SECTION_KEY && !!fieldKey && VIDEO_SPEC_LOCKED_FIELDS.has(fieldKey);
|
||||
}
|
||||
|
||||
function isLockedVideoSpecSection(sectionKey: string | undefined): boolean {
|
||||
return sectionKey === VIDEO_SPEC_SECTION_KEY;
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value ?? null));
|
||||
}
|
||||
|
||||
function pick<T = any>(obj: any, camelKey: string, snakeKey: string, fallback?: T): T {
|
||||
if (!obj || typeof obj !== 'object') return fallback as T;
|
||||
if (obj[camelKey] !== undefined) return obj[camelKey] as T;
|
||||
if (obj[snakeKey] !== undefined) return obj[snakeKey] as T;
|
||||
return fallback as T;
|
||||
}
|
||||
|
||||
function normalizeFlowField(field: any): VideoPromptFlowItemField {
|
||||
return {
|
||||
key: String(field?.key || field?.label || '新流程字段'),
|
||||
label: String(field?.label || field?.key || '新流程字段'),
|
||||
enabled: field?.enabled ?? true,
|
||||
editable: field?.editable ?? true,
|
||||
maxLength: Number(pick(field, 'maxLength', 'max_length', 800) || 800),
|
||||
value: field?.value ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNode(node: any): VideoPromptSchemaNode {
|
||||
const type = String(node?.type || 'string') as VideoPromptSchemaNodeType;
|
||||
const normalized: VideoPromptSchemaNode = {
|
||||
key: String(node?.key || node?.label || '新字段'),
|
||||
label: String(node?.label || node?.key || '新字段'),
|
||||
type,
|
||||
enabled: node?.enabled ?? true,
|
||||
editable: node?.editable ?? true,
|
||||
maxLength: Number(pick(node, 'maxLength', 'max_length', type === 'flow' ? 800 : 1000) || (type === 'flow' ? 800 : 1000)),
|
||||
};
|
||||
|
||||
if (type === 'object') {
|
||||
normalized.children = Array.isArray(node?.children) ? node.children.map(normalizeNode) : [];
|
||||
} else if (type === 'flow') {
|
||||
normalized.contentKey = String(pick(node, 'contentKey', 'content_key', node?.key === '镜头流程' ? '镜头内容' : '动作内容'));
|
||||
const aliases = pick<any[]>(node, 'contentAliases', 'content_aliases', []);
|
||||
normalized.contentAliases = Array.isArray(aliases) ? aliases.map(item => String(item)).filter(Boolean) : [];
|
||||
const itemFields = pick<any[]>(node, 'itemFields', 'item_fields', []);
|
||||
normalized.itemFields = Array.isArray(itemFields) && itemFields.length > 0
|
||||
? itemFields.map(normalizeFlowField)
|
||||
: [{ key: normalized.contentKey, label: normalized.contentKey, enabled: true, editable: true, maxLength: normalized.maxLength || 800, value: '' }];
|
||||
} else {
|
||||
normalized.value = node?.value ?? (type === 'array' ? [] : type === 'boolean' ? false : type === 'number' ? 0 : '无');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeTimeRule(rule: any): VideoPromptTimePlanRule {
|
||||
const rawSegments = Array.isArray(rule?.segments) ? rule.segments : [];
|
||||
const rawRatios = Array.isArray(rule?.ratios) ? rule.ratios : [];
|
||||
return {
|
||||
minDuration: Number(pick(rule, 'minDuration', 'min_duration', 1) || 1),
|
||||
maxDuration: Number(pick(rule, 'maxDuration', 'max_duration', 5) || 5),
|
||||
ratios: rawRatios.map((item: any) => Number(item || 0)),
|
||||
segments: rawSegments.map((segment: any) => ({
|
||||
stage: String(segment?.stage || '新阶段'),
|
||||
description: String(segment?.description || segment?.desc || '阶段说明'),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfig(data: any): VideoPromptSchemaConfigData {
|
||||
const rawSections = Array.isArray(data?.sections) ? data.sections : [];
|
||||
const rawRules = Array.isArray(data?.timePlanRules)
|
||||
? data.timePlanRules
|
||||
: Array.isArray(data?.time_plan_rules)
|
||||
? data.time_plan_rules
|
||||
: [];
|
||||
return {
|
||||
version: data?.version || 'video_prompt_schema_config_v1',
|
||||
enabled: data?.enabled ?? true,
|
||||
sections: rawSections.map(normalizeNode),
|
||||
timePlanRules: rawRules.map(normalizeTimeRule),
|
||||
};
|
||||
}
|
||||
|
||||
function toApiNode(node: VideoPromptSchemaNode, parentKey?: string): any {
|
||||
const lockedSection = isLockedVideoSpecSection(node.key);
|
||||
const lockedField = isLockedVideoSpecField(parentKey, node.key);
|
||||
const base: any = {
|
||||
key: node.key,
|
||||
label: node.label,
|
||||
type: lockedField ? 'string' : node.type,
|
||||
enabled: lockedSection || lockedField ? true : !!node.enabled,
|
||||
editable: lockedField ? false : !!node.editable,
|
||||
max_length: node.maxLength,
|
||||
};
|
||||
if (node.type === 'object') {
|
||||
base.children = (node.children || []).map(child => toApiNode(child, node.key));
|
||||
} else if (node.type === 'flow') {
|
||||
base.content_key = node.contentKey;
|
||||
base.content_aliases = node.contentAliases || [];
|
||||
base.item_fields = (node.itemFields || []).map(field => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
enabled: !!field.enabled,
|
||||
editable: !!field.editable,
|
||||
max_length: field.maxLength,
|
||||
value: field.value ?? '',
|
||||
}));
|
||||
} else {
|
||||
base.value = node.value;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
function toApiConfig(config: VideoPromptSchemaConfigData, enabled: boolean): any {
|
||||
return {
|
||||
version: config.version,
|
||||
enabled,
|
||||
sections: (config.sections || []).map(section => toApiNode(section)),
|
||||
time_plan_rules: (config.timePlanRules || []).map(rule => ({
|
||||
min_duration: rule.minDuration,
|
||||
max_duration: rule.maxDuration,
|
||||
ratios: rule.ratios || [],
|
||||
segments: (rule.segments || []).map(segment => ({
|
||||
stage: segment.stage,
|
||||
description: segment.description,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimePreviewSchema(result: any): any {
|
||||
return result?.runtimeSchema || result?.runtime_schema || {};
|
||||
}
|
||||
|
||||
function runtimePreviewTimePlan(result: any): any[] {
|
||||
const value = result?.timePlan || result?.time_plan || runtimePreviewSchema(result)?.动态时间规划 || [];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function runtimePreviewSnapshot(result: any): any {
|
||||
return result?.schemaConfigSnapshot || result?.schema_config_snapshot || {};
|
||||
}
|
||||
|
||||
function downloadJson(filename: string, data: any): void {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const createNode = (type: VideoPromptSchemaNodeType = 'string'): VideoPromptSchemaNode => ({
|
||||
key: '新字段',
|
||||
label: '新字段',
|
||||
type,
|
||||
enabled: true,
|
||||
editable: true,
|
||||
maxLength: type === 'flow' ? 800 : 1000,
|
||||
value: type === 'array' ? [] : type === 'boolean' ? false : type === 'number' ? 0 : '无',
|
||||
children: type === 'object' ? [] : undefined,
|
||||
contentKey: type === 'flow' ? '动作内容' : undefined,
|
||||
contentAliases: type === 'flow' ? ['动作内容', '动作', '动作说明', '内容', '说明'] : undefined,
|
||||
itemFields: type === 'flow' ? [{ key: '动作内容', label: '动作内容', enabled: true, editable: true, maxLength: 800, value: '' }] : undefined,
|
||||
});
|
||||
|
||||
const createTimeRule = (): VideoPromptTimePlanRule => ({
|
||||
minDuration: 1,
|
||||
maxDuration: 5,
|
||||
ratios: [0.2, 0.4, 0.4],
|
||||
segments: [
|
||||
{ stage: '开场吸引', description: '快速吸引注意力' },
|
||||
{ stage: '核心展示', description: '展示主体动作、核心卖点或主要视觉内容' },
|
||||
{ stage: '收尾引导', description: '给出行动引导并稳定落版' },
|
||||
],
|
||||
});
|
||||
|
||||
const AdminVideoPromptSchemaConfig: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [usingDefault, setUsingDefault] = useState(false);
|
||||
const [config, setConfig] = useState<VideoPromptSchemaConfigData | null>(null);
|
||||
const [defaultConfig, setDefaultConfig] = useState<VideoPromptSchemaConfigData | null>(null);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [previewResult, setPreviewResult] = useState<any>(null);
|
||||
const [previewForm] = Form.useForm();
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const sections = config?.sections || [];
|
||||
const timePlanRules = config?.timePlanRules || [];
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getVideoPromptSchemaConfig();
|
||||
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
|
||||
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
|
||||
setConfig(normalizeConfig(res.data));
|
||||
setDefaultConfig(normalizeConfig((res as any).defaultData ?? (res as any).default_data));
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const updateConfig = (updater: (draft: VideoPromptSchemaConfigData) => void) => {
|
||||
setConfig(prev => {
|
||||
const next = normalizeConfig(clone(prev));
|
||||
updater(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await saveVideoPromptSchemaConfig({ is_enabled: enabled, data: toApiConfig(config, enabled) });
|
||||
setConfig(normalizeConfig(res.data));
|
||||
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
|
||||
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
|
||||
message.success('保存成功');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetDefault = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await resetVideoPromptSchemaConfig();
|
||||
setConfig(normalizeConfig(res.data));
|
||||
setDefaultConfig(normalizeConfig((res as any).defaultData ?? (res as any).default_data));
|
||||
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
|
||||
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
|
||||
message.success('已恢复默认配置');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '恢复默认失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doExport = async () => {
|
||||
try {
|
||||
const res = await exportVideoPromptSchemaConfig();
|
||||
downloadJson('video_prompt_schema_config.json', res);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const onImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const json = JSON.parse(text);
|
||||
const data = json.data || json;
|
||||
const isEnabled = json.isEnabled ?? json.is_enabled ?? enabled;
|
||||
const res = await importVideoPromptSchemaConfig({ is_enabled: !!isEnabled, data });
|
||||
setConfig(normalizeConfig(res.data));
|
||||
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
|
||||
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
|
||||
message.success('导入成功');
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '导入失败,请检查 JSON 格式');
|
||||
}
|
||||
};
|
||||
|
||||
const openPreview = () => {
|
||||
previewForm.setFieldsValue({ duration: 8, aspectRatio: '9:16', resolution: '1080p', frameRate: '30fps' });
|
||||
setPreviewResult(null);
|
||||
setPreviewOpen(true);
|
||||
};
|
||||
|
||||
const doPreview = async () => {
|
||||
if (!config) return;
|
||||
const values = await previewForm.validateFields();
|
||||
try {
|
||||
const res = await previewVideoPromptSchemaConfig({
|
||||
is_enabled: enabled,
|
||||
data: toApiConfig(config, enabled),
|
||||
duration: Number(values.duration || 8),
|
||||
aspect_ratio: values.aspectRatio,
|
||||
resolution: values.resolution,
|
||||
frame_rate: values.frameRate,
|
||||
});
|
||||
setPreviewResult(res);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '预览失败');
|
||||
}
|
||||
};
|
||||
|
||||
const addSection = () => {
|
||||
updateConfig(draft => {
|
||||
draft.sections.push(createNode('object'));
|
||||
});
|
||||
};
|
||||
|
||||
const updateSection = (index: number, patch: Partial<VideoPromptSchemaNode>) => {
|
||||
updateConfig(draft => {
|
||||
const current = draft.sections[index];
|
||||
if (isLockedVideoSpecSection(current?.key)) {
|
||||
delete patch.key;
|
||||
delete patch.type;
|
||||
delete patch.enabled;
|
||||
}
|
||||
draft.sections[index] = { ...draft.sections[index], ...patch };
|
||||
if (patch.type === 'object') {
|
||||
draft.sections[index].children = draft.sections[index].children || [];
|
||||
}
|
||||
if (patch.type === 'flow') {
|
||||
draft.sections[index].contentKey = draft.sections[index].contentKey || '动作内容';
|
||||
draft.sections[index].contentAliases = draft.sections[index].contentAliases || ['动作内容', '动作', '动作说明', '内容', '说明'];
|
||||
draft.sections[index].itemFields = draft.sections[index].itemFields || [{ key: '动作内容', label: '动作内容', enabled: true, editable: true, maxLength: 800, value: '' }];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSection = (index: number) => {
|
||||
updateConfig(draft => {
|
||||
draft.sections.splice(index, 1);
|
||||
});
|
||||
};
|
||||
|
||||
const addChild = (sectionIndex: number) => {
|
||||
updateConfig(draft => {
|
||||
const section = draft.sections[sectionIndex];
|
||||
section.children = section.children || [];
|
||||
section.children.push(createNode('string'));
|
||||
});
|
||||
};
|
||||
|
||||
const updateChild = (sectionIndex: number, childIndex: number, patch: Partial<VideoPromptSchemaNode>) => {
|
||||
updateConfig(draft => {
|
||||
const section = draft.sections[sectionIndex];
|
||||
const children = section.children || [];
|
||||
const child = children[childIndex];
|
||||
if (isLockedVideoSpecField(section?.key, child?.key)) {
|
||||
delete patch.key;
|
||||
delete patch.type;
|
||||
delete patch.enabled;
|
||||
patch.editable = false;
|
||||
}
|
||||
children[childIndex] = { ...children[childIndex], ...patch };
|
||||
draft.sections[sectionIndex].children = children;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteChild = (sectionIndex: number, childIndex: number) => {
|
||||
updateConfig(draft => {
|
||||
draft.sections[sectionIndex].children?.splice(childIndex, 1);
|
||||
});
|
||||
};
|
||||
|
||||
const addFlowField = (sectionIndex: number) => {
|
||||
updateConfig(draft => {
|
||||
const section = draft.sections[sectionIndex];
|
||||
section.itemFields = section.itemFields || [];
|
||||
section.itemFields.push({ key: '新流程字段', label: '新流程字段', enabled: true, editable: true, maxLength: 800, value: '' });
|
||||
});
|
||||
};
|
||||
|
||||
const updateFlowField = (sectionIndex: number, fieldIndex: number, patch: Partial<VideoPromptFlowItemField>) => {
|
||||
updateConfig(draft => {
|
||||
const fields = draft.sections[sectionIndex].itemFields || [];
|
||||
fields[fieldIndex] = { ...fields[fieldIndex], ...patch };
|
||||
draft.sections[sectionIndex].itemFields = fields;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFlowField = (sectionIndex: number, fieldIndex: number) => {
|
||||
updateConfig(draft => {
|
||||
draft.sections[sectionIndex].itemFields?.splice(fieldIndex, 1);
|
||||
});
|
||||
};
|
||||
|
||||
const addTimeRule = () => {
|
||||
updateConfig(draft => {
|
||||
draft.timePlanRules.push(createTimeRule());
|
||||
});
|
||||
};
|
||||
|
||||
const updateTimeRule = (index: number, patch: Partial<VideoPromptTimePlanRule>) => {
|
||||
updateConfig(draft => {
|
||||
draft.timePlanRules[index] = { ...draft.timePlanRules[index], ...patch };
|
||||
});
|
||||
};
|
||||
|
||||
const deleteTimeRule = (index: number) => {
|
||||
updateConfig(draft => {
|
||||
draft.timePlanRules.splice(index, 1);
|
||||
});
|
||||
};
|
||||
|
||||
const updateSegment = (ruleIndex: number, segmentIndex: number, patch: Partial<{ stage: string; description: string; ratio: number }>) => {
|
||||
updateConfig(draft => {
|
||||
const rule = draft.timePlanRules[ruleIndex];
|
||||
if (patch.ratio !== undefined) rule.ratios[segmentIndex] = Number(patch.ratio || 0);
|
||||
if (patch.stage !== undefined) rule.segments[segmentIndex].stage = patch.stage;
|
||||
if (patch.description !== undefined) rule.segments[segmentIndex].description = patch.description;
|
||||
});
|
||||
};
|
||||
|
||||
const addSegment = (ruleIndex: number) => {
|
||||
updateConfig(draft => {
|
||||
const rule = draft.timePlanRules[ruleIndex];
|
||||
rule.ratios.push(1);
|
||||
rule.segments.push({ stage: '新阶段', description: '阶段说明' });
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSegment = (ruleIndex: number, segmentIndex: number) => {
|
||||
updateConfig(draft => {
|
||||
const rule = draft.timePlanRules[ruleIndex];
|
||||
rule.ratios.splice(segmentIndex, 1);
|
||||
rule.segments.splice(segmentIndex, 1);
|
||||
});
|
||||
};
|
||||
|
||||
const sectionItems = useMemo(() => sections.map((section, sectionIndex) => ({
|
||||
key: `section-${sectionIndex}`,
|
||||
label: (
|
||||
<Space>
|
||||
<Text strong>{section.label || section.key || '未命名分组'}</Text>
|
||||
{section.key && section.key !== section.label && <Tag color="blue">key: {section.key}</Tag>}
|
||||
<Tag>{section.type}</Tag>
|
||||
{isLockedVideoSpecSection(section.key) && <Tag color="red">系统锁定</Tag>}
|
||||
{!section.enabled && !isLockedVideoSpecSection(section.key) && <Tag color="default">已禁用</Tag>}
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Row gutter={12} align="middle">
|
||||
<Col span={4}><Input addonBefore="key" value={section.key} maxLength={64} placeholder="传给 AI 的字段名" disabled={isLockedVideoSpecSection(section.key)} onChange={e => updateSection(sectionIndex, { key: e.target.value })} /></Col>
|
||||
<Col span={4}><Input addonBefore="label" value={section.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateSection(sectionIndex, { label: e.target.value })} /></Col>
|
||||
<Col span={4}>
|
||||
<Select value={section.type as any} options={NODE_TYPES} style={{ width: '100%' }} disabled={isLockedVideoSpecSection(section.key)} onChange={value => updateSection(sectionIndex, { type: value })} />
|
||||
</Col>
|
||||
<Col span={3}><Space>启用<Switch checked={isLockedVideoSpecSection(section.key) ? true : section.enabled} disabled={isLockedVideoSpecSection(section.key)} onChange={checked => updateSection(sectionIndex, { enabled: checked })} /></Space></Col>
|
||||
<Col span={3}><Space>可编辑<Switch checked={section.editable} onChange={checked => updateSection(sectionIndex, { editable: checked })} /></Space></Col>
|
||||
<Col span={4}><InputNumber min={1} max={20000} value={section.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { maxLength: Number(value || 1000) })} /></Col>
|
||||
<Col span={2} style={{ textAlign: 'right' }}>
|
||||
<Popconfirm title="确认删除该分组?" disabled={isLockedVideoSpecSection(section.key)} onConfirm={() => deleteSection(sectionIndex)}>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={isLockedVideoSpecSection(section.key)} />
|
||||
</Popconfirm>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{section.type === 'object' && (
|
||||
<Card size="small" title="对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addChild(sectionIndex)}>新增字段</Button>}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{(section.children || []).map((child, childIndex) => (
|
||||
<Row gutter={8} align="middle" key={`child-${sectionIndex}-${childIndex}`}>
|
||||
<Col span={4}><Input addonBefore="key" value={child.key} maxLength={64} placeholder="传给 AI 的字段名" disabled={isLockedVideoSpecField(section.key, child.key)} onChange={e => updateChild(sectionIndex, childIndex, { key: e.target.value })} /></Col>
|
||||
<Col span={4}><Input addonBefore="label" value={child.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateChild(sectionIndex, childIndex, { label: e.target.value })} /></Col>
|
||||
<Col span={3}><Select value={child.type as any} options={NODE_TYPES.filter(item => item.value !== 'object' && item.value !== 'flow')} style={{ width: '100%' }} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={value => updateChild(sectionIndex, childIndex, { type: value })} /></Col>
|
||||
<Col span={2}><Space>启用<Switch checked={isLockedVideoSpecField(section.key, child.key) ? true : child.enabled} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={checked => updateChild(sectionIndex, childIndex, { enabled: checked })} /></Space></Col>
|
||||
<Col span={2}><Space>编辑<Switch checked={isLockedVideoSpecField(section.key, child.key) ? false : child.editable} disabled={isLockedVideoSpecField(section.key, child.key)} onChange={checked => updateChild(sectionIndex, childIndex, { editable: checked })} /></Space></Col>
|
||||
<Col span={3}><InputNumber min={1} max={20000} value={child.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { maxLength: Number(value || 1000) })} /></Col>
|
||||
<Col span={5}>
|
||||
<Input value={Array.isArray(child.value) ? child.value.join('/') : String(child.value ?? '')} maxLength={child.maxLength || 1000} placeholder="默认值" onChange={e => updateChild(sectionIndex, childIndex, { value: child.type === 'array' ? e.target.value.split('/').filter(Boolean) : e.target.value })} />
|
||||
</Col>
|
||||
<Col span={1} style={{ textAlign: 'right' }}>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} disabled={isLockedVideoSpecField(section.key, child.key)} onClick={() => deleteChild(sectionIndex, childIndex)} />
|
||||
</Col>
|
||||
</Row>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{section.type === 'flow' && (
|
||||
<Card size="small" title="流程数组对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addFlowField(sectionIndex)}>新增流程字段</Button>}>
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
<Col span={6}><Input addonBefore="内容字段" value={section.contentKey} maxLength={64} onChange={e => updateSection(sectionIndex, { contentKey: e.target.value })} /></Col>
|
||||
<Col span={18}><Input addonBefore="别名 / 分隔" value={(section.contentAliases || []).join('/')} onChange={e => updateSection(sectionIndex, { contentAliases: e.target.value.split('/').map(item => item.trim()).filter(Boolean) })} /></Col>
|
||||
</Row>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{(section.itemFields || []).map((field, fieldIndex) => (
|
||||
<Row gutter={8} align="middle" key={`flow-${sectionIndex}-${fieldIndex}`}>
|
||||
<Col span={5}><Input addonBefore="key" value={field.key} maxLength={64} placeholder="流程对象字段名" onChange={e => updateFlowField(sectionIndex, fieldIndex, { key: e.target.value })} /></Col>
|
||||
<Col span={5}><Input addonBefore="label" value={field.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateFlowField(sectionIndex, fieldIndex, { label: e.target.value })} /></Col>
|
||||
<Col span={3}><Space>启用<Switch checked={field.enabled} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { enabled: checked })} /></Space></Col>
|
||||
<Col span={3}><Space>编辑<Switch checked={field.editable} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { editable: checked })} /></Space></Col>
|
||||
<Col span={4}><InputNumber min={1} max={20000} value={field.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateFlowField(sectionIndex, fieldIndex, { maxLength: Number(value || 800) })} /></Col>
|
||||
<Col span={3}><Input value={String(field.value ?? '')} maxLength={field.maxLength || 800} placeholder="默认值" onChange={e => updateFlowField(sectionIndex, fieldIndex, { value: e.target.value })} /></Col>
|
||||
<Col span={1} style={{ textAlign: 'right' }}><Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteFlowField(sectionIndex, fieldIndex)} /></Col>
|
||||
</Row>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{section.type !== 'object' && section.type !== 'flow' && (
|
||||
<Card size="small" title="默认值">
|
||||
<TextArea rows={3} value={Array.isArray(section.value) ? section.value.join('\n') : String(section.value ?? '')} maxLength={section.maxLength || 1000} showCount onChange={e => updateSection(sectionIndex, { value: section.type === 'array' ? e.target.value.split('\n').filter(Boolean) : e.target.value })} />
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
})), [sections]);
|
||||
|
||||
if (loading || !config) {
|
||||
return <Spin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Card>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Title level={3} style={{ margin: 0 }}>视频提词 Schema 配置</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
配置会被爆款开头复刻和拆镜复刻第 4 步视频 AI 提词共同复用。传给 AI 时只保留纯键值 schema,不携带显示名、启用、可编辑等管理字段。
|
||||
</Paragraph>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Text>启用自定义配置</Text>
|
||||
<Switch checked={enabled} onChange={setEnabled} />
|
||||
{usingDefault && <Tag color="orange">当前使用默认配置兜底</Tag>}
|
||||
{defaultConfig && <Tag>默认分组 {defaultConfig.sections.length}</Tag>}
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="长度限制策略"
|
||||
description="只对管理后台字段和用户可编辑字段做长度校验,不会自动裁剪 AI 生成结果或第 5 步最终视频 prompt。若供应商因为 prompt 过长拒绝,会按真实失败进入现有日志/退款/重试流程。"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="预览说明"
|
||||
description="预览会跟随右上角「启用自定义配置」开关。关闭时会按默认 CLIENT_SCHEMA_V1 兜底预览;打开后才会按当前页面编辑的 sections 和秒数切片规则预览。"
|
||||
/>
|
||||
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={save}>保存配置</Button>
|
||||
<Button icon={<EyeOutlined />} onClick={openPreview}>预览运行时 Schema</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={doExport}>导出 JSON</Button>
|
||||
<Button icon={<UploadOutlined />} onClick={() => importInputRef.current?.click()}>导入 JSON</Button>
|
||||
<Popconfirm title="确认恢复默认配置?" description="会用 CLIENT_SCHEMA_V1 生成的默认配置覆盖当前配置。" onConfirm={resetDefault}>
|
||||
<Button icon={<ReloadOutlined />}>恢复默认</Button>
|
||||
</Popconfirm>
|
||||
<input ref={importInputRef} type="file" accept="application/json,.json" style={{ display: 'none' }} onChange={onImportFile} />
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'fields',
|
||||
label: 'Schema 字段配置',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="字段说明"
|
||||
description="key 是最终传给 AI 和保存到 prompt_schema 的真实字段名,例如「素材理解」「动作流程」;label 只用于管理后台显示,帮助运营/管理员理解这个字段,不会传给 AI;启用关闭后该字段不会进入运行时 schema;可编辑关闭后用户在第 4 步修改时不能改这个字段。「画面属性」里的视频时长、视频比例、清晰度、帧率、推荐分辨率属于系统锁定参数,不能改 key、不能禁用、不能开放用户编辑。"
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addSection}>新增一级分组</Button>
|
||||
<Collapse items={sectionItems} />
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
label: '秒数切片规则',
|
||||
children: (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="切片规则说明"
|
||||
description="这里决定指定视频时长会被拆成几段,以及每段的阶段名称和说明。运行时会同步生成「动态时间规划」「动作流程」「镜头流程」三个数组,三者时间段保持一致。"
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addTimeRule}>新增秒数规则</Button>
|
||||
{timePlanRules.map((rule, ruleIndex) => (
|
||||
<Card
|
||||
key={ruleIndex}
|
||||
size="small"
|
||||
title={`${rule.minDuration}-${rule.maxDuration} 秒:${rule.segments.length} 段`}
|
||||
extra={<Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteTimeRule(ruleIndex)}>删除规则</Button>}
|
||||
>
|
||||
<Row gutter={12} style={{ marginBottom: 12 }}>
|
||||
<Col span={4}><InputNumber min={1} value={rule.minDuration} addonBefore="最小秒" style={{ width: '100%' }} onChange={value => updateTimeRule(ruleIndex, { minDuration: Number(value || 1) })} /></Col>
|
||||
<Col span={4}><InputNumber min={1} value={rule.maxDuration} addonBefore="最大秒" style={{ width: '100%' }} onChange={value => updateTimeRule(ruleIndex, { maxDuration: Number(value || 1) })} /></Col>
|
||||
<Col span={16}><Button size="small" icon={<PlusOutlined />} onClick={() => addSegment(ruleIndex)}>新增片段</Button></Col>
|
||||
</Row>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{rule.segments.map((segment, segmentIndex) => (
|
||||
<Row gutter={8} align="middle" key={segmentIndex}>
|
||||
<Col span={3}><InputNumber min={0.01} step={0.01} value={rule.ratios[segmentIndex]} addonBefore="比例" style={{ width: '100%' }} onChange={value => updateSegment(ruleIndex, segmentIndex, { ratio: Number(value || 0.01) })} /></Col>
|
||||
<Col span={5}><Input value={segment.stage} maxLength={64} placeholder="阶段" onChange={e => updateSegment(ruleIndex, segmentIndex, { stage: e.target.value })} /></Col>
|
||||
<Col span={15}><Input value={segment.description} maxLength={300} placeholder="说明" onChange={e => updateSegment(ruleIndex, segmentIndex, { description: e.target.value })} /></Col>
|
||||
<Col span={1}><Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteSegment(ruleIndex, segmentIndex)} /></Col>
|
||||
</Row>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'json',
|
||||
label: 'JSON 高级查看',
|
||||
children: <TextArea rows={24} value={JSON.stringify(toApiConfig(config, enabled), null, 2)} readOnly />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Modal title="预览运行时 Schema" open={previewOpen} onCancel={() => setPreviewOpen(false)} width={980} footer={<Space><Button onClick={() => setPreviewOpen(false)}>关闭</Button><Button type="primary" onClick={doPreview}>生成预览</Button></Space>}>
|
||||
<Form form={previewForm} layout="inline" style={{ marginBottom: 16 }}>
|
||||
<Form.Item name="duration" label="时长" rules={[{ required: true }]}><InputNumber min={1} max={3600} /></Form.Item>
|
||||
<Form.Item name="aspectRatio" label="比例" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="resolution" label="清晰度" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="frameRate" label="帧率" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</Form>
|
||||
<Divider />
|
||||
{previewResult ? (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'runtime',
|
||||
label: '运行时 Schema',
|
||||
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewSchema(previewResult), null, 2)} readOnly />,
|
||||
},
|
||||
{
|
||||
key: 'timePlan',
|
||||
label: `动态时间规划(${runtimePreviewTimePlan(previewResult).length} 段)`,
|
||||
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewTimePlan(previewResult), null, 2)} readOnly />,
|
||||
},
|
||||
{
|
||||
key: 'snapshot',
|
||||
label: '配置快照',
|
||||
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewSnapshot(previewResult), null, 2)} readOnly />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Alert type="info" showIcon message="点击生成预览后,可以看到传给 AI 的纯 schema、动态时间规划和本次配置快照。" />
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminVideoPromptSchemaConfig;
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
|
||||
import { getVideoUpscaleConfig, saveVideoUpscaleConfig } from '../api';
|
||||
import type {
|
||||
VideoUpscaleConfigData,
|
||||
VideoUpscaleProcessorKey,
|
||||
VideoUpscaleResolutionRule,
|
||||
} from '../types';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
const PROCESSORS: Array<{ key: VideoUpscaleProcessorKey; label: string }> = [
|
||||
{ key: 'local_ffmpeg_crop_v1', label: '本地 FFmpeg(crop)' },
|
||||
{ key: 'volc_standard_v1', label: '火山画质增强(标准版)' },
|
||||
{ key: 'volc_professional_v1', label: '火山画质增强(专业版)' },
|
||||
{ key: 'volc_large_model_v1', label: '火山画质增强(大模型)' },
|
||||
];
|
||||
|
||||
const RESOLUTION_OPTIONS = ['480p', '720p', '1080p', '2K', '4K'].map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}));
|
||||
|
||||
const defaultRule = (): VideoUpscaleResolutionRule => ({
|
||||
targetResolution: '1080p',
|
||||
providerGenerationResolution: '720p',
|
||||
processorKey: 'local_ffmpeg_crop_v1',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
function normalizeConfig(data: VideoUpscaleConfigData): VideoUpscaleConfigData {
|
||||
return {
|
||||
enabled: !!data.enabled,
|
||||
version: Number(data.version || 1),
|
||||
deleteSourceAfterSuccess: data.deleteSourceAfterSuccess !== false,
|
||||
rules: Array.isArray(data.rules) ? data.rules.map((rule) => ({ ...rule })) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function toSavePayload(data: VideoUpscaleConfigData) {
|
||||
return {
|
||||
data: {
|
||||
enabled: data.enabled,
|
||||
version: data.version,
|
||||
delete_source_after_success: data.deleteSourceAfterSuccess,
|
||||
rules: data.rules.map((rule) => ({
|
||||
target_resolution: rule.targetResolution,
|
||||
provider_generation_resolution: rule.providerGenerationResolution,
|
||||
processor_key: rule.processorKey,
|
||||
enabled: rule.enabled,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const AdminVideoUpscale: React.FC = () => {
|
||||
const { message, modal } = App.useApp();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [config, setConfig] = useState<VideoUpscaleConfigData | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await getVideoUpscaleConfig();
|
||||
setConfig(normalizeConfig(result.data));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '读取视频超分配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const updateRule = (index: number, patch: Partial<VideoUpscaleResolutionRule>) => {
|
||||
setConfig((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
rules: current.rules.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
setConfig((current) => current ? {
|
||||
...current,
|
||||
rules: current.rules.filter((_, itemIndex) => itemIndex !== index),
|
||||
} : current);
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
setConfig((current) => current ? { ...current, rules: [...current.rules, defaultRule()] } : current);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!config) return;
|
||||
const targets = config.rules
|
||||
.filter((item) => item.enabled)
|
||||
.map((item) => item.targetResolution.trim().toLowerCase());
|
||||
if (new Set(targets).size !== targets.length) {
|
||||
message.error('同一个客户目标分辨率只能存在一条启用规则');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await saveVideoUpscaleConfig(toSavePayload(config));
|
||||
setConfig(normalizeConfig(result.data));
|
||||
message.success(`视频超分配置已保存,版本 ${result.data.version}`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !config) {
|
||||
return (
|
||||
<div style={{ minHeight: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||||
<Card>
|
||||
<Row justify="space-between" align="middle" gutter={[16, 16]}>
|
||||
<Col>
|
||||
<Title level={3} style={{ margin: 0 }}>视频超分配置</Title>
|
||||
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
|
||||
规则只按客户选择的目标分辨率匹配。视频比例和最终像素由客户任务参数在创建任务时自动计算并固化快照。
|
||||
</Paragraph>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Tag>配置版本 {config.version}</Tag>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()}>重新加载</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Space direction="vertical" size={14} style={{ marginTop: 20 }}>
|
||||
<Space>
|
||||
<Text strong>全局开启超分</Text>
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
onChange={(enabled) => setConfig({ ...config, enabled })}
|
||||
/>
|
||||
<Text type="secondary">关闭后新任务走原流程,已经创建的任务仍按自身快照执行。</Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Text strong>超分成功后删除源视频</Text>
|
||||
<Switch
|
||||
checked={config.deleteSourceAfterSuccess}
|
||||
onChange={(deleteSourceAfterSuccess) => setConfig({ ...config, deleteSourceAfterSuccess })}
|
||||
/>
|
||||
<Text type="secondary">
|
||||
默认开启。关闭仅用于调试,会保留超分前源视频并持续占用服务器磁盘;超分失败时始终保留源视频。
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="目标分辨率规则"
|
||||
extra={<Button icon={<PlusOutlined />} onClick={addRule}>新增规则</Button>}
|
||||
>
|
||||
{config.rules.length === 0 ? (
|
||||
<Empty description="暂无规则;未匹配规则的视频任务继续走原流程" />
|
||||
) : (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{config.rules.map((rule, index) => {
|
||||
const duplicate = rule.enabled && config.rules.filter(
|
||||
(item) => item.enabled && item.targetResolution.toLowerCase() === rule.targetResolution.toLowerCase(),
|
||||
).length > 1;
|
||||
return (
|
||||
<Card key={`${index}-${rule.targetResolution}`} size="small">
|
||||
<Row gutter={[12, 12]} align="bottom">
|
||||
<Col xs={24} md={5}>
|
||||
<Text type="secondary">客户选择分辨率</Text>
|
||||
<Select
|
||||
value={rule.targetResolution}
|
||||
options={RESOLUTION_OPTIONS}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
onChange={(value) => updateRule(index, { targetResolution: value })}
|
||||
status={duplicate ? 'error' : undefined}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={5}>
|
||||
<Text type="secondary">实际生成分辨率</Text>
|
||||
<Select
|
||||
value={rule.providerGenerationResolution}
|
||||
options={RESOLUTION_OPTIONS}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
onChange={(value) => updateRule(index, { providerGenerationResolution: value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={7}>
|
||||
<Text type="secondary">处理方式</Text>
|
||||
<Select
|
||||
value={rule.processorKey}
|
||||
options={PROCESSORS.map((item) => ({ value: item.key, label: item.label }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
onChange={(value) => updateRule(index, { processorKey: value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={2}>
|
||||
<Text type="secondary">启用</Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Switch checked={rule.enabled} onChange={(enabled) => updateRule(index, { enabled })} />
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} md={2}>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => modal.confirm({
|
||||
title: '删除这条超分规则?',
|
||||
onOk: () => removeRule(index),
|
||||
})}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Text type="secondary">
|
||||
客户选择该目标分辨率时,无论横屏、竖屏或方形比例,都按本规则选择的实际分辨率生成;最终像素由任务比例自动计算。
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminVideoUpscale;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
|
||||
import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { copyToClipboard } from '../../../utils/clipboard';
|
||||
|
||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||
@@ -37,12 +38,8 @@ const MediaPreview: React.FC<MediaPreviewProps> = ({
|
||||
|
||||
const copyUrl = async () => {
|
||||
if (!resolvedUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(resolvedUrl);
|
||||
message.success('资源地址已复制');
|
||||
} catch {
|
||||
message.error('复制失败,请手动复制');
|
||||
}
|
||||
const ok = await copyToClipboard(resolvedUrl);
|
||||
message.success(ok ? '资源地址已复制' : '复制失败,请手动复制');
|
||||
};
|
||||
|
||||
const tools = resolvedUrl ? (
|
||||
|
||||
@@ -21,13 +21,22 @@ const STATUS_LABELS: Record<string, LabelMeta> = {
|
||||
|
||||
// 生成任务 pipeline / download stage
|
||||
creating_provider_task: { text: '创建远端任务', color: 'processing' },
|
||||
provider_result_staged: { text: '供应商结果已暂存', color: 'processing' },
|
||||
waiting_remote: { text: '等待远端结果', color: 'processing' },
|
||||
polling: { text: '轮询远端结果', color: 'processing' },
|
||||
result_ready: { text: '结果已就绪', color: 'success' },
|
||||
download_queued: { text: '下载已入队', color: 'processing' },
|
||||
downloading: { text: '下载中', color: 'processing' },
|
||||
done: { text: '已完成', color: 'success' },
|
||||
download_failed: { text: '下载失败', color: 'error' },
|
||||
retry_waiting: { text: '等待重试', color: 'orange' },
|
||||
upscale_queued: { text: '超分已入队', color: 'purple' },
|
||||
upscale_processing: { text: '本地超分处理中', color: 'purple' },
|
||||
upscale_polling: { text: '轮询远程超分', color: 'purple' },
|
||||
upscale_downloading: { text: '下载超分结果', color: 'purple' },
|
||||
upscale_finalizing: { text: '超分结果最终化', color: 'purple' },
|
||||
upscale_retry_waiting: { text: '超分等待重试', color: 'orange' },
|
||||
upscale_failed: { text: '超分失败', color: 'error' },
|
||||
|
||||
// 拆镜总任务状态
|
||||
pending_analysis: { text: '等待分析', color: 'default' },
|
||||
|
||||
+106
-249
@@ -1,171 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Card, Collapse, Descriptions, Empty, Space, Table, Typography } from 'antd';
|
||||
import { Card, Collapse, Descriptions, Empty, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { JsonBlock } from './JsonCollapse';
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
schema_version: 'Schema 版本',
|
||||
generation_type: '生成类型',
|
||||
business_type: '业务类型',
|
||||
usage: '用途',
|
||||
product_type: '产品类型',
|
||||
target_audience: '目标受众',
|
||||
style: '风格',
|
||||
aspect_ratio: '画面比例',
|
||||
resolution: '分辨率',
|
||||
duration: '时长',
|
||||
fps: '帧率',
|
||||
final_prompt: '最终提示词',
|
||||
prompt: '提示词',
|
||||
description: '说明',
|
||||
content: '内容',
|
||||
time: '时间',
|
||||
time_range: '时间段',
|
||||
start_time: '开始时间',
|
||||
end_time: '结束时间',
|
||||
stage: '阶段',
|
||||
scene: '场景',
|
||||
action: '动作',
|
||||
camera: '镜头',
|
||||
shot: '镜头',
|
||||
lens: '镜头',
|
||||
subtitle: '字幕',
|
||||
voiceover: '口播',
|
||||
audio: '音频',
|
||||
rhythm: '节奏',
|
||||
notes: '备注',
|
||||
};
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
basic: '基础信息',
|
||||
basic_info: '基础信息',
|
||||
material: '素材理解',
|
||||
material_understanding: '素材理解',
|
||||
business: '业务属性',
|
||||
business_info: '业务属性',
|
||||
visual: '画面与风格',
|
||||
visual_style: '画面与风格',
|
||||
time_plan: '时间规划',
|
||||
timeline: '时间规划',
|
||||
scene_timeline: '场景时间线',
|
||||
action_flow: '动作流程',
|
||||
motion_flow: '动作流程',
|
||||
character_action_flow: '角色动作流程',
|
||||
camera_flow: '镜头流程',
|
||||
shot_flow: '镜头流程',
|
||||
lens_flow: '镜头流程',
|
||||
subtitle: '字幕',
|
||||
subtitles: '字幕',
|
||||
voiceover: '口播',
|
||||
audio: '音频',
|
||||
rhythm: '节奏',
|
||||
compliance: '合规控制',
|
||||
final_prompt: '最终提示词',
|
||||
};
|
||||
import type {
|
||||
VideoPromptSchemaFieldConfig,
|
||||
VideoPromptSchemaSectionConfig,
|
||||
} from '../../../utils/videoPromptSchema';
|
||||
import {
|
||||
hasSchemaConfigSnapshot,
|
||||
inferSchemaSections,
|
||||
normalizeSchemaSections,
|
||||
stringifySchemaValue,
|
||||
} from '../../../utils/videoPromptSchema';
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
|
||||
const labelOf = (key: string): string => SECTION_LABELS[key] || FIELD_LABELS[key] || key;
|
||||
|
||||
const isLongText = (value: unknown): boolean => typeof value === 'string' && value.length > 80;
|
||||
|
||||
|
||||
const EMPTY_TEXTS = new Set(['', '无', 'null', 'None', 'none', '未提及', '不适用']);
|
||||
const PLACEHOLDER_FLOW_TEXTS = new Set([
|
||||
'展示主体动作、核心卖点或主要视觉内容',
|
||||
'展示主要动作、核心卖点或主要视觉内容',
|
||||
'展示核心卖点或主要视觉内容',
|
||||
'展示主体动作',
|
||||
'无',
|
||||
]);
|
||||
const ACTION_CONTENT_KEYS = ['动作内容', '动作', '动作说明', '内容', '说明', '主体动作', '动作变化'];
|
||||
const CAMERA_CONTENT_KEYS = ['镜头内容', '镜头', '镜头说明', '运镜', '运镜说明', '内容', '说明'];
|
||||
|
||||
const toText = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
const isEmptyText = (value: unknown): boolean => EMPTY_TEXTS.has(toText(value));
|
||||
|
||||
const isActionFlowSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('action') || lower.includes('motion') || lower.includes('动作');
|
||||
};
|
||||
|
||||
const isCameraFlowSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('camera') || lower.includes('shot') || lower.includes('lens') || lower.includes('镜头');
|
||||
};
|
||||
|
||||
const isTimePlanSection = (sectionKey: string): boolean => {
|
||||
const lower = sectionKey.toLowerCase();
|
||||
return lower.includes('time') || lower.includes('timeline') || lower.includes('时间规划') || lower.includes('动态时间规划');
|
||||
};
|
||||
|
||||
const pickFirstContent = (item: Record<string, unknown>, keys: string[]): string => {
|
||||
for (const key of keys) {
|
||||
if (key in item && !isEmptyText(item[key])) return toText(item[key]);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const normalizeFlowContent = (baseContent: string, extras: string[]): string => {
|
||||
const cleanBase = baseContent.trim();
|
||||
const uniqueExtras = extras.filter((item, index) => item && extras.indexOf(item) === index);
|
||||
if (uniqueExtras.length && (!cleanBase || PLACEHOLDER_FLOW_TEXTS.has(cleanBase))) {
|
||||
return uniqueExtras.join(';');
|
||||
}
|
||||
const parts = cleanBase && !EMPTY_TEXTS.has(cleanBase) ? [cleanBase] : [];
|
||||
uniqueExtras.forEach((item) => {
|
||||
if (item && !parts.includes(item)) parts.push(item);
|
||||
});
|
||||
return parts.join(';') || '-';
|
||||
};
|
||||
|
||||
const normalizeFlowItemForDisplay = (
|
||||
item: Record<string, unknown>,
|
||||
contentKey: '动作内容' | '镜头内容',
|
||||
contentKeys: string[],
|
||||
): Record<string, unknown> => {
|
||||
const allowed = new Set(['时间段', contentKey, ...contentKeys]);
|
||||
const extras: string[] = [];
|
||||
Object.entries(item).forEach(([field, value]) => {
|
||||
if (allowed.has(field)) return;
|
||||
const fieldText = toText(field);
|
||||
const valueText = toText(value);
|
||||
if (fieldText && !EMPTY_TEXTS.has(fieldText)) extras.push(fieldText);
|
||||
if (valueText && !EMPTY_TEXTS.has(valueText) && valueText !== fieldText) extras.push(valueText);
|
||||
});
|
||||
return {
|
||||
时间段: toText(item['时间段']) || '-',
|
||||
[contentKey]: normalizeFlowContent(pickFirstContent(item, contentKeys), extras),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeRecordForSection = (sectionKey: string, item: Record<string, unknown>): Record<string, unknown> => {
|
||||
if (isActionFlowSection(sectionKey)) {
|
||||
return normalizeFlowItemForDisplay(item, '动作内容', ACTION_CONTENT_KEYS);
|
||||
}
|
||||
if (isCameraFlowSection(sectionKey)) {
|
||||
return normalizeFlowItemForDisplay(item, '镜头内容', CAMERA_CONTENT_KEYS);
|
||||
}
|
||||
if (isTimePlanSection(sectionKey)) {
|
||||
return {
|
||||
时间段: toText(item['时间段']) || '-',
|
||||
阶段: toText(item['阶段']) || '-',
|
||||
说明: toText(item['说明']) || '-',
|
||||
};
|
||||
}
|
||||
return item;
|
||||
};
|
||||
|
||||
const renderValue = (value: unknown): React.ReactNode => {
|
||||
if (value === null || value === undefined || value === '') return <Typography.Text type="secondary">-</Typography.Text>;
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
@@ -176,64 +26,55 @@ const renderValue = (value: unknown): React.ReactNode => {
|
||||
return <JsonBlock value={value} maxHeight={220} />;
|
||||
};
|
||||
|
||||
const getArrayMode = (key: string): 'card' | 'table' => {
|
||||
const lower = key.toLowerCase();
|
||||
if (
|
||||
lower.includes('action') ||
|
||||
lower.includes('motion') ||
|
||||
lower.includes('camera') ||
|
||||
lower.includes('shot') ||
|
||||
lower.includes('lens') ||
|
||||
lower.includes('flow') ||
|
||||
lower.includes('动作') ||
|
||||
lower.includes('镜头')
|
||||
) {
|
||||
return 'card';
|
||||
}
|
||||
return 'table';
|
||||
const tagOfEditable = (editable?: boolean) => editable ? <Tag color="processing">可编辑</Tag> : <Tag color="default">只读</Tag>;
|
||||
|
||||
const readSectionValue = (schema: Record<string, any>, key: string): unknown => schema[key];
|
||||
|
||||
const renderObjectSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||
const value = readSectionValue(schema, section.key);
|
||||
if (!isRecord(value)) return <JsonBlock value={value ?? {}} maxHeight={220} />;
|
||||
const fields = section.children.filter((field) => field.enabled);
|
||||
if (!fields.length) return <JsonBlock value={value} maxHeight={220} />;
|
||||
|
||||
return (
|
||||
<Descriptions size="small" column={2} bordered>
|
||||
{fields.map((field) => (
|
||||
<Descriptions.Item key={field.key} label={<Space size={4}>{field.label}{tagOfEditable(field.editable)}</Space>} span={isLongText(value[field.key]) || isRecord(value[field.key]) || Array.isArray(value[field.key]) ? 2 : 1}>
|
||||
{renderValue(value[field.key])}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
);
|
||||
};
|
||||
|
||||
const renderCardArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{items.map((item, index) => {
|
||||
const displayItem = normalizeRecordForSection(sectionKey, item);
|
||||
return (
|
||||
<Card
|
||||
key={`${sectionKey}-${index}`}
|
||||
size="small"
|
||||
title={`${labelOf(sectionKey)} ${index + 1}`}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
{Object.entries(displayItem).map(([field, value]) => (
|
||||
<Descriptions.Item key={field} label={labelOf(field)}>
|
||||
{renderValue(value)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
const buildFlowFields = (section: VideoPromptSchemaSectionConfig, rows: Record<string, unknown>[]): VideoPromptSchemaFieldConfig[] => {
|
||||
const configured = section.itemFields.filter((field) => field.enabled);
|
||||
if (configured.length) {
|
||||
return [{ key: '时间段', label: '时间段', enabled: true, editable: false }, ...configured];
|
||||
}
|
||||
const inferred = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
||||
return inferred.map((key) => ({ key, label: key, enabled: true, editable: false }));
|
||||
};
|
||||
|
||||
const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => {
|
||||
const displayItems = items.map(item => normalizeRecordForSection(sectionKey, item));
|
||||
const fields = Array.from(new Set(displayItems.flatMap(item => Object.keys(item))));
|
||||
const columns: ColumnsType<Record<string, unknown>> = fields.map(field => ({
|
||||
title: labelOf(field),
|
||||
dataIndex: field,
|
||||
key: field,
|
||||
width: isLongText(displayItems.find(item => item[field])?.[field]) ? 280 : 160,
|
||||
render: (value: unknown) => renderValue(value),
|
||||
const renderFlowSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||
const value = readSectionValue(schema, section.key);
|
||||
if (!Array.isArray(value) || !value.every(isRecord)) return <JsonBlock value={value ?? []} maxHeight={260} />;
|
||||
const rows = value as Record<string, unknown>[];
|
||||
const fields = buildFlowFields(section, rows);
|
||||
const columns: ColumnsType<Record<string, unknown>> = fields.map((field) => ({
|
||||
title: <Space size={4}>{field.label}{tagOfEditable(field.editable)}</Space>,
|
||||
dataIndex: field.key,
|
||||
key: field.key,
|
||||
width: isLongText(rows.find((row) => row[field.key])?.[field.key]) ? 320 : 160,
|
||||
render: (cell: unknown) => renderValue(cell),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(_, index) => `${sectionKey}-${index}`}
|
||||
rowKey={(_, index) => `${section.key}-${index}`}
|
||||
columns={columns}
|
||||
dataSource={displayItems}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
scroll={{ x: Math.max(900, fields.length * 180) }}
|
||||
tableLayout="fixed"
|
||||
@@ -241,59 +82,64 @@ const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]):
|
||||
);
|
||||
};
|
||||
|
||||
const renderArray = (sectionKey: string, value: unknown[]): React.ReactNode => {
|
||||
if (!value.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
|
||||
if (value.every(isRecord)) {
|
||||
return getArrayMode(sectionKey) === 'card'
|
||||
? renderCardArray(sectionKey, value)
|
||||
: renderTableArray(sectionKey, value);
|
||||
}
|
||||
return <JsonBlock value={value} maxHeight={260} />;
|
||||
};
|
||||
|
||||
const renderRecord = (value: Record<string, unknown>): React.ReactNode => (
|
||||
<Descriptions size="small" column={2} bordered>
|
||||
{Object.entries(value).map(([field, fieldValue]) => (
|
||||
<Descriptions.Item key={field} label={labelOf(field)} span={Array.isArray(fieldValue) || isRecord(fieldValue) || isLongText(fieldValue) ? 2 : 1}>
|
||||
{Array.isArray(fieldValue)
|
||||
? renderArray(field, fieldValue)
|
||||
: isRecord(fieldValue)
|
||||
? renderRecord(fieldValue)
|
||||
: renderValue(fieldValue)}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
);
|
||||
|
||||
const renderSection = (key: string, value: unknown): React.ReactNode => {
|
||||
if (Array.isArray(value)) return renderArray(key, value);
|
||||
if (isRecord(value)) return renderRecord(value);
|
||||
const renderPrimitiveSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||
const value = readSectionValue(schema, section.key);
|
||||
return renderValue(value);
|
||||
};
|
||||
|
||||
interface VideoPromptSchemaViewerProps {
|
||||
schema?: Record<string, any> | null;
|
||||
finalPrompt?: string | null;
|
||||
schemaConfigSnapshot?: Record<string, any> | null;
|
||||
schemaConfigSource?: string | null;
|
||||
schemaConfigVersion?: string | null;
|
||||
schemaConfigIsFallback?: boolean | null;
|
||||
}
|
||||
|
||||
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schema, finalPrompt }) => {
|
||||
const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({
|
||||
schema,
|
||||
finalPrompt,
|
||||
schemaConfigSnapshot,
|
||||
schemaConfigSource,
|
||||
schemaConfigVersion,
|
||||
schemaConfigIsFallback,
|
||||
}) => {
|
||||
const hasSchema = !!schema && Object.keys(schema).length > 0;
|
||||
if (!hasSchema && !finalPrompt) {
|
||||
return <Empty description="暂无视频提词 schema" />;
|
||||
}
|
||||
|
||||
const schemaItems = hasSchema
|
||||
? Object.entries(schema || {}).map(([key, value]) => ({
|
||||
key,
|
||||
label: labelOf(key),
|
||||
children: renderSection(key, value),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const defaultKeys = finalPrompt ? ['final_prompt'] : [];
|
||||
const safeSchema = hasSchema ? schema as Record<string, any> : {};
|
||||
const hasSnapshot = hasSchemaConfigSnapshot(schemaConfigSnapshot);
|
||||
const sections = hasSnapshot ? normalizeSchemaSections(schemaConfigSnapshot) : inferSchemaSections(safeSchema);
|
||||
const schemaItems = sections
|
||||
.filter((section) => section.key in safeSchema)
|
||||
.map((section) => ({
|
||||
key: section.key,
|
||||
label: (
|
||||
<Space size={8} wrap>
|
||||
<span>{section.label}</span>
|
||||
{tagOfEditable(section.editable)}
|
||||
{!hasSnapshot ? <Tag color="warning">管理后台兜底展示</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
children: section.type === 'object'
|
||||
? renderObjectSection(safeSchema, section)
|
||||
: section.type === 'flow'
|
||||
? renderFlowSection(safeSchema, section)
|
||||
: renderPrimitiveSection(safeSchema, section),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Card size="small" title="Schema 配置来源">
|
||||
<Descriptions size="small" column={3} bordered>
|
||||
<Descriptions.Item label="来源">{schemaConfigSource || schemaConfigSnapshot?.source || (hasSnapshot ? '-' : 'viewer_fallback')}</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">{schemaConfigVersion || schemaConfigSnapshot?.version || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="历史兜底">{schemaConfigIsFallback || !hasSnapshot ? <Tag color="warning">是</Tag> : <Tag color="success">否</Tag>}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{finalPrompt ? (
|
||||
<Card size="small" title="最终视频提示词">
|
||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
|
||||
@@ -301,10 +147,21 @@ const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schem
|
||||
) : null}
|
||||
|
||||
{schemaItems.length ? (
|
||||
<Collapse size="small" items={schemaItems} />
|
||||
) : hasSchema ? (
|
||||
<Card size="small" title="视频提词 Schema">
|
||||
<JsonBlock value={safeSchema} maxHeight={360} />
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{hasSchema ? (
|
||||
<Collapse
|
||||
size="small"
|
||||
defaultActiveKey={defaultKeys}
|
||||
items={schemaItems}
|
||||
items={[{
|
||||
key: 'raw_schema',
|
||||
label: '原始 JSON / 历史脏字段排查',
|
||||
children: <JsonBlock value={safeSchema} maxHeight={420} />,
|
||||
}]}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Dropdown, Image, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
deleteHomeMaterialAsset,
|
||||
getHomeMaterialAssetStatus,
|
||||
getHomeMaterialAssets,
|
||||
regenerateHomeMaterialWatermark,
|
||||
updateHomeMaterialAsset,
|
||||
} from '../../api';
|
||||
import type {
|
||||
HomeMaterialAsset,
|
||||
HomeMaterialAssetQueryParams,
|
||||
HomeMaterialCategory,
|
||||
HomeMaterialMediaReference,
|
||||
HomeMaterialWatermark,
|
||||
HomeMaterialWatermarkConfig,
|
||||
HomeMaterialTextWatermarkConfig,
|
||||
} from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
import WatermarkEditor from './WatermarkEditor';
|
||||
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||
|
||||
interface Props {
|
||||
categories: HomeMaterialCategory[];
|
||||
watermarks: HomeMaterialWatermark[];
|
||||
reloadKey?: number;
|
||||
}
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
draft: { text: '草稿', color: 'default' },
|
||||
processing: { text: '处理中', color: 'processing' },
|
||||
success: { text: '成功', color: 'success' },
|
||||
failed: { text: '失败', color: 'error' },
|
||||
};
|
||||
|
||||
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
|
||||
text: '民众智创',
|
||||
opacityLevel: 2,
|
||||
fontSizePx: 28,
|
||||
color: '#ffffff',
|
||||
rotateDeg: -30,
|
||||
gapX: 220,
|
||||
gapY: 140,
|
||||
staggered: true,
|
||||
};
|
||||
|
||||
const defaultConfig: HomeMaterialWatermarkConfig = {
|
||||
watermarkType: 'image',
|
||||
watermarkId: null,
|
||||
opacityLevel: 6,
|
||||
position: 'bottom_right',
|
||||
customXRatio: null,
|
||||
customYRatio: null,
|
||||
sizeMode: 'ratio',
|
||||
widthRatio: 0.18,
|
||||
widthPx: null,
|
||||
marginX: 24,
|
||||
marginY: 24,
|
||||
textWatermark: defaultTextWatermark,
|
||||
};
|
||||
|
||||
const mediaTypeText: Record<string, string> = {
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
};
|
||||
|
||||
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
|
||||
return {
|
||||
text: raw?.text || defaultTextWatermark.text,
|
||||
opacityLevel: raw?.opacityLevel ?? raw?.opacity_level ?? defaultTextWatermark.opacityLevel,
|
||||
fontSizePx: raw?.fontSizePx ?? raw?.font_size_px ?? defaultTextWatermark.fontSizePx,
|
||||
color: raw?.color || defaultTextWatermark.color,
|
||||
rotateDeg: raw?.rotateDeg ?? raw?.rotate_deg ?? defaultTextWatermark.rotateDeg,
|
||||
gapX: raw?.gapX ?? raw?.gap_x ?? defaultTextWatermark.gapX,
|
||||
gapY: raw?.gapY ?? raw?.gap_y ?? defaultTextWatermark.gapY,
|
||||
staggered: raw?.staggered ?? defaultTextWatermark.staggered,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfig(raw: unknown, fallbackWatermarkId?: string | null): HomeMaterialWatermarkConfig {
|
||||
const data = (raw || {}) as any;
|
||||
const watermarkType = data.watermarkType || data.watermark_type || 'image';
|
||||
return {
|
||||
watermarkType,
|
||||
watermarkId: watermarkType === 'repeated_text' ? null : (data.watermarkId || data.watermark_id || fallbackWatermarkId || null),
|
||||
opacityLevel: data.opacityLevel || data.opacity_level || 6,
|
||||
position: data.position || 'bottom_right',
|
||||
customXRatio: data.customXRatio ?? data.custom_x_ratio ?? null,
|
||||
customYRatio: data.customYRatio ?? data.custom_y_ratio ?? null,
|
||||
sizeMode: data.sizeMode || data.size_mode || 'ratio',
|
||||
widthRatio: data.widthRatio ?? data.width_ratio ?? 0.18,
|
||||
widthPx: data.widthPx ?? data.width_px ?? null,
|
||||
marginX: data.marginX ?? data.margin_x ?? 24,
|
||||
marginY: data.marginY ?? data.margin_y ?? 24,
|
||||
textWatermark: normalizeTextConfig(data.textWatermark || data.text_watermark),
|
||||
};
|
||||
}
|
||||
|
||||
function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
|
||||
if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
|
||||
return !!config.watermarkId;
|
||||
}
|
||||
|
||||
function shortText(value?: string | null, max = 48): string {
|
||||
const text = (value || '').trim();
|
||||
if (!text) return '';
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text;
|
||||
}
|
||||
|
||||
function renderMediaReferences(refs?: HomeMaterialMediaReference[] | null) {
|
||||
const list = refs || [];
|
||||
if (list.length === 0) return <Typography.Text type="secondary">暂无附件</Typography.Text>;
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
{list.map((ref, index) => {
|
||||
const displayUrl = apiUrl(ref.displayUrl || ref.url);
|
||||
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
|
||||
return (
|
||||
<div key={`${ref.url}-${index}`} style={{ border: '1px solid #f0f0f5', borderRadius: 8, padding: 12, display: 'flex', gap: 12 }}>
|
||||
<div style={{ width: 140, minHeight: 80, flexShrink: 0, background: '#f6f7fb', borderRadius: 8, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{ref.type === 'image' && <Image width={140} height={80} style={{ objectFit: 'cover' }} src={previewUrl} />}
|
||||
{ref.type === 'video' && <video src={displayUrl} controls style={{ width: 140, height: 80, objectFit: 'cover', background: '#000' }} />}
|
||||
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 132 }} />}
|
||||
</div>
|
||||
<Space direction="vertical" size={6} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Space wrap>
|
||||
<Tag>{mediaTypeText[ref.type] || ref.type}</Tag>
|
||||
{ref.duration !== undefined && ref.duration !== null && <Tag>{ref.duration}s</Tag>}
|
||||
{ref.role && <Tag>{ref.role}</Tag>}
|
||||
</Space>
|
||||
<Typography.Text strong>{ref.name || '未命名附件'}</Typography.Text>
|
||||
<Typography.Text copyable ellipsis style={{ maxWidth: 620 }}>{ref.url}</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
|
||||
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filters, setFilters] = useState<HomeMaterialAssetQueryParams>({ page: 1, pageSize: 20 });
|
||||
const [preview, setPreview] = useState<HomeMaterialAsset | null>(null);
|
||||
const [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
|
||||
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||
const [regenSubmitting, setRegenSubmitting] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<HomeMaterialAsset | null>(null);
|
||||
const [editPrompt, setEditPrompt] = useState('');
|
||||
const [editRefs, setEditRefs] = useState<HomeMaterialMediaReference[]>([]);
|
||||
const [editSubmitting, setEditSubmitting] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getHomeMaterialAssets(filters);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filters, reloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const processing = items.filter(i => i.status === 'processing');
|
||||
if (processing.length === 0) return;
|
||||
const timer = window.setInterval(async () => {
|
||||
const statuses = await Promise.all(processing.map(i => getHomeMaterialAssetStatus(i.id).catch(() => null)));
|
||||
const changed = statuses.some(s => s && s.status !== 'processing');
|
||||
if (changed) load();
|
||||
}, 2500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [items]);
|
||||
|
||||
const openRegenerate = (row: HomeMaterialAsset) => {
|
||||
setRegen(row);
|
||||
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
|
||||
};
|
||||
|
||||
const openGenerationConfig = (row: HomeMaterialAsset) => {
|
||||
setEditingConfig(row);
|
||||
setEditPrompt(row.generationPrompt || '');
|
||||
setEditRefs(row.mediaReferences || []);
|
||||
};
|
||||
|
||||
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
|
||||
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
|
||||
return apiUrl(row.coverUrl || '');
|
||||
};
|
||||
|
||||
const getVideoUrl = (row: HomeMaterialAsset) => apiUrl(row.watermarkedUrl || row.originalUrl);
|
||||
|
||||
const confirmDelete = (row: HomeMaterialAsset) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除该素材?',
|
||||
content: row.title || row.id,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteHomeMaterialAsset(row.id);
|
||||
message.success('删除成功');
|
||||
await load();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const submitGenerationConfig = async () => {
|
||||
if (!editingConfig || editSubmitting) return;
|
||||
setEditSubmitting(true);
|
||||
try {
|
||||
await updateHomeMaterialAsset(editingConfig.id, {
|
||||
category_id: editingConfig.categoryId,
|
||||
title: editingConfig.title || null,
|
||||
is_active: editingConfig.isActive,
|
||||
sort_order: editingConfig.sortOrder,
|
||||
generation_prompt: editPrompt.trim() || null,
|
||||
media_references: editRefs,
|
||||
});
|
||||
message.success('生成配置已保存');
|
||||
setEditingConfig(null);
|
||||
await load();
|
||||
} finally {
|
||||
setEditSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRegenerate = async () => {
|
||||
if (!regen || regenSubmitting) return;
|
||||
if (!isValidConfig(regenConfig)) {
|
||||
message.warning(regenConfig.watermarkType === 'repeated_text' ? '请输入重复文字水印内容' : '请选择水印图片');
|
||||
return;
|
||||
}
|
||||
setRegenSubmitting(true);
|
||||
try {
|
||||
await regenerateHomeMaterialWatermark(regen.id, {
|
||||
watermark_type: regenConfig.watermarkType,
|
||||
watermark_id: regenConfig.watermarkType === 'image' ? regenConfig.watermarkId : null,
|
||||
opacity_level: regenConfig.opacityLevel,
|
||||
position: regenConfig.position,
|
||||
custom_x_ratio: regenConfig.customXRatio,
|
||||
custom_y_ratio: regenConfig.customYRatio,
|
||||
size_mode: regenConfig.sizeMode,
|
||||
width_ratio: regenConfig.widthRatio,
|
||||
width_px: regenConfig.widthPx,
|
||||
margin_x: regenConfig.marginX,
|
||||
margin_y: regenConfig.marginY,
|
||||
text_watermark: regenConfig.watermarkType === 'repeated_text' && regenConfig.textWatermark ? {
|
||||
text: regenConfig.textWatermark.text,
|
||||
opacity_level: regenConfig.textWatermark.opacityLevel,
|
||||
font_size_px: regenConfig.textWatermark.fontSizePx,
|
||||
color: regenConfig.textWatermark.color,
|
||||
rotate_deg: regenConfig.textWatermark.rotateDeg,
|
||||
gap_x: regenConfig.textWatermark.gapX,
|
||||
gap_y: regenConfig.textWatermark.gapY,
|
||||
staggered: regenConfig.textWatermark.staggered,
|
||||
} : null,
|
||||
wait: false,
|
||||
});
|
||||
message.success('已提交重新生成');
|
||||
setRegen(null);
|
||||
await load();
|
||||
} finally {
|
||||
setRegenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
placeholder="行业"
|
||||
value={filters.categoryId}
|
||||
onChange={(v) => setFilters(f => ({ ...f, categoryId: v, page: 1 }))}
|
||||
options={categories.map(c => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="素材类型"
|
||||
value={filters.mediaType}
|
||||
onChange={(v) => setFilters(f => ({ ...f, mediaType: v, page: 1 }))}
|
||||
options={[{ label: '图片', value: 'image' }, { label: '视频', value: 'video' }]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="处理状态"
|
||||
value={filters.status}
|
||||
onChange={(v) => setFilters(f => ({ ...f, status: v, page: 1 }))}
|
||||
options={Object.entries(statusMap).map(([value, item]) => ({ value, label: item.text }))}
|
||||
/>
|
||||
<Button onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
|
||||
scroll={{ x: 1380 }}
|
||||
columns={[
|
||||
{
|
||||
title: '预览',
|
||||
width: 110,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => {
|
||||
const imageUrl = getPreviewImageUrl(row);
|
||||
if (row.mediaType === 'image') {
|
||||
return imageUrl ? <Image width={86} height={64} style={{ objectFit: 'cover', borderRadius: 6 }} src={imageUrl} /> : '-';
|
||||
}
|
||||
const videoUrl = getVideoUrl(row);
|
||||
const posterUrl = apiUrl(row.coverUrl || '');
|
||||
return videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
poster={posterUrl || undefined}
|
||||
muted
|
||||
preload="metadata"
|
||||
style={{ width: 100, height: 64, objectFit: 'cover', borderRadius: 6, background: '#000' }}
|
||||
/>
|
||||
) : '-';
|
||||
},
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', render: (v: string | null) => v || <span style={{ color: '#999' }}>未命名素材</span> },
|
||||
{ title: '行业', dataIndex: 'categoryName' },
|
||||
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
|
||||
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={statusMap[v]?.color}>{statusMap[v]?.text || v}</Tag> },
|
||||
{
|
||||
title: '生成提词',
|
||||
width: 210,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => row.generationPrompt ? (
|
||||
<Tooltip title={row.generationPrompt}><Typography.Text>{shortText(row.generationPrompt)}</Typography.Text></Tooltip>
|
||||
) : <Typography.Text type="secondary">未设置</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '附件',
|
||||
width: 90,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => <Tag color={(row.mediaReferences?.length || 0) > 0 ? 'blue' : 'default'}>{row.mediaReferences?.length || 0}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '水印',
|
||||
render: (_: unknown, row: HomeMaterialAsset) => {
|
||||
const cfg = normalizeConfig(row.watermarkConfig, row.watermarkId);
|
||||
return cfg.watermarkType === 'repeated_text' ? '重复文字水印' : (row.watermarkName || '图片水印');
|
||||
},
|
||||
},
|
||||
{ title: '排序', dataIndex: 'sortOrder' },
|
||||
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 170,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => (
|
||||
<Space size={8}>
|
||||
<Button size="small" onClick={() => setPreview(row)}>查看</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'generation_config', icon: <EditOutlined />, label: '编辑生成配置' },
|
||||
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
|
||||
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'generation_config') openGenerationConfig(row);
|
||||
if (key === 'regenerate') openRegenerate(row);
|
||||
if (key === 'delete') confirmDelete(row);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<MoreOutlined />}>更多</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={960} destroyOnHidden>
|
||||
{preview && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
|
||||
<div><b>水印素材:</b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</div>
|
||||
<div>
|
||||
<b>生成提词:</b>
|
||||
<div style={{ marginTop: 8, padding: 12, borderRadius: 8, background: '#f8fafc', whiteSpace: 'pre-wrap' }}>
|
||||
{preview.generationPrompt || '未设置'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>附件 / 参考素材:</b>
|
||||
<div style={{ marginTop: 8 }}>{renderMediaReferences(preview.mediaReferences)}</div>
|
||||
</div>
|
||||
{preview.mediaType === 'image' ? (
|
||||
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
|
||||
) : (
|
||||
<video src={getVideoUrl(preview)} poster={apiUrl(preview.coverUrl || '') || undefined} controls style={{ width: '100%', background: '#000' }} />
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title="编辑生成配置"
|
||||
open={!!editingConfig}
|
||||
onCancel={() => !editSubmitting && setEditingConfig(null)}
|
||||
onOk={submitGenerationConfig}
|
||||
confirmLoading={editSubmitting}
|
||||
cancelButtonProps={{ disabled: editSubmitting }}
|
||||
width={920}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={16}>
|
||||
<div>
|
||||
<Typography.Text strong>生成提词</Typography.Text>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
maxLength={8000}
|
||||
showCount
|
||||
value={editPrompt}
|
||||
disabled={editSubmitting}
|
||||
placeholder="请输入用于生成该素材的提示词"
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
style={{ marginTop: 8 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text strong>附件 / 参考素材</Typography.Text>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<MediaReferencesEditor value={editRefs} onChange={setEditRefs} disabled={editSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
<Modal
|
||||
title="重新生成水印"
|
||||
open={!!regen}
|
||||
onCancel={() => !regenSubmitting && setRegen(null)}
|
||||
onOk={submitRegenerate}
|
||||
confirmLoading={regenSubmitting}
|
||||
okButtonProps={{ disabled: regenSubmitting || !isValidConfig(regenConfig) }}
|
||||
cancelButtonProps={{ disabled: regenSubmitting }}
|
||||
width={980}
|
||||
destroyOnHidden
|
||||
>
|
||||
{regen && (
|
||||
<WatermarkEditor
|
||||
value={regenConfig}
|
||||
onChange={setRegenConfig}
|
||||
watermarks={watermarks}
|
||||
mediaUrl={apiUrl(regen.originalUrl || regen.watermarkedUrl)}
|
||||
mediaType={regen.mediaType}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeMaterialAssetTable;
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, message } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import * as AntIcons from '@ant-design/icons';
|
||||
import { deleteHomeMaterialCategory, getHomeMaterialCategories, saveHomeMaterialCategory } from '../../api';
|
||||
import type { HomeMaterialCategory, HomeMaterialCategoryPayload } from '../../types';
|
||||
|
||||
|
||||
const ICON_COMPONENTS = AntIcons as unknown as Record<string, React.ComponentType<{ style?: React.CSSProperties }>>;
|
||||
|
||||
const CATEGORY_ICON_OPTIONS = [
|
||||
{ label: '通用应用', value: 'AppstoreOutlined' },
|
||||
{ label: '图片素材', value: 'PictureOutlined' },
|
||||
{ label: '视频素材', value: 'VideoCameraOutlined' },
|
||||
{ label: '电商购物', value: 'ShoppingOutlined' },
|
||||
{ label: '门店商铺', value: 'ShopOutlined' },
|
||||
{ label: '美妆护肤', value: 'SkinOutlined' },
|
||||
{ label: '餐饮食品', value: 'CoffeeOutlined' },
|
||||
{ label: '家居生活', value: 'HomeOutlined' },
|
||||
{ label: '汽车出行', value: 'CarOutlined' },
|
||||
{ label: '医疗健康', value: 'MedicineBoxOutlined' },
|
||||
{ label: '教育培训', value: 'BookOutlined' },
|
||||
{ label: '数码科技', value: 'LaptopOutlined' },
|
||||
{ label: '旅游定位', value: 'EnvironmentOutlined' },
|
||||
{ label: '摄影影像', value: 'CameraOutlined' },
|
||||
{ label: '礼品活动', value: 'GiftOutlined' },
|
||||
{ label: '热门爆款', value: 'FireOutlined' },
|
||||
{ label: '品牌服务', value: 'CustomerServiceOutlined' },
|
||||
{ label: '营销增长', value: 'RocketOutlined' },
|
||||
{ label: '机构企业', value: 'BankOutlined' },
|
||||
{ label: '娱乐休闲', value: 'SmileOutlined' },
|
||||
];
|
||||
|
||||
function renderIconOption(value?: string | null, label?: string) {
|
||||
if (!value) {
|
||||
return <span style={{ color: '#999' }}>未设置</span>;
|
||||
}
|
||||
const Icon = ICON_COMPONENTS[value];
|
||||
return (
|
||||
<Space size={6}>
|
||||
{Icon ? <Icon /> : null}
|
||||
<span>{label || value}</span>
|
||||
<span style={{ color: '#999' }}>{value}</span>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const HomeMaterialCategoryPanel: React.FC<{ onChanged?: () => void }> = ({ onChanged }) => {
|
||||
const [items, setItems] = useState<HomeMaterialCategory[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<HomeMaterialCategory | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getHomeMaterialCategories({ page, pageSize: 20 });
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page]);
|
||||
|
||||
const openEdit = (row?: HomeMaterialCategory) => {
|
||||
setEditing(row || null);
|
||||
form.setFieldsValue(row ? {
|
||||
name: row.name,
|
||||
key: row.key,
|
||||
description: row.description,
|
||||
icon: row.icon,
|
||||
is_active: row.isActive,
|
||||
sort_order: row.sortOrder,
|
||||
} : { is_active: true, sort_order: 0 });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const values = await form.validateFields();
|
||||
await saveHomeMaterialCategory({ ...(values as HomeMaterialCategoryPayload), id: editing?.id });
|
||||
message.success('保存成功');
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
onChanged?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>新增行业</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ current: page, total, pageSize: 20, onChange: setPage }}
|
||||
columns={[
|
||||
{ title: '行业名称', dataIndex: 'name' },
|
||||
{ title: 'Key', dataIndex: 'key' },
|
||||
{ title: '图标', dataIndex: 'icon', render: (v?: string | null) => renderIconOption(v) },
|
||||
{ title: '启用', dataIndex: 'isActive', render: (v: boolean) => v ? '启用' : '禁用' },
|
||||
{ title: '素材数', dataIndex: 'assetCount' },
|
||||
{ title: '图片/视频', render: (_: unknown, r: HomeMaterialCategory) => `${r.imageCount}/${r.videoCount}` },
|
||||
{ title: '排序', dataIndex: 'sortOrder' },
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, row: HomeMaterialCategory) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>编辑</Button>
|
||||
<Popconfirm title="确认删除该行业?" onConfirm={async () => { await deleteHomeMaterialCategory(row.id); message.success('删除成功'); await load(); onChanged?.(); }}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal title={editing ? '编辑行业' : '新增行业'} open={modalOpen} onOk={submit} onCancel={() => setModalOpen(false)} destroyOnHidden>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}><Input /></Form.Item>
|
||||
<Form.Item name="key" label="行业Key" rules={[{ required: true, message: '请输入行业Key' }, { pattern: /^[A-Za-z0-9_-]+$/, message: '只允许字母、数字、下划线、中划线' }]}><Input /></Form.Item>
|
||||
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
|
||||
<Form.Item name="icon" label="图标">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="请选择行业图标"
|
||||
optionFilterProp="searchText"
|
||||
filterOption={(input, option) => {
|
||||
const text = String((option as any)?.searchText || '').toLowerCase();
|
||||
const value = String(option?.value || '').toLowerCase();
|
||||
const keyword = input.toLowerCase();
|
||||
return text.includes(keyword) || value.includes(keyword);
|
||||
}}
|
||||
options={CATEGORY_ICON_OPTIONS.map(item => ({
|
||||
value: item.value,
|
||||
searchText: `${item.label} ${item.value}`,
|
||||
label: renderIconOption(item.value, item.label),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeMaterialCategoryPanel;
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadHomeMaterialAsset } from '../../api';
|
||||
import type { HomeMaterialCategory, HomeMaterialMediaReference, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import WatermarkEditor from './WatermarkEditor';
|
||||
import MediaReferencesEditor from './MediaReferencesEditor';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
categories: HomeMaterialCategory[];
|
||||
watermarks: HomeMaterialWatermark[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const defaultConfig: HomeMaterialWatermarkConfig = {
|
||||
watermarkType: 'image',
|
||||
watermarkId: null,
|
||||
opacityLevel: 6,
|
||||
position: 'bottom_right',
|
||||
customXRatio: null,
|
||||
customYRatio: null,
|
||||
sizeMode: 'ratio',
|
||||
widthRatio: 0.18,
|
||||
widthPx: null,
|
||||
marginX: 24,
|
||||
marginY: 24,
|
||||
textWatermark: {
|
||||
text: '民众智创',
|
||||
opacityLevel: 2,
|
||||
fontSizePx: 28,
|
||||
color: '#ffffff',
|
||||
rotateDeg: -30,
|
||||
gapX: 220,
|
||||
gapY: 140,
|
||||
staggered: true,
|
||||
},
|
||||
};
|
||||
|
||||
function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
|
||||
if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
|
||||
return !!config.watermarkId;
|
||||
}
|
||||
|
||||
const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, watermarks, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
const [mediaType, setMediaType] = useState<HomeMaterialMediaType>('image');
|
||||
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
|
||||
const [mediaReferences, setMediaReferences] = useState<HomeMaterialMediaReference[]>([]);
|
||||
|
||||
const resetState = () => {
|
||||
setFile(null);
|
||||
setFileList([]);
|
||||
setMediaType('image');
|
||||
setMediaObjectUrl('');
|
||||
setSubmitting(false);
|
||||
setMediaReferences([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState();
|
||||
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
|
||||
setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
|
||||
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined, generation_prompt: undefined });
|
||||
}
|
||||
}, [open, watermarks, form]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
|
||||
};
|
||||
}, [mediaObjectUrl]);
|
||||
|
||||
const mediaUrl = useMemo(() => mediaObjectUrl || undefined, [mediaObjectUrl]);
|
||||
|
||||
const handleBeforeUpload = (selectedFile: File) => {
|
||||
const isImage = selectedFile.type.startsWith('image/');
|
||||
const isVideo = selectedFile.type.startsWith('video/');
|
||||
|
||||
if (mediaType === 'image' && !isImage) {
|
||||
message.warning('当前素材类型为图片,请选择图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (mediaType === 'video' && !isVideo) {
|
||||
message.warning('当前素材类型为视频,请选择视频文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
|
||||
const nextUrl = URL.createObjectURL(selectedFile);
|
||||
setFile(selectedFile);
|
||||
setMediaObjectUrl(nextUrl);
|
||||
setFileList([{ uid: selectedFile.name, name: selectedFile.name, status: 'done', originFileObj: selectedFile }]);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setFile(null);
|
||||
setFileList([]);
|
||||
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
|
||||
setMediaObjectUrl('');
|
||||
};
|
||||
|
||||
const changeMediaType = (value: HomeMaterialMediaType) => {
|
||||
setMediaType(value);
|
||||
handleRemoveFile();
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (submitting) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return;
|
||||
const values = await form.validateFields();
|
||||
if (!file) {
|
||||
message.warning('请选择素材文件');
|
||||
return;
|
||||
}
|
||||
if (config.watermarkType === 'image' && !config.watermarkId) {
|
||||
message.warning('请选择水印图片');
|
||||
return;
|
||||
}
|
||||
if (config.watermarkType === 'repeated_text' && !config.textWatermark?.text?.trim()) {
|
||||
message.warning('请输入重复文字水印内容');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await uploadHomeMaterialAsset({
|
||||
categoryId: values.category_id,
|
||||
file,
|
||||
mediaType,
|
||||
title: values.title?.trim() || null,
|
||||
generationPrompt: values.generation_prompt?.trim() || null,
|
||||
mediaReferences,
|
||||
watermarkType: config.watermarkType,
|
||||
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
|
||||
opacityLevel: config.opacityLevel,
|
||||
position: config.position,
|
||||
customXRatio: config.customXRatio,
|
||||
customYRatio: config.customYRatio,
|
||||
sizeMode: config.sizeMode,
|
||||
widthRatio: config.widthRatio,
|
||||
widthPx: config.widthPx,
|
||||
marginX: config.marginX,
|
||||
marginY: config.marginY,
|
||||
textWatermark: config.watermarkType === 'repeated_text' ? config.textWatermark : null,
|
||||
isActive: values.is_active,
|
||||
sortOrder: values.sort_order,
|
||||
wait: false,
|
||||
});
|
||||
message.success(res.message || '已提交水印处理');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="上传首页素材"
|
||||
open={open}
|
||||
onCancel={closeModal}
|
||||
onOk={submit}
|
||||
confirmLoading={submitting}
|
||||
okButtonProps={{ disabled: submitting || !file || !isValidConfig(config) }}
|
||||
cancelButtonProps={{ disabled: submitting }}
|
||||
width={980}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="category_id" label="行业" rules={[{ required: true, message: '请选择行业' }]}>
|
||||
<Select options={categories.map(c => ({ label: c.name, value: c.id }))} disabled={submitting} />
|
||||
</Form.Item>
|
||||
<Form.Item name="media_type" label="素材类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
value={mediaType}
|
||||
onChange={changeMediaType}
|
||||
disabled={submitting}
|
||||
options={[{ label: '图片', value: 'image' }, { label: '视频', value: 'video' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="素材文件" required>
|
||||
<Upload
|
||||
beforeUpload={(f) => handleBeforeUpload(f as File)}
|
||||
onRemove={handleRemoveFile}
|
||||
fileList={fileList}
|
||||
maxCount={1}
|
||||
accept={mediaType === 'image' ? 'image/*' : 'video/*'}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={submitting} disabled={submitting}>选择{mediaType === 'image' ? '图片' : '视频'}</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="generation_prompt" label="生成提词" extra="选填;会随前台素材接口返回。">
|
||||
<Input.TextArea rows={5} maxLength={8000} showCount disabled={submitting} placeholder="请输入用于生成该素材的提示词" />
|
||||
</Form.Item>
|
||||
<Form.Item label="附件 / 参考素材">
|
||||
<MediaReferencesEditor value={mediaReferences} onChange={setMediaReferences} disabled={submitting} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||
</Form>
|
||||
<WatermarkEditor value={config} onChange={setConfig} watermarks={watermarks} mediaUrl={mediaUrl} mediaType={mediaType} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeMaterialUploadModal;
|
||||
@@ -0,0 +1,167 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Button, Card, Image, Input, InputNumber, Space, Tag, Upload, message } from 'antd';
|
||||
import { DeleteOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadAdminFile } from '../../api';
|
||||
import type { AdminUploadResourceType, HomeMaterialMediaReference } from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
|
||||
interface Props {
|
||||
value?: HomeMaterialMediaReference[] | null;
|
||||
onChange: (value: HomeMaterialMediaReference[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const mediaTypeText: Record<string, string> = {
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
};
|
||||
|
||||
function detectResourceType(file: File): AdminUploadResourceType | null {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
if (file.type.startsWith('video/')) return 'video';
|
||||
if (file.type.startsWith('audio/')) return 'audio';
|
||||
const name = file.name.toLowerCase();
|
||||
if (/\.(jpg|jpeg|png|webp|gif)$/.test(name)) return 'image';
|
||||
if (/\.(mp4|mov|m4v|webm)$/.test(name)) return 'video';
|
||||
if (/\.(mp3|wav|m4a|aac)$/.test(name)) return 'audio';
|
||||
return null;
|
||||
}
|
||||
|
||||
function readDuration(file: File, resourceType: AdminUploadResourceType): Promise<number | null> {
|
||||
if (resourceType !== 'video' && resourceType !== 'audio') return Promise.resolve(null);
|
||||
return new Promise(resolve => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const el = document.createElement(resourceType === 'video' ? 'video' : 'audio');
|
||||
el.preload = 'metadata';
|
||||
el.onloadedmetadata = () => {
|
||||
const duration = Number.isFinite(el.duration) ? Number(el.duration.toFixed(3)) : null;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(duration);
|
||||
};
|
||||
el.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(null);
|
||||
};
|
||||
el.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
const MediaReferencesEditor: React.FC<Props> = ({ value, onChange, disabled }) => {
|
||||
const refs = useMemo(() => value || [], [value]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const updateItem = (index: number, patch: Partial<HomeMaterialMediaReference>) => {
|
||||
onChange(refs.map((item, idx) => idx === index ? { ...item, ...patch } : item));
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onChange(refs.filter((_, idx) => idx !== index));
|
||||
};
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const resourceType = detectResourceType(file);
|
||||
if (!resourceType || !['image', 'video', 'audio'].includes(resourceType)) {
|
||||
message.warning('附件仅支持图片、视频、音频');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const durationSeconds = await readDuration(file, resourceType);
|
||||
const res = await uploadAdminFile(file, {
|
||||
scene: 'home_material_reference',
|
||||
resourceType,
|
||||
durationSeconds,
|
||||
});
|
||||
const ref = res.mediaReference;
|
||||
if (!ref || !['image', 'video', 'audio'].includes(ref.type)) {
|
||||
throw new Error('上传返回附件格式异常');
|
||||
}
|
||||
onChange([
|
||||
...refs,
|
||||
{
|
||||
url: ref.url,
|
||||
type: ref.type as 'image' | 'video' | 'audio',
|
||||
name: ref.name || file.name,
|
||||
duration: ref.duration ?? durationSeconds,
|
||||
source: ref.source,
|
||||
uploadResourceId: ref.uploadResourceId,
|
||||
displayUrl: ref.displayUrl || ref.url,
|
||||
previewUrl: ref.previewUrl || ref.displayUrl || ref.url,
|
||||
role: ref.role || `reference_${ref.type}`,
|
||||
},
|
||||
]);
|
||||
message.success('附件上传成功');
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '附件上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return Upload.LIST_IGNORE;
|
||||
};
|
||||
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
||||
<Upload beforeUpload={(file) => uploadFile(file as File)} showUploadList={false} disabled={disabled || uploading} accept="image/*,video/*,audio/*">
|
||||
<Button icon={<UploadOutlined />} loading={uploading} disabled={disabled || uploading}>上传附件</Button>
|
||||
</Upload>
|
||||
{refs.length === 0 ? (
|
||||
<div style={{ color: '#999', fontSize: 13 }}>暂无附件。可上传图片、视频、音频,保存后会随前台素材接口返回。</div>
|
||||
) : (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={10}>
|
||||
{refs.map((ref, index) => {
|
||||
const previewUrl = apiUrl(ref.previewUrl || ref.displayUrl || ref.url);
|
||||
const displayUrl = apiUrl(ref.displayUrl || ref.url);
|
||||
return (
|
||||
<Card key={`${ref.url}-${index}`} size="small" bodyStyle={{ padding: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div style={{ width: 120, minHeight: 72, flexShrink: 0, borderRadius: 8, overflow: 'hidden', background: '#f6f7fb', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{ref.type === 'image' && <Image width={120} height={72} style={{ objectFit: 'cover' }} src={previewUrl} />}
|
||||
{ref.type === 'video' && <video src={displayUrl} style={{ width: 120, height: 72, objectFit: 'cover', background: '#000' }} controls />}
|
||||
{ref.type === 'audio' && <audio src={displayUrl} controls style={{ width: 112 }} />}
|
||||
</div>
|
||||
<Space direction="vertical" style={{ flex: 1, minWidth: 0 }} size={8}>
|
||||
<Space wrap>
|
||||
<Tag color={ref.type === 'image' ? 'blue' : ref.type === 'video' ? 'purple' : 'green'}>{mediaTypeText[ref.type] || ref.type}</Tag>
|
||||
{ref.uploadResourceId && <Tag>资源ID:{ref.uploadResourceId}</Tag>}
|
||||
</Space>
|
||||
<Input
|
||||
size="small"
|
||||
value={ref.name || ''}
|
||||
placeholder="附件名称"
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateItem(index, { name: e.target.value || null })}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
value={ref.role || ''}
|
||||
placeholder="附件角色,如 reference_image"
|
||||
disabled={disabled}
|
||||
onChange={(e) => updateItem(index, { role: e.target.value || null })}
|
||||
/>
|
||||
{(ref.type === 'video' || ref.type === 'audio') && (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
precision={3}
|
||||
style={{ width: 180 }}
|
||||
value={ref.duration ?? null}
|
||||
placeholder="时长(秒)"
|
||||
disabled={disabled}
|
||||
onChange={(v) => updateItem(index, { duration: v === null ? null : Number(v) })}
|
||||
/>
|
||||
)}
|
||||
<Input size="small" value={ref.url} disabled />
|
||||
</Space>
|
||||
<Button danger size="small" icon={<DeleteOutlined />} disabled={disabled} onClick={() => removeItem(index)}>删除</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaReferencesEditor;
|
||||
@@ -0,0 +1,191 @@
|
||||
import React from 'react';
|
||||
import { Col, ColorPicker, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
|
||||
import type { HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import WatermarkPreview from './WatermarkPreview';
|
||||
|
||||
interface WatermarkEditorProps {
|
||||
value: HomeMaterialWatermarkConfig;
|
||||
onChange: (next: HomeMaterialWatermarkConfig) => void;
|
||||
watermarks: HomeMaterialWatermark[];
|
||||
mediaUrl?: string | null;
|
||||
mediaType?: HomeMaterialMediaType;
|
||||
}
|
||||
|
||||
const positionOptions = [
|
||||
{ label: '左上角', value: 'top_left' },
|
||||
{ label: '顶部居中', value: 'top_center' },
|
||||
{ label: '右上角', value: 'top_right' },
|
||||
{ label: '左侧居中', value: 'middle_left' },
|
||||
{ label: '正中间', value: 'center' },
|
||||
{ label: '右侧居中', value: 'middle_right' },
|
||||
{ label: '左下角', value: 'bottom_left' },
|
||||
{ label: '底部居中', value: 'bottom_center' },
|
||||
{ label: '右下角', value: 'bottom_right' },
|
||||
{ label: '拖拽自定义', value: 'custom' },
|
||||
];
|
||||
|
||||
const defaultTextWatermark = {
|
||||
text: '民众智创',
|
||||
opacityLevel: 2,
|
||||
fontSizePx: 28,
|
||||
color: '#ffffff',
|
||||
rotateDeg: -30,
|
||||
gapX: 220,
|
||||
gapY: 140,
|
||||
staggered: true,
|
||||
};
|
||||
|
||||
const WatermarkEditor: React.FC<WatermarkEditorProps> = ({ value, onChange, watermarks, mediaUrl, mediaType }) => {
|
||||
const patch = (partial: Partial<HomeMaterialWatermarkConfig>) => onChange({ ...value, ...partial });
|
||||
const patchText = (partial: Partial<NonNullable<HomeMaterialWatermarkConfig['textWatermark']>>) => {
|
||||
patch({ textWatermark: { ...defaultTextWatermark, ...(value.textWatermark || {}), ...partial } });
|
||||
};
|
||||
const watermarkUrl = watermarks.find(w => w.id === value.watermarkId)?.fileUrl;
|
||||
const textWatermark = { ...defaultTextWatermark, ...(value.textWatermark || {}) };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="水印类型" required>
|
||||
<Radio.Group
|
||||
value={value.watermarkType || 'image'}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
patch({
|
||||
watermarkType: nextType,
|
||||
textWatermark: nextType === 'repeated_text' ? textWatermark : null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Radio.Button value="image">图片水印</Radio.Button>
|
||||
<Radio.Button value="repeated_text">重复文字水印</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{value.watermarkType === 'repeated_text' ? (
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item label="水印文字" required extra="字体由后端固定开源可商用字体渲染,前端预览层和最终生成使用同一后端渲染逻辑。">
|
||||
<Input
|
||||
maxLength={64}
|
||||
showCount
|
||||
value={textWatermark.text}
|
||||
onChange={(e) => patchText({ text: e.target.value })}
|
||||
placeholder="请输入重复水印文字"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label={`文字透明度:${textWatermark.opacityLevel}/10`} required>
|
||||
<Slider min={1} max={10} step={1} value={textWatermark.opacityLevel} onChange={(v) => patchText({ opacityLevel: v })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="字号(px)" required>
|
||||
<InputNumber min={8} max={160} style={{ width: '100%' }} value={textWatermark.fontSizePx} onChange={(v) => patchText({ fontSizePx: Number(v || 28) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="文字颜色" required>
|
||||
<ColorPicker
|
||||
value={textWatermark.color}
|
||||
onChange={(_, hex) => patchText({ color: hex || '#ffffff' })}
|
||||
showText
|
||||
presets={[{
|
||||
label: '推荐',
|
||||
colors: ['#ffffff', '#000000', '#ff4d4f', '#1677ff', '#52c41a', '#faad14', '#722ed1', '#eb2f96'],
|
||||
}]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="旋转角度" required>
|
||||
<InputNumber min={-90} max={90} style={{ width: '100%' }} value={textWatermark.rotateDeg} onChange={(v) => patchText({ rotateDeg: Number(v ?? -30) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="横向间距" required>
|
||||
<InputNumber min={20} max={2000} style={{ width: '100%' }} value={textWatermark.gapX} onChange={(v) => patchText({ gapX: Number(v || 220) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="纵向间距" required>
|
||||
<InputNumber min={20} max={2000} style={{ width: '100%' }} value={textWatermark.gapY} onChange={(v) => patchText({ gapY: Number(v || 140) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="交错排列" valuePropName="checked">
|
||||
<Switch checked={textWatermark.staggered} onChange={(checked) => patchText({ staggered: checked })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="水印图片" required>
|
||||
<Select
|
||||
placeholder="请选择水印"
|
||||
value={value.watermarkId || undefined}
|
||||
onChange={(v) => patch({ watermarkId: v })}
|
||||
options={watermarks.map(w => ({ label: `${w.name}${w.isDefault ? '(默认)' : ''}`, value: w.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label={`透明度:${value.opacityLevel}/10`} required>
|
||||
<Slider min={1} max={10} step={1} value={value.opacityLevel} onChange={(v) => patch({ opacityLevel: v })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="水印位置" required>
|
||||
<Select value={value.position} onChange={(v) => patch({ position: v })} options={positionOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="大小模式" required>
|
||||
<Select
|
||||
value={value.sizeMode}
|
||||
onChange={(v) => patch({ sizeMode: v })}
|
||||
options={[{ label: '按素材宽度比例', value: 'ratio' }, { label: '固定像素宽度', value: 'px' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{value.sizeMode === 'ratio' ? (
|
||||
<Col span={12}>
|
||||
<Form.Item label="水印宽度比例" required>
|
||||
<InputNumber min={0.01} max={1} step={0.01} style={{ width: '100%' }} value={value.widthRatio ?? 0.18} onChange={(v) => patch({ widthRatio: Number(v || 0.18) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
) : (
|
||||
<Col span={12}>
|
||||
<Form.Item label="水印宽度(px)" required>
|
||||
<InputNumber min={1} max={10000} style={{ width: '100%' }} value={value.widthPx ?? 200} onChange={(v) => patch({ widthPx: Number(v || 200) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={6}>
|
||||
<Form.Item label="横向边距">
|
||||
<InputNumber min={0} max={2000} style={{ width: '100%' }} value={value.marginX} onChange={(v) => patch({ marginX: Number(v || 0) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="纵向边距">
|
||||
<InputNumber min={0} max={2000} style={{ width: '100%' }} value={value.marginY} onChange={(v) => patch({ marginY: Number(v || 0) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<WatermarkPreview
|
||||
mediaUrl={mediaUrl}
|
||||
mediaType={mediaType}
|
||||
watermarkUrl={watermarkUrl}
|
||||
config={value}
|
||||
onChange={(partial) => patch(partial)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkEditor;
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Image, Input, Modal, Popconfirm, Space, Switch, Table, Upload, message } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { deleteHomeMaterialWatermark, getHomeMaterialWatermarks, updateHomeMaterialWatermark, uploadHomeMaterialWatermark } from '../../api';
|
||||
import type { HomeMaterialWatermark } from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
embedded?: boolean;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
const WatermarkLibraryModal: React.FC<Props> = ({ open = true, onClose, embedded = false, onChanged }) => {
|
||||
const [items, setItems] = useState<HomeMaterialWatermark[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<HomeMaterialWatermark | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getHomeMaterialWatermarks({ page: 1, pageSize: 200 });
|
||||
setItems(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (open) load(); }, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const resetUploadState = () => {
|
||||
setFile(null);
|
||||
setFileList([]);
|
||||
setPreviewUrl('');
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const openUpload = () => {
|
||||
setEditing(null);
|
||||
resetUploadState();
|
||||
form.setFieldsValue({ name: '', is_default: false, is_active: true });
|
||||
setUploadOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: HomeMaterialWatermark) => {
|
||||
setEditing(row);
|
||||
resetUploadState();
|
||||
setPreviewUrl(apiUrl(row.fileUrl));
|
||||
form.setFieldsValue({ name: row.name, is_default: row.isDefault, is_active: row.isActive });
|
||||
setUploadOpen(true);
|
||||
};
|
||||
|
||||
const closeUploadModal = () => {
|
||||
if (submitting) return;
|
||||
setUploadOpen(false);
|
||||
resetUploadState();
|
||||
};
|
||||
|
||||
const handleBeforeUpload = (selectedFile: File) => {
|
||||
if (!selectedFile.type.startsWith('image/')) {
|
||||
message.warning('请选择图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
const nextPreviewUrl = URL.createObjectURL(selectedFile);
|
||||
setFile(selectedFile);
|
||||
setPreviewUrl(nextPreviewUrl);
|
||||
setFileList([{ uid: selectedFile.name, name: selectedFile.name, status: 'done', originFileObj: selectedFile }]);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setFile(null);
|
||||
setFileList([]);
|
||||
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl('');
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return;
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (!editing && !file) {
|
||||
message.warning('请选择水印图片');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await updateHomeMaterialWatermark(editing.id, { name: values.name, is_default: values.is_default, is_active: values.is_active });
|
||||
} else if (file) {
|
||||
await uploadHomeMaterialWatermark(file, values.name, values.is_default);
|
||||
}
|
||||
|
||||
message.success('保存成功');
|
||||
setUploadOpen(false);
|
||||
resetUploadState();
|
||||
await load();
|
||||
onChanged?.();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const table = (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openUpload}>上传水印</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ pageSize: 20 }}
|
||||
columns={[
|
||||
{ title: '预览', dataIndex: 'fileUrl', render: (url: string) => <Image width={80} src={apiUrl(url)} /> },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '尺寸', render: (_: unknown, r: HomeMaterialWatermark) => r.width && r.height ? `${r.width}×${r.height}` : '-' },
|
||||
{ title: '默认', dataIndex: 'isDefault', render: (v: boolean) => v ? '是' : '否' },
|
||||
{ title: '启用', dataIndex: 'isActive', render: (v: boolean) => v ? '启用' : '禁用' },
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, row: HomeMaterialWatermark) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>编辑</Button>
|
||||
<Popconfirm title="确认删除该水印?" onConfirm={async () => { await deleteHomeMaterialWatermark(row.id); message.success('删除成功'); await load(); onChanged?.(); }}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑水印' : '上传水印'}
|
||||
open={uploadOpen}
|
||||
onOk={submit}
|
||||
onCancel={closeUploadModal}
|
||||
confirmLoading={submitting}
|
||||
okButtonProps={{ disabled: submitting || (!editing && !file) }}
|
||||
cancelButtonProps={{ disabled: submitting }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{!editing && (
|
||||
<Form.Item label="水印图片" required>
|
||||
<Upload
|
||||
beforeUpload={(f) => handleBeforeUpload(f as File)}
|
||||
onRemove={handleRemoveFile}
|
||||
fileList={fileList}
|
||||
maxCount={1}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={submitting}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={submitting} disabled={submitting}>选择图片</Button>
|
||||
</Upload>
|
||||
{previewUrl && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="水印预览"
|
||||
width={180}
|
||||
style={{ maxHeight: 120, objectFit: 'contain', background: '#0f172a', borderRadius: 8, padding: 8 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
{editing && previewUrl && (
|
||||
<Form.Item label="当前水印">
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="当前水印"
|
||||
width={180}
|
||||
style={{ maxHeight: 120, objectFit: 'contain', background: '#0f172a', borderRadius: 8, padding: 8 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="name" label="水印名称" rules={[{ required: true, message: '请输入水印名称' }]}><Input disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="is_default" label="默认水印" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) return table;
|
||||
return <Modal title="水印库" open={open} onCancel={onClose} footer={null} width={900} destroyOnHidden>{table}</Modal>;
|
||||
};
|
||||
|
||||
export default WatermarkLibraryModal;
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { previewHomeMaterialTextWatermark } from '../../api';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
import type { HomeMaterialMediaType, HomeMaterialTextWatermarkPayload, HomeMaterialWatermarkConfig } from '../../types';
|
||||
|
||||
interface WatermarkPreviewProps {
|
||||
mediaUrl?: string | null;
|
||||
mediaType?: HomeMaterialMediaType;
|
||||
watermarkUrl?: string | null;
|
||||
config: HomeMaterialWatermarkConfig;
|
||||
onChange?: (patch: Partial<HomeMaterialWatermarkConfig>) => void;
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const PREVIEW_MAX_HEIGHT = 360;
|
||||
const PREVIEW_MIN_HEIGHT = 280;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function calcStageSize(containerWidth: number, naturalSize: Size | null): Size {
|
||||
const maxWidth = Math.max(0, containerWidth);
|
||||
if (!maxWidth) return { width: 0, height: PREVIEW_MIN_HEIGHT };
|
||||
|
||||
if (!naturalSize?.width || !naturalSize?.height) {
|
||||
return { width: maxWidth, height: PREVIEW_MIN_HEIGHT };
|
||||
}
|
||||
|
||||
const scale = Math.min(maxWidth / naturalSize.width, PREVIEW_MAX_HEIGHT / naturalSize.height);
|
||||
return {
|
||||
width: Math.max(1, Math.round(naturalSize.width * scale)),
|
||||
height: Math.max(1, Math.round(naturalSize.height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function calcWatermarkWidth(config: HomeMaterialWatermarkConfig, stageWidth: number): number {
|
||||
if (config.sizeMode === 'px' && config.widthPx) {
|
||||
return clamp(config.widthPx, 1, Math.max(1, stageWidth));
|
||||
}
|
||||
const ratio = clamp(config.widthRatio ?? 0.18, 0.01, 1);
|
||||
return Math.max(1, stageWidth * ratio);
|
||||
}
|
||||
|
||||
function calcPresetPosition(config: HomeMaterialWatermarkConfig): React.CSSProperties {
|
||||
const marginX = config.marginX ?? 24;
|
||||
const marginY = config.marginY ?? 24;
|
||||
|
||||
switch (config.position) {
|
||||
case 'top_left':
|
||||
return { left: marginX, top: marginY };
|
||||
case 'top_center':
|
||||
return { left: '50%', top: marginY, transform: 'translateX(-50%)' };
|
||||
case 'top_right':
|
||||
return { right: marginX, top: marginY };
|
||||
case 'middle_left':
|
||||
return { left: marginX, top: '50%', transform: 'translateY(-50%)' };
|
||||
case 'center':
|
||||
return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
|
||||
case 'middle_right':
|
||||
return { right: marginX, top: '50%', transform: 'translateY(-50%)' };
|
||||
case 'bottom_left':
|
||||
return { left: marginX, bottom: marginY };
|
||||
case 'bottom_center':
|
||||
return { left: '50%', bottom: marginY, transform: 'translateX(-50%)' };
|
||||
case 'bottom_right':
|
||||
default:
|
||||
return { right: marginX, bottom: marginY };
|
||||
}
|
||||
}
|
||||
|
||||
function toTextPayload(config: HomeMaterialWatermarkConfig): HomeMaterialTextWatermarkPayload | null {
|
||||
const text = config.textWatermark;
|
||||
if (!text || !text.text?.trim()) return null;
|
||||
return {
|
||||
text: text.text.trim(),
|
||||
opacity_level: text.opacityLevel,
|
||||
font_size_px: text.fontSizePx,
|
||||
color: text.color,
|
||||
rotate_deg: text.rotateDeg,
|
||||
gap_x: text.gapX,
|
||||
gap_y: text.gapY,
|
||||
staggered: text.staggered,
|
||||
};
|
||||
}
|
||||
|
||||
const WatermarkPreview: React.FC<WatermarkPreviewProps> = ({
|
||||
mediaUrl,
|
||||
mediaType = 'image',
|
||||
watermarkUrl,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const outerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const watermarkRef = useRef<HTMLImageElement | null>(null);
|
||||
const dragOffsetRef = useRef({ x: 0, y: 0 });
|
||||
const previewSeqRef = useRef(0);
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [naturalSize, setNaturalSize] = useState<Size | null>(null);
|
||||
const [watermarkSize, setWatermarkSize] = useState<Size>({ width: 0, height: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [textPreviewLayer, setTextPreviewLayer] = useState<string>('');
|
||||
const [textPreviewLoading, setTextPreviewLoading] = useState(false);
|
||||
const [textPreviewError, setTextPreviewError] = useState<string>('');
|
||||
|
||||
const mediaSrc = useMemo(() => apiUrl(mediaUrl), [mediaUrl]);
|
||||
const watermarkSrc = useMemo(() => apiUrl(watermarkUrl), [watermarkUrl]);
|
||||
const stageSize = useMemo(() => calcStageSize(containerWidth, naturalSize), [containerWidth, naturalSize]);
|
||||
const watermarkWidth = useMemo(() => calcWatermarkWidth(config, stageSize.width), [config, stageSize.width]);
|
||||
const isRepeatedText = config.watermarkType === 'repeated_text';
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!outerRef.current) return;
|
||||
const update = () => setContainerWidth(outerRef.current?.clientWidth || 0);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(outerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!watermarkRef.current) return;
|
||||
const update = () => {
|
||||
const rect = watermarkRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
setWatermarkSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(watermarkRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [watermarkSrc, watermarkWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
setNaturalSize(null);
|
||||
setTextPreviewLayer('');
|
||||
setTextPreviewError('');
|
||||
}, [mediaSrc, mediaType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRepeatedText) {
|
||||
setTextPreviewLayer('');
|
||||
setTextPreviewLoading(false);
|
||||
setTextPreviewError('');
|
||||
return;
|
||||
}
|
||||
const textPayload = toTextPayload(config);
|
||||
if (!naturalSize?.width || !naturalSize.height || !textPayload) {
|
||||
setTextPreviewLayer('');
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++previewSeqRef.current;
|
||||
setTextPreviewLoading(true);
|
||||
setTextPreviewError('');
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await previewHomeMaterialTextWatermark({
|
||||
width: naturalSize.width,
|
||||
height: naturalSize.height,
|
||||
text_watermark: textPayload,
|
||||
});
|
||||
if (previewSeqRef.current === seq) {
|
||||
setTextPreviewLayer(res.previewLayerDataUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
if (previewSeqRef.current === seq) {
|
||||
setTextPreviewLayer('');
|
||||
setTextPreviewError(e instanceof Error ? e.message : '文字水印预览生成失败');
|
||||
}
|
||||
} finally {
|
||||
if (previewSeqRef.current === seq) setTextPreviewLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [config, isRepeatedText, naturalSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!dragging || !stageRef.current || !onChange || isRepeatedText) return;
|
||||
|
||||
const rect = stageRef.current.getBoundingClientRect();
|
||||
const wmWidth = watermarkSize.width || watermarkWidth;
|
||||
const wmHeight = watermarkSize.height || 1;
|
||||
const availableX = Math.max(1, rect.width - wmWidth);
|
||||
const availableY = Math.max(1, rect.height - wmHeight);
|
||||
const left = clamp(e.clientX - rect.left - dragOffsetRef.current.x, 0, availableX);
|
||||
const top = clamp(e.clientY - rect.top - dragOffsetRef.current.y, 0, availableY);
|
||||
|
||||
onChange({
|
||||
position: 'custom',
|
||||
customXRatio: Number((left / availableX).toFixed(4)),
|
||||
customYRatio: Number((top / availableY).toFixed(4)),
|
||||
});
|
||||
};
|
||||
const onUp = () => setDragging(false);
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
}, [dragging, isRepeatedText, onChange, watermarkSize.height, watermarkSize.width, watermarkWidth]);
|
||||
|
||||
const watermarkPositionStyle = useMemo<React.CSSProperties>(() => {
|
||||
if (config.position !== 'custom') return calcPresetPosition(config);
|
||||
|
||||
const wmWidth = watermarkSize.width || watermarkWidth;
|
||||
const wmHeight = watermarkSize.height || 1;
|
||||
const availableX = Math.max(0, stageSize.width - wmWidth);
|
||||
const availableY = Math.max(0, stageSize.height - wmHeight);
|
||||
const xRatio = clamp(config.customXRatio ?? 0.5, 0, 1);
|
||||
const yRatio = clamp(config.customYRatio ?? 0.5, 0, 1);
|
||||
|
||||
return {
|
||||
left: availableX * xRatio,
|
||||
top: availableY * yRatio,
|
||||
transform: 'none',
|
||||
};
|
||||
}, [config, stageSize.height, stageSize.width, watermarkSize.height, watermarkSize.width, watermarkWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: Math.max(PREVIEW_MIN_HEIGHT, stageSize.height),
|
||||
background: '#0f172a',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#94a3b8',
|
||||
}}
|
||||
>
|
||||
{mediaSrc ? (
|
||||
<div
|
||||
ref={stageRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: stageSize.width || '100%',
|
||||
height: stageSize.height || PREVIEW_MIN_HEIGHT,
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000',
|
||||
}}
|
||||
>
|
||||
{mediaType === 'video' ? (
|
||||
<video
|
||||
src={mediaSrc}
|
||||
controls
|
||||
onLoadedMetadata={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (target.videoWidth && target.videoHeight) {
|
||||
setNaturalSize({ width: target.videoWidth, height: target.videoHeight });
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={mediaSrc}
|
||||
alt="素材预览"
|
||||
onLoad={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (target.naturalWidth && target.naturalHeight) {
|
||||
setNaturalSize({ width: target.naturalWidth, height: target.naturalHeight });
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRepeatedText && textPreviewLayer && (
|
||||
<img
|
||||
src={textPreviewLayer}
|
||||
alt="重复文字水印预览"
|
||||
draggable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
zIndex: 2,
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRepeatedText && (textPreviewLoading || textPreviewError) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
bottom: 12,
|
||||
zIndex: 3,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(15, 23, 42, 0.82)',
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{textPreviewLoading ? <><Spin size="small" /> 正在生成精准预览...</> : textPreviewError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRepeatedText && watermarkSrc && (
|
||||
<img
|
||||
ref={watermarkRef}
|
||||
src={watermarkSrc}
|
||||
alt="水印预览"
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setWatermarkSize({ width: rect.width, height: rect.height });
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!onChange) return;
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
dragOffsetRef.current = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
setDragging(true);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
cursor: onChange ? 'move' : 'default',
|
||||
width: watermarkWidth,
|
||||
opacity: (config.opacityLevel ?? 6) / 10,
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
pointerEvents: onChange ? 'auto' : 'none',
|
||||
...watermarkPositionStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span>上传素材后可预览水印效果</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkPreview;
|
||||
+1108
-18
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
declare module 'xlsx-js-style' {
|
||||
export const utils: any;
|
||||
export function writeFile(workbook: any, filename: string, options?: any): void;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/** 安全复制文本到剪贴板,兼容非 HTTPS 环境 */
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
// 降级方案:使用 textarea + execCommand
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const succeeded = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return succeeded;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import * as XLSX from 'xlsx-js-style';
|
||||
|
||||
export type ExcelCellValue = string | number | boolean | Date | null | undefined;
|
||||
|
||||
export interface StyledExcelColumn<T = any> {
|
||||
title: string;
|
||||
key?: string;
|
||||
width?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
numFmt?: string;
|
||||
render: (row: T, rowIndex: number) => ExcelCellValue;
|
||||
}
|
||||
|
||||
export interface StyledExcelExportOptions<T = any> {
|
||||
filename: string;
|
||||
sheetName: string;
|
||||
title?: string;
|
||||
metadataRows?: ExcelCellValue[][];
|
||||
summaryRows?: ExcelCellValue[][];
|
||||
columns: StyledExcelColumn<T>[];
|
||||
rows: T[];
|
||||
}
|
||||
|
||||
const BLACK_BORDER = {
|
||||
top: { style: 'thin', color: { rgb: '000000' } },
|
||||
right: { style: 'thin', color: { rgb: '000000' } },
|
||||
bottom: { style: 'thin', color: { rgb: '000000' } },
|
||||
left: { style: 'thin', color: { rgb: '000000' } },
|
||||
};
|
||||
|
||||
const BASE_ALIGNMENT = {
|
||||
vertical: 'center',
|
||||
wrapText: true,
|
||||
};
|
||||
|
||||
const TITLE_STYLE = {
|
||||
font: { bold: true, sz: 16, color: { rgb: 'FFFFFF' } },
|
||||
fill: { fgColor: { rgb: '111827' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
const SECTION_LABEL_STYLE = {
|
||||
font: { bold: true, color: { rgb: '111827' } },
|
||||
fill: { fgColor: { rgb: 'F3F4F6' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
const SECTION_VALUE_STYLE = {
|
||||
font: { color: { rgb: '111827' } },
|
||||
fill: { fgColor: { rgb: 'FFFFFF' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'left' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
const HEADER_STYLE = {
|
||||
font: { bold: true, color: { rgb: 'FFFFFF' } },
|
||||
fill: { fgColor: { rgb: '374151' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'center' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
const BODY_STYLE = {
|
||||
font: { color: { rgb: '111827' } },
|
||||
fill: { fgColor: { rgb: 'FFFFFF' } },
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: 'left' },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
|
||||
function normalizeCellValue(value: ExcelCellValue): ExcelCellValue {
|
||||
if (value === null || value === undefined) return '';
|
||||
return value;
|
||||
}
|
||||
|
||||
function visualLength(value: ExcelCellValue): number {
|
||||
if (value === null || value === undefined) return 0;
|
||||
const text = value instanceof Date ? value.toISOString() : String(value);
|
||||
const lines = text.split(/\r?\n/);
|
||||
return Math.max(...lines.map((line) => Array.from(line).reduce((len, ch) => len + (ch.charCodeAt(0) > 255 ? 2 : 1), 0)), 0);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function estimateColumnWidths(rows: ExcelCellValue[][], columns: StyledExcelColumn[]): { wch: number }[] {
|
||||
const totalColumns = Math.max(columns.length, ...rows.map((row) => row.length));
|
||||
return Array.from({ length: totalColumns }, (_, index) => {
|
||||
const config = columns[index];
|
||||
if (config?.width) return { wch: config.width };
|
||||
|
||||
const maxLength = rows.reduce<number>((max, row) => Math.max(max, visualLength(row[index])), visualLength(config?.title));
|
||||
const minWidth = config?.minWidth ?? 10;
|
||||
const maxWidth = config?.maxWidth ?? 42;
|
||||
return { wch: clamp(Math.ceil(maxLength * 1.15) + 2, minWidth, maxWidth) };
|
||||
});
|
||||
}
|
||||
|
||||
function estimateRowHeight(row: ExcelCellValue[], colWidths: { wch: number }[], baseHeight = 20): number {
|
||||
const maxLines = row.reduce<number>((max, cell, index) => {
|
||||
const width = Math.max(8, colWidths[index]?.wch || 12);
|
||||
const text = cell === null || cell === undefined ? '' : String(cell);
|
||||
const explicitLines = text.split(/\r?\n/);
|
||||
const wrappedLines = explicitLines.reduce((sum, line) => sum + Math.max(1, Math.ceil(visualLength(line) / Math.max(8, width - 2))), 0);
|
||||
return Math.max(max, wrappedLines);
|
||||
}, 1);
|
||||
|
||||
return clamp(baseHeight + (maxLines - 1) * 16, baseHeight, 120);
|
||||
}
|
||||
|
||||
function applyCellStyle(cell: any, style: any, numFmt?: string): void {
|
||||
cell.s = {
|
||||
...style,
|
||||
alignment: { ...style.alignment },
|
||||
border: BLACK_BORDER,
|
||||
};
|
||||
if (numFmt) cell.z = numFmt;
|
||||
}
|
||||
|
||||
function isNumericCell(value: ExcelCellValue): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value);
|
||||
}
|
||||
|
||||
export function exportStyledExcel<T>(options: StyledExcelExportOptions<T>): void {
|
||||
const { filename, sheetName, title, metadataRows = [], summaryRows = [], columns, rows } = options;
|
||||
const totalColumns = Math.max(1, columns.length);
|
||||
const aoa: ExcelCellValue[][] = [];
|
||||
let titleRowIndex = -1;
|
||||
let headerRowIndex = -1;
|
||||
|
||||
if (title) {
|
||||
titleRowIndex = aoa.length;
|
||||
aoa.push([title, ...Array.from({ length: totalColumns - 1 }, () => '')]);
|
||||
}
|
||||
|
||||
metadataRows.forEach((row) => aoa.push(row.map(normalizeCellValue)));
|
||||
summaryRows.forEach((row) => aoa.push(row.map(normalizeCellValue)));
|
||||
|
||||
if (metadataRows.length || summaryRows.length) {
|
||||
aoa.push(Array.from({ length: totalColumns }, () => ''));
|
||||
}
|
||||
|
||||
headerRowIndex = aoa.length;
|
||||
aoa.push(columns.map((column) => column.title));
|
||||
rows.forEach((row, rowIndex) => {
|
||||
aoa.push(columns.map((column) => normalizeCellValue(column.render(row, rowIndex))));
|
||||
});
|
||||
|
||||
const worksheet = XLSX.utils.aoa_to_sheet(aoa);
|
||||
const range = XLSX.utils.decode_range(worksheet['!ref'] || 'A1:A1');
|
||||
const colWidths = estimateColumnWidths(aoa, columns);
|
||||
|
||||
worksheet['!cols'] = colWidths;
|
||||
worksheet['!rows'] = aoa.map((row, index) => {
|
||||
if (index === titleRowIndex) return { hpt: 34 };
|
||||
if (index === headerRowIndex) return { hpt: 28 };
|
||||
if (row.every((cell) => cell === '')) return { hpt: 10 };
|
||||
return { hpt: estimateRowHeight(row, colWidths) };
|
||||
});
|
||||
|
||||
if (titleRowIndex >= 0 && totalColumns > 1) {
|
||||
worksheet['!merges'] = worksheet['!merges'] || [];
|
||||
worksheet['!merges'].push({
|
||||
s: { r: titleRowIndex, c: 0 },
|
||||
e: { r: titleRowIndex, c: totalColumns - 1 },
|
||||
});
|
||||
}
|
||||
|
||||
worksheet['!autofilter'] = {
|
||||
ref: XLSX.utils.encode_range({
|
||||
s: { r: headerRowIndex, c: 0 },
|
||||
e: { r: Math.max(headerRowIndex, aoa.length - 1), c: totalColumns - 1 },
|
||||
}),
|
||||
};
|
||||
|
||||
for (let rowIndex = range.s.r; rowIndex <= range.e.r; rowIndex += 1) {
|
||||
for (let colIndex = range.s.c; colIndex <= range.e.c; colIndex += 1) {
|
||||
const address = XLSX.utils.encode_cell({ r: rowIndex, c: colIndex });
|
||||
const cell = worksheet[address] || { t: 's', v: '' };
|
||||
worksheet[address] = cell;
|
||||
|
||||
if (rowIndex === titleRowIndex) {
|
||||
applyCellStyle(cell, TITLE_STYLE);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rowIndex === headerRowIndex) {
|
||||
applyCellStyle(cell, HEADER_STYLE);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rowIndex < headerRowIndex) {
|
||||
applyCellStyle(cell, colIndex === 0 ? SECTION_LABEL_STYLE : SECTION_VALUE_STYLE, isNumericCell(cell.v) ? '#,##0.00' : undefined);
|
||||
continue;
|
||||
}
|
||||
|
||||
const column = columns[colIndex];
|
||||
const align = column?.align || (isNumericCell(cell.v) ? 'right' : 'left');
|
||||
applyCellStyle(cell, {
|
||||
...BODY_STYLE,
|
||||
alignment: { ...BASE_ALIGNMENT, horizontal: align },
|
||||
}, column?.numFmt || (isNumericCell(cell.v) ? '#,##0.00' : undefined));
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
|
||||
XLSX.writeFile(workbook, filename);
|
||||
}
|
||||
@@ -1,11 +1,58 @@
|
||||
const CST_OFFSET = 8 * 60; // CST = UTC+8, in minutes
|
||||
|
||||
export function formatDate(iso: string | null | undefined): string {
|
||||
if (!iso) return '-';
|
||||
let s = iso.trim();
|
||||
if (!s.includes('T')) s = s.replace(' ', 'T');
|
||||
// Truncate microseconds: 2026-05-13T15:04:04.313751 → 2026-05-13T15:04:04
|
||||
const dotIdx = s.indexOf('.');
|
||||
if (dotIdx > 0) s = s.slice(0, dotIdx);
|
||||
// Remove any trailing timezone info (backend now sends naive datetimes)
|
||||
s = s.replace(/[+-]\d{2}:?\d{0,2}$/, '').replace(/Z$/, '');
|
||||
return s.replace('T', ' ').slice(0, 16);
|
||||
const s = iso.trim();
|
||||
if (!s) return '-';
|
||||
|
||||
// Parse the ISO string, handling timezone offset
|
||||
// Match: 2026-05-13T15:04:04.313751+00:00 or 2026-05-13T15:04:04Z or 2026-05-13T15:04:04
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
|
||||
if (!m) return s.slice(0, 16).replace('T', ' ');
|
||||
|
||||
const [, year, month, day, hour, min, sec, tz] = m;
|
||||
// Build a Date in UTC
|
||||
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
|
||||
|
||||
if (tz && tz !== 'Z') {
|
||||
// Has explicit offset like +00:00 or +08:00 — already accounted for in the matched components
|
||||
// We parsed HH:MM:SS as-is, which are in the given offset.
|
||||
// Convert to UTC first by subtracting the offset
|
||||
const sign = tz[0] === '+' ? 1 : -1;
|
||||
const [oh, om] = tz.slice(1).split(':');
|
||||
const offsetMin = sign * (+oh * 60 + +om);
|
||||
const localMs = utcMs - offsetMin * 60000 + CST_OFFSET * 60000;
|
||||
const d = new Date(localMs);
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
// No tz or Z: if Z it's UTC, if no tz it's naive (assume CST from backend)
|
||||
const isUTC = tz === 'Z';
|
||||
const localMs = isUTC ? utcMs + CST_OFFSET * 60000 : utcMs;
|
||||
const d = new Date(localMs);
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
function pad(n: number): string {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
export function formatDatePrecise(iso: string | null | undefined): string {
|
||||
if (!iso) return '-';
|
||||
const fraction = iso.match(/\.(\d{1,6})/)?.[1]?.padEnd(6, '0') || '000000';
|
||||
const s = iso.trim();
|
||||
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
|
||||
if (!m) return s.replace('T', ' ');
|
||||
const [, year, month, day, hour, min, sec, tz] = m;
|
||||
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
|
||||
let target = utcMs;
|
||||
if (tz === 'Z') target += CST_OFFSET * 60000;
|
||||
else if (tz) {
|
||||
const sign = tz[0] === '+' ? 1 : -1;
|
||||
const compact = tz.slice(1).replace(':', '');
|
||||
const offset = sign * (+compact.slice(0, 2) * 60 + +compact.slice(2, 4));
|
||||
target = utcMs - offset * 60000 + CST_OFFSET * 60000;
|
||||
}
|
||||
const d = new Date(target);
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}.${fraction}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
export interface GenerationStatusLike {
|
||||
status?: string | null;
|
||||
displayStatus?: string | null;
|
||||
pipelineStage?: string | null;
|
||||
shouldPoll?: boolean | null;
|
||||
}
|
||||
|
||||
export type GenerationUiColor = 'default' | 'processing' | 'warning' | 'success' | 'error' | 'blue' | 'orange' | 'purple';
|
||||
|
||||
export interface GenerationUiState {
|
||||
status: string;
|
||||
displayStatus: string;
|
||||
pipelineStage: string;
|
||||
effectiveKey: string;
|
||||
label: string;
|
||||
color: GenerationUiColor;
|
||||
isActive: boolean;
|
||||
isSuccess: boolean;
|
||||
isFailure: boolean;
|
||||
isTerminal: boolean;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUS_KEYS = new Set(['pending', 'optimizing', 'generating']);
|
||||
const ACTIVE_PIPELINE_STAGES = new Set([
|
||||
'queued', 'preparing', 'creating_provider_task', 'provider_result_staged',
|
||||
'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading',
|
||||
'retry_waiting', 'recovery_inconsistent', 'upscale_queued', 'upscale_processing',
|
||||
'upscale_polling', 'upscale_downloading', 'upscale_finalizing', 'upscale_retry_waiting',
|
||||
]);
|
||||
const SUCCESS_KEYS = new Set(['completed', 'done']);
|
||||
const FAILURE_KEYS = new Set(['failed', 'timeout', 'download_failed', 'upscale_failed']);
|
||||
const TERMINAL_KEYS = new Set([...SUCCESS_KEYS, ...FAILURE_KEYS, 'deleted']);
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
pending: '待处理', optimizing: '提词处理中', prompt_optimized: '待生成', generating: '生成中',
|
||||
queued: '已入队', preparing: '准备中', creating_provider_task: '创建供应商任务',
|
||||
provider_result_staged: '供应商结果已暂存', waiting_remote: '等待供应商结果', polling: '轮询供应商结果',
|
||||
result_ready: '远程结果已就绪', download_queued: '下载已入队', downloading: '下载中',
|
||||
retry_waiting: '下载等待重试', recovery_inconsistent: '恢复证据异常', upscale_queued: '超分已入队',
|
||||
upscale_processing: '本地超分处理中', upscale_polling: '轮询远程超分',
|
||||
upscale_downloading: '下载超分结果', upscale_finalizing: '超分结果最终化',
|
||||
upscale_retry_waiting: '超分等待重试', completed: '已完成', done: '已完成', timeout: '任务超时',
|
||||
download_failed: '下载失败', upscale_failed: '超分失败', failed: '失败', deleted: '已删除',
|
||||
};
|
||||
|
||||
const COLOR_MAP: Record<string, GenerationUiColor> = {
|
||||
pending: 'default', optimizing: 'processing', prompt_optimized: 'blue', generating: 'warning',
|
||||
queued: 'processing', preparing: 'processing', creating_provider_task: 'processing',
|
||||
provider_result_staged: 'processing', waiting_remote: 'processing', polling: 'processing',
|
||||
result_ready: 'processing', download_queued: 'processing', downloading: 'processing', retry_waiting: 'orange',
|
||||
recovery_inconsistent: 'orange', upscale_queued: 'purple', upscale_processing: 'purple',
|
||||
upscale_polling: 'purple', upscale_downloading: 'purple', upscale_finalizing: 'purple',
|
||||
upscale_retry_waiting: 'orange', completed: 'success', done: 'success', failed: 'error',
|
||||
timeout: 'error', download_failed: 'error', upscale_failed: 'error', deleted: 'default',
|
||||
};
|
||||
|
||||
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
|
||||
const firstMatching = (values: string[], keys: Set<string>): string => values.find((item) => keys.has(item)) || '';
|
||||
|
||||
export const getGenerationStageLabel = (key?: string | null): string => {
|
||||
const normalized = normalize(key);
|
||||
return LABELS[normalized] || normalized || '未知状态';
|
||||
};
|
||||
|
||||
export const getGenerationStatusColor = (key?: string | null): GenerationUiColor => {
|
||||
const normalized = normalize(key);
|
||||
return COLOR_MAP[normalized] || 'default';
|
||||
};
|
||||
|
||||
export const resolveGenerationUiState = (value: GenerationStatusLike): GenerationUiState => {
|
||||
const status = normalize(value.status);
|
||||
const displayStatus = normalize(value.displayStatus);
|
||||
const pipelineStage = normalize(value.pipelineStage);
|
||||
const values = [pipelineStage, displayStatus, status].filter(Boolean);
|
||||
const failureKey = firstMatching(values, FAILURE_KEYS);
|
||||
const successKey = firstMatching(values, SUCCESS_KEYS);
|
||||
const deletedKey = firstMatching(values, new Set(['deleted']));
|
||||
const effectiveKey = failureKey || deletedKey || successKey || pipelineStage || displayStatus || status || 'pending';
|
||||
const isFailure = FAILURE_KEYS.has(effectiveKey);
|
||||
const isSuccess = SUCCESS_KEYS.has(effectiveKey);
|
||||
const isActive = typeof value.shouldPoll === 'boolean'
|
||||
? value.shouldPoll
|
||||
: (!isFailure && !isSuccess && effectiveKey !== 'deleted' && (ACTIVE_PIPELINE_STAGES.has(pipelineStage) || ACTIVE_STATUS_KEYS.has(status) || ACTIVE_STATUS_KEYS.has(displayStatus)));
|
||||
|
||||
return {
|
||||
status, displayStatus, pipelineStage, effectiveKey,
|
||||
label: getGenerationStageLabel(effectiveKey),
|
||||
color: getGenerationStatusColor(effectiveKey),
|
||||
isActive, isSuccess, isFailure,
|
||||
isTerminal: TERMINAL_KEYS.has(effectiveKey),
|
||||
};
|
||||
};
|
||||
|
||||
export const isGenerationActive = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isActive;
|
||||
export const isGenerationSuccess = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isSuccess;
|
||||
export const isGenerationFailure = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isFailure;
|
||||
export const isGenerationTerminal = (value: GenerationStatusLike): boolean => resolveGenerationUiState(value).isTerminal;
|
||||
@@ -0,0 +1,47 @@
|
||||
const RAW_API_BASE = String(import.meta.env.VITE_API_BASE || 'http://localhost:8000')
|
||||
.trim()
|
||||
.replace(/\/$/, '');
|
||||
|
||||
export const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||
|
||||
export const isAbsoluteLikeUrl = (url: string): boolean => (
|
||||
/^(https?:)?\/\//i.test(url) || /^(blob|data):/i.test(url)
|
||||
);
|
||||
|
||||
export const isBlobUrl = (url?: string | null): boolean => !!url && /^blob:/i.test(url.trim());
|
||||
|
||||
export const apiUrl = (url?: string | null): string => {
|
||||
if (!url) return '';
|
||||
const value = String(url).trim();
|
||||
if (!value) return '';
|
||||
if (isAbsoluteLikeUrl(value)) return value;
|
||||
|
||||
if (!RESOURCE_BASE) {
|
||||
return value.startsWith('/') ? value : `/${value}`;
|
||||
}
|
||||
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
|
||||
};
|
||||
|
||||
export const isUrlExpired = (url?: string | null): boolean => {
|
||||
if (!url || isBlobUrl(url)) return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(apiUrl(url), window.location.origin);
|
||||
const expireValue =
|
||||
parsed.searchParams.get('exp') ||
|
||||
parsed.searchParams.get('expires') ||
|
||||
parsed.searchParams.get('expire') ||
|
||||
parsed.searchParams.get('expires_at') ||
|
||||
parsed.searchParams.get('x-expires');
|
||||
|
||||
if (!expireValue) return false;
|
||||
|
||||
const expireNumber = Number(expireValue);
|
||||
if (!Number.isFinite(expireNumber)) return false;
|
||||
|
||||
const expireMs = expireNumber > 10_000_000_000 ? expireNumber : expireNumber * 1000;
|
||||
return Date.now() >= expireMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
export interface ShotStatusMeta {
|
||||
key: string;
|
||||
text: string;
|
||||
color: string;
|
||||
active: boolean;
|
||||
terminal: boolean;
|
||||
}
|
||||
|
||||
type ShotStatusMap = Record<string, Omit<ShotStatusMeta, 'key'>>;
|
||||
|
||||
const TASK_STATUS_MAP: ShotStatusMap = {
|
||||
pending_analysis: { text: '等待分析', color: 'default', active: true, terminal: false },
|
||||
analyzing: { text: '分析中', color: 'processing', active: true, terminal: false },
|
||||
analysis_completed: { text: '分析完成', color: 'blue', active: false, terminal: false },
|
||||
analysis_failed: { text: '分析失败', color: 'error', active: false, terminal: true },
|
||||
splitting: { text: '拆镜中', color: 'processing', active: true, terminal: false },
|
||||
split_completed: { text: '拆镜完成', color: 'success', active: false, terminal: true },
|
||||
partial_failed: { text: '部分失败', color: 'warning', active: false, terminal: true },
|
||||
failed: { text: '失败', color: 'error', active: false, terminal: true },
|
||||
deleted: { text: '已删除', color: 'default', active: false, terminal: true },
|
||||
};
|
||||
|
||||
const ANALYSIS_STATUS_MAP: ShotStatusMap = {
|
||||
not_required: { text: '无需分析', color: 'default', active: false, terminal: true },
|
||||
pending: { text: '等待分析', color: 'default', active: true, terminal: false },
|
||||
processing: { text: '分析中', color: 'processing', active: true, terminal: false },
|
||||
completed: { text: '分析完成', color: 'success', active: false, terminal: true },
|
||||
failed: { text: '分析失败', color: 'error', active: false, terminal: true },
|
||||
};
|
||||
|
||||
const SPLIT_STATUS_MAP: ShotStatusMap = {
|
||||
none: { text: '未拆镜', color: 'default', active: false, terminal: true },
|
||||
pending: { text: '等待拆镜', color: 'default', active: true, terminal: false },
|
||||
processing: { text: '拆镜中', color: 'processing', active: true, terminal: false },
|
||||
retry_waiting: { text: '等待拆镜重试', color: 'orange', active: true, terminal: false },
|
||||
completed: { text: '拆镜完成', color: 'success', active: false, terminal: true },
|
||||
failed: { text: '拆镜失败', color: 'error', active: false, terminal: true },
|
||||
};
|
||||
|
||||
const REPLICATE_STATUS_MAP: ShotStatusMap = {
|
||||
not_started: { text: '未复刻', color: 'default', active: false, terminal: true },
|
||||
project_created: { text: '已创建项目', color: 'processing', active: true, terminal: false },
|
||||
pending: { text: '等待复刻', color: 'default', active: true, terminal: false },
|
||||
waiting_user: { text: '等待用户操作', color: 'processing', active: true, terminal: false },
|
||||
processing: { text: '复刻中', color: 'processing', active: true, terminal: false },
|
||||
completed: { text: '复刻完成', color: 'success', active: false, terminal: true },
|
||||
failed: { text: '复刻失败', color: 'error', active: false, terminal: true },
|
||||
cancelled: { text: '已取消', color: 'default', active: false, terminal: true },
|
||||
canceled: { text: '已取消', color: 'default', active: false, terminal: true },
|
||||
};
|
||||
|
||||
const normalize = (value?: string | null): string => String(value || '').trim().toLowerCase();
|
||||
|
||||
const resolveMeta = (status: string | null | undefined, map: ShotStatusMap, fallback = '未知状态'): ShotStatusMeta => {
|
||||
const key = normalize(status);
|
||||
return {
|
||||
key,
|
||||
...(map[key] || { text: key || fallback, color: 'default', active: false, terminal: false }),
|
||||
};
|
||||
};
|
||||
|
||||
export const getShotTaskStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, TASK_STATUS_MAP);
|
||||
export const getShotAnalysisStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, ANALYSIS_STATUS_MAP);
|
||||
export const getShotSplitStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, SPLIT_STATUS_MAP);
|
||||
export const getShotReplicateStatusMeta = (status?: string | null): ShotStatusMeta => resolveMeta(status, REPLICATE_STATUS_MAP);
|
||||
|
||||
export const getShotStatusMeta = (status?: string | null): ShotStatusMeta => {
|
||||
const key = normalize(status);
|
||||
return getShotTaskStatusMeta(key).text !== key
|
||||
? getShotTaskStatusMeta(key)
|
||||
: (ANALYSIS_STATUS_MAP[key]
|
||||
? getShotAnalysisStatusMeta(key)
|
||||
: (SPLIT_STATUS_MAP[key]
|
||||
? getShotSplitStatusMeta(key)
|
||||
: getShotReplicateStatusMeta(key)));
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
export type JsonValue = any;
|
||||
export type JsonRecord = Record<string, JsonValue>;
|
||||
|
||||
export interface VideoPromptSchemaFieldConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
enabled: boolean;
|
||||
editable: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
export interface VideoPromptSchemaSectionConfig extends VideoPromptSchemaFieldConfig {
|
||||
contentKey?: string;
|
||||
itemFields: VideoPromptSchemaFieldConfig[];
|
||||
children: VideoPromptSchemaFieldConfig[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(source: JsonRecord, ...keys: string[]): string {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function readBool(source: JsonRecord, defaultValue: boolean, ...keys: string[]): boolean {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (value === true || value === false) return value;
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
function readNumber(source: JsonRecord, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = source[key];
|
||||
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeField(raw: unknown): VideoPromptSchemaFieldConfig | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const key = readString(raw, 'key');
|
||||
if (!key) return null;
|
||||
return {
|
||||
key,
|
||||
label: readString(raw, 'label') || key,
|
||||
type: readString(raw, 'type') || undefined,
|
||||
enabled: readBool(raw, true, 'enabled'),
|
||||
editable: readBool(raw, true, 'editable'),
|
||||
maxLength: readNumber(raw, 'maxLength', 'max_length'),
|
||||
};
|
||||
}
|
||||
|
||||
export function getSchemaConfigData(snapshot: unknown): JsonRecord | null {
|
||||
if (!isRecord(snapshot)) return null;
|
||||
const data = snapshot.data;
|
||||
if (isRecord(data)) return data;
|
||||
if (Array.isArray(snapshot.sections)) return snapshot;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasSchemaConfigSnapshot(snapshot: unknown): boolean {
|
||||
return !!getSchemaConfigData(snapshot);
|
||||
}
|
||||
|
||||
export function normalizeSchemaSections(snapshot: unknown): VideoPromptSchemaSectionConfig[] {
|
||||
const data = getSchemaConfigData(snapshot);
|
||||
const rawSections = Array.isArray(data?.sections) ? data.sections : [];
|
||||
return rawSections
|
||||
.map((raw): VideoPromptSchemaSectionConfig | null => {
|
||||
const base = normalizeField(raw);
|
||||
if (!base || !isRecord(raw)) return null;
|
||||
const children = Array.isArray(raw.children) ? raw.children.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[] : [];
|
||||
const itemFields = Array.isArray(raw.itemFields)
|
||||
? raw.itemFields.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[]
|
||||
: Array.isArray(raw.item_fields)
|
||||
? raw.item_fields.map(normalizeField).filter(Boolean) as VideoPromptSchemaFieldConfig[]
|
||||
: [];
|
||||
return {
|
||||
...base,
|
||||
contentKey: readString(raw, 'contentKey', 'content_key') || undefined,
|
||||
children,
|
||||
itemFields,
|
||||
};
|
||||
})
|
||||
.filter((section): section is VideoPromptSchemaSectionConfig => !!section && section.enabled);
|
||||
}
|
||||
|
||||
export function inferSchemaSections(schema: unknown): VideoPromptSchemaSectionConfig[] {
|
||||
if (!isRecord(schema)) return [];
|
||||
return Object.entries(schema)
|
||||
.filter(([key]) => !['schemaVersion', 'schemaUsage', 'schema_version', 'schema_usage'].includes(key))
|
||||
.map(([key, value]) => {
|
||||
if (Array.isArray(value) && value.every(isRecord)) {
|
||||
const fields = Array.from(new Set(value.flatMap((item) => Object.keys(item))));
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'flow',
|
||||
enabled: true,
|
||||
editable: false,
|
||||
itemFields: fields.filter((field) => field !== '时间段').map((field) => ({ key: field, label: field, enabled: true, editable: false })),
|
||||
children: [],
|
||||
} as VideoPromptSchemaSectionConfig;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'object',
|
||||
enabled: true,
|
||||
editable: false,
|
||||
children: Object.keys(value).map((field) => ({ key: field, label: field, enabled: true, editable: false })),
|
||||
itemFields: [],
|
||||
} as VideoPromptSchemaSectionConfig;
|
||||
}
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
type: 'text',
|
||||
enabled: true,
|
||||
editable: false,
|
||||
children: [],
|
||||
itemFields: [],
|
||||
} as VideoPromptSchemaSectionConfig;
|
||||
});
|
||||
}
|
||||
|
||||
export function stringifySchemaValue(value: JsonValue): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/store/index.ts","./src/types/index.ts","./src/utils/formatdate.ts"],"version":"6.0.3"}
|
||||
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/quotaadjustmodal.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminapikeys.tsx","./src/pages/adminapimodelpricings.tsx","./src/pages/adminapiusage.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminbanktransactions.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditproducts.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/admininvoices.tsx","./src/pages/adminlayout.tsx","./src/pages/adminllmbillingexecutions.tsx","./src/pages/adminllmbillingpolicies.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminscheduledtasks.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/generationtaskstatus.ts","./src/utils/resourceurl.ts","./src/utils/shotreplicatestatus.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||
+11
-1
@@ -6,9 +6,10 @@ SECRET_KEY=local-dev-secret-key-not-for-production
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL=postgresql+asyncpg://videogen:7k33pnXdPL62Yyb4@180.184.42.66:5432/videogen
|
||||
#DATABASE_URL=postgresql+asyncpg://postgres:123456@localhost:5432/videogen_cs
|
||||
|
||||
# Redis (leave empty to disable - rate limiting and captcha will use in-memory fallback)
|
||||
REDIS_URL=redis://localhost:6379/3
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_BROKER_URL=redis://localhost:6379/5
|
||||
CELERY_RESULT_BACKEND=redis://localhost:6379/6
|
||||
|
||||
@@ -42,6 +43,10 @@ CAPTCHA_ENABLED=true
|
||||
# CORS
|
||||
CORS_ORIGINS=["*"]
|
||||
|
||||
# Base URL (用于 favicon、回调地址等)
|
||||
#BASE_URL=http://localhost:8000
|
||||
BASE_URL=https://ceshi.apiforeign.minzhongzc.com
|
||||
|
||||
# RESOURCE
|
||||
RESOURCE_SIGN_SECRET=EOTpDZsEgkaYWPxgtIedOO0lDlH1moTS2rnSIemjzmO3
|
||||
RESOURCE_SIGN_EXPIRE_SECONDS=86400
|
||||
@@ -56,6 +61,11 @@ VIDEO_COVER_WIDTH=600
|
||||
VIDEO_COVER_TIMEOUT_SECONDS=15
|
||||
VIDEO_COVER_FORMAT=png
|
||||
|
||||
# VOLC
|
||||
VOLC_API_KEY=AKLTOWMwMjVhNzg0OGE2NDMwZWJkYWIyNzM3ZmMxMjc5NTQ
|
||||
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||
|
||||
# VOLC_SMS
|
||||
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||
VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||
|
||||
@@ -39,3 +39,6 @@ CAPTCHA_ENABLED=true
|
||||
|
||||
# CORS
|
||||
CORS_ORIGINS=["http://localhost:5173", "http://localhost:3000"]
|
||||
|
||||
# Base URL (用于 favicon、回调地址等)
|
||||
BASE_URL=https://your-domain.com
|
||||
|
||||
@@ -40,6 +40,8 @@ from app.models.material_cost import MaterialCost # noqa: F401
|
||||
from app.models.generated_resource import GeneratedResource # noqa: F401
|
||||
from app.models.pre_test_template import PreTestTemplate # noqa: F401
|
||||
from app.models.upload_task import UploadTask # noqa: F401
|
||||
from app.models.open_type import OpenType # noqa: F401
|
||||
|
||||
|
||||
config = context.config
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""add video engine max audio count
|
||||
|
||||
Revision ID: 054735900c23
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-07-03 11:23:07.721724
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '054735900c23'
|
||||
down_revision: Union[str, None] = 'd4e5f6a7b8c9'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('video_engines', sa.Column('max_audio_count', sa.Integer(), server_default='0', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('video_engines', 'max_audio_count')
|
||||
# ### end Alembic commands ###
|
||||
+935
@@ -0,0 +1,935 @@
|
||||
"""add credit record billing snapshots and frontend user kind
|
||||
|
||||
Revision ID: 0a8da2d3c091
|
||||
Revises: 5e2c124e5484
|
||||
Create Date: 2026-06-25 13:25:21.246025
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0a8da2d3c091"
|
||||
down_revision: Union[str, None] = "5e2c124e5484"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1) 表结构变更。
|
||||
# 注意:autogenerate 生成的 upload_task.other_info 删除语句与本次需求无关,已移除。
|
||||
op.add_column("users", sa.Column("frontend_user_kind", sa.String(length=16), server_default="external", nullable=False))
|
||||
|
||||
op.add_column("credit_records", sa.Column("owner_type", sa.String(length=64), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("owner_id", sa.String(length=64), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("attempt_no", sa.Integer(), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("charge_kind", sa.String(length=32), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("charge_action", sa.String(length=16), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("credit_subject", sa.String(length=32), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("media_type", sa.String(length=16), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("billing_scene", sa.String(length=64), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("source_module", sa.String(length=64), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("source_project_id", sa.String(length=64), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("source_step_id", sa.String(length=64), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("source_step_code", sa.String(length=64), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("token_usage_id", sa.String(length=32), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("input_tokens", sa.Integer(), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("output_tokens", sa.Integer(), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("total_tokens", sa.Integer(), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("engine_type", sa.String(length=16), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("engine_id", sa.String(length=32), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("engine_name", sa.String(length=128), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("engine_provider", sa.String(length=64), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("engine_model_name", sa.String(length=128), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("user_type_snapshot", sa.String(length=16), nullable=True))
|
||||
op.add_column("credit_records", sa.Column("frontend_user_kind_snapshot", sa.String(length=16), nullable=True))
|
||||
|
||||
op.add_column("module_generation_steps", sa.Column("token_usage_id", sa.String(length=32), nullable=True))
|
||||
op.add_column("module_generation_steps", sa.Column("model_config_id", sa.String(length=32), nullable=True))
|
||||
op.add_column("module_generation_steps", sa.Column("input_tokens", sa.Integer(), nullable=True))
|
||||
op.add_column("module_generation_steps", sa.Column("output_tokens", sa.Integer(), nullable=True))
|
||||
op.add_column("module_generation_steps", sa.Column("total_tokens", sa.Integer(), nullable=True))
|
||||
op.add_column("module_generation_steps", sa.Column("text_credits_cost", sa.Float(), nullable=True))
|
||||
|
||||
op.add_column("token_usage", sa.Column("owner_type", sa.String(length=64), nullable=True))
|
||||
op.add_column("token_usage", sa.Column("owner_id", sa.String(length=64), nullable=True))
|
||||
op.add_column("token_usage", sa.Column("biz_key", sa.String(length=160), nullable=True))
|
||||
op.add_column("token_usage", sa.Column("source_module", sa.String(length=64), nullable=True))
|
||||
op.add_column("token_usage", sa.Column("source_step_code", sa.String(length=64), nullable=True))
|
||||
|
||||
# 2) 历史数据补齐。
|
||||
_backfill_legacy_data()
|
||||
|
||||
# 3) 索引。放在历史补齐之后,避免大批量 UPDATE 时维护过多新索引。
|
||||
op.create_index(op.f("ix_users_frontend_user_kind"), "users", ["frontend_user_kind"], unique=False)
|
||||
op.create_index(op.f("ix_users_user_type"), "users", ["user_type"], unique=False)
|
||||
|
||||
op.create_index(op.f("ix_credit_records_billing_scene"), "credit_records", ["billing_scene"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_charge_kind"), "credit_records", ["charge_kind"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_credit_subject"), "credit_records", ["credit_subject"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_engine_id"), "credit_records", ["engine_id"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_engine_type"), "credit_records", ["engine_type"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_frontend_user_kind_snapshot"), "credit_records", ["frontend_user_kind_snapshot"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_media_type"), "credit_records", ["media_type"], unique=False)
|
||||
op.create_index("ix_credit_records_owner", "credit_records", ["owner_type", "owner_id"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_owner_id"), "credit_records", ["owner_id"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_owner_type"), "credit_records", ["owner_type"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_source_module"), "credit_records", ["source_module"], unique=False)
|
||||
op.create_index("ix_credit_records_source_module_scene", "credit_records", ["source_module", "billing_scene"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_source_project_id"), "credit_records", ["source_project_id"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_source_step_code"), "credit_records", ["source_step_code"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_source_step_id"), "credit_records", ["source_step_id"], unique=False)
|
||||
op.create_index("ix_credit_records_subject_media", "credit_records", ["credit_subject", "media_type"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_token_usage_id"), "credit_records", ["token_usage_id"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_type"), "credit_records", ["type"], unique=False)
|
||||
op.create_index("ix_credit_records_user_kind_time", "credit_records", ["user_type_snapshot", "frontend_user_kind_snapshot", "created_at"], unique=False)
|
||||
op.create_index(op.f("ix_credit_records_user_type_snapshot"), "credit_records", ["user_type_snapshot"], unique=False)
|
||||
|
||||
op.create_index(op.f("ix_module_generation_steps_model_config_id"), "module_generation_steps", ["model_config_id"], unique=False)
|
||||
op.create_index(op.f("ix_module_generation_steps_token_usage_id"), "module_generation_steps", ["token_usage_id"], unique=False)
|
||||
|
||||
op.create_index("ix_token_usage_biz_key", "token_usage", ["biz_key"], unique=False)
|
||||
op.create_index("ix_token_usage_owner", "token_usage", ["owner_type", "owner_id"], unique=False)
|
||||
op.create_index(op.f("ix_token_usage_owner_id"), "token_usage", ["owner_id"], unique=False)
|
||||
op.create_index(op.f("ix_token_usage_owner_type"), "token_usage", ["owner_type"], unique=False)
|
||||
op.create_index(op.f("ix_token_usage_source_module"), "token_usage", ["source_module"], unique=False)
|
||||
op.create_index(op.f("ix_token_usage_source_step_code"), "token_usage", ["source_step_code"], unique=False)
|
||||
|
||||
|
||||
def _backfill_legacy_data() -> None:
|
||||
"""修复历史账务流水的归类、模块、步骤、token、模型和引擎快照。
|
||||
|
||||
设计原则:
|
||||
- 只补能确定的数据,不用“同用户 + 时间接近 + token 相等”这类不可靠方式强行匹配。
|
||||
- ModuleGenerationStep 包含 deleted_at 不为空的软删数据,保证历史流水仍可展示来源。
|
||||
- CreditRecord 是账务事实表,历史业务数据软删不影响流水展示。
|
||||
- CreditRecord 统一使用 engine_* 承载执行配置快照:model/image/video。
|
||||
"""
|
||||
|
||||
# users.frontend_user_kind 默认外部用户。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET frontend_user_kind = 'external'
|
||||
WHERE frontend_user_kind IS NULL OR frontend_user_kind NOT IN ('internal', 'external')
|
||||
"""
|
||||
)
|
||||
|
||||
# 用户类型快照:按迁移时 users 当前值兜底。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
user_type_snapshot = COALESCE(NULLIF(u.user_type, ''), CASE WHEN COALESCE(u.is_admin, false) THEN 'admin' ELSE 'frontend' END),
|
||||
frontend_user_kind_snapshot = CASE
|
||||
WHEN COALESCE(NULLIF(u.user_type, ''), CASE WHEN COALESCE(u.is_admin, false) THEN 'admin' ELSE 'frontend' END) = 'frontend'
|
||||
THEN COALESCE(NULLIF(u.frontend_user_kind, ''), 'external')
|
||||
ELSE COALESCE(NULLIF(u.frontend_user_kind, ''), 'external')
|
||||
END
|
||||
FROM users u
|
||||
WHERE cr.user_id = u.id
|
||||
"""
|
||||
)
|
||||
|
||||
# 解析标准 biz_key:owner_type:owner_id:attempt:{n}:charge_kind:charge/refund。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records
|
||||
SET
|
||||
owner_type = split_part(biz_key, ':', 1),
|
||||
owner_id = split_part(biz_key, ':', 2),
|
||||
attempt_no = split_part(biz_key, ':', 4)::integer,
|
||||
charge_kind = split_part(biz_key, ':', 5),
|
||||
charge_action = split_part(biz_key, ':', 6)
|
||||
WHERE biz_key IS NOT NULL
|
||||
AND biz_key ~ '^[^:]+:[^:]+:attempt:[0-9]+:[^:]+:(charge|refund)$'
|
||||
"""
|
||||
)
|
||||
|
||||
# 少数退款记录如果自身 biz_key 不完整,用 refund_for_biz_key 兜底解析业务归属。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records
|
||||
SET
|
||||
owner_type = split_part(refund_for_biz_key, ':', 1),
|
||||
owner_id = split_part(refund_for_biz_key, ':', 2),
|
||||
attempt_no = split_part(refund_for_biz_key, ':', 4)::integer,
|
||||
charge_kind = split_part(refund_for_biz_key, ':', 5),
|
||||
charge_action = 'refund'
|
||||
WHERE type = 'refund'
|
||||
AND (owner_type IS NULL OR owner_type = 'unknown' OR owner_id IS NULL)
|
||||
AND refund_for_biz_key IS NOT NULL
|
||||
AND refund_for_biz_key ~ '^[^:]+:[^:]+:attempt:[0-9]+:[^:]+:(charge|refund)$'
|
||||
"""
|
||||
)
|
||||
|
||||
# 支付充值流水。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'payment_order'),
|
||||
owner_id = COALESCE(cr.owner_id, po.id),
|
||||
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'recharge'),
|
||||
charge_action = COALESCE(cr.charge_action, 'charge'),
|
||||
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'recharge'),
|
||||
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'recharge'),
|
||||
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'payment')
|
||||
FROM payment_orders po
|
||||
WHERE cr.related_id = po.id
|
||||
AND cr.type = 'recharge'
|
||||
AND (cr.description IS NULL OR cr.description NOT LIKE '管理员调整:%')
|
||||
"""
|
||||
)
|
||||
|
||||
# 支付退款扣回积分。历史上它通常是 type=consume,但属于支付退款,不应该混入生成消费。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'payment_order'),
|
||||
owner_id = COALESCE(cr.owner_id, po.id),
|
||||
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'refund'),
|
||||
charge_action = COALESCE(cr.charge_action, 'refund'),
|
||||
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'refund'),
|
||||
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'refund'),
|
||||
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'payment')
|
||||
FROM payment_orders po
|
||||
WHERE cr.related_id = po.id
|
||||
AND cr.type = 'consume'
|
||||
AND (cr.description ILIKE '%退款%' OR po.status = 'refunded')
|
||||
"""
|
||||
)
|
||||
|
||||
# 管理员手动调整,不混入普通充值/消费。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'admin_adjust'),
|
||||
owner_id = COALESCE(cr.owner_id, cr.related_id),
|
||||
charge_kind = 'admin_adjust',
|
||||
charge_action = CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END,
|
||||
credit_subject = 'admin_adjust',
|
||||
billing_scene = 'admin_adjust',
|
||||
source_module = 'admin'
|
||||
WHERE cr.description LIKE '管理员调整:%'
|
||||
"""
|
||||
)
|
||||
|
||||
# GenerationRecord 旧版提示词优化裸扣费:generation.py /optimize 历史上未传 related_id / biz_key / record_meta。
|
||||
# 只做严格唯一匹配:同用户、同项目名称描述、同扣费金额,并且 credit_record 与 generation_record 均唯一时才回填。
|
||||
op.execute(
|
||||
"""
|
||||
WITH candidates AS (
|
||||
SELECT
|
||||
cr.id AS credit_record_id,
|
||||
gr.id AS generation_record_id,
|
||||
gr.text_tokens_used,
|
||||
gr.text_credits_cost
|
||||
FROM credit_records cr
|
||||
JOIN generation_records gr
|
||||
ON gr.user_id = cr.user_id
|
||||
JOIN projects p
|
||||
ON p.id = gr.project_id
|
||||
WHERE cr.type = 'consume'
|
||||
AND cr.biz_key IS NULL
|
||||
AND cr.refund_for_biz_key IS NULL
|
||||
AND cr.related_id IS NULL
|
||||
AND (cr.owner_type IS NULL OR cr.owner_type = 'unknown')
|
||||
AND (cr.source_module IS NULL OR cr.source_module = 'unknown')
|
||||
AND (cr.billing_scene IS NULL OR cr.billing_scene = 'unknown')
|
||||
AND COALESCE(gr.text_credits_cost, 0) > 0
|
||||
AND COALESCE(gr.text_tokens_used, 0) > 0
|
||||
AND cr.description = '提示词优化 - ' || p.name
|
||||
AND ABS(ABS(cr.amount)::numeric - ROUND(COALESCE(gr.text_credits_cost, 0)::numeric, 2)) < 0.01
|
||||
),
|
||||
unique_credit AS (
|
||||
SELECT credit_record_id
|
||||
FROM candidates
|
||||
GROUP BY credit_record_id
|
||||
HAVING COUNT(*) = 1
|
||||
),
|
||||
unique_generation AS (
|
||||
SELECT generation_record_id
|
||||
FROM candidates
|
||||
GROUP BY generation_record_id
|
||||
HAVING COUNT(*) = 1
|
||||
),
|
||||
matched AS (
|
||||
SELECT c.*
|
||||
FROM candidates c
|
||||
JOIN unique_credit uc ON uc.credit_record_id = c.credit_record_id
|
||||
JOIN unique_generation ug ON ug.generation_record_id = c.generation_record_id
|
||||
)
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
related_id = COALESCE(cr.related_id, matched.generation_record_id),
|
||||
owner_type = 'generation_record',
|
||||
owner_id = matched.generation_record_id,
|
||||
attempt_no = COALESCE(cr.attempt_no, 1),
|
||||
charge_kind = 'text_prompt',
|
||||
charge_action = 'charge',
|
||||
credit_subject = 'text',
|
||||
billing_scene = 'generation_record_text_prompt_optimize',
|
||||
source_module = 'generation_record',
|
||||
total_tokens = COALESCE(cr.total_tokens, NULLIF(matched.text_tokens_used, 0)),
|
||||
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
|
||||
FROM matched
|
||||
WHERE cr.id = matched.credit_record_id
|
||||
"""
|
||||
)
|
||||
|
||||
# GenerationRecord:项目记录提词/文件解析/图片理解/图片生成/视频生成。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'generation_record'),
|
||||
owner_id = COALESCE(cr.owner_id, gr.id),
|
||||
related_id = COALESCE(cr.related_id, gr.id),
|
||||
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'generation_record'),
|
||||
media_type = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) IN ('image', 'video') THEN lower(gr.gen_type)
|
||||
ELSE cr.media_type
|
||||
END,
|
||||
total_tokens = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'text_prompt' THEN COALESCE(cr.total_tokens, NULLIF(gr.text_tokens_used, 0))
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'image' THEN COALESCE(cr.total_tokens, NULLIF(gr.image_tokens_used, 0))
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'video' THEN COALESCE(cr.total_tokens, NULLIF(gr.video_tokens_used, 0))
|
||||
ELSE cr.total_tokens
|
||||
END,
|
||||
credit_subject = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' THEN 'media'
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input') THEN 'text'
|
||||
ELSE cr.credit_subject
|
||||
END,
|
||||
billing_scene = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'text_prompt' THEN 'generation_record_text_prompt_optimize'
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'file_parse' THEN 'generation_record_file_parse'
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'vision_input' THEN 'generation_record_vision_input'
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'image' THEN 'generation_record_image_generate'
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(gr.gen_type, '')) = 'video' THEN 'generation_record_video_generate'
|
||||
ELSE cr.billing_scene
|
||||
END,
|
||||
engine_type = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input') THEN COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
|
||||
ELSE cr.engine_type
|
||||
END
|
||||
FROM generation_records gr
|
||||
WHERE (cr.owner_type = 'generation_record' AND cr.owner_id = gr.id)
|
||||
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = gr.id)
|
||||
"""
|
||||
)
|
||||
|
||||
# ModuleGenerationStep 提词优化:从 output_json.usage.credit_biz_key 精准匹配 CreditRecord.biz_key。
|
||||
# 包含软删步骤,不加 deleted_at 过滤。
|
||||
op.execute(
|
||||
"""
|
||||
WITH step_usage AS (
|
||||
SELECT
|
||||
s.id AS step_id,
|
||||
s.project_id,
|
||||
s.module,
|
||||
s.step_code,
|
||||
(s.output_json::jsonb -> 'usage') AS usage,
|
||||
COALESCE(
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'tokenUsageId', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'token_usage_id', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'tokenUsageId', '')
|
||||
) AS token_usage_id,
|
||||
COALESCE(
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigId', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'model_config_id', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'modelConfigId', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'id', '')
|
||||
) AS model_config_id
|
||||
FROM module_generation_steps s
|
||||
WHERE s.output_json IS NOT NULL
|
||||
AND jsonb_typeof(s.output_json::jsonb) = 'object'
|
||||
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
|
||||
)
|
||||
UPDATE module_generation_steps s
|
||||
SET
|
||||
token_usage_id = COALESCE(s.token_usage_id, step_usage.token_usage_id),
|
||||
model_config_id = COALESCE(s.model_config_id, step_usage.model_config_id),
|
||||
input_tokens = COALESCE(
|
||||
s.input_tokens,
|
||||
CASE WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'input_tokens')::integer ELSE NULL END
|
||||
),
|
||||
output_tokens = COALESCE(
|
||||
s.output_tokens,
|
||||
CASE WHEN COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'output_tokens')::integer ELSE NULL END
|
||||
),
|
||||
total_tokens = COALESCE(
|
||||
s.total_tokens,
|
||||
CASE WHEN COALESCE(step_usage.usage ->> 'total_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'total_tokens')::integer ELSE NULL END,
|
||||
CASE
|
||||
WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$'
|
||||
AND COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$'
|
||||
THEN (step_usage.usage ->> 'input_tokens')::integer + (step_usage.usage ->> 'output_tokens')::integer
|
||||
ELSE NULL
|
||||
END
|
||||
),
|
||||
text_credits_cost = COALESCE(
|
||||
s.text_credits_cost,
|
||||
CASE WHEN COALESCE(step_usage.usage ->> 'text_credits_cost', '') ~ '^[0-9]+(\\.[0-9]+)?$' THEN (step_usage.usage ->> 'text_credits_cost')::double precision ELSE NULL END
|
||||
)
|
||||
FROM step_usage
|
||||
WHERE s.id = step_usage.step_id
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
WITH step_usage AS (
|
||||
SELECT
|
||||
s.id AS step_id,
|
||||
s.project_id,
|
||||
s.module,
|
||||
s.step_code,
|
||||
(s.output_json::jsonb -> 'usage') AS usage,
|
||||
COALESCE(
|
||||
NULLIF(s.token_usage_id, ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'tokenUsageId', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'token_usage_id', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'tokenUsageId', '')
|
||||
) AS step_token_usage_id,
|
||||
COALESCE(
|
||||
NULLIF(s.model_config_id, ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigId', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'model_config_id', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'modelConfigId', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'id', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'id', '')
|
||||
) AS step_model_config_id,
|
||||
COALESCE(
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_config_name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelConfigName', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'name', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'model_config_name', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'modelConfigName', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'name', '')
|
||||
) AS step_model_config_name,
|
||||
COALESCE(
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_provider', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelProvider', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'provider', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'provider', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'provider', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'provider', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'model_provider', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'modelProvider', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'provider', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'provider', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'provider', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'provider', '')
|
||||
) AS step_model_provider,
|
||||
COALESCE(
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'model_name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' ->> 'modelName', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'model_name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'model_name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'model_name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config' ->> 'modelName', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'modelConfig' ->> 'modelName', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'usage' -> 'model_config_snapshot' ->> 'modelName', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'model_name', ''),
|
||||
NULLIF(s.output_json::jsonb ->> 'modelName', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'model_name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'model_name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'model_name', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config' ->> 'modelName', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'modelConfig' ->> 'modelName', ''),
|
||||
NULLIF(s.output_json::jsonb -> 'model_config_snapshot' ->> 'modelName', '')
|
||||
) AS step_model_name
|
||||
FROM module_generation_steps s
|
||||
WHERE s.output_json IS NOT NULL
|
||||
AND jsonb_typeof(s.output_json::jsonb) = 'object'
|
||||
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
|
||||
)
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'module_generation_step'),
|
||||
owner_id = COALESCE(cr.owner_id, step_usage.step_id),
|
||||
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'text_prompt'),
|
||||
charge_action = COALESCE(cr.charge_action, 'charge'),
|
||||
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'text'),
|
||||
billing_scene = COALESCE(
|
||||
NULLIF(cr.billing_scene, 'unknown'),
|
||||
CASE
|
||||
WHEN step_usage.module = 'hot_opening_replicate' AND step_usage.step_code = 'image_prompt_optimize' THEN 'hot_opening_image_prompt_optimize'
|
||||
WHEN step_usage.module = 'hot_opening_replicate' AND step_usage.step_code = 'video_prompt_optimize' THEN 'hot_opening_video_prompt_optimize'
|
||||
WHEN step_usage.module = 'shot_replicate' AND step_usage.step_code = 'image_prompt_optimize' THEN 'shot_image_prompt_optimize'
|
||||
WHEN step_usage.module = 'shot_replicate' AND step_usage.step_code = 'video_prompt_optimize' THEN 'shot_video_prompt_optimize'
|
||||
ELSE 'unknown'
|
||||
END
|
||||
),
|
||||
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), step_usage.module),
|
||||
source_project_id = COALESCE(cr.source_project_id, step_usage.project_id),
|
||||
source_step_id = COALESCE(cr.source_step_id, step_usage.step_id),
|
||||
source_step_code = COALESCE(cr.source_step_code, step_usage.step_code),
|
||||
token_usage_id = COALESCE(cr.token_usage_id, step_usage.step_token_usage_id),
|
||||
input_tokens = COALESCE(
|
||||
cr.input_tokens,
|
||||
CASE WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'input_tokens')::integer ELSE NULL END
|
||||
),
|
||||
output_tokens = COALESCE(
|
||||
cr.output_tokens,
|
||||
CASE WHEN COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'output_tokens')::integer ELSE NULL END
|
||||
),
|
||||
total_tokens = COALESCE(
|
||||
cr.total_tokens,
|
||||
CASE WHEN COALESCE(step_usage.usage ->> 'total_tokens', '') ~ '^[0-9]+$' THEN (step_usage.usage ->> 'total_tokens')::integer ELSE NULL END,
|
||||
CASE
|
||||
WHEN COALESCE(step_usage.usage ->> 'input_tokens', '') ~ '^[0-9]+$'
|
||||
AND COALESCE(step_usage.usage ->> 'output_tokens', '') ~ '^[0-9]+$'
|
||||
THEN (step_usage.usage ->> 'input_tokens')::integer + (step_usage.usage ->> 'output_tokens')::integer
|
||||
ELSE NULL
|
||||
END
|
||||
),
|
||||
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model'),
|
||||
engine_id = COALESCE(cr.engine_id, step_usage.step_model_config_id),
|
||||
engine_name = COALESCE(cr.engine_name, step_usage.step_model_config_name),
|
||||
engine_provider = COALESCE(cr.engine_provider, step_usage.step_model_provider),
|
||||
engine_model_name = COALESCE(cr.engine_model_name, step_usage.step_model_name)
|
||||
FROM step_usage
|
||||
WHERE cr.biz_key = step_usage.usage ->> 'credit_biz_key'
|
||||
"""
|
||||
)
|
||||
|
||||
# TokenUsage 如果 usage 中已经记录 token_usage_id,则可以安全补 owner;否则不强行按时间/token 猜。
|
||||
op.execute(
|
||||
"""
|
||||
WITH step_usage AS (
|
||||
SELECT
|
||||
s.id AS step_id,
|
||||
s.module,
|
||||
s.step_code,
|
||||
(s.output_json::jsonb -> 'usage') AS usage
|
||||
FROM module_generation_steps s
|
||||
WHERE s.output_json IS NOT NULL
|
||||
AND jsonb_typeof(s.output_json::jsonb) = 'object'
|
||||
AND jsonb_typeof(s.output_json::jsonb -> 'usage') = 'object'
|
||||
AND NULLIF(s.output_json::jsonb -> 'usage' ->> 'token_usage_id', '') IS NOT NULL
|
||||
)
|
||||
UPDATE token_usage tu
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(tu.owner_type, 'unknown'), 'module_generation_step'),
|
||||
owner_id = COALESCE(tu.owner_id, step_usage.step_id),
|
||||
biz_key = COALESCE(tu.biz_key, NULLIF(step_usage.usage ->> 'credit_biz_key', '')),
|
||||
source_module = COALESCE(NULLIF(tu.source_module, 'unknown'), step_usage.module),
|
||||
source_step_code = COALESCE(tu.source_step_code, step_usage.step_code)
|
||||
FROM step_usage
|
||||
WHERE tu.id = step_usage.usage ->> 'token_usage_id'
|
||||
"""
|
||||
)
|
||||
|
||||
# 拆镜复刻原视频整体分析流水:归属到任务集。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'shot_replicate_task_set'),
|
||||
owner_id = COALESCE(cr.owner_id, ts.id),
|
||||
attempt_no = COALESCE(cr.attempt_no, 1),
|
||||
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'video_analysis'),
|
||||
charge_action = COALESCE(cr.charge_action, CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END),
|
||||
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'analysis'),
|
||||
media_type = COALESCE(cr.media_type, 'video'),
|
||||
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'shot_original_video_analysis'),
|
||||
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'shot_replicate'),
|
||||
source_project_id = COALESCE(cr.source_project_id, ts.id),
|
||||
source_step_code = COALESCE(cr.source_step_code, 'video_analysis'),
|
||||
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
|
||||
FROM shot_replicate_task_sets ts
|
||||
WHERE (
|
||||
(cr.owner_type = 'shot_replicate_task_set' AND cr.owner_id = ts.id)
|
||||
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = ts.id)
|
||||
)
|
||||
AND cr.type IN ('consume', 'refund')
|
||||
AND (
|
||||
cr.charge_kind = 'video_analysis'
|
||||
OR cr.billing_scene IN ('shot_video_analysis', 'shot_original_video_analysis')
|
||||
OR cr.description ILIKE '%视频分析%'
|
||||
OR cr.description ILIKE '%原视频分析%'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 拆镜复刻手动片段分析流水:归属到片段。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'shot_replicate_segment'),
|
||||
owner_id = COALESCE(cr.owner_id, sg.id),
|
||||
attempt_no = COALESCE(cr.attempt_no, 1),
|
||||
charge_kind = COALESCE(NULLIF(cr.charge_kind, 'unknown'), 'video_analysis'),
|
||||
charge_action = COALESCE(cr.charge_action, CASE WHEN cr.type = 'refund' THEN 'refund' ELSE 'charge' END),
|
||||
credit_subject = COALESCE(NULLIF(cr.credit_subject, 'unknown'), 'analysis'),
|
||||
media_type = COALESCE(cr.media_type, 'video'),
|
||||
billing_scene = COALESCE(NULLIF(cr.billing_scene, 'unknown'), 'shot_segment_video_analysis'),
|
||||
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'shot_replicate'),
|
||||
source_project_id = COALESCE(cr.source_project_id, sg.task_set_id),
|
||||
source_step_id = COALESCE(cr.source_step_id, sg.id),
|
||||
source_step_code = COALESCE(cr.source_step_code, 'video_analysis'),
|
||||
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
|
||||
FROM shot_replicate_segments sg
|
||||
WHERE (
|
||||
(cr.owner_type = 'shot_replicate_segment' AND cr.owner_id = sg.id)
|
||||
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = sg.id)
|
||||
)
|
||||
AND cr.type IN ('consume', 'refund')
|
||||
AND (
|
||||
cr.charge_kind = 'video_analysis'
|
||||
OR cr.billing_scene IN ('shot_video_analysis', 'shot_segment_video_analysis')
|
||||
OR cr.description ILIKE '%片段分析%'
|
||||
OR cr.description ILIKE '%手动%分析%'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Module media generation:ChatGenerationTask 被模块步骤引用时,优先归到具体模块/步骤,而不是 AI 创作。
|
||||
op.execute(
|
||||
"""
|
||||
WITH step_task AS (
|
||||
SELECT DISTINCT ON (s.chat_task_id)
|
||||
s.chat_task_id,
|
||||
s.id AS step_id,
|
||||
s.project_id,
|
||||
s.module,
|
||||
s.step_code
|
||||
FROM module_generation_steps s
|
||||
WHERE s.chat_task_id IS NOT NULL
|
||||
ORDER BY s.chat_task_id, s.is_current DESC, s.updated_at DESC
|
||||
)
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), step_task.module),
|
||||
source_project_id = COALESCE(cr.source_project_id, step_task.project_id),
|
||||
source_step_id = COALESCE(cr.source_step_id, step_task.step_id),
|
||||
source_step_code = COALESCE(cr.source_step_code, step_task.step_code),
|
||||
billing_scene = COALESCE(
|
||||
NULLIF(cr.billing_scene, 'unknown'),
|
||||
CASE
|
||||
WHEN step_task.module = 'hot_opening_replicate' AND step_task.step_code = 'image_generate' THEN 'hot_opening_image_generate'
|
||||
WHEN step_task.module = 'hot_opening_replicate' AND step_task.step_code = 'video_generate' THEN 'hot_opening_video_generate'
|
||||
WHEN step_task.module = 'shot_replicate' AND step_task.step_code = 'image_generate' THEN 'shot_image_generate'
|
||||
WHEN step_task.module = 'shot_replicate' AND step_task.step_code = 'video_generate' THEN 'shot_video_generate'
|
||||
ELSE cr.billing_scene
|
||||
END
|
||||
)
|
||||
FROM step_task
|
||||
WHERE cr.owner_type = 'chat_generation_task'
|
||||
AND cr.owner_id = step_task.chat_task_id
|
||||
"""
|
||||
)
|
||||
|
||||
# ChatGenerationTask:AI 创作图片/视频生成,以及媒体 token/引擎快照。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(cr.owner_type, 'unknown'), 'chat_generation_task'),
|
||||
owner_id = COALESCE(cr.owner_id, t.id),
|
||||
source_module = COALESCE(NULLIF(cr.source_module, 'unknown'), 'ai_creation'),
|
||||
credit_subject = CASE WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' THEN 'media' ELSE cr.credit_subject END,
|
||||
media_type = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) IN ('image', 'video') THEN lower(t.gen_type)
|
||||
ELSE cr.media_type
|
||||
END,
|
||||
billing_scene = COALESCE(
|
||||
NULLIF(cr.billing_scene, 'unknown'),
|
||||
CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'image' THEN 'ai_creation_image_generate'
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'video' THEN 'ai_creation_video_generate'
|
||||
ELSE cr.billing_scene
|
||||
END
|
||||
),
|
||||
engine_type = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) IN ('image', 'video') THEN lower(t.gen_type)
|
||||
ELSE cr.engine_type
|
||||
END,
|
||||
engine_id = COALESCE(cr.engine_id, t.engine_id),
|
||||
total_tokens = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'image' THEN COALESCE(cr.total_tokens, NULLIF(t.image_tokens_used, 0))
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') = 'media' AND lower(COALESCE(t.gen_type, '')) = 'video' THEN COALESCE(cr.total_tokens, NULLIF(t.video_tokens_used, 0))
|
||||
ELSE cr.total_tokens
|
||||
END
|
||||
FROM chat_generation_tasks t
|
||||
WHERE (cr.owner_type = 'chat_generation_task' AND cr.owner_id = t.id)
|
||||
OR ((cr.owner_type IS NULL OR cr.owner_type = 'unknown') AND cr.related_id = t.id)
|
||||
"""
|
||||
)
|
||||
|
||||
# 从 TokenUsage 补 CreditRecord 的 token 和 ModelConfig 执行快照,前提是已经能安全匹配 token_usage_id。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
input_tokens = COALESCE(cr.input_tokens, NULLIF(tu.input_tokens, 0)),
|
||||
output_tokens = COALESCE(cr.output_tokens, NULLIF(tu.output_tokens, 0)),
|
||||
total_tokens = COALESCE(cr.total_tokens, NULLIF(tu.total_tokens, 0)),
|
||||
engine_type = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
|
||||
OR COALESCE(NULLIF(cr.credit_subject, 'unknown'), '') IN ('text', 'analysis')
|
||||
THEN COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model')
|
||||
ELSE cr.engine_type
|
||||
END,
|
||||
engine_id = CASE
|
||||
WHEN COALESCE(NULLIF(cr.charge_kind, 'unknown'), '') IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
|
||||
OR COALESCE(NULLIF(cr.credit_subject, 'unknown'), '') IN ('text', 'analysis')
|
||||
THEN COALESCE(cr.engine_id, tu.model_config_id)
|
||||
ELSE cr.engine_id
|
||||
END
|
||||
FROM token_usage tu
|
||||
WHERE cr.token_usage_id = tu.id
|
||||
"""
|
||||
)
|
||||
|
||||
# ModelConfig 快照统一冷备到 engine_*,engine_type=model。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'model'),
|
||||
engine_name = COALESCE(cr.engine_name, mc.name),
|
||||
engine_provider = COALESCE(cr.engine_provider, mc.provider),
|
||||
engine_model_name = COALESCE(cr.engine_model_name, mc.model_name)
|
||||
FROM model_configs mc
|
||||
WHERE cr.engine_id = mc.id
|
||||
AND (
|
||||
cr.engine_type = 'model'
|
||||
OR cr.charge_kind IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
|
||||
OR cr.credit_subject IN ('text', 'analysis')
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# 图片引擎快照。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
engine_name = COALESCE(cr.engine_name, ie.name),
|
||||
engine_provider = COALESCE(cr.engine_provider, ie.provider),
|
||||
engine_model_name = COALESCE(cr.engine_model_name, ie.model_name),
|
||||
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'image')
|
||||
FROM image_engines ie
|
||||
WHERE cr.engine_id = ie.id
|
||||
AND (cr.engine_type = 'image' OR cr.media_type = 'image' OR cr.engine_type IS NULL OR cr.engine_type = 'unknown')
|
||||
"""
|
||||
)
|
||||
|
||||
# 视频引擎快照。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records cr
|
||||
SET
|
||||
engine_name = COALESCE(cr.engine_name, ve.name),
|
||||
engine_provider = COALESCE(cr.engine_provider, ve.provider),
|
||||
engine_model_name = COALESCE(cr.engine_model_name, ve.model_name),
|
||||
engine_type = COALESCE(NULLIF(cr.engine_type, 'unknown'), 'video')
|
||||
FROM video_engines ve
|
||||
WHERE cr.engine_id = ve.id
|
||||
AND (cr.engine_type = 'video' OR cr.media_type = 'video' OR cr.engine_type IS NULL OR cr.engine_type = 'unknown')
|
||||
"""
|
||||
)
|
||||
|
||||
# 退款流水复制原消费流水快照,但 billing_scene 固定为 refund,方便筛选回退流水。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records r
|
||||
SET
|
||||
owner_type = COALESCE(NULLIF(r.owner_type, 'unknown'), c.owner_type),
|
||||
owner_id = COALESCE(r.owner_id, c.owner_id),
|
||||
attempt_no = COALESCE(r.attempt_no, c.attempt_no),
|
||||
charge_kind = COALESCE(NULLIF(r.charge_kind, 'unknown'), c.charge_kind),
|
||||
charge_action = 'refund',
|
||||
credit_subject = COALESCE(NULLIF(r.credit_subject, 'unknown'), c.credit_subject, 'refund'),
|
||||
media_type = COALESCE(r.media_type, c.media_type),
|
||||
billing_scene = 'refund',
|
||||
source_module = COALESCE(NULLIF(r.source_module, 'unknown'), c.source_module),
|
||||
source_project_id = COALESCE(r.source_project_id, c.source_project_id),
|
||||
source_step_id = COALESCE(r.source_step_id, c.source_step_id),
|
||||
source_step_code = COALESCE(r.source_step_code, c.source_step_code),
|
||||
token_usage_id = COALESCE(r.token_usage_id, c.token_usage_id),
|
||||
input_tokens = COALESCE(r.input_tokens, c.input_tokens),
|
||||
output_tokens = COALESCE(r.output_tokens, c.output_tokens),
|
||||
total_tokens = COALESCE(r.total_tokens, c.total_tokens),
|
||||
engine_type = COALESCE(NULLIF(r.engine_type, 'unknown'), c.engine_type),
|
||||
engine_id = COALESCE(r.engine_id, c.engine_id),
|
||||
engine_name = COALESCE(r.engine_name, c.engine_name),
|
||||
engine_provider = COALESCE(r.engine_provider, c.engine_provider),
|
||||
engine_model_name = COALESCE(r.engine_model_name, c.engine_model_name),
|
||||
user_type_snapshot = COALESCE(r.user_type_snapshot, c.user_type_snapshot),
|
||||
frontend_user_kind_snapshot = COALESCE(r.frontend_user_kind_snapshot, c.frontend_user_kind_snapshot)
|
||||
FROM credit_records c
|
||||
WHERE r.type = 'refund'
|
||||
AND r.refund_for_biz_key = c.biz_key
|
||||
"""
|
||||
)
|
||||
|
||||
# 基础分类兜底。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records
|
||||
SET
|
||||
charge_action = COALESCE(charge_action, CASE WHEN type = 'refund' THEN 'refund' ELSE 'charge' END),
|
||||
charge_kind = COALESCE(
|
||||
NULLIF(charge_kind, 'unknown'),
|
||||
CASE
|
||||
WHEN type = 'recharge' THEN 'recharge'
|
||||
WHEN type = 'refund' THEN 'refund'
|
||||
ELSE 'unknown'
|
||||
END
|
||||
),
|
||||
credit_subject = COALESCE(
|
||||
NULLIF(credit_subject, 'unknown'),
|
||||
CASE
|
||||
WHEN type = 'recharge' THEN 'recharge'
|
||||
WHEN type = 'refund' THEN 'refund'
|
||||
WHEN charge_kind = 'media' THEN 'media'
|
||||
WHEN charge_kind IN ('text_prompt', 'file_parse', 'vision_input') THEN 'text'
|
||||
WHEN charge_kind = 'module_create' THEN 'module'
|
||||
WHEN charge_kind = 'video_analysis' THEN 'analysis'
|
||||
WHEN charge_kind = 'video_split' THEN 'split'
|
||||
WHEN charge_kind = 'admin_adjust' THEN 'admin_adjust'
|
||||
ELSE 'unknown'
|
||||
END
|
||||
),
|
||||
source_module = COALESCE(NULLIF(source_module, 'unknown'), CASE WHEN type = 'recharge' THEN 'payment' ELSE 'unknown' END),
|
||||
billing_scene = COALESCE(
|
||||
NULLIF(billing_scene, 'unknown'),
|
||||
CASE
|
||||
WHEN type = 'recharge' THEN 'recharge'
|
||||
WHEN type = 'refund' THEN 'refund'
|
||||
ELSE 'unknown'
|
||||
END
|
||||
),
|
||||
owner_type = COALESCE(NULLIF(owner_type, 'unknown'), 'unknown'),
|
||||
engine_type = CASE
|
||||
WHEN charge_kind IN ('text_prompt', 'file_parse', 'vision_input', 'video_analysis')
|
||||
OR credit_subject IN ('text', 'analysis')
|
||||
THEN COALESCE(NULLIF(engine_type, 'unknown'), 'model')
|
||||
ELSE engine_type
|
||||
END
|
||||
WHERE charge_kind IS NULL
|
||||
OR charge_kind = 'unknown'
|
||||
OR charge_action IS NULL
|
||||
OR credit_subject IS NULL
|
||||
OR credit_subject = 'unknown'
|
||||
OR source_module IS NULL
|
||||
OR source_module = 'unknown'
|
||||
OR billing_scene IS NULL
|
||||
OR billing_scene = 'unknown'
|
||||
OR owner_type IS NULL
|
||||
OR owner_type = 'unknown'
|
||||
"""
|
||||
)
|
||||
|
||||
# 用户快照兜底,避免后台筛选 NULL。
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE credit_records
|
||||
SET
|
||||
user_type_snapshot = COALESCE(user_type_snapshot, 'frontend'),
|
||||
frontend_user_kind_snapshot = COALESCE(frontend_user_kind_snapshot, 'external')
|
||||
WHERE user_type_snapshot IS NULL OR frontend_user_kind_snapshot IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_users_user_type"), table_name="users")
|
||||
op.drop_index(op.f("ix_users_frontend_user_kind"), table_name="users")
|
||||
|
||||
op.drop_index(op.f("ix_token_usage_source_step_code"), table_name="token_usage")
|
||||
op.drop_index(op.f("ix_token_usage_source_module"), table_name="token_usage")
|
||||
op.drop_index(op.f("ix_token_usage_owner_type"), table_name="token_usage")
|
||||
op.drop_index(op.f("ix_token_usage_owner_id"), table_name="token_usage")
|
||||
op.drop_index("ix_token_usage_owner", table_name="token_usage")
|
||||
op.drop_index("ix_token_usage_biz_key", table_name="token_usage")
|
||||
|
||||
op.drop_index(op.f("ix_module_generation_steps_token_usage_id"), table_name="module_generation_steps")
|
||||
op.drop_index(op.f("ix_module_generation_steps_model_config_id"), table_name="module_generation_steps")
|
||||
|
||||
op.drop_index(op.f("ix_credit_records_user_type_snapshot"), table_name="credit_records")
|
||||
op.drop_index("ix_credit_records_user_kind_time", table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_type"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_token_usage_id"), table_name="credit_records")
|
||||
op.drop_index("ix_credit_records_subject_media", table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_source_step_id"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_source_step_code"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_source_project_id"), table_name="credit_records")
|
||||
op.drop_index("ix_credit_records_source_module_scene", table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_source_module"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_owner_type"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_owner_id"), table_name="credit_records")
|
||||
op.drop_index("ix_credit_records_owner", table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_media_type"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_frontend_user_kind_snapshot"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_engine_type"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_engine_id"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_credit_subject"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_charge_kind"), table_name="credit_records")
|
||||
op.drop_index(op.f("ix_credit_records_billing_scene"), table_name="credit_records")
|
||||
|
||||
op.drop_column("token_usage", "source_step_code")
|
||||
op.drop_column("token_usage", "source_module")
|
||||
op.drop_column("token_usage", "biz_key")
|
||||
op.drop_column("token_usage", "owner_id")
|
||||
op.drop_column("token_usage", "owner_type")
|
||||
|
||||
op.drop_column("module_generation_steps", "text_credits_cost")
|
||||
op.drop_column("module_generation_steps", "total_tokens")
|
||||
op.drop_column("module_generation_steps", "output_tokens")
|
||||
op.drop_column("module_generation_steps", "input_tokens")
|
||||
op.drop_column("module_generation_steps", "model_config_id")
|
||||
op.drop_column("module_generation_steps", "token_usage_id")
|
||||
|
||||
op.drop_column("credit_records", "frontend_user_kind_snapshot")
|
||||
op.drop_column("credit_records", "user_type_snapshot")
|
||||
op.drop_column("credit_records", "engine_model_name")
|
||||
op.drop_column("credit_records", "engine_provider")
|
||||
op.drop_column("credit_records", "engine_name")
|
||||
op.drop_column("credit_records", "engine_id")
|
||||
op.drop_column("credit_records", "engine_type")
|
||||
op.drop_column("credit_records", "total_tokens")
|
||||
op.drop_column("credit_records", "output_tokens")
|
||||
op.drop_column("credit_records", "input_tokens")
|
||||
op.drop_column("credit_records", "token_usage_id")
|
||||
op.drop_column("credit_records", "source_step_code")
|
||||
op.drop_column("credit_records", "source_step_id")
|
||||
op.drop_column("credit_records", "source_project_id")
|
||||
op.drop_column("credit_records", "source_module")
|
||||
op.drop_column("credit_records", "billing_scene")
|
||||
op.drop_column("credit_records", "media_type")
|
||||
op.drop_column("credit_records", "credit_subject")
|
||||
op.drop_column("credit_records", "charge_action")
|
||||
op.drop_column("credit_records", "charge_kind")
|
||||
op.drop_column("credit_records", "attempt_no")
|
||||
op.drop_column("credit_records", "owner_id")
|
||||
op.drop_column("credit_records", "owner_type")
|
||||
|
||||
op.drop_column("users", "frontend_user_kind")
|
||||
@@ -0,0 +1,133 @@
|
||||
"""add upload resource physical delete status
|
||||
|
||||
Revision ID: 1475d11b1d74
|
||||
Revises: cd7688999204
|
||||
Create Date: 2026-07-08 15:14:13.995718
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1475d11b1d74'
|
||||
down_revision: Union[str, None] = 'cd7688999204'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('upload_resources',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('module', sa.String(length=64), nullable=False),
|
||||
sa.Column('resource_type', sa.String(length=32), nullable=False),
|
||||
sa.Column('resource_url', sa.Text(), nullable=False),
|
||||
sa.Column('storage_path', sa.Text(), nullable=False),
|
||||
sa.Column('file_name', sa.String(length=255), nullable=True),
|
||||
sa.Column('file_ext', sa.String(length=32), nullable=True),
|
||||
sa.Column('mime_type', sa.String(length=128), nullable=True),
|
||||
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False),
|
||||
sa.Column('duration_seconds', sa.Float(), nullable=True),
|
||||
sa.Column('duration_source', sa.String(length=32), nullable=True),
|
||||
sa.Column('width', sa.BigInteger(), nullable=True),
|
||||
sa.Column('height', sa.BigInteger(), nullable=True),
|
||||
sa.Column('source_model', sa.String(length=64), nullable=True),
|
||||
sa.Column('source_id', sa.String(length=32), nullable=True),
|
||||
sa.Column('source_module', sa.String(length=64), nullable=True),
|
||||
sa.Column('bind_status', sa.String(length=32), server_default='pending', nullable=False),
|
||||
sa.Column('delete_policy', sa.String(length=32), server_default='user_deletable', nullable=False),
|
||||
sa.Column('created_by', sa.String(length=32), server_default='api', nullable=False),
|
||||
sa.Column('metadata_json', sa.Text(), nullable=True),
|
||||
sa.Column('capacity_released_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('physical_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('file_delete_status', sa.String(length=32), server_default='active', nullable=False),
|
||||
sa.Column('file_delete_error', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('storage_path', name='uq_upload_resources_storage_path')
|
||||
)
|
||||
op.create_index(op.f('ix_upload_resources_bind_status'), 'upload_resources', ['bind_status'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_capacity_released_at'), 'upload_resources', ['capacity_released_at'], unique=False)
|
||||
op.create_index('ix_upload_resources_created', 'upload_resources', ['created_at'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_created_by'), 'upload_resources', ['created_by'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_delete_policy'), 'upload_resources', ['delete_policy'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_deleted_at'), 'upload_resources', ['deleted_at'], unique=False)
|
||||
op.create_index('ix_upload_resources_file_delete_status', 'upload_resources', ['file_delete_status'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_module'), 'upload_resources', ['module'], unique=False)
|
||||
op.create_index('ix_upload_resources_physical_deleted', 'upload_resources', ['physical_deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_resource_type'), 'upload_resources', ['resource_type'], unique=False)
|
||||
op.create_index('ix_upload_resources_source', 'upload_resources', ['source_model', 'source_id', 'deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_source_id'), 'upload_resources', ['source_id'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_source_model'), 'upload_resources', ['source_model'], unique=False)
|
||||
op.create_index('ix_upload_resources_url', 'upload_resources', ['resource_url'], unique=False)
|
||||
op.create_index('ix_upload_resources_user_bind', 'upload_resources', ['user_id', 'bind_status', 'deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_upload_resources_user_id'), 'upload_resources', ['user_id'], unique=False)
|
||||
op.create_index('ix_upload_resources_user_module_type', 'upload_resources', ['user_id', 'module', 'resource_type', 'deleted_at'], unique=False)
|
||||
op.create_index(
|
||||
'ix_upload_resources_history_lookup',
|
||||
'upload_resources',
|
||||
['user_id', 'resource_type', sa.text('created_at DESC')],
|
||||
unique=False,
|
||||
postgresql_where=sa.text(
|
||||
"deleted_at IS NULL "
|
||||
"AND bind_status = 'pending' "
|
||||
"AND delete_policy = 'user_deletable' "
|
||||
"AND source_model IS NULL "
|
||||
"AND source_id IS NULL"
|
||||
),
|
||||
)
|
||||
op.add_column('user_resource_month_stats', sa.Column('upload_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('audio_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('shot_segment_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('upload_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('audio_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_month_stats', sa.Column('shot_segment_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('upload_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('audio_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('shot_segment_size_bytes', sa.BigInteger(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('upload_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('audio_count', sa.Integer(), server_default='0', nullable=False))
|
||||
op.add_column('user_resource_total_stats', sa.Column('shot_segment_count', sa.Integer(), server_default='0', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('user_resource_total_stats', 'shot_segment_count')
|
||||
op.drop_column('user_resource_total_stats', 'audio_count')
|
||||
op.drop_column('user_resource_total_stats', 'upload_count')
|
||||
op.drop_column('user_resource_total_stats', 'shot_segment_size_bytes')
|
||||
op.drop_column('user_resource_total_stats', 'audio_size_bytes')
|
||||
op.drop_column('user_resource_total_stats', 'upload_size_bytes')
|
||||
op.drop_column('user_resource_month_stats', 'shot_segment_count')
|
||||
op.drop_column('user_resource_month_stats', 'audio_count')
|
||||
op.drop_column('user_resource_month_stats', 'upload_count')
|
||||
op.drop_column('user_resource_month_stats', 'shot_segment_size_bytes')
|
||||
op.drop_column('user_resource_month_stats', 'audio_size_bytes')
|
||||
op.drop_column('user_resource_month_stats', 'upload_size_bytes')
|
||||
op.drop_index('ix_upload_resources_history_lookup', table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_user_module_type', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_user_id'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_user_bind', table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_url', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_source_model'), table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_source_id'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_source', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_resource_type'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_physical_deleted', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_module'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_file_delete_status', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_deleted_at'), table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_delete_policy'), table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_created_by'), table_name='upload_resources')
|
||||
op.drop_index('ix_upload_resources_created', table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_capacity_released_at'), table_name='upload_resources')
|
||||
op.drop_index(op.f('ix_upload_resources_bind_status'), table_name='upload_resources')
|
||||
op.drop_table('upload_resources')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,107 @@
|
||||
"""add team management
|
||||
|
||||
Revision ID: 170005531deb
|
||||
Revises: 78fb32c26a6e
|
||||
Create Date: 2026-06-30 11:07:14.278871
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '170005531deb'
|
||||
down_revision: Union[str, None] = '78fb32c26a6e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('teams',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False, comment='团队名称'),
|
||||
sa.Column('code', sa.String(length=64), nullable=True, comment='团队编码'),
|
||||
sa.Column('description', sa.String(length=512), nullable=True, comment='团队备注'),
|
||||
sa.Column('status', sa.String(length=16), server_default='active', nullable=False, comment='团队状态:active启用,disabled禁用'),
|
||||
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序值,越小越靠前'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('ix_teams_code', 'teams', ['code'], unique=False)
|
||||
op.create_index(op.f('ix_teams_deleted_at'), 'teams', ['deleted_at'], unique=False)
|
||||
op.create_index(op.f('ix_teams_name'), 'teams', ['name'], unique=False)
|
||||
op.create_index(op.f('ix_teams_sort_order'), 'teams', ['sort_order'], unique=False)
|
||||
op.create_index(op.f('ix_teams_status'), 'teams', ['status'], unique=False)
|
||||
op.create_index('ix_teams_status_sort', 'teams', ['status', 'sort_order', 'created_at'], unique=False)
|
||||
op.drop_index(op.f('ix_contact_requests_phone'), table_name='contact_requests')
|
||||
op.drop_index(op.f('ix_contact_requests_user_id'), table_name='contact_requests')
|
||||
op.drop_table('contact_requests')
|
||||
op.add_column('credit_records', sa.Column('team_id_snapshot', sa.String(length=32), nullable=True))
|
||||
op.add_column('credit_records', sa.Column('team_name_snapshot', sa.String(length=128), nullable=True))
|
||||
op.drop_index(op.f('ix_credit_records_engine_type'), table_name='credit_records')
|
||||
op.create_index(op.f('ix_credit_records_team_id_snapshot'), 'credit_records', ['team_id_snapshot'], unique=False)
|
||||
op.create_index('ix_credit_records_team_time', 'credit_records', ['team_id_snapshot', 'created_at'], unique=False)
|
||||
op.create_index('ix_credit_records_user_kind_team_time', 'credit_records', ['user_type_snapshot', 'frontend_user_kind_snapshot', 'team_id_snapshot', 'created_at'], unique=False)
|
||||
op.create_index('idx_genrec_project_status', 'generation_records', ['project_id', 'status'], unique=False)
|
||||
op.create_index('idx_genrec_user_status_created', 'generation_records', ['user_id', 'status', 'created_at'], unique=False)
|
||||
op.create_index('idx_menu_target_active_sort', 'menu_configs', ['menu_target', 'is_active', 'sort_order'], unique=False)
|
||||
op.create_index(op.f('ix_menu_configs_parent_id'), 'menu_configs', ['parent_id'], unique=False)
|
||||
op.create_index('idx_oplog_created', 'operation_logs', ['created_at'], unique=False)
|
||||
op.create_index('idx_oplog_user_created', 'operation_logs', ['user_id', 'created_at'], unique=False)
|
||||
op.create_index('idx_payorder_status_created', 'payment_orders', ['status', 'created_at'], unique=False)
|
||||
op.create_index('idx_payorder_user_status_created', 'payment_orders', ['user_id', 'status', 'created_at'], unique=False)
|
||||
op.create_index('idx_upload_oauth_status', 'upload_task', ['oauth_id', 'status'], unique=False)
|
||||
op.create_index('idx_upload_user_status_created', 'upload_task', ['user_id', 'status', 'created_at'], unique=False)
|
||||
op.add_column('users', sa.Column('team_id', sa.String(length=32), nullable=True))
|
||||
op.create_index(op.f('ix_users_team_id'), 'users', ['team_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_team_id'), table_name='users')
|
||||
op.drop_column('users', 'team_id')
|
||||
op.drop_index('idx_upload_user_status_created', table_name='upload_task')
|
||||
op.drop_index('idx_upload_oauth_status', table_name='upload_task')
|
||||
op.drop_index('idx_payorder_user_status_created', table_name='payment_orders')
|
||||
op.drop_index('idx_payorder_status_created', table_name='payment_orders')
|
||||
op.drop_index('idx_oplog_user_created', table_name='operation_logs')
|
||||
op.drop_index('idx_oplog_created', table_name='operation_logs')
|
||||
op.drop_index(op.f('ix_menu_configs_parent_id'), table_name='menu_configs')
|
||||
op.drop_index('idx_menu_target_active_sort', table_name='menu_configs')
|
||||
op.drop_index('idx_genrec_user_status_created', table_name='generation_records')
|
||||
op.drop_index('idx_genrec_project_status', table_name='generation_records')
|
||||
op.drop_index('ix_credit_records_user_kind_team_time', table_name='credit_records')
|
||||
op.drop_index('ix_credit_records_team_time', table_name='credit_records')
|
||||
op.drop_index(op.f('ix_credit_records_team_id_snapshot'), table_name='credit_records')
|
||||
op.create_index(op.f('ix_credit_records_engine_type'), 'credit_records', ['engine_type'], unique=False)
|
||||
op.drop_column('credit_records', 'team_name_snapshot')
|
||||
op.drop_column('credit_records', 'team_id_snapshot')
|
||||
op.create_table('contact_requests',
|
||||
sa.Column('id', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
|
||||
sa.Column('user_id', sa.VARCHAR(length=32), autoincrement=False, nullable=False),
|
||||
sa.Column('phone', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('company_name', sa.VARCHAR(length=128), autoincrement=False, nullable=False),
|
||||
sa.Column('industry', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=64), autoincrement=False, nullable=False),
|
||||
sa.Column('message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('is_handled', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True, precision=6), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True, precision=6), server_default=sa.text('now()'), autoincrement=False, nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('contact_requests_user_id_fkey')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('contact_requests_pkey'))
|
||||
)
|
||||
op.create_index(op.f('ix_contact_requests_user_id'), 'contact_requests', ['user_id'], unique=False)
|
||||
op.create_index(op.f('ix_contact_requests_phone'), 'contact_requests', ['phone'], unique=False)
|
||||
op.drop_index('ix_teams_status_sort', table_name='teams')
|
||||
op.drop_index(op.f('ix_teams_status'), table_name='teams')
|
||||
op.drop_index(op.f('ix_teams_sort_order'), table_name='teams')
|
||||
op.drop_index(op.f('ix_teams_name'), table_name='teams')
|
||||
op.drop_index(op.f('ix_teams_deleted_at'), table_name='teams')
|
||||
op.drop_index('ix_teams_code', table_name='teams')
|
||||
op.drop_table('teams')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,25 @@
|
||||
"""merge changes from remote
|
||||
|
||||
Revision ID: 1a4d1f095fa1
|
||||
Revises: 20260811_20260811, 8a6d2f4c9b10
|
||||
Create Date: 2026-08-11 10:30:10.431672
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1a4d1f095fa1'
|
||||
down_revision: Union[str, None] = ('20260811_20260811', '8a6d2f4c9b10')
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
pass
|
||||
@@ -0,0 +1,82 @@
|
||||
"""add team manager and invite system
|
||||
|
||||
Revision ID: 20260706_20260706
|
||||
Revises: 054735900c23
|
||||
Create Date: 2026-07-06 10:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '20260706_20260706'
|
||||
down_revision: Union[str, None] = '054735900c23'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# teams 表新增 manager_id 列
|
||||
op.add_column('teams', sa.Column('manager_id', sa.String(length=32), nullable=True))
|
||||
op.create_index(op.f('ix_teams_manager_id'), 'teams', ['manager_id'], unique=False)
|
||||
op.create_foreign_key('fk_teams_manager_id', 'teams', 'users', ['manager_id'], ['id'])
|
||||
|
||||
# 邀请码表
|
||||
op.create_table('team_invitations',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('team_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('code', sa.String(length=64), nullable=False),
|
||||
sa.Column('created_by', sa.String(length=32), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), server_default='active', nullable=False),
|
||||
sa.Column('max_uses', sa.Integer(), nullable=True),
|
||||
sa.Column('use_count', sa.Integer(), server_default='0', nullable=False),
|
||||
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code', name='uq_team_invitations_code')
|
||||
)
|
||||
op.create_index(op.f('ix_team_invitations_team_id'), 'team_invitations', ['team_id'], unique=False)
|
||||
op.create_index(op.f('ix_team_invitations_code'), 'team_invitations', ['code'], unique=False)
|
||||
op.create_index('ix_team_invitations_team', 'team_invitations', ['team_id', 'status'], unique=False)
|
||||
|
||||
# 加入申请表
|
||||
op.create_table('team_join_requests',
|
||||
sa.Column('id', sa.String(length=32), nullable=False),
|
||||
sa.Column('team_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||
sa.Column('invitation_id', sa.String(length=32), nullable=True),
|
||||
sa.Column('status', sa.String(length=16), server_default='pending', nullable=False),
|
||||
sa.Column('note', sa.String(length=256), nullable=True),
|
||||
sa.Column('handled_by', sa.String(length=32), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['invitation_id'], ['team_invitations.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_team_join_requests_team_id'), 'team_join_requests', ['team_id'], unique=False)
|
||||
op.create_index(op.f('ix_team_join_requests_user_id'), 'team_join_requests', ['user_id'], unique=False)
|
||||
op.create_index('ix_tjr_team_status', 'team_join_requests', ['team_id', 'status'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index('ix_tjr_team_status', table_name='team_join_requests')
|
||||
op.drop_index(op.f('ix_team_join_requests_user_id'), table_name='team_join_requests')
|
||||
op.drop_index(op.f('ix_team_join_requests_team_id'), table_name='team_join_requests')
|
||||
op.drop_table('team_join_requests')
|
||||
|
||||
op.drop_index('ix_team_invitations_team', table_name='team_invitations')
|
||||
op.drop_index(op.f('ix_team_invitations_code'), table_name='team_invitations')
|
||||
op.drop_index(op.f('ix_team_invitations_team_id'), table_name='team_invitations')
|
||||
op.drop_table('team_invitations')
|
||||
|
||||
op.drop_constraint('fk_teams_manager_id', 'teams', type_='foreignkey')
|
||||
op.drop_index(op.f('ix_teams_manager_id'), table_name='teams')
|
||||
op.drop_column('teams', 'manager_id')
|
||||
@@ -0,0 +1,37 @@
|
||||
"""2026070901_add_credits_ratio_增加上传图片积分规则
|
||||
|
||||
Revision ID: 2026070901
|
||||
Revises: 6c1aaf036f43
|
||||
Create Date: 2026-07-01 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-01 的数据库迁移内容:
|
||||
1. 积分规则表增加传入视频计费字段(input_video_ratio, input_video_base_credits, input_video_per_second_credits)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '2026070901'
|
||||
down_revision: Union[str, None] = '6c1aaf036f43'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-01 - 积分规则表增加传入图片计费字段
|
||||
# ========================================
|
||||
op.add_column('credit_ratios', sa.Column('input_image_ratio', sa.Float(), server_default='1.0', nullable=False))
|
||||
op.add_column('credit_ratios', sa.Column('input_image_base_credits', sa.Float(), server_default='0.0', nullable=False))
|
||||
op.add_column('credit_ratios', sa.Column('input_image_per_image_credits', sa.Float(), server_default='0.5', nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-01 - 积分规则表增加传入图片计费字段(回滚)
|
||||
# ========================================
|
||||
op.drop_column('credit_ratios', 'input_image_per_image_credits')
|
||||
op.drop_column('credit_ratios', 'input_image_base_credits')
|
||||
op.drop_column('credit_ratios', 'input_image_ratio')
|
||||
@@ -0,0 +1,51 @@
|
||||
"""2026070902_add_generated_date_expression_indexes
|
||||
|
||||
Revision ID: 2026070902
|
||||
Revises: 2026070901
|
||||
Create Date: 2026-07-09 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-09 的数据库迁移内容:
|
||||
1. chat_generation_tasks 表增加 generated_at 日期表达式索引,优化按日期分组/筛选
|
||||
2. generation_records 表增加 generated_at 日期表达式索引,优化按日期分组/筛选
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '2026070902'
|
||||
down_revision: Union[str, None] = '2026070901'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-09 - 生成记录表增加日期表达式索引
|
||||
# ========================================
|
||||
# 使用 CAST(timezone('utc', generated_at) AS date) 而不是 date(generated_at):
|
||||
# 1) 当 generated_at 为 timestamptz 时,date() 的结果依赖会话时区,属于 STABLE 函数,
|
||||
# 无法用于索引表达式。显式指定 UTC 时区后转为 date 是纯计算,属于 IMMUTABLE。
|
||||
# 2) 必须用 CAST(... AS date) 而不能用 ::date::: 的优先级高于函数调用,
|
||||
# PG 会把 func(x)::date 解析为 func(x::date),导致语法错误。
|
||||
op.create_index(
|
||||
'idx_chat_generation_tasks_generated_date',
|
||||
'chat_generation_tasks',
|
||||
[sa.text("CAST(timezone('utc', generated_at) AS date)")],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
'idx_generation_records_generated_date',
|
||||
'generation_records',
|
||||
[sa.text("CAST(timezone('utc', generated_at) AS date)")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-09 - 生成记录表增加日期表达式索引(回滚)
|
||||
# ========================================
|
||||
op.drop_index('idx_generation_records_generated_date', table_name='generation_records')
|
||||
op.drop_index('idx_chat_generation_tasks_generated_date', table_name='chat_generation_tasks')
|
||||
@@ -0,0 +1,654 @@
|
||||
"""2026073101_add_column_comments
|
||||
|
||||
Revision ID: 2026073101
|
||||
Revises: f7g8h9i0j1k2
|
||||
Create Date: 2026-07-31 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-31 的数据库迁移内容:
|
||||
给所有表字段添加 COMMENT 注释,便于数据库维护与排查。
|
||||
仅使用 COMMENT ON COLUMN/COMMENT ON TABLE 语句,不修改列类型与约束。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = '2026073101'
|
||||
down_revision: Union[str, None] = 'f7g8h9i0j1k2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _comment_table(table_name: str, comment: str) -> None:
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
||||
|
||||
|
||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
||||
escaped = comment.replace("'", "''")
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ============================================================
|
||||
# users 表
|
||||
# ============================================================
|
||||
_comment_table("users", "用户表")
|
||||
_comment_column("users", "id", "主键ID")
|
||||
_comment_column("users", "username", "用户名,唯一")
|
||||
_comment_column("users", "email", "邮箱,唯一")
|
||||
_comment_column("users", "phone", "手机号,唯一")
|
||||
_comment_column("users", "hashed_password", "加密后的密码")
|
||||
_comment_column("users", "avatar", "头像URL")
|
||||
_comment_column("users", "credits", "账户积分余额")
|
||||
_comment_column("users", "is_active", "是否启用,True启用")
|
||||
_comment_column("users", "is_admin", "是否管理员,True管理员")
|
||||
_comment_column("users", "user_type", "用户类型:frontend前台用户,admin后台管理员")
|
||||
_comment_column("users", "frontend_user_kind", "前台用户类型:internal内部用户,external外部用户")
|
||||
_comment_column("users", "team_id", "当前归属团队ID,仅前台用户有意义")
|
||||
_comment_column("users", "last_login_at", "最后登录时间")
|
||||
_comment_column("users", "password_set_at", "密码设置时间,NULL表示未设置密码")
|
||||
_comment_column("users", "allowed_menus", "允许访问的菜单列表(JSON),NULL表示继承默认")
|
||||
_comment_column("users", "private_portrait_asset_limit", "私域人像素材总量上限,0表示关闭模块")
|
||||
_comment_column("users", "created_at", "创建时间")
|
||||
_comment_column("users", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# projects 表
|
||||
# ============================================================
|
||||
_comment_table("projects", "项目表")
|
||||
_comment_column("projects", "id", "主键ID")
|
||||
_comment_column("projects", "user_id", "所属用户ID")
|
||||
_comment_column("projects", "name", "项目名称")
|
||||
_comment_column("projects", "industry", "所属行业")
|
||||
_comment_column("projects", "created_at", "创建时间")
|
||||
_comment_column("projects", "updated_at", "更新时间")
|
||||
_comment_column("projects", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# credit_ratios 表
|
||||
# ============================================================
|
||||
_comment_table("credit_ratios", "积分计费规则表")
|
||||
_comment_column("credit_ratios", "id", "主键ID")
|
||||
_comment_column("credit_ratios", "model_config_id", "引擎ID:图片对应image_engines.id,视频对应video_engines.id")
|
||||
_comment_column("credit_ratios", "gen_type", "生成类型:image图片,video视频")
|
||||
_comment_column("credit_ratios", "resolution", "分辨率档位:图片(2K/4K) / 视频(480p/720p/1080p)")
|
||||
_comment_column("credit_ratios", "ratio", "生成倍率,最终积分 = (基础积分+单位积分×时长/张数) × 倍率")
|
||||
_comment_column("credit_ratios", "base_credits", "生成基础积分")
|
||||
_comment_column("credit_ratios", "per_second_credits", "视频每秒积分 / 图片每张积分")
|
||||
_comment_column("credit_ratios", "input_video_ratio", "传入视频积分倍率")
|
||||
_comment_column("credit_ratios", "input_video_base_credits", "传入视频基础积分")
|
||||
_comment_column("credit_ratios", "input_video_per_second_credits", "传入视频每秒积分")
|
||||
_comment_column("credit_ratios", "input_image_ratio", "传入图片积分倍率")
|
||||
_comment_column("credit_ratios", "input_image_base_credits", "传入图片基础积分")
|
||||
_comment_column("credit_ratios", "input_image_per_image_credits", "传入图片每张积分")
|
||||
_comment_column("credit_ratios", "created_at", "创建时间")
|
||||
_comment_column("credit_ratios", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# credit_records 表
|
||||
# ============================================================
|
||||
_comment_table("credit_records", "积分流水表")
|
||||
_comment_column("credit_records", "id", "主键ID")
|
||||
_comment_column("credit_records", "user_id", "所属用户ID")
|
||||
_comment_column("credit_records", "type", "流水类型:charge扣费,recharge充值,refund退款,gift赠送")
|
||||
_comment_column("credit_records", "amount", "流水金额,扣费为负数,充值/退款/赠送为正数")
|
||||
_comment_column("credit_records", "balance_after", "流水后账户余额")
|
||||
_comment_column("credit_records", "description", "流水描述")
|
||||
_comment_column("credit_records", "related_id", "关联业务ID,如生成任务ID/订单ID")
|
||||
_comment_column("credit_records", "biz_key", "业务幂等键,格式如 owner_type:owner_id:attempt_no:charge_kind:action")
|
||||
_comment_column("credit_records", "refund_for_biz_key", "退款时,对应的扣费biz_key")
|
||||
_comment_column("credit_records", "owner_type", "归属类型:chat_generation_task/ generation_record等")
|
||||
_comment_column("credit_records", "owner_id", "归属业务记录ID")
|
||||
_comment_column("credit_records", "attempt_no", "计费尝试次数,重试时递增")
|
||||
_comment_column("credit_records", "charge_kind", "扣费大类:media媒体生成,prompt提示词等")
|
||||
_comment_column("credit_records", "charge_action", "扣费动作:charge扣费,refund退款")
|
||||
_comment_column("credit_records", "credit_subject", "计费科目:image/video/text")
|
||||
_comment_column("credit_records", "media_type", "媒体类型:与credit_subject配合细分")
|
||||
_comment_column("credit_records", "billing_scene", "计费场景:如chat_creation、project等")
|
||||
_comment_column("credit_records", "source_module", "来源模块:generation_record/module_generation等")
|
||||
_comment_column("credit_records", "source_project_id", "来源项目ID")
|
||||
_comment_column("credit_records", "source_step_id", "来源步骤ID")
|
||||
_comment_column("credit_records", "source_step_code", "来源步骤编码")
|
||||
_comment_column("credit_records", "token_usage_id", "关联Token消耗记录ID")
|
||||
_comment_column("credit_records", "input_tokens", "输入Token数量快照")
|
||||
_comment_column("credit_records", "output_tokens", "输出Token数量快照")
|
||||
_comment_column("credit_records", "total_tokens", "总Token数量快照")
|
||||
_comment_column("credit_records", "engine_type", "引擎类型:image/video/text")
|
||||
_comment_column("credit_records", "engine_id", "使用的引擎ID")
|
||||
_comment_column("credit_records", "engine_name", "引擎名称快照")
|
||||
_comment_column("credit_records", "engine_provider", "引擎供应商快照:ark/其他")
|
||||
_comment_column("credit_records", "engine_model_name", "引擎模型名快照")
|
||||
_comment_column("credit_records", "user_type_snapshot", "用户类型快照:frontend/admin")
|
||||
_comment_column("credit_records", "frontend_user_kind_snapshot", "前台用户类型快照:internal/external")
|
||||
_comment_column("credit_records", "team_id_snapshot", "团队ID快照,流水发生时的归属团队")
|
||||
_comment_column("credit_records", "team_name_snapshot", "团队名称快照")
|
||||
_comment_column("credit_records", "created_at", "创建时间")
|
||||
_comment_column("credit_records", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# chat_generation_tasks 表
|
||||
# ============================================================
|
||||
_comment_table("chat_generation_tasks", "AI创作任务表(不绑定项目的聊天式生成)")
|
||||
_comment_column("chat_generation_tasks", "id", "主键ID,顶层/子任务ID")
|
||||
_comment_column("chat_generation_tasks", "user_id", "所属用户ID")
|
||||
_comment_column("chat_generation_tasks", "original_prompt", "原始用户提示词")
|
||||
_comment_column("chat_generation_tasks", "optimized_prompt", "优化后的提示词")
|
||||
_comment_column("chat_generation_tasks", "gen_type", "生成类型:image图片,video视频")
|
||||
_comment_column("chat_generation_tasks", "duration", "视频时长(秒)")
|
||||
_comment_column("chat_generation_tasks", "aspect_ratio", "视频比例:16:9/9:16等")
|
||||
_comment_column("chat_generation_tasks", "resolution", "用户选择的分辨率")
|
||||
_comment_column("chat_generation_tasks", "provider_generation_resolution", "供应商实际生成分辨率")
|
||||
_comment_column("chat_generation_tasks", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
||||
_comment_column("chat_generation_tasks", "video_upscale_snapshot_json", "视频超分参数快照JSON")
|
||||
_comment_column("chat_generation_tasks", "image_size", "图片分辨率档位:2K/4K")
|
||||
_comment_column("chat_generation_tasks", "image_proportion", "图片比例:1:1/16:9等")
|
||||
_comment_column("chat_generation_tasks", "image_px", "图片像素,如2048×2048")
|
||||
_comment_column("chat_generation_tasks", "status", "任务状态:generating/success/failed等")
|
||||
_comment_column("chat_generation_tasks", "pipeline_stage", "流水线阶段:prompt_optimized/resource_generated等")
|
||||
_comment_column("chat_generation_tasks", "generation_mode", "生成模式:chatapi_async单份异步/chatapi_main多份主任务")
|
||||
_comment_column("chat_generation_tasks", "parent_task_id", "父任务ID,多份生成时子任务关联主任务")
|
||||
_comment_column("chat_generation_tasks", "generation_count", "生成份数,主任务表示总共多少份")
|
||||
_comment_column("chat_generation_tasks", "generation_index", "第N份子任务,主任务为NULL")
|
||||
_comment_column("chat_generation_tasks", "generation_attempt_no", "生成尝试次数,重试时递增")
|
||||
_comment_column("chat_generation_tasks", "resource_generation_started_at", "资源生成开始时间")
|
||||
_comment_column("chat_generation_tasks", "provider_create_claim_token", "供应商创建任务分布式租约token")
|
||||
_comment_column("chat_generation_tasks", "provider_create_lease_until", "供应商创建租约过期时间")
|
||||
_comment_column("chat_generation_tasks", "provider_create_started_at", "供应商创建任务开始时间")
|
||||
_comment_column("chat_generation_tasks", "media_references", "参考素材JSON数组")
|
||||
_comment_column("chat_generation_tasks", "provider_task_id", "供应商任务ID")
|
||||
_comment_column("chat_generation_tasks", "seedance_task_id", "Seedance任务ID(兼容字段)")
|
||||
_comment_column("chat_generation_tasks", "remote_result_url", "供应商返回的远程资源URL")
|
||||
_comment_column("chat_generation_tasks", "image_url", "图片结果URL")
|
||||
_comment_column("chat_generation_tasks", "video_url", "视频结果URL")
|
||||
_comment_column("chat_generation_tasks", "video_cover_url", "视频封面URL")
|
||||
_comment_column("chat_generation_tasks", "engine_id", "使用的引擎ID")
|
||||
_comment_column("chat_generation_tasks", "engine_snapshot_json", "引擎参数快照JSON")
|
||||
_comment_column("chat_generation_tasks", "provider_response_json", "供应商完整响应JSON")
|
||||
_comment_column("chat_generation_tasks", "credits_cost", "媒体生成消耗的总积分")
|
||||
_comment_column("chat_generation_tasks", "text_credits_cost", "提示词优化消耗积分")
|
||||
_comment_column("chat_generation_tasks", "text_tokens_used", "提示词优化Token消耗")
|
||||
_comment_column("chat_generation_tasks", "video_tokens_used", "视频生成Token消耗")
|
||||
_comment_column("chat_generation_tasks", "image_tokens_used", "图片生成Token消耗")
|
||||
_comment_column("chat_generation_tasks", "retry_count", "重试次数(兼容旧字段)")
|
||||
_comment_column("chat_generation_tasks", "manual_retry_count", "用户手动重试次数")
|
||||
_comment_column("chat_generation_tasks", "poll_error_count", "轮询错误次数")
|
||||
_comment_column("chat_generation_tasks", "poll_count", "轮询总次数")
|
||||
_comment_column("chat_generation_tasks", "last_poll_at", "最后一次轮询时间")
|
||||
_comment_column("chat_generation_tasks", "poll_started_at", "本次轮询开始时间")
|
||||
_comment_column("chat_generation_tasks", "next_poll_at", "下一次轮询触发时间")
|
||||
_comment_column("chat_generation_tasks", "poll_interval_seconds", "轮询间隔秒数")
|
||||
_comment_column("chat_generation_tasks", "poll_claim_token", "轮询分布式租约token")
|
||||
_comment_column("chat_generation_tasks", "poll_lease_until", "轮询租约过期时间")
|
||||
_comment_column("chat_generation_tasks", "deadline_at", "任务截止时间,超时自动失败")
|
||||
_comment_column("chat_generation_tasks", "generated_at", "资源生成完成时间")
|
||||
_comment_column("chat_generation_tasks", "error_message", "错误信息")
|
||||
_comment_column("chat_generation_tasks", "idempotency_key", "幂等键,防重复创建")
|
||||
_comment_column("chat_generation_tasks", "download_celery_task_id", "下载步骤Celery任务ID")
|
||||
_comment_column("chat_generation_tasks", "download_enqueued_at", "下载入队时间")
|
||||
_comment_column("chat_generation_tasks", "download_started_at", "下载开始时间")
|
||||
_comment_column("chat_generation_tasks", "download_claim_token", "下载租约token")
|
||||
_comment_column("chat_generation_tasks", "download_lease_until", "下载租约过期时间")
|
||||
_comment_column("chat_generation_tasks", "download_next_retry_at", "下载下次重试时间")
|
||||
_comment_column("chat_generation_tasks", "download_attempt_count", "下载重试次数")
|
||||
_comment_column("chat_generation_tasks", "download_last_error", "下载最后一次错误信息")
|
||||
_comment_column("chat_generation_tasks", "download_storage_date_dir", "下载存储日期目录")
|
||||
_comment_column("chat_generation_tasks", "created_at", "创建时间")
|
||||
_comment_column("chat_generation_tasks", "updated_at", "更新时间")
|
||||
_comment_column("chat_generation_tasks", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# generation_records 表
|
||||
# ============================================================
|
||||
_comment_table("generation_records", "项目生成记录表(绑定项目的旧版生成)")
|
||||
_comment_column("generation_records", "id", "主键ID")
|
||||
_comment_column("generation_records", "user_id", "所属用户ID")
|
||||
_comment_column("generation_records", "project_id", "所属项目ID")
|
||||
_comment_column("generation_records", "original_prompt", "原始提示词")
|
||||
_comment_column("generation_records", "optimized_prompt", "优化后的提示词")
|
||||
_comment_column("generation_records", "prompt_usage_snapshot_json", "提示词消耗快照JSON")
|
||||
_comment_column("generation_records", "gen_type", "生成类型:image/video")
|
||||
_comment_column("generation_records", "duration", "视频时长秒数")
|
||||
_comment_column("generation_records", "aspect_ratio", "视频比例")
|
||||
_comment_column("generation_records", "resolution", "分辨率档位")
|
||||
_comment_column("generation_records", "provider_generation_resolution", "供应商实际分辨率")
|
||||
_comment_column("generation_records", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
||||
_comment_column("generation_records", "video_upscale_snapshot_json", "视频超分快照JSON")
|
||||
_comment_column("generation_records", "image_size", "图片分辨率档位")
|
||||
_comment_column("generation_records", "image_proportion", "图片比例")
|
||||
_comment_column("generation_records", "image_px", "图片像素尺寸")
|
||||
_comment_column("generation_records", "status", "任务状态")
|
||||
_comment_column("generation_records", "pipeline_stage", "流水线阶段")
|
||||
_comment_column("generation_records", "video_url", "视频结果URL")
|
||||
_comment_column("generation_records", "video_cover_url", "视频封面URL")
|
||||
_comment_column("generation_records", "image_url", "图片结果URL")
|
||||
_comment_column("generation_records", "media_references", "参考素材JSON数组")
|
||||
_comment_column("generation_records", "include_media_references", "是否包含参考素材")
|
||||
_comment_column("generation_records", "video_url_expires_at", "视频URL过期时间")
|
||||
_comment_column("generation_records", "seedance_task_id", "Seedance任务ID")
|
||||
_comment_column("generation_records", "credits_cost", "媒体生成消耗积分")
|
||||
_comment_column("generation_records", "text_credits_cost", "提示词消耗积分")
|
||||
_comment_column("generation_records", "text_tokens_used", "提示词Token数")
|
||||
_comment_column("generation_records", "video_tokens_used", "视频Token数")
|
||||
_comment_column("generation_records", "image_tokens_used", "图片Token数")
|
||||
_comment_column("generation_records", "generated_at", "生成完成时间")
|
||||
_comment_column("generation_records", "error_message", "错误信息")
|
||||
_comment_column("generation_records", "idempotency_key", "幂等键")
|
||||
_comment_column("generation_records", "generation_attempt_no", "生成尝试次数")
|
||||
_comment_column("generation_records", "resource_generation_started_at", "资源生成开始时间")
|
||||
_comment_column("generation_records", "deadline_at", "任务截止时间")
|
||||
_comment_column("generation_records", "engine_id", "使用引擎ID")
|
||||
_comment_column("generation_records", "engine_snapshot_json", "引擎参数快照JSON")
|
||||
_comment_column("generation_records", "provider_response_json", "供应商响应JSON")
|
||||
_comment_column("generation_records", "remote_result_url", "远程资源URL")
|
||||
_comment_column("generation_records", "provider_create_claim_token", "供应商创建租约token")
|
||||
_comment_column("generation_records", "provider_create_lease_until", "供应商创建租约过期")
|
||||
_comment_column("generation_records", "provider_create_started_at", "供应商创建开始时间")
|
||||
_comment_column("generation_records", "retry_count", "重试次数(兼容)")
|
||||
_comment_column("generation_records", "manual_retry_count", "手动重试次数")
|
||||
_comment_column("generation_records", "poll_error_count", "轮询错误次数")
|
||||
_comment_column("generation_records", "poll_count", "轮询次数")
|
||||
_comment_column("generation_records", "last_poll_at", "最后轮询时间")
|
||||
_comment_column("generation_records", "poll_started_at", "轮询开始时间")
|
||||
_comment_column("generation_records", "next_poll_at", "下次轮询时间")
|
||||
_comment_column("generation_records", "poll_interval_seconds", "轮询间隔秒")
|
||||
_comment_column("generation_records", "poll_claim_token", "轮询租约token")
|
||||
_comment_column("generation_records", "poll_lease_until", "轮询租约过期")
|
||||
_comment_column("generation_records", "download_celery_task_id", "下载Celery任务ID")
|
||||
_comment_column("generation_records", "download_enqueued_at", "下载开始入队时间")
|
||||
_comment_column("generation_records", "download_started_at", "下载开始时间")
|
||||
_comment_column("generation_records", "download_claim_token", "下载租约token")
|
||||
_comment_column("generation_records", "download_lease_until", "下载租约过期")
|
||||
_comment_column("generation_records", "download_next_retry_at", "下载下次重试")
|
||||
_comment_column("generation_records", "download_attempt_count", "下载重试次数")
|
||||
_comment_column("generation_records", "download_last_error", "下载最后错误")
|
||||
_comment_column("generation_records", "download_storage_date_dir", "下载存储日期目录")
|
||||
_comment_column("generation_records", "created_at", "创建时间")
|
||||
_comment_column("generation_records", "updated_at", "更新时间")
|
||||
_comment_column("generation_records", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# generated_resources 表
|
||||
# ============================================================
|
||||
_comment_table("generated_resources", "生成资源账本表(统一记录所有生成的图片/视频)")
|
||||
_comment_column("generated_resources", "id", "主键ID")
|
||||
_comment_column("generated_resources", "user_id", "所属用户ID")
|
||||
_comment_column("generated_resources", "resource_type", "资源类型:image/video")
|
||||
_comment_column("generated_resources", "resource_url", "资源访问URL")
|
||||
_comment_column("generated_resources", "remote_url", "供应商原始远程URL")
|
||||
_comment_column("generated_resources", "storage_type", "存储类型:local本地/oss对象存储")
|
||||
_comment_column("generated_resources", "storage_path", "存储路径")
|
||||
_comment_column("generated_resources", "file_name", "文件名,平台素材名称")
|
||||
_comment_column("generated_resources", "file_size_bytes", "文件大小(字节)")
|
||||
_comment_column("generated_resources", "source_model", "来源模型:chat_generation_task/generation_record")
|
||||
_comment_column("generated_resources", "source_model_module", "来源模块描述")
|
||||
_comment_column("generated_resources", "source_id", "来源记录ID")
|
||||
_comment_column("generated_resources", "engine_id", "使用引擎ID")
|
||||
_comment_column("generated_resources", "engine_type", "引擎类型:image/video")
|
||||
_comment_column("generated_resources", "provider", "供应商:ark/其他")
|
||||
_comment_column("generated_resources", "model_name", "模型名称")
|
||||
_comment_column("generated_resources", "generated_at", "资源生成完成时间")
|
||||
_comment_column("generated_resources", "resource_month", "资源归属月份,按月统计")
|
||||
_comment_column("generated_resources", "extra_json", "扩展字段JSON")
|
||||
_comment_column("generated_resources", "created_at", "创建时间")
|
||||
_comment_column("generated_resources", "updated_at", "更新时间")
|
||||
_comment_column("generated_resources", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# upload_resources 表
|
||||
# ============================================================
|
||||
_comment_table("upload_resources", "用户上传资源账本表(用户上传/模块上传/切片文件)")
|
||||
_comment_column("upload_resources", "id", "主键ID")
|
||||
_comment_column("upload_resources", "user_id", "所属用户ID")
|
||||
_comment_column("upload_resources", "module", "所属模块:conversation/generation_record等")
|
||||
_comment_column("upload_resources", "resource_type", "资源类型:image/video/audio/file")
|
||||
_comment_column("upload_resources", "resource_url", "资源访问URL")
|
||||
_comment_column("upload_resources", "storage_path", "存储路径,唯一")
|
||||
_comment_column("upload_resources", "file_name", "原始文件名")
|
||||
_comment_column("upload_resources", "file_ext", "文件扩展名")
|
||||
_comment_column("upload_resources", "mime_type", "MIME类型")
|
||||
_comment_column("upload_resources", "file_size_bytes", "文件大小(字节)")
|
||||
_comment_column("upload_resources", "duration_seconds", "音视频时长(秒)")
|
||||
_comment_column("upload_resources", "duration_source", "时长来源:probe探测/用户设置")
|
||||
_comment_column("upload_resources", "width", "图片/视频宽度(像素)")
|
||||
_comment_column("upload_resources", "height", "图片/视频高度(像素)")
|
||||
_comment_column("upload_resources", "source_model", "关联业务模型")
|
||||
_comment_column("upload_resources", "source_id", "关联业务记录ID")
|
||||
_comment_column("upload_resources", "source_module", "关联业务模块")
|
||||
_comment_column("upload_resources", "bind_status", "绑定状态:pending待绑定/bound已绑定/unbound已解绑")
|
||||
_comment_column("upload_resources", "delete_policy", "删除策略:user_deletable用户可删/keep_forever永久保留")
|
||||
_comment_column("upload_resources", "created_by", "创建来源:api用户上传/worker系统生成")
|
||||
_comment_column("upload_resources", "metadata_json", "媒体元数据JSON")
|
||||
_comment_column("upload_resources", "capacity_released_at", "容量统计中已释放时间")
|
||||
_comment_column("upload_resources", "physical_deleted_at", "物理文件删除时间")
|
||||
_comment_column("upload_resources", "file_delete_status", "文件删除状态:active待删/deleting删除中/deleted已删除/error失败")
|
||||
_comment_column("upload_resources", "file_delete_error", "文件删除失败信息")
|
||||
_comment_column("upload_resources", "created_at", "创建时间")
|
||||
_comment_column("upload_resources", "updated_at", "更新时间")
|
||||
_comment_column("upload_resources", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# image_engines 表
|
||||
# ============================================================
|
||||
_comment_table("image_engines", "图片生成引擎配置表")
|
||||
_comment_column("image_engines", "id", "主键ID")
|
||||
_comment_column("image_engines", "name", "引擎显示名称")
|
||||
_comment_column("image_engines", "provider", "供应商:ark/其他")
|
||||
_comment_column("image_engines", "api_base", "API基础地址")
|
||||
_comment_column("image_engines", "api_key", "API密钥")
|
||||
_comment_column("image_engines", "model_name", "模型名")
|
||||
_comment_column("image_engines", "supported_models", "支持的模型列表JSON")
|
||||
_comment_column("image_engines", "supported_sizes", "支持尺寸JSON:{分辨率:{比例:像素}}")
|
||||
_comment_column("image_engines", "default_size", "默认分辨率档位")
|
||||
_comment_column("image_engines", "max_image_count", "允许生成图片数量上限")
|
||||
_comment_column("image_engines", "multi_generation_enabled", "是否允许多份生成")
|
||||
_comment_column("image_engines", "max_generation_count", "多份生成最大份数")
|
||||
_comment_column("image_engines", "multi_image_max_images", "组图接口参考图+生成图数量上限")
|
||||
_comment_column("image_engines", "max_reference_image_count", "最多参考图片张数")
|
||||
_comment_column("image_engines", "output_format", "输出格式,空表示使用默认")
|
||||
_comment_column("image_engines", "generate_url", "生成接口URL,留空使用SDK默认")
|
||||
_comment_column("image_engines", "is_active", "是否启用")
|
||||
_comment_column("image_engines", "priority", "排序优先级,越大越优先")
|
||||
_comment_column("image_engines", "created_at", "创建时间")
|
||||
_comment_column("image_engines", "updated_at", "更新时间")
|
||||
_comment_column("image_engines", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# video_engines 表
|
||||
# ============================================================
|
||||
_comment_table("video_engines", "视频生成引擎配置表")
|
||||
_comment_column("video_engines", "id", "主键ID")
|
||||
_comment_column("video_engines", "name", "引擎显示名称")
|
||||
_comment_column("video_engines", "provider", "供应商:ark/其他")
|
||||
_comment_column("video_engines", "api_base", "API基础地址")
|
||||
_comment_column("video_engines", "api_key", "API密钥")
|
||||
_comment_column("video_engines", "model_name", "模型名")
|
||||
_comment_column("video_engines", "supported_ratios", "支持比例JSON数组")
|
||||
_comment_column("video_engines", "supported_resolutions", "支持分辨率JSON数组")
|
||||
_comment_column("video_engines", "supported_durations", "支持时长JSON数组")
|
||||
_comment_column("video_engines", "max_duration", "最大时长秒数")
|
||||
_comment_column("video_engines", "max_image_count", "最多参考图片张数,0表示不支持")
|
||||
_comment_column("video_engines", "max_video_count", "最多参考视频段数,0表示不支持")
|
||||
_comment_column("video_engines", "max_audio_count", "最多参考音频段数,0表示不支持")
|
||||
_comment_column("video_engines", "multi_generation_enabled", "是否允许多份生成")
|
||||
_comment_column("video_engines", "max_generation_count", "多份生成最大份数")
|
||||
_comment_column("video_engines", "supports_first_last_frame", "是否支持首尾帧参考")
|
||||
_comment_column("video_engines", "supports_universal_reference", "是否支持通用参考素材")
|
||||
_comment_column("video_engines", "generate_url", "生成接口URL")
|
||||
_comment_column("video_engines", "query_url", "查询接口URL")
|
||||
_comment_column("video_engines", "is_active", "是否启用")
|
||||
_comment_column("video_engines", "priority", "排序优先级")
|
||||
_comment_column("video_engines", "created_at", "创建时间")
|
||||
_comment_column("video_engines", "updated_at", "更新时间")
|
||||
_comment_column("video_engines", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# model_configs 表
|
||||
# ============================================================
|
||||
_comment_table("model_configs", "文本模型配置表(提示词优化等文本模型)")
|
||||
_comment_column("model_configs", "id", "主键ID")
|
||||
_comment_column("model_configs", "name", "模型显示名称")
|
||||
_comment_column("model_configs", "provider", "供应商")
|
||||
_comment_column("model_configs", "api_base", "API基础地址")
|
||||
_comment_column("model_configs", "api_key", "API密钥")
|
||||
_comment_column("model_configs", "model_name", "模型名")
|
||||
_comment_column("model_configs", "weight", "权重,权重选择时使用")
|
||||
_comment_column("model_configs", "max_tokens", "最大输出Token数")
|
||||
_comment_column("model_configs", "temperature", "采样温度")
|
||||
_comment_column("model_configs", "is_active", "是否启用")
|
||||
_comment_column("model_configs", "priority", "排序优先级")
|
||||
_comment_column("model_configs", "created_at", "创建时间")
|
||||
_comment_column("model_configs", "updated_at", "更新时间")
|
||||
_comment_column("model_configs", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# system_configs 表
|
||||
# ============================================================
|
||||
_comment_table("system_configs", "系统配置表")
|
||||
_comment_column("system_configs", "id", "主键ID")
|
||||
_comment_column("system_configs", "key", "配置键名,唯一")
|
||||
_comment_column("system_configs", "value", "配置值")
|
||||
_comment_column("system_configs", "description", "配置说明")
|
||||
_comment_column("system_configs", "created_at", "创建时间")
|
||||
_comment_column("system_configs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# operation_logs 表
|
||||
# ============================================================
|
||||
_comment_table("operation_logs", "操作日志表")
|
||||
_comment_column("operation_logs", "id", "主键ID")
|
||||
_comment_column("operation_logs", "user_id", "操作用户ID")
|
||||
_comment_column("operation_logs", "username", "操作用户名")
|
||||
_comment_column("operation_logs", "action", "操作动作:CREATE/UPDATE/DELETE等")
|
||||
_comment_column("operation_logs", "method", "HTTP方法:GET/POST/PUT/DELETE")
|
||||
_comment_column("operation_logs", "path", "请求路径")
|
||||
_comment_column("operation_logs", "detail", "操作详情JSON")
|
||||
_comment_column("operation_logs", "ip", "客户端IP")
|
||||
_comment_column("operation_logs", "created_at", "创建时间")
|
||||
_comment_column("operation_logs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# notifications 表
|
||||
# ============================================================
|
||||
_comment_table("notifications", "通知消息表")
|
||||
_comment_column("notifications", "id", "主键ID")
|
||||
_comment_column("notifications", "user_id", "接收用户ID,NULL表示全体广播")
|
||||
_comment_column("notifications", "title", "通知标题")
|
||||
_comment_column("notifications", "content", "通知内容")
|
||||
_comment_column("notifications", "type", "通知类型:system系统公告/billing账单通知等")
|
||||
_comment_column("notifications", "is_read", "是否已读")
|
||||
_comment_column("notifications", "related_id", "关联业务ID")
|
||||
_comment_column("notifications", "created_at", "创建时间")
|
||||
_comment_column("notifications", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# recharge_packages 表
|
||||
# ============================================================
|
||||
_comment_table("recharge_packages", "积分充值套餐表")
|
||||
_comment_column("recharge_packages", "id", "主键ID")
|
||||
_comment_column("recharge_packages", "name", "套餐名称")
|
||||
_comment_column("recharge_packages", "credits", "套餐包含积分")
|
||||
_comment_column("recharge_packages", "price", "套餐价格(元)")
|
||||
_comment_column("recharge_packages", "bonus_credits", "赠送积分")
|
||||
_comment_column("recharge_packages", "description", "套餐描述")
|
||||
_comment_column("recharge_packages", "package_type", "套餐类型:normal普通/gift赠送首充等")
|
||||
_comment_column("recharge_packages", "is_gift", "是否赠送套餐")
|
||||
_comment_column("recharge_packages", "is_active", "是否启用")
|
||||
_comment_column("recharge_packages", "sort_order", "排序值,越小越靠前")
|
||||
_comment_column("recharge_packages", "created_at", "创建时间")
|
||||
_comment_column("recharge_packages", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# payment_orders 表
|
||||
# ============================================================
|
||||
_comment_table("payment_orders", "支付订单表")
|
||||
_comment_column("payment_orders", "id", "主键ID")
|
||||
_comment_column("payment_orders", "user_id", "下单用户ID")
|
||||
_comment_column("payment_orders", "order_no", "订单号,唯一")
|
||||
_comment_column("payment_orders", "amount", "支付金额(元)")
|
||||
_comment_column("payment_orders", "credits", "获得积分总数(含赠送)")
|
||||
_comment_column("payment_orders", "payment_method", "支付方式:wxpay/alipay等")
|
||||
_comment_column("payment_orders", "status", "订单状态:pending待支付/paid已支付/refunded已退款/failed失败")
|
||||
_comment_column("payment_orders", "paid_at", "支付成功时间")
|
||||
_comment_column("payment_orders", "trade_no", "第三方支付流水号")
|
||||
_comment_column("payment_orders", "refund_trade_no", "退款流水号")
|
||||
_comment_column("payment_orders", "refunded_at", "退款完成时间")
|
||||
_comment_column("payment_orders", "refund_amount", "退款金额")
|
||||
_comment_column("payment_orders", "created_at", "创建时间")
|
||||
_comment_column("payment_orders", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# video_upscale_tasks 表
|
||||
# ============================================================
|
||||
_comment_table("video_upscale_tasks", "视频超分任务表")
|
||||
_comment_column("video_upscale_tasks", "id", "主键ID")
|
||||
_comment_column("video_upscale_tasks", "chat_generation_task_id", "关联AI创作任务ID,与generation_record_id二选一")
|
||||
_comment_column("video_upscale_tasks", "generation_record_id", "关联项目生成记录ID,与chat_generation_task_id二选一")
|
||||
_comment_column("video_upscale_tasks", "api_generation_task_id", "关联API生成任务ID")
|
||||
_comment_column("video_upscale_tasks", "status", "任务状态:pending/processing/success/failed")
|
||||
_comment_column("video_upscale_tasks", "stage", "阶段:upscale_queued/upscale_processing等")
|
||||
_comment_column("video_upscale_tasks", "processor_key", "处理节点标识")
|
||||
_comment_column("video_upscale_tasks", "attempt_count", "执行尝试次数")
|
||||
_comment_column("video_upscale_tasks", "failure_count", "失败次数")
|
||||
_comment_column("video_upscale_tasks", "manual_retry_count", "手动重试次数")
|
||||
_comment_column("video_upscale_tasks", "next_retry_at", "下次重试时间")
|
||||
_comment_column("video_upscale_tasks", "last_error", "最后错误信息")
|
||||
_comment_column("video_upscale_tasks", "source_local_path", "源视频本地路径")
|
||||
_comment_column("video_upscale_tasks", "source_file_size_bytes", "源文件大小(字节)")
|
||||
_comment_column("video_upscale_tasks", "source_width", "源视频宽度")
|
||||
_comment_column("video_upscale_tasks", "source_height", "源视频高度")
|
||||
_comment_column("video_upscale_tasks", "source_duration_seconds", "源视频时长秒数")
|
||||
_comment_column("video_upscale_tasks", "source_deleted_at", "源文件删除时间")
|
||||
_comment_column("video_upscale_tasks", "source_delete_error", "源文件删除错误")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url", "源文件远程URL")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url_signed_at", "远程URL签名时间")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url_expires_at", "远程URL过期时间")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url_last_probe_at", "远程URL最后探测时间")
|
||||
_comment_column("video_upscale_tasks", "source_remote_url_probe_status", "远程URL探测状态")
|
||||
_comment_column("video_upscale_tasks", "input_source_type", "输入源类型:local/remote")
|
||||
_comment_column("video_upscale_tasks", "input_source_fallback_count", "输入源回退次数")
|
||||
_comment_column("video_upscale_tasks", "target_width", "目标宽度像素")
|
||||
_comment_column("video_upscale_tasks", "target_height", "目标高度像素")
|
||||
_comment_column("video_upscale_tasks", "effective_target_width", "实际生效目标宽度")
|
||||
_comment_column("video_upscale_tasks", "effective_target_height", "实际生效目标高度")
|
||||
_comment_column("video_upscale_tasks", "provider_task_id", "供应商超分任务ID")
|
||||
_comment_column("video_upscale_tasks", "provider_request_json", "供应商请求JSON")
|
||||
_comment_column("video_upscale_tasks", "provider_response_json", "供应商响应JSON")
|
||||
_comment_column("video_upscale_tasks", "provider_output_url", "供应商输出URL")
|
||||
_comment_column("video_upscale_tasks", "provider_output_url_expires_at", "供应商输出URL过期")
|
||||
_comment_column("video_upscale_tasks", "provider_submitted_at", "提交供应商时间")
|
||||
_comment_column("video_upscale_tasks", "final_local_path", "最终本地文件路径")
|
||||
_comment_column("video_upscale_tasks", "final_resource_url", "最终资源访问URL")
|
||||
_comment_column("video_upscale_tasks", "final_file_size_bytes", "最终文件大小(字节)")
|
||||
_comment_column("video_upscale_tasks", "celery_task_id", "Celery任务ID")
|
||||
_comment_column("video_upscale_tasks", "lease_token", "分布式租约token")
|
||||
_comment_column("video_upscale_tasks", "lease_until", "租约过期时间")
|
||||
_comment_column("video_upscale_tasks", "started_at", "开始处理时间")
|
||||
_comment_column("video_upscale_tasks", "completed_at", "完成时间")
|
||||
_comment_column("video_upscale_tasks", "failed_at", "失败时间")
|
||||
_comment_column("video_upscale_tasks", "created_at", "创建时间")
|
||||
_comment_column("video_upscale_tasks", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# shot_replicate_task_sets 表
|
||||
# ============================================================
|
||||
_comment_table("shot_replicate_task_sets", "拆镜复刻总任务集")
|
||||
_comment_column("shot_replicate_task_sets", "id", "主键ID")
|
||||
_comment_column("shot_replicate_task_sets", "user_id", "所属用户ID")
|
||||
_comment_column("shot_replicate_task_sets", "title", "任务集标题")
|
||||
_comment_column("shot_replicate_task_sets", "video_url", "上传视频访问URL")
|
||||
_comment_column("shot_replicate_task_sets", "video_path", "上传视频存储路径")
|
||||
_comment_column("shot_replicate_task_sets", "video_duration_seconds", "上传视频总时长秒数")
|
||||
_comment_column("shot_replicate_task_sets", "status", "总任务状态:pending_analysis/analyzing/analysis_done等")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_status", "AI分析状态:pending/processing/success/failed")
|
||||
_comment_column("shot_replicate_task_sets", "split_status", "切片状态:none/slicing/sliced")
|
||||
_comment_column("shot_replicate_task_sets", "original_video_content", "原视频内容描述")
|
||||
_comment_column("shot_replicate_task_sets", "original_video_category", "原视频行业分类")
|
||||
_comment_column("shot_replicate_task_sets", "original_video_audience", "原视频目标受众")
|
||||
_comment_column("shot_replicate_task_sets", "ai_suggestion_json", "AI复刻建议JSON")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_raw_json", "AI分析原始JSON")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_result_json", "AI分析结果JSON")
|
||||
_comment_column("shot_replicate_task_sets", "segment_count", "总拆镜头数")
|
||||
_comment_column("shot_replicate_task_sets", "completed_segment_count", "已完成镜头数")
|
||||
_comment_column("shot_replicate_task_sets", "failed_segment_count", "失败镜头数")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_attempt_no", "AI分析尝试次数")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_claim_token", "AI分析租约token")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_started_at", "AI分析开始时间")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_lease_until", "AI分析租约过期")
|
||||
_comment_column("shot_replicate_task_sets", "analysis_error_message", "AI分析错误信息")
|
||||
_comment_column("shot_replicate_task_sets", "split_error_message", "切片错误信息")
|
||||
_comment_column("shot_replicate_task_sets", "idempotency_key", "幂等键")
|
||||
_comment_column("shot_replicate_task_sets", "created_at", "创建时间")
|
||||
_comment_column("shot_replicate_task_sets", "updated_at", "更新时间")
|
||||
_comment_column("shot_replicate_task_sets", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# teams 表(已经有部分comment,补齐未加的)
|
||||
# ============================================================
|
||||
_comment_table("teams", "团队表")
|
||||
_comment_column("teams", "id", "主键ID")
|
||||
_comment_column("teams", "name", "团队名称")
|
||||
_comment_column("teams", "code", "团队编码")
|
||||
_comment_column("teams", "description", "团队备注")
|
||||
_comment_column("teams", "status", "团队状态:active启用,disabled禁用")
|
||||
_comment_column("teams", "sort_order", "排序值,越小越靠前")
|
||||
_comment_column("teams", "manager_id", "团队管理人ID")
|
||||
_comment_column("teams", "created_at", "创建时间")
|
||||
_comment_column("teams", "updated_at", "更新时间")
|
||||
_comment_column("teams", "deleted_at", "软删除时间")
|
||||
|
||||
# ============================================================
|
||||
# user_resource_capacity_configs 表(已部分有comment)
|
||||
# ============================================================
|
||||
_comment_table("user_resource_capacity_configs", "用户个人容量配置表")
|
||||
_comment_column("user_resource_capacity_configs", "id", "主键ID")
|
||||
_comment_column("user_resource_capacity_configs", "user_id", "用户ID")
|
||||
_comment_column("user_resource_capacity_configs", "enabled", "是否启用该用户个人容量限制")
|
||||
_comment_column("user_resource_capacity_configs", "limit_value", "容量数值,最小1,最多3位小数")
|
||||
_comment_column("user_resource_capacity_configs", "limit_unit", "容量单位:MB/GB/TB")
|
||||
_comment_column("user_resource_capacity_configs", "limit_bytes", "换算后的容量字节数")
|
||||
_comment_column("user_resource_capacity_configs", "created_at", "创建时间")
|
||||
_comment_column("user_resource_capacity_configs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# resources_material 表(已部分有comment)
|
||||
# ============================================================
|
||||
_comment_table("resources_material", "资源素材对接表(第三方平台素材同步)")
|
||||
_comment_column("resources_material", "id", "主键")
|
||||
_comment_column("resources_material", "oauth_id", "授权表user_oauth自增id")
|
||||
_comment_column("resources_material", "advertiser_id", "广告主id")
|
||||
_comment_column("resources_material", "target_table", "资源表名称")
|
||||
_comment_column("resources_material", "target_id", "资源表id")
|
||||
_comment_column("resources_material", "material_id", "素材id")
|
||||
_comment_column("resources_material", "upload_id", "上传资源平台id,图片id,视频id")
|
||||
_comment_column("resources_material", "resource_type", "资源类型,image或者video")
|
||||
_comment_column("resources_material", "user_id", "用户登录id")
|
||||
_comment_column("resources_material", "task_id", "前测任务id")
|
||||
_comment_column("resources_material", "note", "前测失败备注或者其他备注")
|
||||
_comment_column("resources_material", "status", "前测状态(FAILED/PENDING/SUCCESS)")
|
||||
_comment_column("resources_material", "pre_result", "前测结果,JSON数组对象")
|
||||
_comment_column("resources_material", "pre_test_template_id", "前测模板id")
|
||||
_comment_column("resources_material", "created_at", "创建时间")
|
||||
_comment_column("resources_material", "updated_at", "更新时间")
|
||||
_comment_column("resources_material", "deleted_at", "软删除时间")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 注释回滚时选择清空所有注释即可,不影响功能
|
||||
op.execute("""
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN (
|
||||
'users', 'projects', 'credit_ratios', 'credit_records',
|
||||
'chat_generation_tasks', 'generation_records',
|
||||
'generated_resources', 'upload_resources',
|
||||
'image_engines', 'video_engines', 'model_configs',
|
||||
'system_configs', 'operation_logs', 'notifications',
|
||||
'recharge_packages', 'payment_orders',
|
||||
'video_upscale_tasks', 'shot_replicate_task_sets',
|
||||
'teams', 'user_resource_capacity_configs',
|
||||
'resources_material'
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('COMMENT ON COLUMN %I.%I IS NULL', r.table_name, r.column_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
# 清空表注释
|
||||
for t in [
|
||||
"users", "projects", "credit_ratios", "credit_records",
|
||||
"chat_generation_tasks", "generation_records",
|
||||
"generated_resources", "upload_resources",
|
||||
"image_engines", "video_engines", "model_configs",
|
||||
"system_configs", "operation_logs", "notifications",
|
||||
"recharge_packages", "payment_orders",
|
||||
"video_upscale_tasks", "shot_replicate_task_sets",
|
||||
"teams", "user_resource_capacity_configs",
|
||||
"resources_material",
|
||||
]:
|
||||
op.execute(f"COMMENT ON TABLE {t} IS NULL")
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
"""2026080401_add_vp_v3_virtual_portrait_tables_and_quota
|
||||
|
||||
Revision ID: 2026080401
|
||||
Revises: 2026073101
|
||||
Create Date: 2026-08-04 00:00:00.000000
|
||||
|
||||
API V3 虚拟素材库中转表 + 密钥配额表:
|
||||
1. vp_v3_api_key_quotas: 每个 API Key 的虚拟素材配额(项目数/素材数/存储 MB)
|
||||
2. vp_v3_projects: V3 虚拟素材项目(=火山一个 AssetGroup)
|
||||
3. vp_v3_assets: V3 虚拟素材(图片/视频)
|
||||
|
||||
备注:
|
||||
* 数据与前台用户私域素材库(private_portrait_* 表)完全隔离
|
||||
* 归属按 api_keys.id(V3 调用方)而非 users.id
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '2026080401'
|
||||
down_revision: Union[str, None] = '2026073101'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ==============================================================
|
||||
# 1. vp_v3_api_key_quotas:API Key 虚拟素材配额
|
||||
# ==============================================================
|
||||
op.create_table(
|
||||
'vp_v3_api_key_quotas',
|
||||
sa.Column('id', sa.String(length=32), nullable=False, comment='主键ID'),
|
||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
||||
comment='所属 API Key,唯一:一个 API Key 只有一份虚拟素材配额'),
|
||||
# 配额上限(默认 0=不可用)
|
||||
sa.Column('project_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='虚拟项目上限,默认 0 不可创建'),
|
||||
sa.Column('asset_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='虚拟素材总数上限(图片+视频),默认 0 不可上传'),
|
||||
sa.Column('storage_mb_limit', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='上传存储上限 MB,默认 0 不可上传文件'),
|
||||
# 已使用量(冗余,每次增删同步,和 COUNT 不一致时以 COUNT 为准)
|
||||
sa.Column('project_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='已创建项目数(未删除)'),
|
||||
sa.Column('asset_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='已上传素材数(未删除,图片+视频)'),
|
||||
sa.Column('storage_mb_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='已占用存储 MB(未删除文件大小合计,1MB=1024*1024)'),
|
||||
sa.Column('remark', sa.Text(), nullable=True, comment='后台备注'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
||||
comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
comment='最后更新时间'),
|
||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_unique_constraint('uq_vp_v3_api_key_quotas_key_id', 'vp_v3_api_key_quotas', ['api_key_id'])
|
||||
op.create_index('idx_vp_v3_api_key_quotas_api_key_id', 'vp_v3_api_key_quotas', ['api_key_id'])
|
||||
|
||||
# ==============================================================
|
||||
# 2. vp_v3_projects:V3 虚拟素材项目
|
||||
# ==============================================================
|
||||
op.create_table(
|
||||
'vp_v3_projects',
|
||||
sa.Column('id', sa.String(length=32), nullable=False, comment='项目ID'),
|
||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
||||
comment='所属 API Key(V3 调用方)'),
|
||||
sa.Column('name', sa.String(length=128), nullable=False, comment='项目展示名称'),
|
||||
sa.Column('name_slug', sa.String(length=128), nullable=False, comment='名称安全 slug(构建远端 GroupName 用)'),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('remote_project_name', sa.String(length=256), nullable=False,
|
||||
comment='火山 ProjectName(快照)'),
|
||||
sa.Column('remote_group_id', sa.String(length=128), nullable=False,
|
||||
comment='火山 AssetGroup Id'),
|
||||
sa.Column('remote_group_name', sa.String(length=256), nullable=True,
|
||||
comment='火山 AssetGroup Name 快照'),
|
||||
sa.Column('status', sa.String(length=32), nullable=False, server_default=sa.text("'active'"),
|
||||
index=True,
|
||||
comment='项目状态:active/creating_remote_group/create_group_failed/deleting'),
|
||||
# 计数
|
||||
sa.Column('asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('active_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('image_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('video_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('active_image_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('active_video_asset_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('storage_mb_used', sa.Integer(), nullable=False, server_default=sa.text('0'),
|
||||
comment='项目占用存储 MB(未删除素材文件大小合计)'),
|
||||
sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True),
|
||||
# 远端删除状态
|
||||
sa.Column('remote_delete_status', sa.String(length=32), nullable=False, server_default=sa.text("'none'"),
|
||||
index=True, comment='远端删除状态:none/pending/processing/deleted/failed'),
|
||||
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('remote_delete_error', sa.Text(), nullable=True),
|
||||
sa.Column('error_message', sa.Text(), nullable=True, comment='创建失败等错误信息'),
|
||||
sa.Column('raw_response_json', sa.Text(), nullable=True, comment='火山原始响应'),
|
||||
# 软删除 + 时间
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='删除时间(NULL=未删除)'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
||||
comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
comment='最后更新时间'),
|
||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('idx_vp_v3_projects_key_status_created', 'vp_v3_projects',
|
||||
['api_key_id', 'status', 'created_at'])
|
||||
op.create_index('idx_vp_v3_projects_remote_project_name', 'vp_v3_projects', ['remote_project_name'])
|
||||
op.create_index('idx_vp_v3_projects_remote_group_id', 'vp_v3_projects', ['remote_group_id'])
|
||||
op.execute(
|
||||
"CREATE INDEX idx_vp_v3_projects_key_deleted ON vp_v3_projects (api_key_id, deleted_at)"
|
||||
" WHERE deleted_at IS NULL;"
|
||||
)
|
||||
|
||||
# ==============================================================
|
||||
# 3. vp_v3_assets:V3 虚拟素材
|
||||
# ==============================================================
|
||||
op.create_table(
|
||||
'vp_v3_assets',
|
||||
sa.Column('id', sa.String(length=32), nullable=False, comment='素材ID'),
|
||||
sa.Column('api_key_id', sa.String(length=32), nullable=False,
|
||||
comment='所属 API Key(V3 调用方)'),
|
||||
sa.Column('project_id', sa.String(length=32), nullable=False, comment='所属项目ID'),
|
||||
sa.Column('remote_project_name', sa.String(length=256), nullable=False,
|
||||
comment='火山 ProjectName'),
|
||||
sa.Column('remote_group_id', sa.String(length=128), nullable=False,
|
||||
comment='火山 AssetGroup Id'),
|
||||
sa.Column('remote_asset_id', sa.String(length=128), nullable=True, comment='火山素材 Id'),
|
||||
sa.Column('asset_type', sa.String(length=16), nullable=False, server_default=sa.text("'Image'"),
|
||||
comment='素材类型:Image=图片 / Video=视频', index=True),
|
||||
sa.Column('name', sa.String(length=128), nullable=True, comment='素材展示名称', index=True),
|
||||
sa.Column('source_url', sa.Text(), nullable=False, comment='本地上传后的访问 URL'),
|
||||
sa.Column('preview_url', sa.Text(), nullable=True, comment='给前端预览/显示用的 URL'),
|
||||
sa.Column('remote_url', sa.Text(), nullable=True, comment='火山返回的资源访问 URL(可能带签名)'),
|
||||
sa.Column('remote_url_expired_at', sa.DateTime(timezone=True), nullable=True,
|
||||
comment='remote_url 过期时间'),
|
||||
sa.Column('upload_resource_id', sa.String(length=32), nullable=True, index=True,
|
||||
comment='本地上传 resource_id,供容量释放用'),
|
||||
sa.Column('video_duration', sa.Float(), nullable=True, comment='视频时长,秒'),
|
||||
sa.Column('video_cover_url', sa.Text(), nullable=True, comment='视频封面预览'),
|
||||
sa.Column('file_size_bytes', sa.Integer(), nullable=True, comment='素材文件大小,字节'),
|
||||
sa.Column('mime_type', sa.String(length=128), nullable=True),
|
||||
sa.Column('status', sa.String(length=32), nullable=False, server_default=sa.text("'creating'"),
|
||||
index=True,
|
||||
comment='素材状态:creating/审核中 active/可用 failed/失败 deleting/删除中'),
|
||||
sa.Column('moderation_json', sa.Text(), nullable=True, comment='火山审核结果 JSON'),
|
||||
sa.Column('error_message', sa.Text(), nullable=True, comment='失败原因'),
|
||||
sa.Column('raw_response_json', sa.Text(), nullable=True, comment='火山原始响应 JSON'),
|
||||
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True, index=True,
|
||||
comment='下次轮询时间(创建中状态自动轮询)'),
|
||||
sa.Column('poll_count', sa.Integer(), nullable=False, server_default=sa.text('0')),
|
||||
sa.Column('remote_delete_status', sa.String(length=32), nullable=False, server_default=sa.text("'none'"),
|
||||
index=True, comment='远端删除状态:none/pending/processing/deleted/failed'),
|
||||
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('remote_delete_error', sa.Text(), nullable=True),
|
||||
# 软删除 + 时间
|
||||
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True, comment='删除时间(NULL=未删除)'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(),
|
||||
comment='创建时间'),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
onupdate=sa.func.now(),
|
||||
comment='最后更新时间'),
|
||||
sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['vp_v3_projects.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_unique_constraint('uq_vp_v3_assets_remote_asset_id', 'vp_v3_assets', ['remote_asset_id'])
|
||||
op.create_index('idx_vp_v3_assets_key_status_created', 'vp_v3_assets',
|
||||
['api_key_id', 'status', 'created_at'])
|
||||
op.create_index('idx_vp_v3_assets_project_status_created', 'vp_v3_assets',
|
||||
['project_id', 'status', 'created_at'])
|
||||
op.create_index('idx_vp_v3_assets_asset_type', 'vp_v3_assets', ['asset_type'])
|
||||
op.create_index('idx_vp_v3_assets_remote_delete_status', 'vp_v3_assets', ['remote_delete_status'])
|
||||
op.execute(
|
||||
"CREATE INDEX idx_vp_v3_assets_next_poll_status ON vp_v3_assets (next_poll_at, status)"
|
||||
" WHERE deleted_at IS NULL AND next_poll_at IS NOT NULL;"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('vp_v3_assets')
|
||||
op.drop_table('vp_v3_projects')
|
||||
op.drop_index('idx_vp_v3_api_key_quotas_api_key_id', table_name='vp_v3_api_key_quotas')
|
||||
op.drop_constraint('uq_vp_v3_api_key_quotas_key_id', 'vp_v3_api_key_quotas', type_='unique')
|
||||
op.drop_table('vp_v3_api_key_quotas')
|
||||
@@ -0,0 +1,984 @@
|
||||
"""2026080601_add_missing_table_and_column_comments
|
||||
|
||||
Revision ID: 2026080601
|
||||
Revises: 2026080401
|
||||
Create Date: 2026-08-06 00:00:00.000000
|
||||
|
||||
给前端迁移遗漏的 models 表添加表注释和字段注释。
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = '2026080601'
|
||||
down_revision: Union[str, None] = '2026080401'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _comment_table(table_name: str, comment: str) -> None:
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
||||
|
||||
|
||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
||||
escaped = comment.replace("'", "''")
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ============================================================
|
||||
# notification_reads 表
|
||||
# ============================================================
|
||||
_comment_table("notification_reads", "通知已读记录表")
|
||||
_comment_column("notification_reads", "id", "主键ID")
|
||||
_comment_column("notification_reads", "notification_id", "通知ID")
|
||||
_comment_column("notification_reads", "user_id", "已读用户ID")
|
||||
_comment_column("notification_reads", "created_at", "创建时间")
|
||||
_comment_column("notification_reads", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# menu_configs 表
|
||||
# ============================================================
|
||||
_comment_table("menu_configs", "菜单配置表")
|
||||
_comment_column("menu_configs", "id", "主键ID")
|
||||
_comment_column("menu_configs", "label", "菜单显示名称")
|
||||
_comment_column("menu_configs", "path", "菜单路由路径")
|
||||
_comment_column("menu_configs", "icon", "菜单图标名称")
|
||||
_comment_column("menu_configs", "sort_order", "排序值,越小越靠前")
|
||||
_comment_column("menu_configs", "is_active", "是否启用")
|
||||
_comment_column("menu_configs", "parent_id", "父菜单ID")
|
||||
_comment_column("menu_configs", "menu_type", "菜单类型:page页面/directory目录/link链接")
|
||||
_comment_column("menu_configs", "menu_target", "菜单目标:frontend前台/admin后台")
|
||||
_comment_column("menu_configs", "is_default", "是否默认菜单,新用户自动分配")
|
||||
_comment_column("menu_configs", "created_at", "创建时间")
|
||||
_comment_column("menu_configs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# team_join_requests 表
|
||||
# ============================================================
|
||||
_comment_table("team_join_requests", "团队加入申请记录表")
|
||||
_comment_column("team_join_requests", "id", "主键ID")
|
||||
_comment_column("team_join_requests", "team_id", "目标团队ID")
|
||||
_comment_column("team_join_requests", "user_id", "申请人用户ID")
|
||||
_comment_column("team_join_requests", "invitation_id", "关联邀请ID(通过邀请链接申请时记录)")
|
||||
_comment_column("team_join_requests", "status", "申请状态:pending待处理/approved已通过/rejected已拒绝")
|
||||
_comment_column("team_join_requests", "note", "申请备注")
|
||||
_comment_column("team_join_requests", "handled_by", "处理人用户ID")
|
||||
_comment_column("team_join_requests", "created_at", "创建时间")
|
||||
_comment_column("team_join_requests", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# team_invitations 表
|
||||
# ============================================================
|
||||
_comment_table("team_invitations", "团队邀请记录表")
|
||||
_comment_column("team_invitations", "id", "主键ID")
|
||||
_comment_column("team_invitations", "team_id", "所属团队ID")
|
||||
_comment_column("team_invitations", "code", "邀请码,唯一")
|
||||
_comment_column("team_invitations", "created_by", "创建人用户ID")
|
||||
_comment_column("team_invitations", "status", "邀请状态:active启用/disabled禁用")
|
||||
_comment_column("team_invitations", "max_uses", "最大使用次数,NULL表示不限")
|
||||
_comment_column("team_invitations", "use_count", "已使用次数")
|
||||
_comment_column("team_invitations", "expires_at", "过期时间,NULL表示永不过期")
|
||||
_comment_column("team_invitations", "created_at", "创建时间")
|
||||
_comment_column("team_invitations", "updated_at", "更新时间")
|
||||
_comment_column("team_invitations", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# contact_requests 表
|
||||
# ============================================================
|
||||
_comment_table("contact_requests", "用户联系/咨询申请表")
|
||||
_comment_column("contact_requests", "id", "主键ID")
|
||||
_comment_column("contact_requests", "user_id", "提交用户ID")
|
||||
_comment_column("contact_requests", "phone", "联系电话")
|
||||
_comment_column("contact_requests", "company_name", "公司名称")
|
||||
_comment_column("contact_requests", "industry", "所属行业")
|
||||
_comment_column("contact_requests", "name", "联系人姓名")
|
||||
_comment_column("contact_requests", "message", "留言内容")
|
||||
_comment_column("contact_requests", "is_handled", "是否已处理")
|
||||
_comment_column("contact_requests", "submit_date", "提交日期(YYYY-MM-DD),用于每日限1次控制")
|
||||
_comment_column("contact_requests", "created_at", "创建时间")
|
||||
_comment_column("contact_requests", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# token_usage 表
|
||||
# ============================================================
|
||||
_comment_table("token_usage", "Token消耗记录表")
|
||||
_comment_column("token_usage", "id", "主键ID")
|
||||
_comment_column("token_usage", "model_config_id", "模型配置ID")
|
||||
_comment_column("token_usage", "user_id", "所属用户ID")
|
||||
_comment_column("token_usage", "input_tokens", "输入Token数")
|
||||
_comment_column("token_usage", "output_tokens", "输出Token数")
|
||||
_comment_column("token_usage", "total_tokens", "总Token数")
|
||||
_comment_column("token_usage", "owner_type", "归属类型:chat_generation_task/module_generation_step等")
|
||||
_comment_column("token_usage", "owner_id", "归属记录ID")
|
||||
_comment_column("token_usage", "biz_key", "业务幂等键")
|
||||
_comment_column("token_usage", "source_module", "来源模块")
|
||||
_comment_column("token_usage", "source_step_code", "来源步骤编码")
|
||||
_comment_column("token_usage", "created_at", "创建时间")
|
||||
_comment_column("token_usage", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# industry_configs 表
|
||||
# ============================================================
|
||||
_comment_table("industry_configs", "行业配置表")
|
||||
_comment_column("industry_configs", "id", "主键ID")
|
||||
_comment_column("industry_configs", "key", "行业唯一标识键")
|
||||
_comment_column("industry_configs", "label", "行业显示名称")
|
||||
_comment_column("industry_configs", "icon", "图标名称")
|
||||
_comment_column("industry_configs", "description", "行业描述")
|
||||
_comment_column("industry_configs", "skills", "行业技能列表JSON数组")
|
||||
_comment_column("industry_configs", "is_active", "是否启用")
|
||||
_comment_column("industry_configs", "sort_order", "排序值,越小越靠前")
|
||||
_comment_column("industry_configs", "created_at", "创建时间")
|
||||
_comment_column("industry_configs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# chat_generation_task_events 表
|
||||
# ============================================================
|
||||
_comment_table("chat_generation_task_events", "AI创作任务事件日志表(追加写入)")
|
||||
_comment_column("chat_generation_task_events", "id", "主键ID")
|
||||
_comment_column("chat_generation_task_events", "owner_type", "归属类型:chat_generation_task/generation_record")
|
||||
_comment_column("chat_generation_task_events", "task_id", "关联AI创作任务ID")
|
||||
_comment_column("chat_generation_task_events", "generation_record_id", "关联项目生成记录ID")
|
||||
_comment_column("chat_generation_task_events", "generation_attempt_no", "生成尝试次数")
|
||||
_comment_column("chat_generation_task_events", "generation_mode", "生成模式")
|
||||
_comment_column("chat_generation_task_events", "event_type", "事件类型")
|
||||
_comment_column("chat_generation_task_events", "from_status", "变更前状态")
|
||||
_comment_column("chat_generation_task_events", "to_status", "变更后状态")
|
||||
_comment_column("chat_generation_task_events", "from_stage", "变更前阶段")
|
||||
_comment_column("chat_generation_task_events", "to_stage", "变更后阶段")
|
||||
_comment_column("chat_generation_task_events", "message", "事件描述信息")
|
||||
_comment_column("chat_generation_task_events", "detail_json", "事件详情JSON")
|
||||
_comment_column("chat_generation_task_events", "created_at", "创建时间")
|
||||
|
||||
# ============================================================
|
||||
# chat_provider_call_logs 表
|
||||
# ============================================================
|
||||
_comment_table("chat_provider_call_logs", "供应商调用审计日志表")
|
||||
_comment_column("chat_provider_call_logs", "id", "主键ID")
|
||||
_comment_column("chat_provider_call_logs", "owner_type", "归属类型:chat_generation_task/generation_record")
|
||||
_comment_column("chat_provider_call_logs", "task_id", "关联AI创作任务ID")
|
||||
_comment_column("chat_provider_call_logs", "generation_record_id", "关联项目生成记录ID")
|
||||
_comment_column("chat_provider_call_logs", "generation_attempt_no", "生成尝试次数")
|
||||
_comment_column("chat_provider_call_logs", "generation_mode", "生成模式")
|
||||
_comment_column("chat_provider_call_logs", "provider", "供应商:ark/seedance等")
|
||||
_comment_column("chat_provider_call_logs", "api_type", "API类型:image_generate/video_create等")
|
||||
_comment_column("chat_provider_call_logs", "model", "模型名称")
|
||||
_comment_column("chat_provider_call_logs", "engine_id", "引擎ID")
|
||||
_comment_column("chat_provider_call_logs", "status", "调用状态:success/failed")
|
||||
_comment_column("chat_provider_call_logs", "latency_ms", "调用耗时(毫秒)")
|
||||
_comment_column("chat_provider_call_logs", "http_status", "HTTP状态码")
|
||||
_comment_column("chat_provider_call_logs", "provider_task_id", "供应商任务ID")
|
||||
_comment_column("chat_provider_call_logs", "request_hash", "请求内容哈希")
|
||||
_comment_column("chat_provider_call_logs", "response_hash", "响应内容哈希")
|
||||
_comment_column("chat_provider_call_logs", "request_excerpt", "请求内容摘录")
|
||||
_comment_column("chat_provider_call_logs", "response_excerpt", "响应内容摘录")
|
||||
_comment_column("chat_provider_call_logs", "prompt_tokens", "提示词Token数")
|
||||
_comment_column("chat_provider_call_logs", "completion_tokens", "补全Token数")
|
||||
_comment_column("chat_provider_call_logs", "total_tokens", "总Token数")
|
||||
_comment_column("chat_provider_call_logs", "error_code", "错误码")
|
||||
_comment_column("chat_provider_call_logs", "error_message", "错误信息")
|
||||
_comment_column("chat_provider_call_logs", "created_at", "创建时间")
|
||||
|
||||
# ============================================================
|
||||
# open_type 表
|
||||
# ============================================================
|
||||
_comment_table("open_type", "开户方式管理表")
|
||||
_comment_column("open_type", "id", "主键")
|
||||
_comment_column("open_type", "type_name", "标题名称")
|
||||
_comment_column("open_type", "open_type", "开户方式id")
|
||||
_comment_column("open_type", "description", "开户方式描述")
|
||||
_comment_column("open_type", "thumb", "缩略图")
|
||||
_comment_column("open_type", "created_at", "创建时间")
|
||||
_comment_column("open_type", "updated_at", "更新时间")
|
||||
_comment_column("open_type", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# pre_test_template 表
|
||||
# ============================================================
|
||||
_comment_table("pre_test_template", "素材前测模板表")
|
||||
_comment_column("pre_test_template", "id", "主键")
|
||||
_comment_column("pre_test_template", "name", "模板名称")
|
||||
_comment_column("pre_test_template", "user_id", "用户id")
|
||||
_comment_column("pre_test_template", "note", "模板备注")
|
||||
_comment_column("pre_test_template", "platform", "投放平台(AD/QIANCHUAN/LOCAL)")
|
||||
_comment_column("pre_test_template", "external_action", "转化目标")
|
||||
_comment_column("pre_test_template", "cpa_bid", "目标转化成本:[1, 10000]")
|
||||
_comment_column("pre_test_template", "audience_gender", "性别(ALL/MALE/FEMALE)")
|
||||
_comment_column("pre_test_template", "audience_age", "受众年龄,JSON数组")
|
||||
_comment_column("pre_test_template", "audience_region", "受众地区,JSON数组(二级行政区域code)")
|
||||
_comment_column("pre_test_template", "audience_network", "网络类型,JSON数组")
|
||||
_comment_column("pre_test_template", "cus_name", "客户主体名称")
|
||||
_comment_column("pre_test_template", "pricing_type", "出价类型(OCPC/CPA/OCPM)")
|
||||
_comment_column("pre_test_template", "cost_cap", "是否最优成本出价(仅AD支持)")
|
||||
_comment_column("pre_test_template", "target_cost", "是否稳定成本出价(仅AD支持)")
|
||||
_comment_column("pre_test_template", "nobid", "是否最大转化出价(仅AD支持)")
|
||||
_comment_column("pre_test_template", "cpc_bid", "目标点击成本:[1, 10000]")
|
||||
_comment_column("pre_test_template", "budget", "预算金额:[1, 10000]")
|
||||
_comment_column("pre_test_template", "is_default", "是否默认模板")
|
||||
_comment_column("pre_test_template", "created_at", "创建时间")
|
||||
_comment_column("pre_test_template", "updated_at", "更新时间")
|
||||
_comment_column("pre_test_template", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# material_cost 表
|
||||
# ============================================================
|
||||
_comment_table("material_cost", "素材消耗数据表(广告投放消耗统计)")
|
||||
_comment_column("material_cost", "id", "主键")
|
||||
_comment_column("material_cost", "oauth_id", "授权表user_oauth自增id")
|
||||
_comment_column("material_cost", "advertiser_id", "广告主id")
|
||||
_comment_column("material_cost", "material_id", "素材id")
|
||||
_comment_column("material_cost", "consume_date", "消耗日期")
|
||||
_comment_column("material_cost", "stat_cost", "消耗金额")
|
||||
_comment_column("material_cost", "show_cnt", "展示数")
|
||||
_comment_column("material_cost", "cpm_platform", "平均千次展现费用(元)")
|
||||
_comment_column("material_cost", "click_cnt", "点击数")
|
||||
_comment_column("material_cost", "ctr", "点击率")
|
||||
_comment_column("material_cost", "cpc_platform", "平均点击单价(元)")
|
||||
_comment_column("material_cost", "convert_cnt", "转化数")
|
||||
_comment_column("material_cost", "conversion_cost", "平均转化成本(元)")
|
||||
_comment_column("material_cost", "conversion_rate", "转化率")
|
||||
_comment_column("material_cost", "deep_convert_cnt", "深度转化数")
|
||||
_comment_column("material_cost", "deep_convert_cost", "深度转化成本(元)")
|
||||
_comment_column("material_cost", "deep_convert_rate", "深度转化率")
|
||||
_comment_column("material_cost", "active", "激活数")
|
||||
_comment_column("material_cost", "active_cost", "激活成本(元)")
|
||||
_comment_column("material_cost", "active_rate", "激活率")
|
||||
_comment_column("material_cost", "active_register", "注册数")
|
||||
_comment_column("material_cost", "active_register_cost", "注册成本(元)")
|
||||
_comment_column("material_cost", "active_register_rate", "注册率")
|
||||
_comment_column("material_cost", "attribution_next_day_open_cnt", "次留数")
|
||||
_comment_column("material_cost", "attribution_next_day_open_cost", "次留成本")
|
||||
_comment_column("material_cost", "attribution_next_day_open_rate", "次留率")
|
||||
_comment_column("material_cost", "active_pay", "首次付费数")
|
||||
_comment_column("material_cost", "active_pay_cost", "首次付费成本(元)")
|
||||
_comment_column("material_cost", "active_pay_rate", "首次付费率")
|
||||
_comment_column("material_cost", "phone", "点击电话按钮")
|
||||
_comment_column("material_cost", "form", "用户在门店落地页多线沟通提交表单的次数")
|
||||
_comment_column("material_cost", "download_start", "用户点击下载开始的次数")
|
||||
_comment_column("material_cost", "form_submit", "用户查看附加创意后,提交表单的次数")
|
||||
_comment_column("material_cost", "button", "用户点击按钮button的次数")
|
||||
_comment_column("material_cost", "view", "用户在关键页面的浏览次数")
|
||||
_comment_column("material_cost", "message", "用户点击短信咨询的次数")
|
||||
_comment_column("material_cost", "consult", "用户点击在线咨询按钮的次数")
|
||||
_comment_column("material_cost", "consult_effective", "用户在门店落地页多线沟通的在线咨询中有效咨询的次数")
|
||||
_comment_column("material_cost", "shopping", "用户购买商品的次数")
|
||||
_comment_column("material_cost", "customer_effective", "有效获客")
|
||||
_comment_column("material_cost", "attribution_game_in_app_ltv_1day", "当日付费金额")
|
||||
_comment_column("material_cost", "attribution_game_in_app_roi_1day", "当日付费ROI")
|
||||
_comment_column("material_cost", "loan_completion", "完件数")
|
||||
_comment_column("material_cost", "loan_completion_cost", "完件成本(元)")
|
||||
_comment_column("material_cost", "loan_completion_rate", "完件率")
|
||||
_comment_column("material_cost", "loan_credit", "授信数")
|
||||
_comment_column("material_cost", "loan_credit_cost", "授信成本(元)")
|
||||
_comment_column("material_cost", "loan_credit_rate", "授信率")
|
||||
_comment_column("material_cost", "in_app_order_gmv", "引流电商订单GMV")
|
||||
_comment_column("material_cost", "in_app_order_roi", "引流电商订单ROI")
|
||||
_comment_column("material_cost", "in_app_pay_gmv", "引流电商支付GMV")
|
||||
_comment_column("material_cost", "in_app_pay_roi", "引流电商支付ROI")
|
||||
_comment_column("material_cost", "total_play", "播放量")
|
||||
_comment_column("material_cost", "valid_play", "有效播放数")
|
||||
_comment_column("material_cost", "valid_play_cost", "有效播放成本(元)")
|
||||
_comment_column("material_cost", "valid_play_rate", "有效播放率")
|
||||
_comment_column("material_cost", "valid_play_of_mille", "千次有效播放数")
|
||||
_comment_column("material_cost", "valid_play_cost_of_mille", "千次有效播放成本(元)")
|
||||
_comment_column("material_cost", "average_play_time_per_play", "平均单次播放时长")
|
||||
_comment_column("material_cost", "play_over_rate", "完播率")
|
||||
_comment_column("material_cost", "dy_like", "点赞数")
|
||||
_comment_column("material_cost", "dy_comment", "评论量")
|
||||
_comment_column("material_cost", "dy_share", "分享量")
|
||||
_comment_column("material_cost", "report_cnt", "举报数")
|
||||
_comment_column("material_cost", "created_at", "创建时间")
|
||||
_comment_column("material_cost", "updated_at", "更新时间")
|
||||
_comment_column("material_cost", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# user_oauth 表
|
||||
# ============================================================
|
||||
_comment_table("user_oauth", "用户授权账户表(第三方平台授权)")
|
||||
_comment_column("user_oauth", "id", "主键")
|
||||
_comment_column("user_oauth", "account_id", "授权账户id")
|
||||
_comment_column("user_oauth", "account_name", "授权账户name")
|
||||
_comment_column("user_oauth", "account_role", "授权账户角色")
|
||||
_comment_column("user_oauth", "account_username", "授权账户登录账号")
|
||||
_comment_column("user_oauth", "account_userid", "授权账户登录userid")
|
||||
_comment_column("user_oauth", "user_id", "用户id")
|
||||
_comment_column("user_oauth", "open_type", "开户方式(1=千川,2=广告,3=本地推,4=星图,5=快手代理商,6=巨量星图,7=巨量服务单,8=腾讯服务单,9=腾讯营销K2,10=腾讯营销K3)")
|
||||
_comment_column("user_oauth", "port_type", "平台端口(1=巨量,2=磁力,3=巨量星图,4=服务单,5=腾讯)")
|
||||
_comment_column("user_oauth", "appid", "授权应用id")
|
||||
_comment_column("user_oauth", "access_token", "授权token")
|
||||
_comment_column("user_oauth", "access_token_expired", "token过期时间")
|
||||
_comment_column("user_oauth", "refresh_token", "授权刷新token")
|
||||
_comment_column("user_oauth", "refresh_token_expired", "刷新token过期时间")
|
||||
_comment_column("user_oauth", "material_auth_status", "是否敏感物料授权(true=是,false=否)")
|
||||
_comment_column("user_oauth", "created_at", "创建时间")
|
||||
_comment_column("user_oauth", "updated_at", "更新时间")
|
||||
_comment_column("user_oauth", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# user_oauth_account 表
|
||||
# ============================================================
|
||||
_comment_table("user_oauth_account", "授权账户详情表(广告账户映射)")
|
||||
_comment_column("user_oauth_account", "id", "主键")
|
||||
_comment_column("user_oauth_account", "oauth_id", "授权表中的id")
|
||||
_comment_column("user_oauth_account", "advertiser_id", "广告主账户id")
|
||||
_comment_column("user_oauth_account", "advertiser_name", "广告账户名")
|
||||
_comment_column("user_oauth_account", "advertiser_role", "广告账户类型")
|
||||
_comment_column("user_oauth_account", "created_at", "创建时间")
|
||||
_comment_column("user_oauth_account", "updated_at", "更新时间")
|
||||
_comment_column("user_oauth_account", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# user_oauth_app 表
|
||||
# ============================================================
|
||||
_comment_table("user_oauth_app", "授权应用管理表(应用密钥配置)")
|
||||
_comment_column("user_oauth_app", "id", "主键")
|
||||
_comment_column("user_oauth_app", "app_id", "应用id")
|
||||
_comment_column("user_oauth_app", "secret", "应用密钥")
|
||||
_comment_column("user_oauth_app", "status", "状态,1=正常,2=禁用")
|
||||
_comment_column("user_oauth_app", "max_count", "应用最大可以授权多少个用户")
|
||||
_comment_column("user_oauth_app", "auth_url", "应用授权链接")
|
||||
_comment_column("user_oauth_app", "company", "应用归属公司名称")
|
||||
_comment_column("user_oauth_app", "open_type", "开户方式")
|
||||
_comment_column("user_oauth_app", "create_by", "创建者")
|
||||
_comment_column("user_oauth_app", "created_at", "创建时间")
|
||||
_comment_column("user_oauth_app", "updated_at", "更新时间")
|
||||
_comment_column("user_oauth_app", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# upload_task 表
|
||||
# ============================================================
|
||||
_comment_table("upload_task", "上传任务表(素材上传记录)")
|
||||
_comment_column("upload_task", "id", "主键")
|
||||
_comment_column("upload_task", "user_id", "用户登录id")
|
||||
_comment_column("upload_task", "advertiser_id", "广告主id")
|
||||
_comment_column("upload_task", "resource_id", "资源id")
|
||||
_comment_column("upload_task", "status", "上传状态:1待上传,2上传中,3上传成功,4上传失败")
|
||||
_comment_column("upload_task", "note", "上传备注")
|
||||
_comment_column("upload_task", "oauth_id", "授权表id")
|
||||
_comment_column("upload_task", "other_info", "其他信息")
|
||||
_comment_column("upload_task", "created_at", "创建时间")
|
||||
_comment_column("upload_task", "updated_at", "更新时间")
|
||||
_comment_column("upload_task", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# user_resource_month_stats 表
|
||||
# ============================================================
|
||||
_comment_table("user_resource_month_stats", "用户月份资源空间聚合表")
|
||||
_comment_column("user_resource_month_stats", "id", "主键ID")
|
||||
_comment_column("user_resource_month_stats", "user_id", "所属用户ID")
|
||||
_comment_column("user_resource_month_stats", "stat_month", "统计月份")
|
||||
_comment_column("user_resource_month_stats", "active_size_bytes", "活跃资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "deleted_size_bytes", "已删除资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "total_generated_size_bytes", "累计生成资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "upload_size_bytes", "上传资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "image_size_bytes", "图片资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "video_size_bytes", "视频资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "audio_size_bytes", "音频资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "shot_segment_size_bytes", "拆镜切片资源大小(字节)")
|
||||
_comment_column("user_resource_month_stats", "active_count", "活跃资源数量")
|
||||
_comment_column("user_resource_month_stats", "deleted_count", "已删除资源数量")
|
||||
_comment_column("user_resource_month_stats", "image_count", "图片资源数量")
|
||||
_comment_column("user_resource_month_stats", "video_count", "视频资源数量")
|
||||
_comment_column("user_resource_month_stats", "upload_count", "上传资源数量")
|
||||
_comment_column("user_resource_month_stats", "audio_count", "音频资源数量")
|
||||
_comment_column("user_resource_month_stats", "shot_segment_count", "拆镜切片数量")
|
||||
_comment_column("user_resource_month_stats", "last_recalculated_at", "最后重新计算时间")
|
||||
_comment_column("user_resource_month_stats", "created_at", "创建时间")
|
||||
_comment_column("user_resource_month_stats", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# user_resource_total_stats 表
|
||||
# ============================================================
|
||||
_comment_table("user_resource_total_stats", "用户全局资源空间聚合表")
|
||||
_comment_column("user_resource_total_stats", "id", "主键ID")
|
||||
_comment_column("user_resource_total_stats", "user_id", "所属用户ID")
|
||||
_comment_column("user_resource_total_stats", "active_size_bytes", "活跃资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "deleted_size_bytes", "已删除资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "total_generated_size_bytes", "累计生成资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "upload_size_bytes", "上传资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "image_size_bytes", "图片资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "video_size_bytes", "视频资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "audio_size_bytes", "音频资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "shot_segment_size_bytes", "拆镜切片资源大小(字节)")
|
||||
_comment_column("user_resource_total_stats", "active_count", "活跃资源数量")
|
||||
_comment_column("user_resource_total_stats", "deleted_count", "已删除资源数量")
|
||||
_comment_column("user_resource_total_stats", "image_count", "图片资源数量")
|
||||
_comment_column("user_resource_total_stats", "video_count", "视频资源数量")
|
||||
_comment_column("user_resource_total_stats", "upload_count", "上传资源数量")
|
||||
_comment_column("user_resource_total_stats", "audio_count", "音频资源数量")
|
||||
_comment_column("user_resource_total_stats", "shot_segment_count", "拆镜切片数量")
|
||||
_comment_column("user_resource_total_stats", "last_recalculated_at", "最后重新计算时间")
|
||||
_comment_column("user_resource_total_stats", "created_at", "创建时间")
|
||||
_comment_column("user_resource_total_stats", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# home_material_assets 表
|
||||
# ============================================================
|
||||
_comment_table("home_material_assets", "首页素材资产表")
|
||||
_comment_column("home_material_assets", "id", "主键ID")
|
||||
_comment_column("home_material_assets", "category_id", "行业类别ID")
|
||||
_comment_column("home_material_assets", "title", "素材标题")
|
||||
_comment_column("home_material_assets", "media_type", "素材类型:image图片,video视频")
|
||||
_comment_column("home_material_assets", "original_url", "原始素材URL")
|
||||
_comment_column("home_material_assets", "original_storage_path", "原始素材本地路径")
|
||||
_comment_column("home_material_assets", "watermarked_url", "水印素材URL")
|
||||
_comment_column("home_material_assets", "watermarked_storage_path", "水印素材本地路径")
|
||||
_comment_column("home_material_assets", "cover_url", "视频封面URL")
|
||||
_comment_column("home_material_assets", "cover_storage_path", "视频封面本地路径")
|
||||
_comment_column("home_material_assets", "watermark_id", "水印图片ID")
|
||||
_comment_column("home_material_assets", "watermark_config_json", "水印配置快照JSON")
|
||||
_comment_column("home_material_assets", "generation_prompt", "生成提词")
|
||||
_comment_column("home_material_assets", "media_references_json", "附件/参考素材JSON字符串")
|
||||
_comment_column("home_material_assets", "status", "处理状态:draft/processing/success/failed")
|
||||
_comment_column("home_material_assets", "error_message", "处理失败原因")
|
||||
_comment_column("home_material_assets", "width", "素材宽度")
|
||||
_comment_column("home_material_assets", "height", "素材高度")
|
||||
_comment_column("home_material_assets", "duration_seconds", "视频时长,图片为空")
|
||||
_comment_column("home_material_assets", "file_size_bytes", "原始文件大小")
|
||||
_comment_column("home_material_assets", "watermarked_file_size_bytes", "水印后文件大小")
|
||||
_comment_column("home_material_assets", "is_active", "是否前台展示")
|
||||
_comment_column("home_material_assets", "sort_order", "排序,越小越靠前")
|
||||
_comment_column("home_material_assets", "processed_at", "处理完成时间")
|
||||
_comment_column("home_material_assets", "created_by", "创建管理员ID")
|
||||
_comment_column("home_material_assets", "updated_by", "更新管理员ID")
|
||||
_comment_column("home_material_assets", "created_at", "创建时间")
|
||||
_comment_column("home_material_assets", "updated_at", "更新时间")
|
||||
_comment_column("home_material_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# home_material_categories 表
|
||||
# ============================================================
|
||||
_comment_table("home_material_categories", "首页素材行业类别表")
|
||||
_comment_column("home_material_categories", "id", "主键ID")
|
||||
_comment_column("home_material_categories", "name", "行业名称")
|
||||
_comment_column("home_material_categories", "key", "行业唯一标识,前台可按key查询")
|
||||
_comment_column("home_material_categories", "description", "行业描述")
|
||||
_comment_column("home_material_categories", "icon", "前端图标名称")
|
||||
_comment_column("home_material_categories", "is_active", "是否启用")
|
||||
_comment_column("home_material_categories", "sort_order", "排序,越小越靠前")
|
||||
_comment_column("home_material_categories", "created_by", "创建管理员ID")
|
||||
_comment_column("home_material_categories", "updated_by", "更新管理员ID")
|
||||
_comment_column("home_material_categories", "created_at", "创建时间")
|
||||
_comment_column("home_material_categories", "updated_at", "更新时间")
|
||||
_comment_column("home_material_categories", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# home_material_watermarks 表
|
||||
# ============================================================
|
||||
_comment_table("home_material_watermarks", "首页素材水印图片库")
|
||||
_comment_column("home_material_watermarks", "id", "主键ID")
|
||||
_comment_column("home_material_watermarks", "name", "水印名称")
|
||||
_comment_column("home_material_watermarks", "file_url", "水印图片URL")
|
||||
_comment_column("home_material_watermarks", "storage_path", "水印图片本地路径")
|
||||
_comment_column("home_material_watermarks", "file_name", "原始文件名")
|
||||
_comment_column("home_material_watermarks", "file_size_bytes", "文件大小")
|
||||
_comment_column("home_material_watermarks", "width", "水印图片宽度")
|
||||
_comment_column("home_material_watermarks", "height", "水印图片高度")
|
||||
_comment_column("home_material_watermarks", "is_default", "是否默认水印")
|
||||
_comment_column("home_material_watermarks", "is_active", "是否启用")
|
||||
_comment_column("home_material_watermarks", "created_by", "创建管理员ID")
|
||||
_comment_column("home_material_watermarks", "updated_by", "更新管理员ID")
|
||||
_comment_column("home_material_watermarks", "created_at", "创建时间")
|
||||
_comment_column("home_material_watermarks", "updated_at", "更新时间")
|
||||
_comment_column("home_material_watermarks", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# module_generation_projects 表
|
||||
# ============================================================
|
||||
_comment_table("module_generation_projects", "通用模块生成项目/总任务表")
|
||||
_comment_column("module_generation_projects", "id", "主键ID")
|
||||
_comment_column("module_generation_projects", "user_id", "所属用户ID")
|
||||
_comment_column("module_generation_projects", "module", "业务模块标识")
|
||||
_comment_column("module_generation_projects", "flow_version", "项目流程版本号")
|
||||
_comment_column("module_generation_projects", "title", "项目标题")
|
||||
_comment_column("module_generation_projects", "status", "项目状态:pending/processing/success/failed")
|
||||
_comment_column("module_generation_projects", "current_step_code", "当前执行步骤编码")
|
||||
_comment_column("module_generation_projects", "final_image_url", "最终生成图片URL")
|
||||
_comment_column("module_generation_projects", "final_video_url", "最终生成视频URL")
|
||||
_comment_column("module_generation_projects", "final_video_cover_url", "最终生成视频封面URL")
|
||||
_comment_column("module_generation_projects", "error_message", "错误信息")
|
||||
_comment_column("module_generation_projects", "idempotency_key", "幂等键")
|
||||
_comment_column("module_generation_projects", "completed_at", "项目完成时间")
|
||||
_comment_column("module_generation_projects", "created_at", "创建时间")
|
||||
_comment_column("module_generation_projects", "updated_at", "更新时间")
|
||||
_comment_column("module_generation_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# module_generation_steps 表
|
||||
# ============================================================
|
||||
_comment_table("module_generation_steps", "通用模块生成步骤表")
|
||||
_comment_column("module_generation_steps", "id", "主键ID")
|
||||
_comment_column("module_generation_steps", "project_id", "所属项目ID")
|
||||
_comment_column("module_generation_steps", "user_id", "所属用户ID")
|
||||
_comment_column("module_generation_steps", "module", "业务模块标识")
|
||||
_comment_column("module_generation_steps", "step_index", "步骤序号")
|
||||
_comment_column("module_generation_steps", "step_code", "步骤编码")
|
||||
_comment_column("module_generation_steps", "status", "步骤状态:pending/processing/success/failed")
|
||||
_comment_column("module_generation_steps", "version", "步骤重建版本号")
|
||||
_comment_column("module_generation_steps", "is_current", "是否为当前版本")
|
||||
_comment_column("module_generation_steps", "parent_step_id", "父步骤ID")
|
||||
_comment_column("module_generation_steps", "source_step_id", "源步骤ID(复制来源)")
|
||||
_comment_column("module_generation_steps", "chat_task_id", "关联AI创作任务ID")
|
||||
_comment_column("module_generation_steps", "input_json", "步骤输入JSON")
|
||||
_comment_column("module_generation_steps", "output_json", "步骤输出JSON")
|
||||
_comment_column("module_generation_steps", "error_message", "错误信息")
|
||||
_comment_column("module_generation_steps", "started_at", "步骤开始时间")
|
||||
_comment_column("module_generation_steps", "completed_at", "步骤完成时间")
|
||||
_comment_column("module_generation_steps", "token_usage_id", "关联Token消耗记录ID")
|
||||
_comment_column("module_generation_steps", "model_config_id", "模型配置ID")
|
||||
_comment_column("module_generation_steps", "input_tokens", "输入Token数")
|
||||
_comment_column("module_generation_steps", "output_tokens", "输出Token数")
|
||||
_comment_column("module_generation_steps", "total_tokens", "总Token数")
|
||||
_comment_column("module_generation_steps", "text_credits_cost", "提示词优化消耗积分")
|
||||
_comment_column("module_generation_steps", "created_at", "创建时间")
|
||||
_comment_column("module_generation_steps", "updated_at", "更新时间")
|
||||
_comment_column("module_generation_steps", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# shot_replicate_segments 表
|
||||
# ============================================================
|
||||
_comment_table("shot_replicate_segments", "拆镜复刻片段表")
|
||||
_comment_column("shot_replicate_segments", "id", "主键ID")
|
||||
_comment_column("shot_replicate_segments", "task_set_id", "所属任务集ID")
|
||||
_comment_column("shot_replicate_segments", "user_id", "所属用户ID")
|
||||
_comment_column("shot_replicate_segments", "segment_index", "镜头序号")
|
||||
_comment_column("shot_replicate_segments", "source_mode", "来源模式:auto自动拆镜/manual手动")
|
||||
_comment_column("shot_replicate_segments", "start_second", "片段开始时间(秒)")
|
||||
_comment_column("shot_replicate_segments", "end_second", "片段结束时间(秒)")
|
||||
_comment_column("shot_replicate_segments", "duration_seconds", "片段时长(秒)")
|
||||
_comment_column("shot_replicate_segments", "time_node", "时间节点显示字符串")
|
||||
_comment_column("shot_replicate_segments", "split_status", "切片状态:pending/slicing/sliced/failed")
|
||||
_comment_column("shot_replicate_segments", "analysis_status", "AI分析状态:pending/processing/success/failed")
|
||||
_comment_column("shot_replicate_segments", "replicate_status", "复刻状态:not_started/processing/completed/failed")
|
||||
_comment_column("shot_replicate_segments", "segment_video_url", "片段视频访问URL")
|
||||
_comment_column("shot_replicate_segments", "segment_video_path", "片段视频存储路径")
|
||||
_comment_column("shot_replicate_segments", "original_video_content", "原视频内容描述")
|
||||
_comment_column("shot_replicate_segments", "original_video_category", "原视频行业分类")
|
||||
_comment_column("shot_replicate_segments", "original_video_audience", "原视频目标受众")
|
||||
_comment_column("shot_replicate_segments", "segment_content", "片段内容描述")
|
||||
_comment_column("shot_replicate_segments", "segment_category", "片段行业分类")
|
||||
_comment_column("shot_replicate_segments", "segment_audience", "片段目标受众")
|
||||
_comment_column("shot_replicate_segments", "analysis_json", "AI分析结果JSON")
|
||||
_comment_column("shot_replicate_segments", "ai_suggestion_json", "AI复刻建议JSON")
|
||||
_comment_column("shot_replicate_segments", "module_project_id", "关联模块生成项目ID")
|
||||
_comment_column("shot_replicate_segments", "split_claim_token", "切片租约token")
|
||||
_comment_column("shot_replicate_segments", "split_celery_task_id", "切片Celery任务ID")
|
||||
_comment_column("shot_replicate_segments", "split_enqueued_at", "切片入队时间")
|
||||
_comment_column("shot_replicate_segments", "split_started_at", "切片开始时间")
|
||||
_comment_column("shot_replicate_segments", "split_lease_until", "切片租约过期时间")
|
||||
_comment_column("shot_replicate_segments", "split_next_retry_at", "切片下次重试时间")
|
||||
_comment_column("shot_replicate_segments", "split_retry_count", "切片重试次数")
|
||||
_comment_column("shot_replicate_segments", "split_last_error", "切片最后错误信息")
|
||||
_comment_column("shot_replicate_segments", "split_completed_at", "切片完成时间")
|
||||
_comment_column("shot_replicate_segments", "analysis_attempt_no", "AI分析尝试次数")
|
||||
_comment_column("shot_replicate_segments", "analysis_claim_token", "AI分析租约token")
|
||||
_comment_column("shot_replicate_segments", "analysis_started_at", "AI分析开始时间")
|
||||
_comment_column("shot_replicate_segments", "analysis_lease_until", "AI分析租约过期")
|
||||
_comment_column("shot_replicate_segments", "analysis_error_message", "AI分析错误信息")
|
||||
_comment_column("shot_replicate_segments", "created_at", "创建时间")
|
||||
_comment_column("shot_replicate_segments", "updated_at", "更新时间")
|
||||
_comment_column("shot_replicate_segments", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# private_portrait_projects 表
|
||||
# ============================================================
|
||||
_comment_table("private_portrait_projects", "用户私域人像素材项目表")
|
||||
_comment_column("private_portrait_projects", "id", "主键ID")
|
||||
_comment_column("private_portrait_projects", "user_id", "所属用户ID")
|
||||
_comment_column("private_portrait_projects", "library_type", "素材库类型:real_person真人认证/aigc_virtual虚拟人像")
|
||||
_comment_column("private_portrait_projects", "name", "用户展示项目名")
|
||||
_comment_column("private_portrait_projects", "name_slug", "项目名安全slug")
|
||||
_comment_column("private_portrait_projects", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("private_portrait_projects", "description", "项目描述")
|
||||
_comment_column("private_portrait_projects", "status", "项目状态:active/creating/create_failed/deleting")
|
||||
_comment_column("private_portrait_projects", "asset_group_count", "素材分组数量")
|
||||
_comment_column("private_portrait_projects", "asset_count", "素材总数")
|
||||
_comment_column("private_portrait_projects", "image_asset_count", "图片素材数")
|
||||
_comment_column("private_portrait_projects", "video_asset_count", "视频素材数")
|
||||
_comment_column("private_portrait_projects", "active_asset_count", "有效素材数")
|
||||
_comment_column("private_portrait_projects", "active_image_asset_count", "有效图片素材数")
|
||||
_comment_column("private_portrait_projects", "active_video_asset_count", "有效视频素材数")
|
||||
_comment_column("private_portrait_projects", "last_used_at", "最后使用时间")
|
||||
_comment_column("private_portrait_projects", "created_at", "创建时间")
|
||||
_comment_column("private_portrait_projects", "updated_at", "更新时间")
|
||||
_comment_column("private_portrait_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# private_portrait_asset_groups 表
|
||||
# ============================================================
|
||||
_comment_table("private_portrait_asset_groups", "本地项目组与火山Asset Group映射表")
|
||||
_comment_column("private_portrait_asset_groups", "id", "主键ID")
|
||||
_comment_column("private_portrait_asset_groups", "user_id", "所属用户ID")
|
||||
_comment_column("private_portrait_asset_groups", "project_id", "所属项目ID")
|
||||
_comment_column("private_portrait_asset_groups", "library_type", "素材库类型:real_person/aigc_virtual")
|
||||
_comment_column("private_portrait_asset_groups", "remote_group_id", "火山远端AssetGroup ID")
|
||||
_comment_column("private_portrait_asset_groups", "remote_group_name", "火山远端AssetGroup名称")
|
||||
_comment_column("private_portrait_asset_groups", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("private_portrait_asset_groups", "group_type", "分组类型")
|
||||
_comment_column("private_portrait_asset_groups", "status", "分组状态:active/creating/create_failed/deleting")
|
||||
_comment_column("private_portrait_asset_groups", "remote_delete_status", "远端删除状态:none/deleting/deleted/failed")
|
||||
_comment_column("private_portrait_asset_groups", "remote_deleted_at", "远端删除时间")
|
||||
_comment_column("private_portrait_asset_groups", "remote_delete_error", "远端删除错误")
|
||||
_comment_column("private_portrait_asset_groups", "raw_response_json", "火山原始响应JSON")
|
||||
_comment_column("private_portrait_asset_groups", "created_at", "创建时间")
|
||||
_comment_column("private_portrait_asset_groups", "updated_at", "更新时间")
|
||||
_comment_column("private_portrait_asset_groups", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# private_portrait_assets 表
|
||||
# ============================================================
|
||||
_comment_table("private_portrait_assets", "火山Asset本地映射表(素材文件记录)")
|
||||
_comment_column("private_portrait_assets", "id", "主键ID")
|
||||
_comment_column("private_portrait_assets", "user_id", "所属用户ID")
|
||||
_comment_column("private_portrait_assets", "project_id", "所属项目ID")
|
||||
_comment_column("private_portrait_assets", "group_id", "所属分组ID")
|
||||
_comment_column("private_portrait_assets", "library_type", "素材库类型:real_person/aigc_virtual")
|
||||
_comment_column("private_portrait_assets", "remote_group_id", "火山远端AssetGroup ID")
|
||||
_comment_column("private_portrait_assets", "remote_asset_id", "火山远端Asset ID")
|
||||
_comment_column("private_portrait_assets", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("private_portrait_assets", "asset_type", "素材类型:Image图片/Video视频")
|
||||
_comment_column("private_portrait_assets", "name", "素材名称")
|
||||
_comment_column("private_portrait_assets", "source_url", "本地上传后的访问URL")
|
||||
_comment_column("private_portrait_assets", "preview_url", "前端预览URL")
|
||||
_comment_column("private_portrait_assets", "remote_url", "火山返回的资源访问URL")
|
||||
_comment_column("private_portrait_assets", "remote_url_expired_at", "火山URL过期时间")
|
||||
_comment_column("private_portrait_assets", "video_duration", "视频素材时长,秒")
|
||||
_comment_column("private_portrait_assets", "video_cover_url", "视频素材封面预览地址")
|
||||
_comment_column("private_portrait_assets", "file_size", "素材文件大小,字节")
|
||||
_comment_column("private_portrait_assets", "mime_type", "MIME类型")
|
||||
_comment_column("private_portrait_assets", "status", "素材状态:creating/active/failed/deleting")
|
||||
_comment_column("private_portrait_assets", "moderation_json", "火山审核结果JSON")
|
||||
_comment_column("private_portrait_assets", "last_poll_at", "最后轮询时间")
|
||||
_comment_column("private_portrait_assets", "next_poll_at", "下次轮询时间")
|
||||
_comment_column("private_portrait_assets", "poll_count", "轮询次数")
|
||||
_comment_column("private_portrait_assets", "remote_delete_status", "远端删除状态:none/deleting/deleted/failed")
|
||||
_comment_column("private_portrait_assets", "remote_deleted_at", "远端删除时间")
|
||||
_comment_column("private_portrait_assets", "remote_delete_error", "远端删除错误")
|
||||
_comment_column("private_portrait_assets", "error_message", "错误信息")
|
||||
_comment_column("private_portrait_assets", "raw_response_json", "火山原始响应JSON")
|
||||
_comment_column("private_portrait_assets", "created_at", "创建时间")
|
||||
_comment_column("private_portrait_assets", "updated_at", "更新时间")
|
||||
_comment_column("private_portrait_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# private_portrait_validate_sessions 表
|
||||
# ============================================================
|
||||
_comment_table("private_portrait_validate_sessions", "火山真人认证H5会话表")
|
||||
_comment_column("private_portrait_validate_sessions", "id", "主键ID")
|
||||
_comment_column("private_portrait_validate_sessions", "user_id", "所属用户ID")
|
||||
_comment_column("private_portrait_validate_sessions", "project_id", "关联项目ID")
|
||||
_comment_column("private_portrait_validate_sessions", "byted_token", "火山byted_token")
|
||||
_comment_column("private_portrait_validate_sessions", "h5_link", "认证H5链接")
|
||||
_comment_column("private_portrait_validate_sessions", "callback_url", "火山回调URL")
|
||||
_comment_column("private_portrait_validate_sessions", "result_code", "认证结果码")
|
||||
_comment_column("private_portrait_validate_sessions", "algorithm_base_resp_code", "算法基础响应码")
|
||||
_comment_column("private_portrait_validate_sessions", "verify_type", "认证类型")
|
||||
_comment_column("private_portrait_validate_sessions", "status", "会话状态:created/group_active/expired/failed")
|
||||
_comment_column("private_portrait_validate_sessions", "remote_group_id", "火山远端AssetGroup ID")
|
||||
_comment_column("private_portrait_validate_sessions", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("private_portrait_validate_sessions", "expired_at", "会话过期时间")
|
||||
_comment_column("private_portrait_validate_sessions", "error_message", "错误信息")
|
||||
_comment_column("private_portrait_validate_sessions", "raw_callback_json", "火山回调原始JSON")
|
||||
_comment_column("private_portrait_validate_sessions", "raw_response_json", "火山原始响应JSON")
|
||||
_comment_column("private_portrait_validate_sessions", "created_at", "创建时间")
|
||||
_comment_column("private_portrait_validate_sessions", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# vp_v3_projects 表
|
||||
# ============================================================
|
||||
_comment_table("vp_v3_projects", "API V3虚拟素材项目表(按API Key隔离)")
|
||||
_comment_column("vp_v3_projects", "id", "主键ID")
|
||||
_comment_column("vp_v3_projects", "api_key_id", "所属API Key(V3调用方)")
|
||||
_comment_column("vp_v3_projects", "name", "项目展示名称")
|
||||
_comment_column("vp_v3_projects", "name_slug", "名称安全slug(构建远端GroupName用)")
|
||||
_comment_column("vp_v3_projects", "description", "项目描述")
|
||||
_comment_column("vp_v3_projects", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("vp_v3_projects", "remote_group_id", "火山AssetGroup Id")
|
||||
_comment_column("vp_v3_projects", "remote_group_name", "火山AssetGroup Name快照")
|
||||
_comment_column("vp_v3_projects", "status", "项目状态:active/creating_remote_group/create_group_failed/deleting")
|
||||
_comment_column("vp_v3_projects", "asset_count", "素材总数")
|
||||
_comment_column("vp_v3_projects", "active_asset_count", "有效素材数")
|
||||
_comment_column("vp_v3_projects", "image_asset_count", "图片素材数")
|
||||
_comment_column("vp_v3_projects", "video_asset_count", "视频素材数")
|
||||
_comment_column("vp_v3_projects", "active_image_asset_count", "有效图片素材数")
|
||||
_comment_column("vp_v3_projects", "active_video_asset_count", "有效视频素材数")
|
||||
_comment_column("vp_v3_projects", "storage_mb_used", "项目占用存储MB")
|
||||
_comment_column("vp_v3_projects", "last_used_at", "最后使用时间")
|
||||
_comment_column("vp_v3_projects", "remote_delete_status", "远端删除状态")
|
||||
_comment_column("vp_v3_projects", "remote_deleted_at", "远端删除时间")
|
||||
_comment_column("vp_v3_projects", "remote_delete_error", "远端删除错误")
|
||||
_comment_column("vp_v3_projects", "error_message", "创建失败等错误信息")
|
||||
_comment_column("vp_v3_projects", "raw_response_json", "火山原始响应")
|
||||
_comment_column("vp_v3_projects", "created_at", "创建时间")
|
||||
_comment_column("vp_v3_projects", "updated_at", "更新时间")
|
||||
_comment_column("vp_v3_projects", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# vp_v3_assets 表
|
||||
# ============================================================
|
||||
_comment_table("vp_v3_assets", "API V3虚拟素材表(图片/视频)")
|
||||
_comment_column("vp_v3_assets", "id", "主键ID")
|
||||
_comment_column("vp_v3_assets", "api_key_id", "所属API Key")
|
||||
_comment_column("vp_v3_assets", "project_id", "所属项目ID")
|
||||
_comment_column("vp_v3_assets", "remote_project_name", "火山ProjectName快照")
|
||||
_comment_column("vp_v3_assets", "remote_group_id", "火山AssetGroup ID")
|
||||
_comment_column("vp_v3_assets", "remote_asset_id", "火山远端Asset ID")
|
||||
_comment_column("vp_v3_assets", "asset_type", "素材类型:Image=图片/Video=视频")
|
||||
_comment_column("vp_v3_assets", "name", "素材名称")
|
||||
_comment_column("vp_v3_assets", "source_url", "本地上传后的访问URL(UploadResource返回的)")
|
||||
_comment_column("vp_v3_assets", "preview_url", "给前端预览/显示用的URL")
|
||||
_comment_column("vp_v3_assets", "remote_url", "火山返回的资源访问URL")
|
||||
_comment_column("vp_v3_assets", "remote_url_expired_at", "火山URL过期时间")
|
||||
_comment_column("vp_v3_assets", "upload_resource_id", "本地UploadResource账本resource_id")
|
||||
_comment_column("vp_v3_assets", "video_duration", "视频时长,秒")
|
||||
_comment_column("vp_v3_assets", "video_cover_url", "视频封面预览")
|
||||
_comment_column("vp_v3_assets", "file_size_bytes", "素材文件大小,字节")
|
||||
_comment_column("vp_v3_assets", "mime_type", "MIME类型")
|
||||
_comment_column("vp_v3_assets", "status", "素材状态:creating/审核中active/可用failed/失败deleting/删除中")
|
||||
_comment_column("vp_v3_assets", "moderation_json", "火山审核结果JSON")
|
||||
_comment_column("vp_v3_assets", "error_message", "失败原因")
|
||||
_comment_column("vp_v3_assets", "raw_response_json", "火山原始响应JSON")
|
||||
_comment_column("vp_v3_assets", "last_poll_at", "最后轮询时间")
|
||||
_comment_column("vp_v3_assets", "next_poll_at", "下次轮询时间")
|
||||
_comment_column("vp_v3_assets", "poll_count", "轮询次数")
|
||||
_comment_column("vp_v3_assets", "remote_delete_status", "远端删除状态")
|
||||
_comment_column("vp_v3_assets", "remote_deleted_at", "远端删除时间")
|
||||
_comment_column("vp_v3_assets", "remote_delete_error", "远端删除错误")
|
||||
_comment_column("vp_v3_assets", "created_at", "创建时间")
|
||||
_comment_column("vp_v3_assets", "updated_at", "更新时间")
|
||||
_comment_column("vp_v3_assets", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# vp_v3_api_key_quotas 表
|
||||
# ============================================================
|
||||
_comment_table("vp_v3_api_key_quotas", "API V3虚拟素材库配额表(每个ApiKey一份)")
|
||||
_comment_column("vp_v3_api_key_quotas", "id", "主键ID")
|
||||
_comment_column("vp_v3_api_key_quotas", "api_key_id", "所属API Key,唯一:一个API Key只有一份虚拟素材配额")
|
||||
_comment_column("vp_v3_api_key_quotas", "project_limit", "虚拟项目上限,默认0不可创建")
|
||||
_comment_column("vp_v3_api_key_quotas", "asset_limit", "虚拟素材总数上限(图片+视频),默认0不可上传")
|
||||
_comment_column("vp_v3_api_key_quotas", "storage_mb_limit", "上传存储上限MB,默认0不可上传文件")
|
||||
_comment_column("vp_v3_api_key_quotas", "project_used", "已创建项目数(未删除)")
|
||||
_comment_column("vp_v3_api_key_quotas", "asset_used", "已上传素材数(未删除,图片+视频)")
|
||||
_comment_column("vp_v3_api_key_quotas", "storage_mb_used", "已占用存储MB(未删除文件大小合计)")
|
||||
_comment_column("vp_v3_api_key_quotas", "remark", "后台备注")
|
||||
_comment_column("vp_v3_api_key_quotas", "created_at", "创建时间")
|
||||
_comment_column("vp_v3_api_key_quotas", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# api_keys 表
|
||||
# ============================================================
|
||||
_comment_table("api_keys", "对外开放API密钥管理表")
|
||||
_comment_column("api_keys", "id", "主键ID")
|
||||
_comment_column("api_keys", "company_name", "公司/组织名称")
|
||||
_comment_column("api_keys", "api_key_hash", "API Key哈希值,唯一")
|
||||
_comment_column("api_keys", "api_key_prefix", "API Key前缀")
|
||||
_comment_column("api_keys", "api_key_encrypted", "AES-256-GCM加密的完整API Key")
|
||||
_comment_column("api_keys", "description", "描述信息")
|
||||
_comment_column("api_keys", "callable_models", "可调用模型配置JSON数组")
|
||||
_comment_column("api_keys", "quota_limit", "配额总量,NULL=无限")
|
||||
_comment_column("api_keys", "quota_cycle", "配额周期:daily/monthly/one_time/NULL=无限")
|
||||
_comment_column("api_keys", "quota_used", "当前周期已使用量")
|
||||
_comment_column("api_keys", "valid_from", "有效期开始时间")
|
||||
_comment_column("api_keys", "valid_until", "有效期结束时间")
|
||||
_comment_column("api_keys", "max_concurrent_video_tasks", "最大并发视频任务数,NULL=无限")
|
||||
_comment_column("api_keys", "is_active", "是否启用")
|
||||
_comment_column("api_keys", "last_used_at", "最后使用时间")
|
||||
_comment_column("api_keys", "created_at", "创建时间")
|
||||
_comment_column("api_keys", "updated_at", "更新时间")
|
||||
_comment_column("api_keys", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# api_generation_tasks 表
|
||||
# ============================================================
|
||||
_comment_table("api_generation_tasks", "对外开放API生成任务表")
|
||||
_comment_column("api_generation_tasks", "id", "主键ID")
|
||||
_comment_column("api_generation_tasks", "api_key_id", "所属API Key")
|
||||
_comment_column("api_generation_tasks", "external_idempotency_key", "外部幂等键")
|
||||
_comment_column("api_generation_tasks", "original_prompt", "原始提示词")
|
||||
_comment_column("api_generation_tasks", "optimized_prompt", "优化后的提示词")
|
||||
_comment_column("api_generation_tasks", "gen_type", "生成类型:image/video")
|
||||
_comment_column("api_generation_tasks", "duration", "视频时长(秒)")
|
||||
_comment_column("api_generation_tasks", "aspect_ratio", "视频比例")
|
||||
_comment_column("api_generation_tasks", "resolution", "分辨率档位")
|
||||
_comment_column("api_generation_tasks", "provider_generation_resolution", "供应商实际生成分辨率")
|
||||
_comment_column("api_generation_tasks", "image_size", "图片分辨率档位")
|
||||
_comment_column("api_generation_tasks", "image_proportion", "图片比例")
|
||||
_comment_column("api_generation_tasks", "image_px", "图片像素")
|
||||
_comment_column("api_generation_tasks", "generation_count", "生成份数")
|
||||
_comment_column("api_generation_tasks", "engine_id", "引擎ID")
|
||||
_comment_column("api_generation_tasks", "model_name", "模型名称")
|
||||
_comment_column("api_generation_tasks", "media_references", "用户原始上传的媒体URL")
|
||||
_comment_column("api_generation_tasks", "local_media_json", "下载到本地的媒体文件路径JSON")
|
||||
_comment_column("api_generation_tasks", "engine_snapshot_json", "引擎参数快照JSON")
|
||||
_comment_column("api_generation_tasks", "request_params_json", "完整原始请求参数")
|
||||
_comment_column("api_generation_tasks", "status", "任务状态")
|
||||
_comment_column("api_generation_tasks", "pipeline_stage", "流水线阶段")
|
||||
_comment_column("api_generation_tasks", "generation_attempt_no", "生成尝试次数")
|
||||
_comment_column("api_generation_tasks", "resource_generation_started_at", "资源生成开始时间")
|
||||
_comment_column("api_generation_tasks", "deadline_at", "任务截止时间")
|
||||
_comment_column("api_generation_tasks", "provider_task_id", "供应商任务ID")
|
||||
_comment_column("api_generation_tasks", "remote_result_url", "供应商远程资源URL")
|
||||
_comment_column("api_generation_tasks", "provider_response_json", "供应商响应JSON")
|
||||
_comment_column("api_generation_tasks", "image_url", "图片结果URL")
|
||||
_comment_column("api_generation_tasks", "video_url", "视频结果URL")
|
||||
_comment_column("api_generation_tasks", "video_cover_url", "视频封面URL")
|
||||
_comment_column("api_generation_tasks", "error_message", "错误信息")
|
||||
_comment_column("api_generation_tasks", "generated_at", "生成完成时间")
|
||||
_comment_column("api_generation_tasks", "video_upscale_enabled_snapshot", "是否开启视频超分")
|
||||
_comment_column("api_generation_tasks", "video_upscale_snapshot_json", "视频超分参数快照JSON")
|
||||
_comment_column("api_generation_tasks", "credits_cost", "消耗积分")
|
||||
_comment_column("api_generation_tasks", "video_tokens_used", "视频Token消耗")
|
||||
_comment_column("api_generation_tasks", "image_tokens_used", "图片Token消耗")
|
||||
_comment_column("api_generation_tasks", "next_poll_at", "下次轮询时间")
|
||||
_comment_column("api_generation_tasks", "poll_interval_seconds", "轮询间隔秒数")
|
||||
_comment_column("api_generation_tasks", "poll_count", "轮询次数")
|
||||
_comment_column("api_generation_tasks", "last_poll_at", "最后轮询时间")
|
||||
_comment_column("api_generation_tasks", "provider_create_claim_token", "供应商创建任务租约token")
|
||||
_comment_column("api_generation_tasks", "provider_create_lease_until", "供应商创建租约过期")
|
||||
_comment_column("api_generation_tasks", "provider_create_started_at", "供应商创建开始时间")
|
||||
_comment_column("api_generation_tasks", "poll_started_at", "轮询开始时间")
|
||||
_comment_column("api_generation_tasks", "poll_claim_token", "轮询租约token")
|
||||
_comment_column("api_generation_tasks", "poll_lease_until", "轮询租约过期")
|
||||
_comment_column("api_generation_tasks", "poll_error_count", "轮询错误次数")
|
||||
_comment_column("api_generation_tasks", "download_celery_task_id", "下载Celery任务ID")
|
||||
_comment_column("api_generation_tasks", "download_enqueued_at", "下载入队时间")
|
||||
_comment_column("api_generation_tasks", "download_started_at", "下载开始时间")
|
||||
_comment_column("api_generation_tasks", "download_claim_token", "下载租约token")
|
||||
_comment_column("api_generation_tasks", "download_lease_until", "下载租约过期")
|
||||
_comment_column("api_generation_tasks", "download_next_retry_at", "下载下次重试时间")
|
||||
_comment_column("api_generation_tasks", "download_attempt_count", "下载重试次数")
|
||||
_comment_column("api_generation_tasks", "download_last_error", "下载最后错误信息")
|
||||
_comment_column("api_generation_tasks", "download_storage_date_dir", "下载存储日期目录")
|
||||
_comment_column("api_generation_tasks", "local_path", "本地存储路径")
|
||||
_comment_column("api_generation_tasks", "created_at", "创建时间")
|
||||
_comment_column("api_generation_tasks", "updated_at", "更新时间")
|
||||
_comment_column("api_generation_tasks", "deleted_at", "软删除时间,NULL表示未删除")
|
||||
|
||||
# ============================================================
|
||||
# api_usage_logs 表
|
||||
# ============================================================
|
||||
_comment_table("api_usage_logs", "API调用详细消耗记录表")
|
||||
_comment_column("api_usage_logs", "id", "主键ID")
|
||||
_comment_column("api_usage_logs", "api_key_id", "所属API Key")
|
||||
_comment_column("api_usage_logs", "api_generation_task_id", "关联生成任务ID")
|
||||
_comment_column("api_usage_logs", "price_action", "操作类型:deduct=扣除, refund=退回")
|
||||
_comment_column("api_usage_logs", "request_type", "请求类型:video_create/image_generate")
|
||||
_comment_column("api_usage_logs", "model_name", "模型名称")
|
||||
_comment_column("api_usage_logs", "gen_type", "生成类型:image/video")
|
||||
_comment_column("api_usage_logs", "resolution", "分辨率")
|
||||
_comment_column("api_usage_logs", "duration", "视频时长(秒)")
|
||||
_comment_column("api_usage_logs", "credits_cost", "实际扣除金额")
|
||||
_comment_column("api_usage_logs", "refund_amount", "退回金额")
|
||||
_comment_column("api_usage_logs", "quota_before", "操作前配额余额")
|
||||
_comment_column("api_usage_logs", "quota_after", "操作后配额余额")
|
||||
_comment_column("api_usage_logs", "tokens_used", "Token用量")
|
||||
_comment_column("api_usage_logs", "request_duration_ms", "端到端耗时")
|
||||
_comment_column("api_usage_logs", "price_detail_json", "价格计算明细JSON")
|
||||
_comment_column("api_usage_logs", "status", "调用状态:success/failed")
|
||||
_comment_column("api_usage_logs", "error_message", "错误信息")
|
||||
_comment_column("api_usage_logs", "error_code", "错误码")
|
||||
_comment_column("api_usage_logs", "request_payload_json", "原始请求快照")
|
||||
_comment_column("api_usage_logs", "created_at", "创建时间")
|
||||
_comment_column("api_usage_logs", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# api_model_pricings 表
|
||||
# ============================================================
|
||||
_comment_table("api_model_pricings", "API模型价格表(全局统一配置)")
|
||||
_comment_column("api_model_pricings", "id", "主键ID")
|
||||
_comment_column("api_model_pricings", "model_config_id", "引擎ID:图片对应image_engines.id,视频对应video_engines.id")
|
||||
_comment_column("api_model_pricings", "gen_type", "生成类型:image/video")
|
||||
_comment_column("api_model_pricings", "resolution", "分辨率档位")
|
||||
_comment_column("api_model_pricings", "price_ratio", "价格系数(乘数)")
|
||||
_comment_column("api_model_pricings", "base_price", "基础价格(元)")
|
||||
_comment_column("api_model_pricings", "per_second_price", "每秒价格(视频,元)")
|
||||
_comment_column("api_model_pricings", "input_video_ratio", "传入视频系数")
|
||||
_comment_column("api_model_pricings", "input_video_base_price", "传入视频基础价(元)")
|
||||
_comment_column("api_model_pricings", "input_video_per_second_price", "传入视频每秒价(元)")
|
||||
_comment_column("api_model_pricings", "input_image_ratio", "传入图片系数")
|
||||
_comment_column("api_model_pricings", "input_image_base_price", "传入图片基础价(元)")
|
||||
_comment_column("api_model_pricings", "input_image_per_image_price", "传入图片每张价(元)")
|
||||
_comment_column("api_model_pricings", "created_at", "创建时间")
|
||||
_comment_column("api_model_pricings", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# api_upscale_links 表
|
||||
# ============================================================
|
||||
_comment_table("api_upscale_links", "API任务与超分任务关联表")
|
||||
_comment_column("api_upscale_links", "id", "主键ID")
|
||||
_comment_column("api_upscale_links", "api_generation_task_id", "关联API生成任务ID")
|
||||
_comment_column("api_upscale_links", "video_upscale_task_id", "关联视频超分任务ID")
|
||||
_comment_column("api_upscale_links", "created_at", "创建时间")
|
||||
_comment_column("api_upscale_links", "updated_at", "更新时间")
|
||||
|
||||
# ============================================================
|
||||
# api_key_upscale_configs 表
|
||||
# ============================================================
|
||||
_comment_table("api_key_upscale_configs", "API Key级别超分配置表")
|
||||
_comment_column("api_key_upscale_configs", "id", "主键ID")
|
||||
_comment_column("api_key_upscale_configs", "api_key_id", "所属API Key")
|
||||
_comment_column("api_key_upscale_configs", "enabled", "是否启用超分")
|
||||
_comment_column("api_key_upscale_configs", "delete_source_after_success", "超分成功后是否删除源文件")
|
||||
_comment_column("api_key_upscale_configs", "rules_json", "超分规则JSON数组")
|
||||
_comment_column("api_key_upscale_configs", "created_at", "创建时间")
|
||||
_comment_column("api_key_upscale_configs", "updated_at", "更新时间")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("""
|
||||
DO $$
|
||||
DECLARE
|
||||
r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT table_name, column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name IN (
|
||||
'notification_reads', 'menu_configs',
|
||||
'team_join_requests', 'team_invitations',
|
||||
'contact_requests', 'token_usage',
|
||||
'industry_configs', 'chat_generation_task_events',
|
||||
'chat_provider_call_logs', 'open_type',
|
||||
'pre_test_template', 'material_cost',
|
||||
'user_oauth', 'user_oauth_account', 'user_oauth_app',
|
||||
'upload_task', 'user_resource_month_stats',
|
||||
'user_resource_total_stats', 'home_material_assets',
|
||||
'home_material_categories', 'home_material_watermarks',
|
||||
'module_generation_projects', 'module_generation_steps',
|
||||
'shot_replicate_segments',
|
||||
'private_portrait_projects', 'private_portrait_asset_groups',
|
||||
'private_portrait_assets', 'private_portrait_validate_sessions',
|
||||
'vp_v3_projects', 'vp_v3_assets', 'vp_v3_api_key_quotas',
|
||||
'api_keys', 'api_generation_tasks', 'api_usage_logs',
|
||||
'api_model_pricings', 'api_upscale_links',
|
||||
'api_key_upscale_configs'
|
||||
)
|
||||
LOOP
|
||||
EXECUTE format('COMMENT ON COLUMN %I.%I IS NULL', r.table_name, r.column_name);
|
||||
END LOOP;
|
||||
END $$;
|
||||
""")
|
||||
for t in [
|
||||
"notification_reads", "menu_configs",
|
||||
"team_join_requests", "team_invitations",
|
||||
"contact_requests", "token_usage",
|
||||
"industry_configs", "chat_generation_task_events",
|
||||
"chat_provider_call_logs", "open_type",
|
||||
"pre_test_template", "material_cost",
|
||||
"user_oauth", "user_oauth_account", "user_oauth_app",
|
||||
"upload_task", "user_resource_month_stats",
|
||||
"user_resource_total_stats", "home_material_assets",
|
||||
"home_material_categories", "home_material_watermarks",
|
||||
"module_generation_projects", "module_generation_steps",
|
||||
"shot_replicate_segments",
|
||||
"private_portrait_projects", "private_portrait_asset_groups",
|
||||
"private_portrait_assets", "private_portrait_validate_sessions",
|
||||
"vp_v3_projects", "vp_v3_assets", "vp_v3_api_key_quotas",
|
||||
"api_keys", "api_generation_tasks", "api_usage_logs",
|
||||
"api_model_pricings", "api_upscale_links",
|
||||
"api_key_upscale_configs",
|
||||
]:
|
||||
op.execute(f"COMMENT ON TABLE {t} IS NULL")
|
||||
@@ -0,0 +1,109 @@
|
||||
"""发票管理表迁移
|
||||
|
||||
创建 invoices(发票主表)和 invoice_orders(发票-订单关联表)。
|
||||
|
||||
Revision ID: 20260810_20260810
|
||||
Revises: 2026080601
|
||||
Create Date: 2026-08-10 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '20260810_20260810'
|
||||
down_revision: Union[str, None] = '2026080601'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _comment_table(table_name: str, comment: str) -> None:
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
||||
|
||||
|
||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
||||
escaped = comment.replace("'", "''")
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ============================================================
|
||||
# 1. 创建 invoices 表
|
||||
# ============================================================
|
||||
op.create_table(
|
||||
'invoices',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('invoice_no', sa.String(32), nullable=False, unique=True),
|
||||
sa.Column('header_type', sa.String(16), nullable=False),
|
||||
sa.Column('header_name', sa.String(128), nullable=False),
|
||||
sa.Column('header_tax_no', sa.String(32), nullable=True),
|
||||
sa.Column('header_register_address', sa.String(256), nullable=True),
|
||||
sa.Column('header_register_phone', sa.String(32), nullable=True),
|
||||
sa.Column('header_bank_name', sa.String(128), nullable=True),
|
||||
sa.Column('header_bank_account', sa.String(64), nullable=True),
|
||||
sa.Column('email', sa.String(128), nullable=False),
|
||||
sa.Column('total_amount', sa.Float, nullable=False, server_default='0'),
|
||||
sa.Column('total_credits', sa.Float, nullable=False, server_default='0'),
|
||||
sa.Column('status', sa.String(16), nullable=False, server_default='processing'),
|
||||
sa.Column('failure_reason', sa.Text, nullable=True),
|
||||
sa.Column('issued_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_invoices_user_created', 'invoices', ['user_id', 'created_at'])
|
||||
op.create_index('idx_invoices_status_created', 'invoices', ['status', 'created_at'])
|
||||
op.create_index('idx_invoices_invoice_no', 'invoices', ['invoice_no'], unique=True)
|
||||
|
||||
# ============================================================
|
||||
# 2. 创建 invoice_orders 表
|
||||
# ============================================================
|
||||
op.create_table(
|
||||
'invoice_orders',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('invoice_id', sa.String(32), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('order_id', sa.String(32), sa.ForeignKey('payment_orders.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('order_no', sa.String(64), nullable=False),
|
||||
sa.Column('amount', sa.Float, nullable=False, server_default='0'),
|
||||
sa.Column('credits', sa.Float, nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_invoice_orders_invoice', 'invoice_orders', ['invoice_id'])
|
||||
op.create_index('idx_invoice_orders_order', 'invoice_orders', ['order_id'])
|
||||
op.create_unique_constraint('uq_invoice_orders', 'invoice_orders', ['invoice_id', 'order_id'])
|
||||
|
||||
# ============================================================
|
||||
# 3. 表注释和字段注释
|
||||
# ============================================================
|
||||
_comment_table('invoices', '发票主表')
|
||||
_comment_column('invoices', 'id', '主键')
|
||||
_comment_column('invoices', 'user_id', '申请用户ID')
|
||||
_comment_column('invoices', 'invoice_no', '发票编号')
|
||||
_comment_column('invoices', 'header_type', '抬头类型: personal/company')
|
||||
_comment_column('invoices', 'header_name', '抬头名称')
|
||||
_comment_column('invoices', 'header_tax_no', '税号')
|
||||
_comment_column('invoices', 'header_register_address', '注册地址')
|
||||
_comment_column('invoices', 'header_register_phone', '注册电话')
|
||||
_comment_column('invoices', 'header_bank_name', '开户行')
|
||||
_comment_column('invoices', 'header_bank_account', '银行账号')
|
||||
_comment_column('invoices', 'email', '电子邮箱(必填)')
|
||||
_comment_column('invoices', 'total_amount', '开票总金额')
|
||||
_comment_column('invoices', 'total_credits', '总积分')
|
||||
_comment_column('invoices', 'status', '状态: processing/success/failed')
|
||||
_comment_column('invoices', 'failure_reason', '失败原因')
|
||||
_comment_column('invoices', 'issued_at', '开票成功时间')
|
||||
|
||||
_comment_table('invoice_orders', '发票-订单关联表')
|
||||
_comment_column('invoice_orders', 'id', '主键')
|
||||
_comment_column('invoice_orders', 'invoice_id', '发票ID')
|
||||
_comment_column('invoice_orders', 'order_id', '订单ID')
|
||||
_comment_column('invoice_orders', 'order_no', '订单号(冗余)')
|
||||
_comment_column('invoice_orders', 'amount', '订单金额(冗余)')
|
||||
_comment_column('invoice_orders', 'credits', '订单积分(冗余)')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('invoice_orders')
|
||||
op.drop_table('invoices')
|
||||
@@ -0,0 +1,65 @@
|
||||
"""发票抬头表迁移
|
||||
|
||||
创建 invoice_headers(发票抬头表),用于用户管理常用发票抬头。
|
||||
|
||||
Revision ID: 20260811_20260811
|
||||
Revises: 20260810_20260810
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '20260811_20260811'
|
||||
down_revision: Union[str, None] = '20260810_20260810'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _comment_table(table_name: str, comment: str) -> None:
|
||||
op.execute(f"COMMENT ON TABLE {table_name} IS '{comment}'")
|
||||
|
||||
|
||||
def _comment_column(table_name: str, column_name: str, comment: str) -> None:
|
||||
escaped = comment.replace("'", "''")
|
||||
op.execute(f"COMMENT ON COLUMN {table_name}.{column_name} IS '{escaped}'")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'invoice_headers',
|
||||
sa.Column('id', sa.String(32), primary_key=True),
|
||||
sa.Column('user_id', sa.String(32), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
|
||||
sa.Column('type', sa.String(16), nullable=False),
|
||||
sa.Column('name', sa.String(128), nullable=False),
|
||||
sa.Column('tax_no', sa.String(32), nullable=True),
|
||||
sa.Column('register_address', sa.String(256), nullable=True),
|
||||
sa.Column('register_phone', sa.String(32), nullable=True),
|
||||
sa.Column('bank_name', sa.String(128), nullable=True),
|
||||
sa.Column('bank_account', sa.String(64), nullable=True),
|
||||
sa.Column('email', sa.String(128), nullable=True),
|
||||
sa.Column('is_default', sa.Boolean, nullable=False, server_default='false'),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index('idx_invoice_headers_user', 'invoice_headers', ['user_id'])
|
||||
|
||||
# 表注释和字段注释
|
||||
_comment_table('invoice_headers', '发票抬头表')
|
||||
_comment_column('invoice_headers', 'id', '主键')
|
||||
_comment_column('invoice_headers', 'user_id', '用户ID')
|
||||
_comment_column('invoice_headers', 'type', '抬头类型: personal/company')
|
||||
_comment_column('invoice_headers', 'name', '抬头名称')
|
||||
_comment_column('invoice_headers', 'tax_no', '税号')
|
||||
_comment_column('invoice_headers', 'register_address', '注册地址')
|
||||
_comment_column('invoice_headers', 'register_phone', '注册电话')
|
||||
_comment_column('invoice_headers', 'bank_name', '开户行')
|
||||
_comment_column('invoice_headers', 'bank_account', '银行账号')
|
||||
_comment_column('invoice_headers', 'email', '接收邮箱')
|
||||
_comment_column('invoice_headers', 'is_default', '是否默认')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('invoice_headers')
|
||||
@@ -0,0 +1,30 @@
|
||||
"""单设备登录拆分设备类型 + 用户级覆盖
|
||||
|
||||
Revision ID: 20260813_split_device_type
|
||||
Revises: 20260813_token_version
|
||||
Create Date: 2026-08-13 12:00:00.000000
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '20260813_split_device_type'
|
||||
down_revision = '20260813_token_version'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# 拆分为按设备类型管理
|
||||
op.add_column('users', sa.Column('pc_token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.add_column('users', sa.Column('mobile_token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.drop_column('users', 'token_version')
|
||||
# 用户级覆盖:None=跟随全局, True=强制启用, False=强制禁用
|
||||
op.add_column('users', sa.Column('single_device_login_override', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
|
||||
op.drop_column('users', 'single_device_login_override')
|
||||
op.drop_column('users', 'mobile_token_version')
|
||||
op.drop_column('users', 'pc_token_version')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user