Compare commits
498
Commits
chenweiqiang
...
main_dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9c0a31a4b | ||
|
|
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 |
+7
-2
@@ -8,13 +8,17 @@ __pycache__/
|
|||||||
.claude/
|
.claude/
|
||||||
.vscode/
|
.vscode/
|
||||||
.trae/
|
.trae/
|
||||||
video-gen-app/dist/
|
# video-gen-app/dist/
|
||||||
video-gen-api/dist/
|
#video-gen-api/dist/
|
||||||
|
bak/
|
||||||
|
|
||||||
# 使用通配符
|
# 使用通配符
|
||||||
*.log
|
*.log
|
||||||
*.tmp
|
*.tmp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.playwright-mcp
|
||||||
|
*.zip
|
||||||
|
*.bat
|
||||||
|
|
||||||
# 否定规则(不忽略)
|
# 否定规则(不忽略)
|
||||||
# !important.log
|
# !important.log
|
||||||
@@ -23,3 +27,4 @@ video-gen-api/dist/
|
|||||||
# *.pyc
|
# *.pyc
|
||||||
# !dir/*.pycnode_modules/
|
# !dir/*.pycnode_modules/
|
||||||
*.tmp.*
|
*.tmp.*
|
||||||
|
*_上线.py
|
||||||
+813
@@ -0,0 +1,813 @@
|
|||||||
|
# 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)` 传递
|
||||||
|
|
||||||
|
### 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` |
|
||||||
+245
-70
@@ -5,11 +5,13 @@
|
|||||||
```
|
```
|
||||||
video_item/
|
video_item/
|
||||||
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
|
├── video-gen-api/ # 后端 (Python FastAPI + SQLAlchemy + Alembic)
|
||||||
├── video-gen-app/ # 前台 (React 19 + Vite 8 + Ant Design 6 + Tailwind)
|
├── video-gen-app/ # 前台/用户端 (React 19 + Vite 8 + Ant Design 6)
|
||||||
├── video-gen-admin/ # 后台管理 (React 19 + Vite 8 + Ant Design 6)
|
├── video-gen-admin/ # 后台管理端 (React 19 + Vite 8 + Ant Design 6)
|
||||||
└── DEPLOYMENT.md # 本文档
|
└── DEPLOYMENT.md # 本文档
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**三个前端/后端的关系:** `video-gen-app` (用户前台) 和 `video-gen-admin` (管理后台) 都连接同一个 `video-gen-api` 后端。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 一、环境要求
|
## 一、环境要求
|
||||||
@@ -18,14 +20,13 @@ video_item/
|
|||||||
|------|---------|------|
|
|------|---------|------|
|
||||||
| Python | >= 3.10 | 推荐 3.12 |
|
| Python | >= 3.10 | 推荐 3.12 |
|
||||||
| Node.js | >= 18 | 推荐 20+ |
|
| Node.js | >= 18 | 推荐 20+ |
|
||||||
| PostgreSQL | >= 14 | 推荐 16 |
|
| PostgreSQL | >= 14 | 推荐 16;**必须** |
|
||||||
| Redis | >= 6 | 可选,推荐用于限流/验证码/Celery |
|
| Redis | >= 6 | 推荐用于: 限流/验证码/Celery/任务状态 |
|
||||||
| FFmpeg | 任意 | 可选,用于视频封面截帧 |
|
| FFmpeg | 任意 | 用于视频封面截帧,留空时从 PATH 自动查找 |
|
||||||
| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 |
|
|
||||||
| wechatpayv3 | >=2.0.2 | 可选,用于支付 |
|
|
||||||
| volcengine-python-sdk | >=1.1.0 | 可选,用于视频生成 |
|
|
||||||
| ca-certificates | 任意 | **必须**,HTTPS 请求需要(新服务器/容器常缺) |
|
| 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. 安装依赖
|
### 1. 安装依赖
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS 请求(支付宝/火山等)全部失败
|
# ⚠️ 新服务器/容器必须先装 CA 证书,否则 HTTPS(支付宝/火山等)全部失败
|
||||||
# CentOS/RHEL
|
# CentOS/RHEL
|
||||||
sudo yum install -y ca-certificates
|
sudo yum install -y ca-certificates
|
||||||
# Ubuntu/Debian
|
# Ubuntu/Debian
|
||||||
@@ -49,7 +50,10 @@ python -m venv .venv
|
|||||||
# Linux/Mac
|
# Linux/Mac
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
|
||||||
# 安装基础依赖 + PostgreSQL 驱动
|
# 安装基础依赖
|
||||||
|
pip install -e .
|
||||||
|
|
||||||
|
# 安装 PostgreSQL 驱动(生产环境必须)
|
||||||
pip install -e ".[pg]"
|
pip install -e ".[pg]"
|
||||||
|
|
||||||
# 如需 Redis 支持(限流、验证码、Celery)
|
# 如需 Redis 支持(限流、验证码、Celery)
|
||||||
@@ -58,21 +62,19 @@ pip install -e ".[pg,redis]"
|
|||||||
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
# 如需 Celery 异步任务(ChatAPI 生成流水线)
|
||||||
pip install -e ".[pg,redis,celery]"
|
pip install -e ".[pg,redis,celery]"
|
||||||
|
|
||||||
#安装阿里支付sdk
|
# 如需支付宝
|
||||||
pip install -e ".[pg,redis,celery,alipay]"
|
pip install -e ".[pg,redis,celery,alipay]"
|
||||||
|
|
||||||
# 安装微信支付sdk
|
# 如需微信支付
|
||||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3]"
|
pip install -e ".[pg,redis,celery,alipay,wxpay]"
|
||||||
|
|
||||||
#安装火山sdk
|
|
||||||
pip install -e ".[pg,redis,celery,alipay,wechatpayv3,volc]"
|
|
||||||
|
|
||||||
|
|
||||||
|
# 如需火山引擎 SDK(短信等)
|
||||||
|
pip install -e ".[pg,redis,celery,alipay,wxpay,volc]"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 配置环境变量
|
### 2. 配置环境变量
|
||||||
|
|
||||||
复制 `.env.example` 为 `.env`,修改以下关键配置:
|
复制 `.env.example` 为 `.env`,修改关键配置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -81,13 +83,14 @@ cp .env.example .env
|
|||||||
```ini
|
```ini
|
||||||
# ── 基础配置 ──
|
# ── 基础配置 ──
|
||||||
APP_NAME=VideoGen API
|
APP_NAME=VideoGen API
|
||||||
|
APP_VERSION=1.0.0
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
SECRET_KEY=改成一个随机的长字符串
|
SECRET_KEY=改成一个随机的长字符串(JWT 签名密钥)
|
||||||
|
|
||||||
# ── 数据库 ──
|
# ── 数据库(必须) ──
|
||||||
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
|
DATABASE_URL=postgresql+asyncpg://用户名:密码@localhost:5432/videogen
|
||||||
|
|
||||||
# ── Redis(可选,留空则禁用限流和验证码) ──
|
# ── Redis(可选,留空则禁用限流/验证码/Celery) ──
|
||||||
REDIS_URL=redis://localhost:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# ── JWT ──
|
# ── JWT ──
|
||||||
@@ -101,24 +104,30 @@ SEEDANCE_API_BASE=https://ark.cn-beijing.volces.com/api/v3
|
|||||||
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
|
SEEDANCE_CALLBACK_URL=https://你的域名/api/generation-records/callback
|
||||||
|
|
||||||
# ── LLM 提示词优化 ──
|
# ── 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,留空则禁用) ──
|
# ── 前后端通信加密(32字节 base64,留空则禁用) ──
|
||||||
ENCRYPTION_KEY=你的32字节base64密钥
|
ENCRYPTION_KEY=你的32字节base64密钥
|
||||||
|
|
||||||
# ── 存储路径 ──
|
# ── 存储路径(本地存储) ──
|
||||||
STORAGE_TYPE=local
|
STORAGE_TYPE=local
|
||||||
STORAGE_LOCAL_PATH=./storage/generate/videos
|
STORAGE_LOCAL_PATH=./storage/generate/videos
|
||||||
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
|
STORAGE_IMAGE_LOCAL_PATH=./storage/generate/images
|
||||||
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
|
STORAGE_VIDEO_COVER_LOCAL_PATH=./storage/generate/covers
|
||||||
UPLOAD_LOCAL_PATH=./storage/uploads
|
UPLOAD_LOCAL_PATH=./storage/uploads
|
||||||
|
|
||||||
# ── 跨域(生产环境务必限制域名) ──
|
# ── 跨域(生产环境务必限制域名,默认 ["*"] 允许所有) ──
|
||||||
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
|
CORS_ORIGINS=["https://你的前台域名.com", "https://你的后台域名.com"]
|
||||||
|
|
||||||
# ── 回调基础地址 ──
|
# ── 回调基础地址 ──
|
||||||
BASE_URL=https://你的域名
|
BASE_URL=https://你的域名
|
||||||
|
|
||||||
|
# ── 验证码 ──
|
||||||
|
CAPTCHA_ENABLED=true
|
||||||
|
|
||||||
# ── 短信(火山引擎 SDK,SMS_MOCK=false 时生效) ──
|
# ── 短信(火山引擎 SDK,SMS_MOCK=false 时生效) ──
|
||||||
SMS_MOCK=true
|
SMS_MOCK=true
|
||||||
VOLC_SMS_ACCESS_KEY_ID=
|
VOLC_SMS_ACCESS_KEY_ID=
|
||||||
@@ -129,6 +138,12 @@ VOLC_SMS_SIGN=短信签名
|
|||||||
|
|
||||||
# ── 支付(PAYMENT_MOCK=false 时生效) ──
|
# ── 支付(PAYMENT_MOCK=false 时生效) ──
|
||||||
PAYMENT_MOCK=true
|
PAYMENT_MOCK=true
|
||||||
|
WECHAT_MCH_ID=
|
||||||
|
WECHAT_API_KEY=
|
||||||
|
ALIPAY_APP_ID=
|
||||||
|
ALIPAY_PRIVATE_KEY=
|
||||||
|
ALIPAY_PUBLIC_KEY=
|
||||||
|
ALIPAY_NOTIFY_URL=
|
||||||
|
|
||||||
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
|
# ── Celery(可选,留空则禁用 ChatAPI 异步流水线) ──
|
||||||
CELERY_BROKER_URL=redis://localhost:6379/5
|
CELERY_BROKER_URL=redis://localhost:6379/5
|
||||||
@@ -138,27 +153,30 @@ CELERY_RESULT_BACKEND=redis://localhost:6379/6
|
|||||||
FFMPEG_BIN=/usr/bin/ffmpeg
|
FFMPEG_BIN=/usr/bin/ffmpeg
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **关于 ENCRYPTION_KEY 的生成:** 需要 32 字节(256 位)的 base64 编码字符串。生成方式:`openssl rand -base64 32`。前后端必须使用**完全相同**的密钥。
|
||||||
|
|
||||||
### 3. 初始化数据库
|
### 3. 初始化数据库
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建 PostgreSQL 数据库
|
# 创建 PostgreSQL 数据库
|
||||||
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
|
psql -U postgres -c "CREATE DATABASE videogen OWNER videogen;"
|
||||||
|
|
||||||
# 启动后端(首次启动自动建表 + 填充种子数据)
|
# 启动后端(首次启动自动建表)
|
||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
首次启动会自动完成:
|
首次启动时会**自动创建所有数据表**(41 个 model)。
|
||||||
- 创建所有数据表(23 个 model)
|
|
||||||
- 创建管理员账号:`admin` / `123456`
|
|
||||||
- 创建演示用户:`demo` / `123456`(手机号 `13888888888`)
|
|
||||||
- 填充系统配置、视频引擎(Seedance 2.0 / 2.0 fast)、图片引擎(Seedream 5.0)、模型配置、积分比例、菜单配置、充值套餐(4 档)、行业配置
|
|
||||||
|
|
||||||
**首次部署后务必修改默认密码。**
|
> ⚠️ **关于种子数据:** 代码中包含 `_seed_data()` 函数(创建管理员/演示用户、系统配置、引擎配置等),但当前在 `main.py` 中被**注释掉了**(`# await _seed_data()`)。因此首次启动**不会**自动创建管理员账号。
|
||||||
|
>
|
||||||
|
> **如果你需要种子数据,**有以下选择:
|
||||||
|
> 1. 在 `main.py` 中取消注释 `# await _seed_data()` 后重启
|
||||||
|
> 2. 手动通过 API 或数据库脚本创建管理员账号
|
||||||
|
> 3. 自行编写独立的种子脚本调用 `_seed_data()`
|
||||||
|
|
||||||
### 4. 数据库迁移 (Alembic)
|
### 4. 数据库迁移 (Alembic)
|
||||||
|
|
||||||
项目使用 Alembic 管理数据库结构变更,`env.py` 已导入全部 20 个 model。
|
项目使用 Alembic 管理数据库结构变更。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd video-gen-api
|
cd video-gen-api
|
||||||
@@ -179,17 +197,13 @@ python -m alembic history
|
|||||||
python -m alembic downgrade -1
|
python -m alembic downgrade -1
|
||||||
```
|
```
|
||||||
|
|
||||||
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
**部署流程:** 拉取代码后先执行 `alembic upgrade head`,再重启后端服务。
|
||||||
|
|
||||||
**新增 model 时:** 需要在 `alembic/env.py` 中添加对应的 import。
|
|
||||||
|
|
||||||
### 5. 生产运行
|
### 5. 生产运行
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 方式一:直接运行(推荐 4 workers)
|
# 直接运行(推荐 4 workers)
|
||||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 4
|
||||||
|
|
||||||
# 方式二:使用 systemd (Linux)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`:
|
**systemd 服务文件** `/etc/systemd/system/videogen-api.service`:
|
||||||
@@ -220,11 +234,22 @@ sudo systemctl start videogen-api
|
|||||||
|
|
||||||
### 6. Celery Worker(可选)
|
### 6. Celery Worker(可选)
|
||||||
|
|
||||||
ChatAPI 异步生成流水线需要 Celery Worker。需要 Redis 作为 Broker。
|
ChatAPI 异步生成流水线需要 Celery Worker,依赖 Redis 作为 Broker。
|
||||||
|
|
||||||
|
Celery 使用 **6 个队列**,按功能分离:
|
||||||
|
|
||||||
|
| 队列 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `gen_chatapi_create` | ChatAPI 生成任务创建(含爆款开头/拆镜复刻的提词步骤) |
|
||||||
|
| `gen_provider_poll` | 轮询火山引擎生成状态 |
|
||||||
|
| `gen_result_download` | 下载生成的视频/图片结果 |
|
||||||
|
| `gen_recovery` | 容灾恢复任务(统一队列,避免占用业务 worker) |
|
||||||
|
| `gen_private_portrait` | 真人素材认证与同步 |
|
||||||
|
| `default` | 默认队列(用户 OAuth、清理任务等) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 启动 Worker(处理 3 个队列:gen_chatapi_create, gen_provider_poll, gen_result_download)
|
# 启动 Worker(消费所有队列)
|
||||||
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,default
|
celery -A app.tasks.celery_app worker -l info -Q gen_chatapi_create,gen_provider_poll,gen_result_download,gen_recovery,gen_private_portrait,default
|
||||||
```
|
```
|
||||||
|
|
||||||
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`:
|
**systemd 服务文件** `/etc/systemd/system/videogen-worker.service`:
|
||||||
@@ -239,7 +264,7 @@ Type=simple
|
|||||||
User=www-data
|
User=www-data
|
||||||
WorkingDirectory=/opt/video-gen-api
|
WorkingDirectory=/opt/video-gen-api
|
||||||
Environment=PATH=/opt/video-gen-api/.venv/bin
|
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_recovery,gen_private_portrait,default
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|
||||||
@@ -249,23 +274,25 @@ WantedBy=multi-user.target
|
|||||||
|
|
||||||
### 7. Docker 部署(可选)
|
### 7. Docker 部署(可选)
|
||||||
|
|
||||||
项目提供 `Dockerfile` 和 `docker-compose.yml`,一键启动完整环境:
|
项目提供 `Dockerfile` 和 `docker-compose.yml`。
|
||||||
|
|
||||||
|
> ⚠️ **注意:** 默认 `Dockerfile` 只安装基础依赖(`pip install .`),生产使用需改为 `".[pg,redis,celery]"`。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd video-gen-api
|
cd video-gen-api
|
||||||
|
|
||||||
# 注意:Dockerfile 默认只装基础依赖,需修改为安装 pg+redis+celery
|
# 使用前需修改 Dockerfile 第 6 行为:
|
||||||
# 将 Dockerfile 中的 RUN pip install --no-cache-dir . 改为:
|
|
||||||
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
|
# RUN pip install --no-cache-dir ".[pg,redis,celery]"
|
||||||
|
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
启动的服务:
|
启动的服务:
|
||||||
|
|
||||||
| 服务 | 端口 | 说明 |
|
| 服务 | 端口 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| api | 8000 | FastAPI 应用(带 `--reload`,开发模式) |
|
| api | 8000 | FastAPI 应用(`--reload` 开发模式) |
|
||||||
| worker | - | Celery Worker |
|
| worker | - | Celery Worker(消费所有队列) |
|
||||||
| postgres | 5432 | PostgreSQL 16 |
|
| postgres | 5432 | PostgreSQL 16 |
|
||||||
| redis | 6379 | Redis 7 |
|
| redis | 6379 | Redis 7 |
|
||||||
|
|
||||||
@@ -276,7 +303,7 @@ server {
|
|||||||
listen 80;
|
listen 80;
|
||||||
server_name api.yourdomain.com;
|
server_name api.yourdomain.com;
|
||||||
|
|
||||||
# 上传文件大小限制
|
# 上传文件大小限制(视频上传需要较大值)
|
||||||
client_max_body_size 100M;
|
client_max_body_size 100M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@@ -300,6 +327,8 @@ server {
|
|||||||
|
|
||||||
## 三、前台部署 (video-gen-app)
|
## 三、前台部署 (video-gen-app)
|
||||||
|
|
||||||
|
用户前台,面向最终用户。
|
||||||
|
|
||||||
### 1. 安装依赖 & 构建
|
### 1. 安装依赖 & 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -310,8 +339,8 @@ npm install
|
|||||||
# 配置 API 地址(创建 .env.production)
|
# 配置 API 地址(创建 .env.production)
|
||||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
||||||
|
|
||||||
# 如需前后端加密通信
|
# 如需前后端加密通信(与后端 ENCRYPTION_KEY 相同)
|
||||||
echo "VITE_ENCRYPTION_KEY=与后端ENCRYPTION_KEY相同" >> .env.production
|
echo "VITE_ENCRYPTION_KEY=密钥" >> .env.production
|
||||||
|
|
||||||
# 构建
|
# 构建
|
||||||
npm run build
|
npm run build
|
||||||
@@ -319,7 +348,15 @@ npm run build
|
|||||||
|
|
||||||
构建产物在 `dist/` 目录。
|
构建产物在 `dist/` 目录。
|
||||||
|
|
||||||
### 2. Nginx 配置
|
### 2. 前端环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||||
|
| `VITE_USE_MOCK` | 是否使用 mock 数据(无需后端) | `false` |
|
||||||
|
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥(需与后端一致) | 空(不加密) |
|
||||||
|
|
||||||
|
### 3. Nginx 配置
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -328,12 +365,12 @@ server {
|
|||||||
root /opt/video-gen-app/dist;
|
root /opt/video-gen-app/dist;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
# SPA 路由
|
# SPA 路由:所有页面请求回退到 index.html
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 静态资源缓存
|
# 静态资源缓存(带 hash 的文件名可长期缓存)
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 1y;
|
expires 1y;
|
||||||
add_header Cache-Control "public, immutable";
|
add_header Cache-Control "public, immutable";
|
||||||
@@ -345,6 +382,8 @@ server {
|
|||||||
|
|
||||||
## 四、后台管理部署 (video-gen-admin)
|
## 四、后台管理部署 (video-gen-admin)
|
||||||
|
|
||||||
|
管理员后台,面向运营/管理人员。
|
||||||
|
|
||||||
### 1. 安装依赖 & 构建
|
### 1. 安装依赖 & 构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -356,13 +395,21 @@ npm install
|
|||||||
echo "VITE_API_BASE=https://api.yourdomain.com" > .env.production
|
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
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Nginx 配置
|
### 2. 后台环境变量
|
||||||
|
|
||||||
|
| 变量 | 说明 | 默认值 |
|
||||||
|
|------|------|--------|
|
||||||
|
| `VITE_API_BASE` | 后端 API 地址 | `http://localhost:8000` |
|
||||||
|
| `VITE_USE_MOCK` | 是否使用 mock 数据 | `false` |
|
||||||
|
| `VITE_ENCRYPTION_KEY` | 前后端通信加密密钥 | 空(不加密) |
|
||||||
|
|
||||||
|
### 3. Nginx 配置
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -456,6 +503,8 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
|||||||
|
|
||||||
## 七、默认账号
|
## 七、默认账号
|
||||||
|
|
||||||
|
> ⚠️ 默认账号仅在种子数据被执行后存在(见第三节第 3 点说明)。
|
||||||
|
|
||||||
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|
| 角色 | 用户名 | 手机号 | 密码 | 积分 |
|
||||||
|------|--------|--------|------|------|
|
|------|--------|--------|------|------|
|
||||||
| 管理员 | admin | 13800000000 | 123456 | 10000 |
|
| 管理员 | admin | 13800000000 | 123456 | 10000 |
|
||||||
@@ -467,32 +516,84 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
|||||||
|
|
||||||
## 八、架构说明
|
## 八、架构说明
|
||||||
|
|
||||||
### 异步任务处理
|
### 后端启动时的后台任务
|
||||||
|
|
||||||
系统有两套异步任务机制:
|
后端启动时(`lifespan`)会自动启动以下**进程内**后台任务:
|
||||||
|
|
||||||
| 机制 | 说明 | 依赖 |
|
| 任务 | 说明 |
|
||||||
|------|------|------|
|
|------|------|
|
||||||
| 内置 TaskQueue | asyncio 实现,运行在 uvicorn 进程内,轮询视频/图片生成状态 | 无额外依赖 |
|
| `task_queue` (video_queue) | 内置 asyncio 任务队列,轮询视频/图片生成状态 |
|
||||||
| Celery Worker | 处理 ChatAPI 异步生成流水线,3 个队列分优先级 | Redis 作为 Broker |
|
| `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` — 请求/响应日志记录
|
1. `RequestLoggingMiddleware` — 请求/响应日志记录
|
||||||
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
|
2. `AntiCrawlerMiddleware` — 反爬虫(拦截空 UA 和常见 bot)
|
||||||
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
|
3. `RateLimitMiddleware` — 滑动窗口限流(Redis 支撑)
|
||||||
4. `RequestEncryptMiddleware` — AES-256-GCM 请求/响应加密
|
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` | 始终开启 |
|
| 请求/响应日志 | `log/RequestResponse/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||||
| AI 模型日志 | `log/AiModel/{日期}.log` | `AI_LOG_ENABLED` 环境变量 |
|
| AI 模型日志 | `log/AiModel/{日期}.log` | AES-CBC-256,密钥硬编码 |
|
||||||
| Python 控制台日志 | stderr | `DEBUG=true` 时输出 INFO,否则 WARNING |
|
|
||||||
|
|
||||||
文件日志使用 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 +623,15 @@ sudo certbot --nginx -d yourdomain.com -d admin.yourdomain.com -d api.yourdomain
|
|||||||
│ ├── .env # 环境变量
|
│ ├── .env # 环境变量
|
||||||
│ ├── .venv/ # Python 虚拟环境
|
│ ├── .venv/ # Python 虚拟环境
|
||||||
│ ├── app/ # 应用代码
|
│ ├── app/ # 应用代码
|
||||||
|
│ │ ├── api/ # API 路由
|
||||||
|
│ │ │ ├── v1/ # v1 版本接口(前端用户端 + 部分管理接口)
|
||||||
|
│ │ │ └── admin/ # 管理端接口
|
||||||
|
│ │ ├── middleware/ # 中间件
|
||||||
|
│ │ ├── models/ # 数据模型 (41 个)
|
||||||
|
│ │ ├── services/ # 业务服务
|
||||||
|
│ │ ├── tasks/ # Celery 任务
|
||||||
|
│ │ ├── enums/ # 枚举定义
|
||||||
|
│ │ └── utils/ # 工具函数
|
||||||
│ ├── alembic/ # 数据库迁移文件
|
│ ├── alembic/ # 数据库迁移文件
|
||||||
│ ├── storage/
|
│ ├── storage/
|
||||||
│ │ ├── generate/
|
│ │ ├── generate/
|
||||||
@@ -565,4 +675,69 @@ cd /opt/video-gen-api && python -m alembic upgrade head
|
|||||||
|
|
||||||
# 生成迁移文件
|
# 生成迁移文件
|
||||||
cd /opt/video-gen-api && python -m alembic revision --autogenerate -m "描述"
|
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,4 +1,4 @@
|
|||||||
#VITE_API_BASE=http://localhost:8000
|
# VITE_API_BASE=http://192.168.120.17:8000
|
||||||
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
|
VITE_API_BASE=http://ceshi.apiforeign.minzhong.cn
|
||||||
VITE_USE_MOCK=false
|
VITE_USE_MOCK=false
|
||||||
# Encryption disabled for dev — enable in production
|
# Encryption disabled for dev — enable in production
|
||||||
|
|||||||
+548
File diff suppressed because one or more lines are too long
-405
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-D3ycepPx.js"></script>
|
<script type="module" crossorigin src="/assets/index-B5S0hm2T.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Generated
+169
@@ -14,6 +14,7 @@
|
|||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.0",
|
||||||
|
"xlsx-js-style": "^1.2.0",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1235,6 +1236,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": {
|
"node_modules/antd": {
|
||||||
"version": "6.3.7",
|
"version": "6.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/antd/-/antd-6.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/antd/-/antd-6.3.7.tgz",
|
||||||
@@ -1299,6 +1316,28 @@
|
|||||||
"react-dom": ">=18.0.0"
|
"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": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -1308,6 +1347,34 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/compute-scroll-into-view": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz",
|
||||||
@@ -1327,6 +1394,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1349,6 +1428,15 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1367,6 +1455,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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1726,6 +1829,18 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.6",
|
"version": "19.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||||
@@ -1863,6 +1978,18 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/string-convert": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
|
||||||
@@ -2001,6 +2128,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": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.13",
|
"version": "5.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.0",
|
||||||
|
"xlsx-js-style": "^1.2.0",
|
||||||
"zustand": "^5.0.13"
|
"zustand": "^5.0.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
|||||||
import { ConfigProvider, App as AntApp, Spin } from 'antd';
|
import { ConfigProvider, App as AntApp, Spin } from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import AdminLayout from './pages/AdminLayout';
|
import AdminLayout from './pages/AdminLayout';
|
||||||
|
import AdminAuthoriz from './pages/AdminAuthoriz';
|
||||||
|
import AdminConsume from './pages/AdminConsume';
|
||||||
import AdminLoginPage from './pages/AdminLoginPage';
|
import AdminLoginPage from './pages/AdminLoginPage';
|
||||||
import AdminDashboard from './pages/AdminDashboard';
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
|
import AdminPlatform from './pages/AdminPlatform';
|
||||||
import AdminUsers from './pages/AdminUsers';
|
import AdminUsers from './pages/AdminUsers';
|
||||||
|
import AdminTeams from './pages/AdminTeams';
|
||||||
import AdminModels from './pages/AdminModels';
|
import AdminModels from './pages/AdminModels';
|
||||||
import AdminSettings from './pages/AdminSettings';
|
import AdminSettings from './pages/AdminSettings';
|
||||||
import AdminNotificationManager from './pages/AdminNotificationManager';
|
import AdminNotificationManager from './pages/AdminNotificationManager';
|
||||||
@@ -27,6 +31,14 @@ import AdminHotOpeningReplicationDetail from './pages/AdminHotOpeningReplication
|
|||||||
import AdminShotReplications from './pages/AdminShotReplications';
|
import AdminShotReplications from './pages/AdminShotReplications';
|
||||||
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
|
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
|
||||||
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
||||||
|
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
||||||
|
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 { useAdminStore } from './store';
|
import { useAdminStore } from './store';
|
||||||
|
|
||||||
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -73,6 +85,7 @@ const App = () => {
|
|||||||
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
<Route path="/" element={<ProtectedRoute><AdminLayout /></ProtectedRoute>}>
|
||||||
<Route index element={<AdminDashboard />} />
|
<Route index element={<AdminDashboard />} />
|
||||||
<Route path="users" element={<AdminUsers />} />
|
<Route path="users" element={<AdminUsers />} />
|
||||||
|
<Route path="teams" element={<AdminTeams />} />
|
||||||
<Route path="credit-records" element={<AdminCreditRecords />} />
|
<Route path="credit-records" element={<AdminCreditRecords />} />
|
||||||
<Route path="models" element={<AdminModels />} />
|
<Route path="models" element={<AdminModels />} />
|
||||||
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
<Route path="credit-ratios" element={<AdminCreditRatios />} />
|
||||||
@@ -84,6 +97,7 @@ const App = () => {
|
|||||||
<Route path="payment" element={<AdminPaymentConfig />} />
|
<Route path="payment" element={<AdminPaymentConfig />} />
|
||||||
<Route path="payment-stats" element={<AdminPaymentStats />} />
|
<Route path="payment-stats" element={<AdminPaymentStats />} />
|
||||||
<Route path="settings" element={<AdminSettings />} />
|
<Route path="settings" element={<AdminSettings />} />
|
||||||
|
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
|
||||||
<Route path="notifications" element={<AdminNotificationManager />} />
|
<Route path="notifications" element={<AdminNotificationManager />} />
|
||||||
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
|
||||||
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
<Route path="operation-logs" element={<AdminOperationLogs />} />
|
||||||
@@ -94,6 +108,15 @@ const App = () => {
|
|||||||
<Route path="shot-replications" element={<AdminShotReplications />} />
|
<Route path="shot-replications" element={<AdminShotReplications />} />
|
||||||
<Route path="shot-replications/task-sets/:taskSetId" element={<AdminShotTaskSetDetail />} />
|
<Route path="shot-replications/task-sets/:taskSetId" element={<AdminShotTaskSetDetail />} />
|
||||||
<Route path="shot-replications/projects/:projectId" element={<AdminReplicationProjectDetail moduleType="shot_replicate" />} />
|
<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>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Real API client for connecting to the FastAPI backend.
|
* Real API client for connecting to the FastAPI backend.
|
||||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
* 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 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 {
|
interface RequestOptions {
|
||||||
method?: string;
|
method?: string;
|
||||||
@@ -24,6 +25,7 @@ function toCamel(s: string): string {
|
|||||||
function keysToCamel(obj: unknown): unknown {
|
function keysToCamel(obj: unknown): unknown {
|
||||||
if (Array.isArray(obj)) return obj.map(keysToCamel);
|
if (Array.isArray(obj)) return obj.map(keysToCamel);
|
||||||
if (obj !== null && typeof obj === 'object') {
|
if (obj !== null && typeof obj === 'object') {
|
||||||
|
if (obj instanceof File || obj instanceof Blob || obj instanceof FormData) return obj;
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), keysToCamel(v)])
|
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> {
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
|
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {};
|
||||||
'Content-Type': 'application/json',
|
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||||||
};
|
|
||||||
|
|
||||||
if (auth) {
|
if (auth) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bodyStr: string | undefined;
|
let requestBody: BodyInit | undefined;
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
const json = JSON.stringify(body);
|
if (isFormData) {
|
||||||
if (encryptBody) {
|
requestBody = body as FormData;
|
||||||
headers['X-Encrypted'] = 'true';
|
|
||||||
bodyStr = JSON.stringify({ data: await encrypt(json) });
|
|
||||||
} else {
|
} 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}`, {
|
const res = await fetch(`${BASE_URL}/api${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: bodyStr,
|
body: requestBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
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
|
// If the response is encrypted, decrypt it
|
||||||
const hasEncryptedData = parsed && typeof parsed.data === 'string';
|
const hasEncryptedData = parsed && typeof parsed.data === 'string';
|
||||||
if (hasEncryptedData && encryptBody) {
|
if (hasEncryptedData && encryptBody && !isFormData) {
|
||||||
const decrypted = await tryDecrypt(parsed.data);
|
const decrypted = await tryDecrypt(parsed.data);
|
||||||
if (decrypted !== null) {
|
if (decrypted !== null) {
|
||||||
parsed = JSON.parse(decrypted);
|
parsed = JSON.parse(decrypted);
|
||||||
@@ -107,10 +113,15 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
|
|
||||||
// Handle error responses
|
// Handle error responses
|
||||||
if (!res.ok) {
|
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) {
|
if (res.status === 401) {
|
||||||
clearToken();
|
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);
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
@@ -121,7 +132,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
// Convenience methods
|
// Convenience methods
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string, auth = true) => apiRequest<T>(path, { auth }),
|
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 }),
|
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 }),
|
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 }),
|
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.
|
* AES-256-GCM encryption/decryption for API request/response.
|
||||||
* Uses Web Crypto API with a shared symmetric key.
|
* 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';
|
const ALGO = 'AES-GCM';
|
||||||
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
|
|||||||
|
|
||||||
let cryptoKey: CryptoKey | null = null;
|
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> {
|
async function getCryptoKey(): Promise<CryptoKey> {
|
||||||
if (cryptoKey) return cryptoKey;
|
if (cryptoKey) return cryptoKey;
|
||||||
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
||||||
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
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));
|
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
||||||
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
||||||
if (keyBytes.length !== 32) {
|
if (keyBytes.length !== 32) {
|
||||||
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encrypt(plaintext: string): Promise<string> {
|
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 key = await getCryptoKey();
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||||
const encoded = new TextEncoder().encode(plaintext);
|
const encoded = new TextEncoder().encode(plaintext);
|
||||||
@@ -38,6 +53,9 @@ export async function encrypt(plaintext: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function decrypt(cipherB64: 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 key = await getCryptoKey();
|
||||||
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
||||||
const iv = combined.slice(0, IV_LENGTH);
|
const iv = combined.slice(0, IV_LENGTH);
|
||||||
|
|||||||
@@ -10,6 +10,35 @@ import type {
|
|||||||
AdminHotOpeningTaskQueryParams, HotOpeningTaskListOut, ReplicationProjectDetailOut,
|
AdminHotOpeningTaskQueryParams, HotOpeningTaskListOut, ReplicationProjectDetailOut,
|
||||||
AdminShotTaskSetQueryParams, ShotTaskSetListOut, ShotTaskSetDetailOut,
|
AdminShotTaskSetQueryParams, ShotTaskSetListOut, ShotTaskSetDetailOut,
|
||||||
AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut,
|
AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut,
|
||||||
|
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
|
||||||
|
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
|
||||||
|
AdminCreditRecordListResponse, AdminCreditRecordQueryParams,
|
||||||
|
ResourceCapacityConfigOut, ResourceCapacityConfigPayload, AdminUserResourceCapacityOut,
|
||||||
|
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||||
|
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
|
||||||
|
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
HomeMaterialAsset,
|
||||||
|
HomeMaterialAssetQueryParams,
|
||||||
|
HomeMaterialAssetStatusOut,
|
||||||
|
HomeMaterialAssetUpdatePayload,
|
||||||
|
HomeMaterialCategory,
|
||||||
|
HomeMaterialCategoryPayload,
|
||||||
|
HomeMaterialCategoryQueryParams,
|
||||||
|
HomeMaterialConfig,
|
||||||
|
HomeMaterialListResponse,
|
||||||
|
HomeMaterialRegeneratePayload,
|
||||||
|
HomeMaterialTextWatermarkConfig,
|
||||||
|
HomeMaterialTextWatermarkPayload,
|
||||||
|
HomeMaterialTextWatermarkPreviewRequest,
|
||||||
|
HomeMaterialTextWatermarkPreviewResponse,
|
||||||
|
HomeMaterialUploadAssetParams,
|
||||||
|
HomeMaterialUploadResult,
|
||||||
|
HomeMaterialWatermark,
|
||||||
|
HomeMaterialWatermarkPayload,
|
||||||
|
HomeMaterialWatermarkQueryParams,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
// ── Auth ──────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────
|
||||||
@@ -97,11 +126,72 @@ export async function getAdminStats(startDate?: string, endDate?: string): Promi
|
|||||||
return api.get(`/admin/stats${query ? `?${query}` : ''}`);
|
return api.get(`/admin/stats${query ? `?${query}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminUsers(page = 1, pageSize = 20, search?: string): Promise<{ items: AdminUser[]; total: number }> {
|
export async function getAdminUsers(
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
search?: string,
|
||||||
|
userType?: string,
|
||||||
|
frontendUserKind?: string,
|
||||||
|
teamId?: string,
|
||||||
|
): Promise<{ items: AdminUser[]; total: number }> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('page', String(page));
|
params.set('page', String(page));
|
||||||
params.set('page_size', String(pageSize));
|
params.set('page_size', String(pageSize));
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
|
if (userType) params.set('user_type', userType);
|
||||||
|
if (frontendUserKind) params.set('frontend_user_kind', frontendUserKind);
|
||||||
|
if (teamId) params.set('team_id', teamId);
|
||||||
|
return api.get(`/admin/users?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFrontendUserKind(userId: string, frontendUserKind: 'internal' | 'external'): Promise<AdminUser> {
|
||||||
|
return api.put(`/admin/users/${userId}/frontend-kind`, { frontend_user_kind: frontendUserKind });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserTeam(userId: string, teamId: string | null): Promise<AdminUser> {
|
||||||
|
return api.put(`/admin/users/${userId}/team`, { team_id: teamId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAdminTeams(filters?: AdminTeamQueryParams): Promise<AdminTeamListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
setMaybe(params, 'page', filters?.page);
|
||||||
|
setMaybe(params, 'page_size', filters?.pageSize);
|
||||||
|
setMaybe(params, 'keyword', filters?.keyword);
|
||||||
|
setMaybe(params, 'status', filters?.status);
|
||||||
|
const q = params.toString() ? `?${params}` : '';
|
||||||
|
return api.get(`/admin/teams${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamOptions(includeDisabled = true): Promise<AdminTeamOption[]> {
|
||||||
|
return api.get(`/admin/teams/options?include_disabled=${includeDisabled ? 'true' : 'false'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveAdminTeam(team: Partial<AdminTeamPayload> & { id?: string }): Promise<AdminTeam> {
|
||||||
|
const payload = {
|
||||||
|
name: team.name,
|
||||||
|
code: team.code || null,
|
||||||
|
description: team.description || null,
|
||||||
|
status: team.status || 'active',
|
||||||
|
sort_order: team.sort_order ?? 0,
|
||||||
|
};
|
||||||
|
if (team.id) return api.put(`/admin/teams/${team.id}`, payload);
|
||||||
|
return api.post('/admin/teams', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAdminTeam(id: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/teams/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTeamManager(teamId: string, userId: string | null): Promise<AdminTeam> {
|
||||||
|
return api.put(`/admin/teams/${teamId}/manager`, { user_id: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeamMembersForAdmin(teamId: string, page = 1, pageSize = 50): Promise<{ items: AdminUser[]; total: number }> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', String(page));
|
||||||
|
params.set('page_size', String(pageSize));
|
||||||
|
params.set('user_type', 'frontend');
|
||||||
|
params.set('team_id', teamId);
|
||||||
return api.get(`/admin/users?${params.toString()}`);
|
return api.get(`/admin/users?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,54 +224,80 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
|
|||||||
await api.put(`/admin/system-configs/${id}`, { value });
|
await api.put(`/admin/system-configs/${id}`, { value });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadPdf(file: File, configKey: string): Promise<{ url: string }> {
|
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
|
||||||
const formData = new FormData();
|
return api.get('/admin/resource-capacity/global');
|
||||||
formData.append('file', file);
|
}
|
||||||
formData.append('config_key', configKey);
|
|
||||||
const token = localStorage.getItem('auth_token');
|
export async function saveGlobalResourceCapacity(payload: ResourceCapacityConfigPayload): Promise<ResourceCapacityConfigOut> {
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
return api.put('/admin/resource-capacity/global', {
|
||||||
const res = await fetch(`${baseUrl}/api/admin/upload-pdf`, {
|
enabled: payload.enabled,
|
||||||
method: 'POST',
|
limit_value: payload.limitValue ?? null,
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
limit_unit: payload.limitUnit ?? null,
|
||||||
body: formData,
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
}
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || '上传失败');
|
export async function getUserResourceCapacity(userId: string): Promise<AdminUserResourceCapacityOut> {
|
||||||
|
return api.get(`/admin/users/${userId}/resource-capacity`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveUserResourceCapacity(userId: string, payload: ResourceCapacityConfigPayload): Promise<AdminUserResourceCapacityOut> {
|
||||||
|
return api.put(`/admin/users/${userId}/resource-capacity`, {
|
||||||
|
enabled: payload.enabled,
|
||||||
|
limit_value: payload.limitValue ?? null,
|
||||||
|
limit_unit: payload.limitUnit ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserResourceCapacity(userId: string): Promise<AdminUserResourceCapacityOut> {
|
||||||
|
return api.delete(`/admin/users/${userId}/resource-capacity`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadAdminFile(
|
||||||
|
file: File,
|
||||||
|
options: { scene: AdminUploadScene; resourceType: AdminUploadResourceType; durationSeconds?: number | null },
|
||||||
|
): Promise<AdminUploadFileResult> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
form.append('scene', options.scene);
|
||||||
|
form.append('resource_type', options.resourceType);
|
||||||
|
if (options.durationSeconds !== undefined && options.durationSeconds !== null) {
|
||||||
|
form.append('duration_seconds', String(options.durationSeconds));
|
||||||
}
|
}
|
||||||
return res.json();
|
return api.post<AdminUploadFileResult>('/admin/uploads/files', form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPdf(file: File, _configKey?: string): Promise<{ url: string }> {
|
||||||
|
const res = await uploadAdminFile(file, { scene: 'system_pdf', resourceType: 'pdf' });
|
||||||
|
return { url: res.url };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadLogo(file: File): Promise<{ url: string }> {
|
export async function uploadLogo(file: File): Promise<{ url: string }> {
|
||||||
const formData = new FormData();
|
const res = await uploadAdminFile(file, { scene: 'system_logo', resourceType: 'image' });
|
||||||
formData.append('file', file);
|
return { url: res.url };
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
|
||||||
const res = await fetch(`${baseUrl}/api/admin/upload-logo`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || '上传失败');
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCreditRecords(filters?: {
|
function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
|
||||||
user_id?: string;
|
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
|
||||||
user_name?: string;
|
}
|
||||||
type?: string;
|
|
||||||
start_date?: string;
|
export async function getCreditRecords(filters?: AdminCreditRecordQueryParams): Promise<AdminCreditRecordListResponse> {
|
||||||
end_date?: string;
|
|
||||||
}): Promise<any> {
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (filters?.user_id) params.set('user_id', filters.user_id);
|
setMaybe(params, 'page', filters?.page);
|
||||||
if (filters?.user_name) params.set('user_name', filters.user_name);
|
setMaybe(params, 'page_size', filters?.pageSize);
|
||||||
if (filters?.type) params.set('type', filters.type);
|
setMaybe(params, 'user_id', filters?.userId);
|
||||||
if (filters?.start_date) params.set('start_date', filters.start_date);
|
setMaybe(params, 'user_name', filters?.userName);
|
||||||
if (filters?.end_date) params.set('end_date', filters.end_date);
|
setMaybe(params, 'user_type', filters?.userType);
|
||||||
|
setMaybe(params, 'frontend_user_kind', filters?.frontendUserKind);
|
||||||
|
setMaybe(params, 'team_id', filters?.teamId);
|
||||||
|
setMaybe(params, 'record_type', filters?.recordType || filters?.type);
|
||||||
|
setMaybe(params, 'credit_subject', filters?.creditSubject);
|
||||||
|
setMaybe(params, 'media_type', filters?.mediaType);
|
||||||
|
setMaybe(params, 'charge_kind', filters?.chargeKind);
|
||||||
|
setMaybe(params, 'source_module', filters?.sourceModule);
|
||||||
|
setMaybe(params, 'source_step_code', filters?.sourceStepCode);
|
||||||
|
setMaybe(params, 'billing_scene', filters?.billingScene);
|
||||||
|
setMaybe(params, 'start_date', filters?.startDate);
|
||||||
|
setMaybe(params, 'end_date', filters?.endDate);
|
||||||
const q = params.toString() ? `?${params}` : '';
|
const q = params.toString() ? `?${params}` : '';
|
||||||
return api.get(`/admin/credit-records${q}`);
|
return api.get(`/admin/credit-records${q}`);
|
||||||
}
|
}
|
||||||
@@ -322,7 +438,7 @@ export async function deleteMenuConfig(id: string): Promise<void> {
|
|||||||
|
|
||||||
// ── User Creation ───────────────────────────────────────
|
// ── User Creation ───────────────────────────────────────
|
||||||
|
|
||||||
export async function createUser(data: { username: string; password: string; email?: string; phone?: string; credits: number; user_type: string; allowed_menus?: string[] | null }): Promise<any> {
|
export async function createUser(data: { username?: string; password: string; email?: string; phone?: string; credits: number; user_type: string; is_admin?: boolean; frontend_user_kind?: string; allowed_menus?: string[] | null; private_portrait_asset_limit?: number }): Promise<any> {
|
||||||
return api.post('/admin/users', data);
|
return api.post('/admin/users', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +446,10 @@ export async function updateUserMenus(userId: string, allowedMenus: string[] | n
|
|||||||
await api.put(`/admin/users/${userId}/menus`, { allowed_menus: allowedMenus });
|
await api.put(`/admin/users/${userId}/menus`, { allowed_menus: allowedMenus });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateUserAdminStatus(userId: string, isAdmin: boolean): Promise<void> {
|
||||||
|
await api.put(`/admin/users/${userId}/admin-status`, { is_admin: isAdmin });
|
||||||
|
}
|
||||||
|
|
||||||
export async function resetUserPassword(userId: string, newPassword: string): Promise<void> {
|
export async function resetUserPassword(userId: string, newPassword: string): Promise<void> {
|
||||||
await api.put(`/admin/users/${userId}/reset-password`, { new_password: newPassword });
|
await api.put(`/admin/users/${userId}/reset-password`, { new_password: newPassword });
|
||||||
}
|
}
|
||||||
@@ -375,7 +495,7 @@ export async function createOauthApp(data: {
|
|||||||
app_id: string;
|
app_id: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
open_type: number;
|
open_type: number;
|
||||||
count?: number;
|
max_count?: number;
|
||||||
auth_url?: string;
|
auth_url?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
}): Promise<any> {
|
}): Promise<any> {
|
||||||
@@ -401,6 +521,64 @@ export async function deleteOauthApp(id: string): Promise<void> {
|
|||||||
await api.get(`/admin/user-oauth-apps/delete/${id}`);
|
await api.get(`/admin/user-oauth-apps/delete/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Open Type ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getOpenTypeList(params?: {
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
type_name?: string;
|
||||||
|
open_type?: number;
|
||||||
|
}): Promise<{
|
||||||
|
pagination: any;
|
||||||
|
data: never[];
|
||||||
|
}> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.page) q.set('page', String(params.page));
|
||||||
|
if (params?.page_size) q.set('page_size', String(params.page_size));
|
||||||
|
if (params?.type_name) q.set('type_name', params.type_name);
|
||||||
|
if (params?.open_type) q.set('open_type', String(params.open_type));
|
||||||
|
const qs = q.toString();
|
||||||
|
return api.get(`/open-type/list${qs ? `?${qs}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpenType(id: string): Promise<any> {
|
||||||
|
return api.get(`/open-type/select/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOpenType(data: {
|
||||||
|
open_type: number;
|
||||||
|
description: string;
|
||||||
|
type_name: string;
|
||||||
|
thumb?: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
return api.post('/open-type/create', data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOpenType(id: string, data: {
|
||||||
|
open_type?: number;
|
||||||
|
description?: string;
|
||||||
|
type_name?: string;
|
||||||
|
thumb?: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
return api.put(`/open-type/update/${id}`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOpenType(id: string): Promise<void> {
|
||||||
|
await api.delete(`/open-type/delete/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenTypeItem {
|
||||||
|
id: string;
|
||||||
|
openType: number;
|
||||||
|
typeName: string;
|
||||||
|
description: string;
|
||||||
|
thumb?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOpenTypeAll(): Promise<{ data: OpenTypeItem[] }> {
|
||||||
|
return api.get('/open-type/open_type_all');
|
||||||
|
}
|
||||||
|
|
||||||
// ── Generation Records (Admin) ─────────────────────────────
|
// ── Generation Records (Admin) ─────────────────────────────
|
||||||
|
|
||||||
export async function getAdminGenerationRecords(params?: {
|
export async function getAdminGenerationRecords(params?: {
|
||||||
@@ -441,6 +619,9 @@ export async function getAdminGenerationAiTasks(params?: GenerationAITaskQueryPa
|
|||||||
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
if (params?.pageSize) q.set('page_size', String(params.pageSize));
|
||||||
if (params?.userId) q.set('user_id', params.userId);
|
if (params?.userId) q.set('user_id', params.userId);
|
||||||
if (params?.userName) q.set('user_name', params.userName);
|
if (params?.userName) q.set('user_name', params.userName);
|
||||||
|
if (params?.engineId) q.set('engine_id', params.engineId);
|
||||||
|
if (params?.createdStart) q.set('created_start', params.createdStart);
|
||||||
|
if (params?.createdEnd) q.set('created_end', params.createdEnd);
|
||||||
const qs = q.toString();
|
const qs = q.toString();
|
||||||
return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`);
|
return api.get<GenerationAITaskListOut>(`/generation-ai/tasks${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
@@ -511,3 +692,358 @@ export async function getAdminShotSegmentDetail(segmentId: string): Promise<Shot
|
|||||||
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
|
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
|
||||||
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
|
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ── Video Prompt Schema Config (Admin) ─────────────────────
|
||||||
|
|
||||||
|
export async function getVideoPromptSchemaConfig(): Promise<VideoPromptSchemaConfigOut> {
|
||||||
|
return api.get<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveVideoPromptSchemaConfig(payload: VideoPromptSchemaConfigSavePayload): Promise<VideoPromptSchemaConfigOut> {
|
||||||
|
return api.put<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetVideoPromptSchemaConfig(): Promise<VideoPromptSchemaConfigOut> {
|
||||||
|
return api.post<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config/reset-default');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportVideoPromptSchemaConfig(): Promise<VideoPromptSchemaExportOut> {
|
||||||
|
return api.get<VideoPromptSchemaExportOut>('/admin/video-prompt-schema-config/export');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importVideoPromptSchemaConfig(payload: VideoPromptSchemaConfigSavePayload): Promise<VideoPromptSchemaConfigOut> {
|
||||||
|
return api.post<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config/import', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewVideoPromptSchemaConfig(payload: VideoPromptSchemaPreviewPayload): Promise<VideoPromptSchemaPreviewOut> {
|
||||||
|
return api.post<VideoPromptSchemaPreviewOut>('/admin/video-prompt-schema-config/preview', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取授权链接
|
||||||
|
export interface RequestOAuthParams {
|
||||||
|
open_type: number;
|
||||||
|
}
|
||||||
|
export async function requestOAuth(params: RequestOAuthParams): Promise<any> {
|
||||||
|
return api.post(`/user-oauth/request_oauth`, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 授权列表
|
||||||
|
export interface OAuthListParams {
|
||||||
|
account_userid?: string;
|
||||||
|
open_type?: number;
|
||||||
|
account_id?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
export async function getOAuthList(params: OAuthListParams): Promise<any> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.account_userid) query.set('account_userid', params.account_userid);
|
||||||
|
if (params.open_type !== undefined) query.set('open_type', String(params.open_type));
|
||||||
|
if (params.account_id) query.set('account_id', params.account_id);
|
||||||
|
if (params.page !== undefined) query.set('page', String(params.page));
|
||||||
|
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||||
|
return api.get(`/user-oauth/oauth_list?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadImage(file: File): Promise<{ url: string; filename: string }> {
|
||||||
|
const res = await uploadAdminFile(file, { scene: 'open_type_thumb', resourceType: 'image' });
|
||||||
|
return { url: res.url, filename: res.originalFileName || res.fileName };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义表头字段
|
||||||
|
export async function getFields(): Promise<any> {
|
||||||
|
return api.get('/material-consumption/fields');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询素材消耗列表
|
||||||
|
export interface MaterialConsumpParams {
|
||||||
|
advertiser_id?: string;
|
||||||
|
user_id?: string;
|
||||||
|
consume_date?: [string, string];
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
export async function getMaterialConsumpList(params: MaterialConsumpParams): Promise<any> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
|
||||||
|
if (params.user_id) query.set('user_id', params.user_id);
|
||||||
|
if (params.consume_date !== undefined) query.set('consume_date', params.consume_date[0] + ',' + params.consume_date[1]);
|
||||||
|
if (params.page !== undefined) query.set('page', String(params.page));
|
||||||
|
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||||
|
return api.get(`/material-consumption/admin/list?${query.toString()}`);
|
||||||
|
}
|
||||||
|
// ── 首页素材行业装修 ──────────────────────────────────────
|
||||||
|
|
||||||
|
export async function getHomeMaterialConfig(): Promise<HomeMaterialConfig> {
|
||||||
|
return api.get<HomeMaterialConfig>('/admin/home-material/config');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHomeMaterialConfig(payload: HomeMaterialConfig): Promise<HomeMaterialConfig> {
|
||||||
|
return api.put<HomeMaterialConfig>('/admin/home-material/config', {
|
||||||
|
enabled: payload.enabled,
|
||||||
|
title: payload.title,
|
||||||
|
subtitle: payload.subtitle,
|
||||||
|
show_original_in_admin: payload.showOriginalInAdmin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHomeMaterialCategories(filters?: HomeMaterialCategoryQueryParams): Promise<HomeMaterialListResponse<HomeMaterialCategory>> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
setMaybe(params, 'page', filters?.page);
|
||||||
|
setMaybe(params, 'page_size', filters?.pageSize);
|
||||||
|
setMaybe(params, 'keyword', filters?.keyword);
|
||||||
|
setMaybe(params, 'is_active', filters?.isActive);
|
||||||
|
const q = params.toString() ? `?${params}` : '';
|
||||||
|
return api.get(`/admin/home-material/categories${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHomeMaterialCategory(payload: HomeMaterialCategoryPayload & { id?: string }): Promise<HomeMaterialCategory> {
|
||||||
|
const body = {
|
||||||
|
name: payload.name,
|
||||||
|
key: payload.key,
|
||||||
|
description: payload.description || null,
|
||||||
|
icon: payload.icon || null,
|
||||||
|
is_active: payload.is_active,
|
||||||
|
sort_order: payload.sort_order,
|
||||||
|
};
|
||||||
|
if (payload.id) return api.put(`/admin/home-material/categories/${payload.id}`, body);
|
||||||
|
return api.post('/admin/home-material/categories', body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHomeMaterialCategory(id: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/home-material/categories/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHomeMaterialWatermarks(filters?: HomeMaterialWatermarkQueryParams): Promise<HomeMaterialListResponse<HomeMaterialWatermark>> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
setMaybe(params, 'page', filters?.page);
|
||||||
|
setMaybe(params, 'page_size', filters?.pageSize);
|
||||||
|
setMaybe(params, 'is_active', filters?.isActive);
|
||||||
|
const q = params.toString() ? `?${params}` : '';
|
||||||
|
return api.get(`/admin/home-material/watermarks${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadHomeMaterialWatermark(file: File, name?: string, isDefault = false): Promise<HomeMaterialWatermark> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
if (name) form.append('name', name);
|
||||||
|
form.append('is_default', String(isDefault));
|
||||||
|
return api.post<HomeMaterialWatermark>('/admin/home-material/watermarks', form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHomeMaterialWatermark(id: string, payload: HomeMaterialWatermarkPayload): Promise<HomeMaterialWatermark> {
|
||||||
|
return api.put(`/admin/home-material/watermarks/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHomeMaterialWatermark(id: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/home-material/watermarks/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHomeMaterialAssets(filters?: HomeMaterialAssetQueryParams): Promise<HomeMaterialListResponse<HomeMaterialAsset>> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
setMaybe(params, 'page', filters?.page);
|
||||||
|
setMaybe(params, 'page_size', filters?.pageSize);
|
||||||
|
setMaybe(params, 'category_id', filters?.categoryId);
|
||||||
|
setMaybe(params, 'media_type', filters?.mediaType);
|
||||||
|
setMaybe(params, 'status', filters?.status);
|
||||||
|
setMaybe(params, 'is_active', filters?.isActive);
|
||||||
|
setMaybe(params, 'keyword', filters?.keyword);
|
||||||
|
const q = params.toString() ? `?${params}` : '';
|
||||||
|
return api.get(`/admin/home-material/assets${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHomeMaterialAsset(id: string): Promise<HomeMaterialAsset> {
|
||||||
|
return api.get(`/admin/home-material/assets/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHomeMaterialAssetStatus(id: string): Promise<HomeMaterialAssetStatusOut> {
|
||||||
|
return api.get(`/admin/home-material/assets/${id}/status`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toHomeMaterialTextWatermarkPayload(config?: HomeMaterialTextWatermarkConfig | null): HomeMaterialTextWatermarkPayload | null {
|
||||||
|
if (!config) return null;
|
||||||
|
return {
|
||||||
|
text: config.text,
|
||||||
|
opacity_level: config.opacityLevel,
|
||||||
|
font_size_px: config.fontSizePx,
|
||||||
|
color: config.color,
|
||||||
|
rotate_deg: config.rotateDeg,
|
||||||
|
gap_x: config.gapX,
|
||||||
|
gap_y: config.gapY,
|
||||||
|
staggered: config.staggered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetParams): Promise<HomeMaterialUploadResult> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('category_id', payload.categoryId);
|
||||||
|
form.append('file', payload.file);
|
||||||
|
form.append('media_type', payload.mediaType);
|
||||||
|
if (payload.title && payload.title.trim()) form.append('title', payload.title.trim());
|
||||||
|
if (payload.generationPrompt && payload.generationPrompt.trim()) form.append('generation_prompt', payload.generationPrompt.trim());
|
||||||
|
if (payload.mediaReferences && payload.mediaReferences.length > 0) form.append('media_references_json', JSON.stringify(payload.mediaReferences));
|
||||||
|
form.append('watermark_type', payload.watermarkType);
|
||||||
|
|
||||||
|
if (payload.watermarkType === 'image') {
|
||||||
|
if (payload.watermarkId) form.append('watermark_id', payload.watermarkId);
|
||||||
|
if (payload.watermarkFile) form.append('watermark_file', payload.watermarkFile);
|
||||||
|
form.append('opacity_level', String(payload.opacityLevel));
|
||||||
|
form.append('position', payload.position);
|
||||||
|
if (payload.customXRatio !== undefined && payload.customXRatio !== null) form.append('custom_x_ratio', String(payload.customXRatio));
|
||||||
|
if (payload.customYRatio !== undefined && payload.customYRatio !== null) form.append('custom_y_ratio', String(payload.customYRatio));
|
||||||
|
form.append('size_mode', payload.sizeMode);
|
||||||
|
if (payload.widthRatio !== undefined && payload.widthRatio !== null) form.append('width_ratio', String(payload.widthRatio));
|
||||||
|
if (payload.widthPx !== undefined && payload.widthPx !== null) form.append('width_px', String(payload.widthPx));
|
||||||
|
form.append('margin_x', String(payload.marginX));
|
||||||
|
form.append('margin_y', String(payload.marginY));
|
||||||
|
} else {
|
||||||
|
const text = toHomeMaterialTextWatermarkPayload(payload.textWatermark);
|
||||||
|
if (text) {
|
||||||
|
form.append('text_watermark_text', text.text);
|
||||||
|
form.append('text_watermark_opacity_level', String(text.opacity_level));
|
||||||
|
form.append('text_watermark_font_size_px', String(text.font_size_px));
|
||||||
|
form.append('text_watermark_color', text.color);
|
||||||
|
form.append('text_watermark_rotate_deg', String(text.rotate_deg));
|
||||||
|
form.append('text_watermark_gap_x', String(text.gap_x));
|
||||||
|
form.append('text_watermark_gap_y', String(text.gap_y));
|
||||||
|
form.append('text_watermark_staggered', String(text.staggered));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.append('is_active', String(payload.isActive));
|
||||||
|
form.append('sort_order', String(payload.sortOrder));
|
||||||
|
form.append('wait', String(!!payload.wait));
|
||||||
|
form.append('wait_timeout_seconds', String(payload.waitTimeoutSeconds ?? 30));
|
||||||
|
return api.post<HomeMaterialUploadResult>('/admin/home-material/assets', form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateHomeMaterialAsset(id: string, payload: HomeMaterialAssetUpdatePayload): Promise<HomeMaterialAsset> {
|
||||||
|
return api.put(`/admin/home-material/assets/${id}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewHomeMaterialTextWatermark(payload: HomeMaterialTextWatermarkPreviewRequest): Promise<HomeMaterialTextWatermarkPreviewResponse> {
|
||||||
|
return api.post<HomeMaterialTextWatermarkPreviewResponse>('/admin/home-material/text-watermark-preview', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateHomeMaterialWatermark(id: string, payload: HomeMaterialRegeneratePayload): Promise<HomeMaterialUploadResult> {
|
||||||
|
return api.post(`/admin/home-material/assets/${id}/regenerate-watermark`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHomeMaterialAsset(id: string): Promise<void> {
|
||||||
|
await api.delete(`/admin/home-material/assets/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthAccountParams {
|
||||||
|
id?: string;
|
||||||
|
phone?: string;
|
||||||
|
account_id?: string;
|
||||||
|
account_userid?: string;
|
||||||
|
created_at?: [string, string];
|
||||||
|
appid?: string;
|
||||||
|
open_type?: number;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
export async function getOAuthAccountList(params: OAuthAccountParams): Promise<any> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.id) query.set('id', params.id);
|
||||||
|
if (params.phone) query.set('phone', params.phone);
|
||||||
|
if (params.account_id) query.set('account_id', params.account_id);
|
||||||
|
if (params.account_userid) query.set('account_userid', params.account_userid);
|
||||||
|
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
|
||||||
|
if (params.appid) query.set('appid', params.appid);
|
||||||
|
if (params.open_type !== undefined) query.set('open_type', String(params.open_type));
|
||||||
|
if (params.page !== undefined) query.set('page', String(params.page));
|
||||||
|
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||||
|
return api.get(`/material-admin/oauth-list?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterialListParams {
|
||||||
|
id?: string;
|
||||||
|
phone?: string;
|
||||||
|
resource_type?: string;
|
||||||
|
advertiser_id?: string;
|
||||||
|
material_id?: string;
|
||||||
|
upload_id?: string;
|
||||||
|
created_at?: [string, string];
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
export async function getMaterialList(params: MaterialListParams): Promise<any> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.id) query.set('id', params.id);
|
||||||
|
if (params.phone) query.set('phone', params.phone);
|
||||||
|
if (params.resource_type) query.set('resource_type', params.resource_type);
|
||||||
|
if (params.advertiser_id) query.set('advertiser_id', params.advertiser_id);
|
||||||
|
if (params.material_id) query.set('material_id', params.material_id);
|
||||||
|
if (params.upload_id) query.set('upload_id', params.upload_id);
|
||||||
|
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
if (params.page !== undefined) query.set('page', String(params.page));
|
||||||
|
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||||
|
return api.get(`/material-admin/material-list?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreTestTemplateListParams {
|
||||||
|
id?: string;
|
||||||
|
phone?: string;
|
||||||
|
created_at?: [string, string];
|
||||||
|
page?: number;
|
||||||
|
page_size?: number;
|
||||||
|
}
|
||||||
|
export async function getPreTestTemplateList(params: PreTestTemplateListParams): Promise<any> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params.id) query.set('id', params.id);
|
||||||
|
if (params.phone) query.set('phone', params.phone);
|
||||||
|
if (params.created_at !== undefined) query.set('created_at', params.created_at[0] + ',' + params.created_at[1]);
|
||||||
|
if (params.page !== undefined) query.set('page', String(params.page));
|
||||||
|
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||||
|
return api.get(`/material-admin/pre-test-template-list?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetAreaParams {
|
||||||
|
level?: string;
|
||||||
|
parent_code?: string;
|
||||||
|
}
|
||||||
|
export async function getArea(params?: GetAreaParams): Promise<any> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
if (params?.level) query.set('level', params.level);
|
||||||
|
if (params?.parent_code) query.set('parent_code', params.parent_code);
|
||||||
|
return api.get(`/pre-test-template/getArea?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private Portrait Admin ────────────────────────────────
|
||||||
|
export async function adminGetPrivatePortraitProjects(params: { userId?: string; libraryType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitProjectListOut> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set('page', String(params.page || 1));
|
||||||
|
query.set('page_size', String(params.pageSize || 20));
|
||||||
|
if (params.userId) query.set('user_id', params.userId);
|
||||||
|
if (params.libraryType) query.set('library_type', params.libraryType);
|
||||||
|
if (params.keyword) query.set('keyword', params.keyword);
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
return api.get<PrivatePortraitProjectListOut>(`/admin/private-portrait/projects?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminGetPrivatePortraitAssets(params: { userId?: string; projectId?: string; libraryType?: string; assetType?: string; keyword?: string; status?: string; page?: number; pageSize?: number } = {}): Promise<PrivatePortraitAssetListOut> {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set('page', String(params.page || 1));
|
||||||
|
query.set('page_size', String(params.pageSize || 20));
|
||||||
|
if (params.userId) query.set('user_id', params.userId);
|
||||||
|
if (params.projectId) query.set('project_id', params.projectId);
|
||||||
|
if (params.libraryType) query.set('library_type', params.libraryType);
|
||||||
|
if (params.assetType) query.set('asset_type', params.assetType);
|
||||||
|
if (params.keyword) query.set('keyword', params.keyword);
|
||||||
|
if (params.status) query.set('status', params.status);
|
||||||
|
return api.get<PrivatePortraitAssetListOut>(`/admin/private-portrait/assets?${query.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function adminGetPrivatePortraitConfig(userId: string): Promise<PrivatePortraitConfig> {
|
||||||
|
return api.get<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUpdatePrivatePortraitConfig(userId: string, limit: number): Promise<PrivatePortraitConfig> {
|
||||||
|
return api.put<PrivatePortraitConfig>(`/admin/private-portrait/users/${userId}/config`, { private_portrait_asset_limit: limit });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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,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 }>(`/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(`/contact/requests/${id}/handle`);
|
||||||
|
message.success('已标记为处理');
|
||||||
|
fetchData();
|
||||||
|
} catch (err: any) {
|
||||||
|
message.error(err?.message || '操作失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/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;
|
||||||
@@ -22,6 +22,12 @@ interface CreditRatio {
|
|||||||
ratio: number;
|
ratio: number;
|
||||||
baseCredits: number;
|
baseCredits: number;
|
||||||
perSecondCredits: number;
|
perSecondCredits: number;
|
||||||
|
inputVideoRatio: number;
|
||||||
|
inputVideoBaseCredits: number;
|
||||||
|
inputVideoPerSecondCredits: number;
|
||||||
|
inputImageRatio: number;
|
||||||
|
inputImageBaseCredits: number;
|
||||||
|
inputImagePerImageCredits: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreditRatioFormValues {
|
interface CreditRatioFormValues {
|
||||||
@@ -31,6 +37,12 @@ interface CreditRatioFormValues {
|
|||||||
ratio: number;
|
ratio: number;
|
||||||
baseCredits: number;
|
baseCredits: number;
|
||||||
perSecondCredits?: number;
|
perSecondCredits?: number;
|
||||||
|
inputVideoRatio?: number;
|
||||||
|
inputVideoBaseCredits?: number;
|
||||||
|
inputVideoPerSecondCredits?: number;
|
||||||
|
inputImageRatio?: number;
|
||||||
|
inputImageBaseCredits?: number;
|
||||||
|
inputImagePerImageCredits?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
||||||
@@ -117,7 +129,7 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
const payload = {
|
const payload: any = {
|
||||||
model_config_id: values.modelConfigId,
|
model_config_id: values.modelConfigId,
|
||||||
gen_type: values.genType,
|
gen_type: values.genType,
|
||||||
resolution: values.resolution,
|
resolution: values.resolution,
|
||||||
@@ -125,6 +137,14 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
base_credits: values.baseCredits,
|
base_credits: values.baseCredits,
|
||||||
per_second_credits: values.genType === 'image' ? 0 : (values.perSecondCredits || 0),
|
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) {
|
if (modal.ratio) {
|
||||||
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
||||||
message.success('已更新');
|
message.success('已更新');
|
||||||
@@ -174,10 +194,27 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
ratio: ratio.ratio,
|
ratio: ratio.ratio,
|
||||||
baseCredits: ratio.baseCredits,
|
baseCredits: ratio.baseCredits,
|
||||||
perSecondCredits: ratio.perSecondCredits,
|
perSecondCredits: ratio.perSecondCredits,
|
||||||
|
inputVideoRatio: ratio.inputVideoRatio,
|
||||||
|
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
|
||||||
|
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
|
||||||
|
inputImageRatio: ratio.inputImageRatio,
|
||||||
|
inputImageBaseCredits: ratio.inputImageBaseCredits,
|
||||||
|
inputImagePerImageCredits: ratio.inputImagePerImageCredits,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
form.resetFields();
|
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 +253,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) => {
|
render: (_: any, r: CreditRatio) => {
|
||||||
let total: number;
|
let total: number;
|
||||||
if (r.genType === 'image') {
|
if (r.genType === 'image') {
|
||||||
total = Math.round(r.baseCredits * r.ratio);
|
total = Math.round(r.baseCredits * r.ratio);
|
||||||
|
if (r.inputImageRatio && r.inputImagePerImageCredits) {
|
||||||
|
total += Math.round(
|
||||||
|
(r.inputImageBaseCredits + r.inputImagePerImageCredits * 3) * r.inputImageRatio
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
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>;
|
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} 积分</Typography.Text>;
|
||||||
},
|
},
|
||||||
@@ -243,7 +339,7 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{/* Text Credit Rate */}
|
{/* Text Credit Rate */}
|
||||||
<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', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
|
<FontSizeOutlined style={{ fontSize: 18, color: '#f59e0b' }} />
|
||||||
@@ -258,16 +354,18 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||||
<Typography.Text>每1000 token消耗积分:</Typography.Text>
|
<Typography.Text>每1000 token消耗积分:</Typography.Text>
|
||||||
<InputNumber
|
<Space.Compact style={{ width: 160 }}>
|
||||||
min={0}
|
<InputNumber
|
||||||
max={1000}
|
min={0}
|
||||||
step={0.01}
|
max={1000}
|
||||||
value={textRate}
|
step={0.01}
|
||||||
onChange={(v) => setTextRate(v || 0)}
|
value={textRate}
|
||||||
size="large"
|
onChange={(v) => setTextRate(v || 0)}
|
||||||
style={{ width: 160 }}
|
size="large"
|
||||||
addonAfter="积分"
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
<Typography.Text>积分</Typography.Text>
|
||||||
|
</Space.Compact>
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
示例:1000 token = {textRate} 积分,500 token = {(500 * textRate / 1000).toFixed(4)} 积分
|
示例:1000 token = {textRate} 积分,500 token = {(500 * textRate / 1000).toFixed(4)} 积分
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -275,7 +373,7 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Generation Credit Ratios */}
|
{/* 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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
<CalculatorOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
@@ -297,7 +395,7 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 860 }}
|
scroll={{ x: 1500 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -347,6 +445,54 @@ const AdminCreditRatios: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,202 +1,386 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Select, Space, Table, Tag, Typography, message, Input, DatePicker,
|
Button, Card, DatePicker, Input, message, Select, Space, Table, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
WalletOutlined, ArrowUpOutlined, ArrowDownOutlined, ReloadOutlined, RollbackOutlined,
|
ArrowDownOutlined, ArrowUpOutlined, DownloadOutlined, ReloadOutlined, RollbackOutlined, WalletOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { getCreditRecords } from '../api';
|
import { exportStyledExcel, type StyledExcelColumn } from '../utils/excelExport';
|
||||||
import { formatDate } from '../utils/formatDate';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { getCreditRecords, getTeamOptions } from '../api';
|
||||||
|
import type { AdminCreditRecord, AdminCreditRecordQueryParams, AdminCreditRecordSummary, AdminTeamOption } from '../types';
|
||||||
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
interface CreditRecord {
|
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
type: string;
|
|
||||||
amount: number;
|
|
||||||
balanceAfter: number;
|
|
||||||
description: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CREDIT_TYPE_MAP: Record<string, { text: string; color: string; icon: React.ReactNode }> = {
|
const DEFAULT_SUMMARY: AdminCreditRecordSummary = {
|
||||||
recharge: { text: '充值', color: 'green', icon: <ArrowUpOutlined /> },
|
totalRecharge: 0,
|
||||||
consume: { text: '消费', color: 'red', icon: <ArrowDownOutlined /> },
|
totalConsume: 0,
|
||||||
refund: { text: '退回', color: 'blue', icon: <RollbackOutlined /> },
|
totalRefund: 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 /> },
|
||||||
|
team_internal: { text: '团队内部', color: 'cyan', icon: <WalletOutlined /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
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: '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 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 {};
|
||||||
|
}
|
||||||
|
|
||||||
const AdminCreditRecords: React.FC = () => {
|
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 [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [exportProgress, setExportProgress] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(10);
|
const [pageSize, setPageSize] = useState(10);
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
|
||||||
const [userNameFilter, setUserNameFilter] = useState<string>('');
|
const [userScope, setUserScope] = useState('');
|
||||||
|
const [teamFilter, setTeamFilter] = useState('');
|
||||||
|
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||||||
|
const [recordType, setRecordType] = useState('');
|
||||||
|
const [creditSubject, setCreditSubject] = useState('');
|
||||||
|
const [mediaType, setMediaType] = useState('');
|
||||||
|
const [chargeKind, setChargeKind] = 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 [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null]>([null, null]);
|
||||||
|
|
||||||
|
const query = useMemo<AdminCreditRecordQueryParams>(() => ({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
userName: userNameFilter || undefined,
|
||||||
|
teamId: teamFilter || undefined,
|
||||||
|
recordType: recordType || undefined,
|
||||||
|
creditSubject: creditSubject || undefined,
|
||||||
|
mediaType: mediaType || undefined,
|
||||||
|
chargeKind: chargeKind || 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, sourceModule, sourceStepCode, billingScene, dateRange, userScope]);
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const filters: { type?: string; user_name?: string; start_date?: string; end_date?: string; page?: number; page_size?: number } = {};
|
const res = await getCreditRecords(query);
|
||||||
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);
|
|
||||||
setRecords(res.items || []);
|
setRecords(res.items || []);
|
||||||
setTotal(res.total || 0);
|
setTotal(res.total || 0);
|
||||||
} catch {
|
setSummary(res.summary || DEFAULT_SUMMARY);
|
||||||
message.error('加载积分记录失败');
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载积分记录失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, [page, pageSize, typeFilter, userNameFilter, dateRange]);
|
useEffect(() => { load(); }, [query]);
|
||||||
|
|
||||||
const handlePageChange = (p: number, ps: number) => {
|
useEffect(() => {
|
||||||
setPage(p);
|
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||||||
setPageSize(ps);
|
}, []);
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
|
||||||
load();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setTypeFilter('');
|
setUserScope('');
|
||||||
|
setTeamFilter('');
|
||||||
|
setRecordType('');
|
||||||
|
setCreditSubject('');
|
||||||
|
setMediaType('');
|
||||||
|
setChargeKind('');
|
||||||
|
setSourceModule('');
|
||||||
|
setSourceStepCode('');
|
||||||
|
setBillingScene('');
|
||||||
setUserNameFilter('');
|
setUserNameFilter('');
|
||||||
setDateRange([null, null]);
|
setDateRange([null, null]);
|
||||||
load();
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalRecharge = records.filter(r => r.type === 'recharge').reduce((s, r) => s + r.amount, 0);
|
const exportExcel = async () => {
|
||||||
const totalConsume = records.filter(r => r.type === 'consume').reduce((s, r) => s + Math.abs(r.amount), 0);
|
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: 14, align: 'center', render: (r) => r.recordTypeLabel || r.type || '-' },
|
||||||
|
{ 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.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: 12, maxWidth: 14, align: 'right', numFmt: '#,##0.00', render: (r) => r.balanceAfter },
|
||||||
|
{ 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: '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') || '不限'}`],
|
||||||
|
['导出时间', dayjs().format('YYYY-MM-DD HH:mm:ss')],
|
||||||
|
['导出条数', totalRows],
|
||||||
|
],
|
||||||
|
summaryRows: [
|
||||||
|
['总充值', exportSummary.totalRecharge],
|
||||||
|
['总消费', exportSummary.totalConsume],
|
||||||
|
['总回退', exportSummary.totalRefund],
|
||||||
|
['交易笔数', 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 = [
|
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: 'username', width: 120,
|
{ title: '用户类型', dataIndex: 'userTypeLabel', width: 120, render: (_: string, r: AdminCreditRecord) => <Tag color={r.userType === 'admin' ? 'orange' : 'blue'}>{r.userTypeLabel || '-'}</Tag> },
|
||||||
render: (v: string) => <Typography.Text strong>{v}</Typography.Text>,
|
{ title: '归属团队', dataIndex: 'teamNameSnapshot', width: 130, render: (v: string) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text> },
|
||||||
},
|
{ 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: 'creditSubjectLabel', width: 150, render: (v: string) => <Tag>{v || '-'}</Tag> },
|
||||||
title: '类型', dataIndex: 'type', width: 100,
|
{ title: '模块', dataIndex: 'sourceModuleLabel', width: 130, render: (v: string) => v || '-' },
|
||||||
render: (v: string) => {
|
{ title: '步骤/场景', key: 'scene', width: 210, render: (_: any, r: AdminCreditRecord) => <div><div>{r.billingSceneLabel || '-'}</div><div style={{ fontSize: 12, color: '#94a3b8' }}>{r.sourceStepCodeLabel || '-'}</div></div> },
|
||||||
const cfg = CREDIT_TYPE_MAP[v] || { text: v || '-', color: 'default', icon: null };
|
{ title: '媒体', dataIndex: 'mediaTypeLabel', width: 80, render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
|
||||||
return (
|
{ title: '变动积分', dataIndex: 'amount', width: 120, sorter: (a: AdminCreditRecord, b: AdminCreditRecord) => a.amount - b.amount, render: (v: number) => <Typography.Text strong style={{ color: v > 0 ? '#10b981' : '#ef4444' }}>{v > 0 ? '+' : ''}{n(v)}</Typography.Text> },
|
||||||
<Tag color={cfg.color} icon={cfg.icon}>
|
{ title: '余额', dataIndex: 'balanceAfter', width: 110, render: (v: number) => n(v) },
|
||||||
{cfg.text}
|
{ 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> },
|
||||||
</Tag>
|
{ 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> },
|
||||||
{
|
|
||||||
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: '变动后余额', dataIndex: 'balanceAfter', width: 120,
|
|
||||||
render: (v: number) => <Typography.Text type="secondary">{v.toLocaleString()}</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>,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Summary Cards */}
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', 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' }}>总充值</div><div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{n(summary.totalRecharge)}</div></div></Space></Card>
|
||||||
<Card bordered={false} style={{ flex: 1, borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><ArrowDownOutlined style={{ color: '#ef4444', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总消费</div><div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{n(summary.totalConsume)}</div></div></Space></Card>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><RollbackOutlined style={{ color: '#3b82f6', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>总回退</div><div style={{ fontSize: 22, fontWeight: 800, color: '#3b82f6' }}>+{n(summary.totalRefund)}</div></div></Space></Card>
|
||||||
<div style={{
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}><Space><WalletOutlined style={{ color: '#6366f1', fontSize: 22 }} /><div><div style={{ color: '#94a3b8' }}>交易 / 生成</div><div style={{ fontSize: 22, fontWeight: 800 }}>{n(summary.transactionCount)} / {n(summary.generationCount)}</div></div></Space></Card>
|
||||||
width: 44, height: 44, borderRadius: 10,
|
</div>
|
||||||
background: 'rgba(16,185,129,0.08)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 16, marginBottom: 16 }}>
|
||||||
fontSize: 20, color: '#10b981',
|
<Card size="small" bordered={false}>图片生成:{n(summary.imageGenerationCount)} 条 / {n(summary.imageConsume)} 积分</Card>
|
||||||
}}><ArrowUpOutlined /></div>
|
<Card size="small" bordered={false}>视频生成:{n(summary.videoGenerationCount)} 条 / {n(summary.videoConsume)} 积分</Card>
|
||||||
<div>
|
<Card size="small" bordered={false}>提词消费:{n(summary.textConsume)} 积分</Card>
|
||||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总充值</div>
|
<Card size="small" bordered={false}>视频分析:{n(summary.analysisConsume)} 积分</Card>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#10b981' }}>+{totalRecharge.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>总消费</div>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#ef4444' }}>-{totalConsume.toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>交易笔数</div>
|
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#1a1a2e' }}>{total}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<Space wrap>
|
<Space wrap>
|
||||||
|
<Select value={userScope} onChange={(v) => { setPage(1); setUserScope(v); }} style={{ width: 150 }} options={userScopeOptions} />
|
||||||
<Select
|
<Select
|
||||||
value={typeFilter}
|
value={teamFilter}
|
||||||
onChange={(value) => setTypeFilter(value)}
|
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||||||
style={{ width: 120 }}
|
style={{ width: 170 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: '', label: '全部类型' },
|
{ value: '', label: '全部团队' },
|
||||||
{ value: 'recharge', label: '充值' },
|
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||||||
{ value: 'consume', label: '消费' },
|
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||||
{ value: 'refund', label: '退回' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Select value={recordType} onChange={(v) => { setPage(1); setRecordType(v); }} style={{ width: 130 }} options={recordTypeOptions} />
|
||||||
placeholder="用户名搜索"
|
<Select value={creditSubject} onChange={(v) => { setPage(1); setCreditSubject(v); }} style={{ width: 180 }} options={creditSubjectOptions} />
|
||||||
value={userNameFilter}
|
<Select value={mediaType} onChange={(v) => { setPage(1); setMediaType(v); }} style={{ width: 110 }} options={mediaTypeOptions} />
|
||||||
onChange={(e) => setUserNameFilter(e.target.value)}
|
<Select value={chargeKind} onChange={(v) => { setPage(1); setChargeKind(v); }} style={{ width: 150 }} options={chargeKindOptions} />
|
||||||
style={{ width: 180 }}
|
<Select value={sourceModule} onChange={(v) => { setPage(1); setSourceModule(v); }} style={{ width: 150 }} options={sourceModuleOptions} />
|
||||||
allowClear
|
<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} />
|
||||||
<DatePicker.RangePicker
|
<Input placeholder="用户名/手机号/邮箱" value={userNameFilter} onChange={(e) => { setPage(1); setUserNameFilter(e.target.value); }} style={{ width: 180 }} allowClear />
|
||||||
value={dateRange}
|
<DatePicker.RangePicker value={dateRange} onChange={(dates) => { setPage(1); setDateRange(dates ? [dates[0], dates[1]] : [null, null]); }} placeholder={['开始日期', '结束日期']} style={{ width: 250 }} />
|
||||||
onChange={(dates) => { if (dates) setDateRange([dates[0], dates[1]]); }}
|
|
||||||
placeholder={['开始日期', '结束日期']}
|
|
||||||
style={{ width: 250 }}
|
|
||||||
/>
|
|
||||||
</Space>
|
</Space>
|
||||||
<Space>
|
<Space>
|
||||||
<Button type="primary" onClick={handleSearch}>搜索</Button>
|
|
||||||
<Button onClick={handleReset}>重置</Button>
|
<Button onClick={handleReset}>重置</Button>
|
||||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||||
|
<Button type="primary" icon={<DownloadOutlined />} loading={exporting} onClick={exportExcel}>下载 Excel</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
{exportProgress && <div style={{ marginBottom: 12, color: '#6366f1' }}>{exportProgress}</div>}
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={records}
|
dataSource={records}
|
||||||
@@ -204,13 +388,13 @@ const AdminCreditRecords: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize: pageSize,
|
pageSize,
|
||||||
total: total,
|
total,
|
||||||
onChange: handlePageChange,
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showTotal: (t) => `共 ${t} 条记录`,
|
showTotal: (t) => `共 ${t} 条记录`,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 800 }}
|
scroll={{ x: 2050 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
DatePicker,
|
||||||
Empty,
|
Empty,
|
||||||
Input,
|
Input,
|
||||||
message,
|
message,
|
||||||
@@ -14,26 +15,41 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
|
AudioOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
|
LinkOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
|
RobotOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
VideoCameraOutlined,
|
VideoCameraOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { getAdminGenerationAiTasks } from '../api';
|
import { getAdminGenerationAiTasks } from '../api';
|
||||||
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
|
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
// 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。
|
// 资源 URL 由后端返回相对路径,例如 /generate/images/xxx.png?exp=xxx&sign=xxx。
|
||||||
// 如果 VITE_API_BASE 被配置成 http://host/api 或 /api,这里会去掉 /api,避免拼成 /api/generate/xxx 导致 404。
|
// 如果 VITE_API_BASE 被配置成 http://host/api 或 /api,这里会去掉 /api,避免拼成 /api/generate/xxx 导致 404。
|
||||||
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||||
const PAGE_SIZE = 20;
|
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';
|
type ResourceState = 'empty' | 'checking' | 'valid' | 'invalid';
|
||||||
|
|
||||||
interface PreviewResourceState {
|
interface PreviewResourceState {
|
||||||
@@ -200,6 +216,10 @@ const InfoItem: React.FC<{ label: string; value?: React.ReactNode }> = ({ label,
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const todayStart = () => dayjs().startOf('day');
|
||||||
|
|
||||||
|
const todayEnd = () => dayjs().endOf('day');
|
||||||
|
|
||||||
const AdminGenerationAiRecords: React.FC = () => {
|
const AdminGenerationAiRecords: React.FC = () => {
|
||||||
const [records, setRecords] = useState<GenerationAITaskOut[]>([]);
|
const [records, setRecords] = useState<GenerationAITaskOut[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
@@ -208,10 +228,14 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
|
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('');
|
const [filterStatus, setFilterStatus] = useState<string>('');
|
||||||
const [filterGenType, setFilterGenType] = useState<string>('');
|
const [filterGenType, setFilterGenType] = useState<string>('');
|
||||||
|
const [filterEngineId, setFilterEngineId] = useState<string>('');
|
||||||
const [inputUserId, setInputUserId] = useState<string>('');
|
const [inputUserId, setInputUserId] = useState<string>('');
|
||||||
const [inputUserName, setInputUserName] = useState<string>('');
|
const [inputUserName, setInputUserName] = useState<string>('');
|
||||||
const [queryUserId, setQueryUserId] = useState<string>('');
|
const [queryUserId, setQueryUserId] = useState<string>('');
|
||||||
const [queryUserName, setQueryUserName] = 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 [reloadKey, setReloadKey] = useState(0);
|
||||||
|
|
||||||
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
|
const [preview, setPreview] = useState<GenerationAITaskOut | null>(null);
|
||||||
@@ -219,6 +243,13 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
const [videoPlaying, setVideoPlaying] = useState(false);
|
const [videoPlaying, setVideoPlaying] = useState(false);
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null);
|
const videoRef = useRef<HTMLVideoElement | null>(null);
|
||||||
|
|
||||||
|
// 资源预览弹窗(附件 / 最终结果)
|
||||||
|
const [resourcePreview, setResourcePreview] = useState<{
|
||||||
|
url: string;
|
||||||
|
type: 'image' | 'video';
|
||||||
|
title: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -227,6 +258,9 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
status: filterStatus || undefined,
|
status: filterStatus || undefined,
|
||||||
userId: queryUserId || undefined,
|
userId: queryUserId || undefined,
|
||||||
userName: queryUserName || 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,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
@@ -237,7 +271,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [filterGenType, filterStatus, page, queryUserId, queryUserName]);
|
}, [filterGenType, filterStatus, page, queryUserId, queryUserName, queryEngineId, queryCreatedRange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -274,6 +308,24 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
setQueryUserId(inputUserId.trim());
|
setQueryUserId(inputUserId.trim());
|
||||||
setQueryUserName(inputUserName.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);
|
setReloadKey((v) => v + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -341,6 +393,21 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
window.open(apiUrl(url), '_blank', 'noopener,noreferrer');
|
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(() => [
|
const columns = useMemo(() => [
|
||||||
{
|
{
|
||||||
title: '用户', key: 'user', width: 150,
|
title: '用户', key: 'user', width: 150,
|
||||||
@@ -358,6 +425,88 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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 (r.status !== 'completed') {
|
||||||
|
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,
|
title: '提示词', key: 'prompt', ellipsis: true,
|
||||||
render: (_: any, r: GenerationAITaskOut) => (
|
render: (_: any, r: GenerationAITaskOut) => (
|
||||||
@@ -764,7 +913,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
@@ -795,10 +944,41 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
{ value: 'video', label: '视频' },
|
{ value: 'video', label: '视频' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder="引擎筛选"
|
||||||
|
value={filterEngineId || undefined}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
onChange={(v) => { setFilterEngineId(v || ''); setPage(1); setQueryEngineId(v || ''); }}
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={Array.from(
|
||||||
|
new Map(
|
||||||
|
records
|
||||||
|
.filter((r) => r.engineId)
|
||||||
|
.map((r) => [r.engineId, {
|
||||||
|
value: r.engineId,
|
||||||
|
label: getEngineName(r.engineSnapshot as any) || r.engineId,
|
||||||
|
}]),
|
||||||
|
).values(),
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<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
|
<Input
|
||||||
placeholder="用户ID搜索"
|
placeholder="用户ID搜索"
|
||||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
style={{ width: 180 }}
|
style={{ width: 150 }}
|
||||||
value={inputUserId}
|
value={inputUserId}
|
||||||
onChange={(e) => setInputUserId(e.target.value)}
|
onChange={(e) => setInputUserId(e.target.value)}
|
||||||
onPressEnter={handleSearch}
|
onPressEnter={handleSearch}
|
||||||
@@ -807,7 +987,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
<Input
|
<Input
|
||||||
placeholder="用户名搜索"
|
placeholder="用户名搜索"
|
||||||
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
style={{ width: 160 }}
|
style={{ width: 150 }}
|
||||||
value={inputUserName}
|
value={inputUserName}
|
||||||
onChange={(e) => setInputUserName(e.target.value)}
|
onChange={(e) => setInputUserName(e.target.value)}
|
||||||
onPressEnter={handleSearch}
|
onPressEnter={handleSearch}
|
||||||
@@ -816,6 +996,9 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
|
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>
|
||||||
搜索
|
搜索
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button onClick={handleReset} style={{ borderRadius: 8 }}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -824,7 +1007,7 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns as any}
|
columns={columns as any}
|
||||||
dataSource={records}
|
dataSource={records}
|
||||||
scroll={{ x: 1180 }}
|
scroll={{ x: 1650 }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
@@ -912,11 +1095,62 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
|
|
||||||
{renderReferences()}
|
{renderReferences()}
|
||||||
|
|
||||||
|
{/* 附件快速查看区 */}
|
||||||
|
{(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>
|
||||||
|
<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 === 'completed' ? (
|
{preview.status === 'completed' ? (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||||
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}>
|
||||||
</Typography.Text>
|
{preview.genType === 'video' ? '生成视频' : '生成图片'}
|
||||||
|
</Typography.Text>
|
||||||
|
{preview.genType === 'video' && preview.videoUrl ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<PlayCircleOutlined />}
|
||||||
|
onClick={() => handlePreviewResource(preview.videoUrl!, 'video', '生成视频')}
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
弹窗播放
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{preview.genType === 'image' && preview.imageUrl ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="link"
|
||||||
|
icon={<FileImageOutlined />}
|
||||||
|
onClick={() => handlePreviewResource(preview.imageUrl!, 'image', '生成图片')}
|
||||||
|
style={{ padding: 0 }}
|
||||||
|
>
|
||||||
|
弹窗查看
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
|
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -936,6 +1170,34 @@ const AdminGenerationAiRecords: React.FC = () => {
|
|||||||
<Empty description="暂无详情" />
|
<Empty description="暂无详情" />
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
username: item.username,
|
username: item.username,
|
||||||
projectId: item.projectId,
|
projectId: item.projectId,
|
||||||
projectName: item.projectName,
|
projectName: item.projectName,
|
||||||
|
industry: item.industry,
|
||||||
originalPrompt: item.originalPrompt,
|
originalPrompt: item.originalPrompt,
|
||||||
optimizedPrompt: item.optimizedPrompt,
|
optimizedPrompt: item.optimizedPrompt,
|
||||||
duration: item.duration,
|
duration: item.duration,
|
||||||
@@ -390,6 +391,10 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
|
title: '项目', dataIndex: 'projectName', width: 120, ellipsis: true,
|
||||||
render: (v: string) => <Typography.Text style={{ fontSize: 13 }}>{v || '-'}</Typography.Text>,
|
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,
|
title: '类型', dataIndex: 'genType', width: 90, ellipsis: true,
|
||||||
render: (v: string) => {
|
render: (v: string) => {
|
||||||
@@ -867,7 +872,7 @@ const AdminGenerationRecords: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<VideoCameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
<VideoCameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -17,6 +17,7 @@ interface ImageEngine {
|
|||||||
supportedModels: string[];
|
supportedModels: string[];
|
||||||
supportedSizes: Record<string, Record<string, string>>;
|
supportedSizes: Record<string, Record<string, string>>;
|
||||||
defaultSize: string;
|
defaultSize: string;
|
||||||
|
maxImageCount: number;
|
||||||
generateUrl: string;
|
generateUrl: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
@@ -40,25 +41,35 @@ function parseSizes(val: unknown): Record<string, Record<string, string>> {
|
|||||||
|
|
||||||
// Default size options with pixel mappings
|
// Default size options with pixel mappings
|
||||||
const SIZE_OPTIONS: Record<string, Record<string, string>> = {
|
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": {
|
"2K": {
|
||||||
"1:1": "2048×2048",
|
"1:1": "2048x2048",
|
||||||
"4:3": "2304×1728",
|
"4:3": "2304x1728",
|
||||||
"3:4": "1728×2304",
|
"3:4": "1728x2304",
|
||||||
"16:9": "2560×1440",
|
"16:9": "2848x1600",
|
||||||
"9:16": "1600×2848",
|
"9:16": "1600x2848",
|
||||||
"3:2": "2496×1664",
|
"3:2": "2496x1664",
|
||||||
"2:3": "1664×2496",
|
"2:3": "1664x2496",
|
||||||
"21:9": "3024×1296",
|
"21:9": "3136x1344",
|
||||||
},
|
},
|
||||||
"4K": {
|
"4K": {
|
||||||
"1:1": "4096×4096",
|
"1:1": "4096x4096",
|
||||||
"4:3": "4608×3456",
|
"4:3": "4704x3520",
|
||||||
"3:4": "3520×4704",
|
"3:4": "3520x4704",
|
||||||
"16:9": "5404×3040",
|
"16:9": "5504x3040",
|
||||||
"9:16": "3040×5504",
|
"9:16": "3040x5504",
|
||||||
"3:2": "4992×3328",
|
"3:2": "4992x3328",
|
||||||
"2:3": "3328×4992",
|
"2:3": "3328x4992",
|
||||||
"21:9": "6197×2656",
|
"21:9": "6197x2656",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,7 +104,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
// Build supportedSizes from form values
|
// Build supportedSizes from form values
|
||||||
const sizes: Record<string, Record<string, string>> = {};
|
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}`] || [];
|
const selected: string[] = values[`size_${tier}`] || [];
|
||||||
if (selected.length > 0) {
|
if (selected.length > 0) {
|
||||||
sizes[tier] = {};
|
sizes[tier] = {};
|
||||||
@@ -111,6 +122,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
supported_models: JSON.stringify(values.supportedModels || []),
|
supported_models: JSON.stringify(values.supportedModels || []),
|
||||||
supported_sizes: JSON.stringify(sizes),
|
supported_sizes: JSON.stringify(sizes),
|
||||||
default_size: values.defaultSize || '2K',
|
default_size: values.defaultSize || '2K',
|
||||||
|
max_image_count: values.maxImageCount ?? 0,
|
||||||
generate_url: values.generateUrl || '',
|
generate_url: values.generateUrl || '',
|
||||||
is_active: values.isActive ?? true,
|
is_active: values.isActive ?? true,
|
||||||
priority: values.priority ?? 0,
|
priority: values.priority ?? 0,
|
||||||
@@ -145,7 +157,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
setModal({ open: true, engine: engine || null });
|
setModal({ open: true, engine: engine || null });
|
||||||
if (engine) {
|
if (engine) {
|
||||||
const sizeFields: Record<string, string[]> = {};
|
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] || {});
|
sizeFields[`size_${tier}`] = Object.keys(engine.supportedSizes?.[tier] || {});
|
||||||
}
|
}
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
@@ -158,6 +170,8 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
isActive: true, priority: 0,
|
isActive: true, priority: 0,
|
||||||
supportedModels: ['doubao-seedream-5-0-260128'],
|
supportedModels: ['doubao-seedream-5-0-260128'],
|
||||||
defaultSize: '2K',
|
defaultSize: '2K',
|
||||||
|
maxImageCount: 0,
|
||||||
|
size_1K: ALL_RATIOS,
|
||||||
size_2K: ALL_RATIOS,
|
size_2K: ALL_RATIOS,
|
||||||
size_4K: ALL_RATIOS,
|
size_4K: ALL_RATIOS,
|
||||||
});
|
});
|
||||||
@@ -184,6 +198,16 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
</div>
|
</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,
|
title: '2K 支持比例', key: 'sizes_2k', width: 260,
|
||||||
render: (_: any, r: ImageEngine) => {
|
render: (_: any, r: ImageEngine) => {
|
||||||
@@ -204,6 +228,10 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
))}</Space>;
|
))}</Space>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: '最大图片', dataIndex: 'maxImageCount', width: 100,
|
||||||
|
render: (v: number) => <Tag color="purple">{v} 张</Tag>,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'isActive', width: 80,
|
title: '状态', dataIndex: 'isActive', width: 80,
|
||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||||
@@ -287,7 +315,7 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{["2K", "4K"].map(tier => (
|
{["1K", "2K", "4K"].map(tier => (
|
||||||
<div key={tier} style={{
|
<div key={tier} style={{
|
||||||
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
background: '#fafbfc', borderRadius: 10, padding: '12px 16px',
|
||||||
marginBottom: 12, border: '1px solid #f0f0f5',
|
marginBottom: 12, border: '1px solid #f0f0f5',
|
||||||
@@ -311,10 +339,14 @@ const AdminImageEngines: React.FC = () => {
|
|||||||
|
|
||||||
<Form.Item name="defaultSize" label="默认尺寸档位">
|
<Form.Item name="defaultSize" label="默认尺寸档位">
|
||||||
<Select size="large" options={[
|
<Select size="large" options={[
|
||||||
|
{ value: '1K', label: '1K' },
|
||||||
{ value: '2K', label: '2K' },
|
{ value: '2K', label: '2K' },
|
||||||
{ value: '4K', label: '4K' },
|
{ value: '4K', label: '4K' },
|
||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="maxImageCount" label="最大图片数量">
|
||||||
|
<Input type="number" size="large" />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="generateUrl" label="生成接口地址">
|
<Form.Item name="generateUrl" label="生成接口地址">
|
||||||
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
|
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const AdminLayout: React.FC = () => {
|
|||||||
const logo = info.siteLogo || '';
|
const logo = info.siteLogo || '';
|
||||||
setSiteName(name);
|
setSiteName(name);
|
||||||
setSiteLogo(logo);
|
setSiteLogo(logo);
|
||||||
document.title = name;
|
document.title = name === '管理后台' ? name : name + ' - 管理后台';
|
||||||
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
|
localStorage.setItem('siteInfo', JSON.stringify({ siteName: name, siteLogo: logo }));
|
||||||
if (logo) {
|
if (logo) {
|
||||||
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
|
let faviconLink = document.querySelector('link[rel="icon"]') as HTMLLinkElement;
|
||||||
@@ -287,7 +287,6 @@ const AdminLayout: React.FC = () => {
|
|||||||
bottom: 16,
|
bottom: 16,
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
padding: 12,
|
|
||||||
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
|
background: 'linear-gradient(135deg, rgba(248, 250, 252, 0.9) 0%, rgba(241, 245, 249, 0.9) 100%)',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: '1px solid rgba(0, 0, 0, 0.06)',
|
border: '1px solid rgba(0, 0, 0, 0.06)',
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ const AdminLoginPage = () => {
|
|||||||
await login(values.username, values.password, values.rememberMe);
|
await login(values.username, values.password, values.rememberMe);
|
||||||
message.success('登录成功');
|
message.success('登录成功');
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch {
|
} catch (error: any) {
|
||||||
message.error('登录失败');
|
const errorMsg = error?.message || '登录失败';
|
||||||
|
message.error(errorMsg);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -80,7 +81,7 @@ const AdminLoginPage = () => {
|
|||||||
bottom: -100, left: -80, filter: 'blur(50px)',
|
bottom: -100, left: -80, filter: 'blur(50px)',
|
||||||
}} />
|
}} />
|
||||||
|
|
||||||
<Card bordered={false} style={{
|
<Card variant="outlined" style={{
|
||||||
width: 440,
|
width: 440,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.08)',
|
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 (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<MenuOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
<MenuOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ const AdminModels: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
共 {models.length} 个模型配置,按权重进行加权随机调度
|
共 {models.length} 个模型配置,按权重进行加权随机调度
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ const AdminNotificationManager: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
<BellOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
|
|||||||
@@ -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 React, { useEffect, useState } from 'react';
|
||||||
import {
|
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';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
MenuOutlined, ReloadOutlined, PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
|
MenuOutlined, ReloadOutlined, PlusOutlined, EyeOutlined, EditOutlined, DeleteOutlined,
|
||||||
@@ -13,7 +13,7 @@ interface OAuthApp {
|
|||||||
appId: string;
|
appId: string;
|
||||||
secret: string;
|
secret: string;
|
||||||
status: number;
|
status: number;
|
||||||
count: number;
|
maxCount: number;
|
||||||
openType: number;
|
openType: number;
|
||||||
authUrl?: string;
|
authUrl?: string;
|
||||||
company?: string;
|
company?: string;
|
||||||
@@ -54,7 +54,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
app_id: values.app_id,
|
app_id: values.app_id,
|
||||||
secret: values.secret,
|
secret: values.secret,
|
||||||
open_type: values.open_type,
|
open_type: values.open_type,
|
||||||
count: values.count,
|
max_count: values.max_count,
|
||||||
auth_url: values.auth_url,
|
auth_url: values.auth_url,
|
||||||
company: values.company,
|
company: values.company,
|
||||||
});
|
});
|
||||||
@@ -82,11 +82,11 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
const app = await getOauthApp(id);
|
const app = await getOauthApp(id);
|
||||||
setCurrentApp(app);
|
setCurrentApp(app);
|
||||||
updateForm.setFieldsValue({
|
updateForm.setFieldsValue({
|
||||||
app_id: app.appId,
|
appId: app.appId,
|
||||||
secret: app.secret,
|
secret: app.secret,
|
||||||
open_type: app.openType,
|
openType: app.openType,
|
||||||
count: app.count,
|
maxCount: app.maxCount,
|
||||||
auth_url: app.authUrl,
|
authUrl: app.authUrl,
|
||||||
company: app.company,
|
company: app.company,
|
||||||
});
|
});
|
||||||
setUpdateModalVisible(true);
|
setUpdateModalVisible(true);
|
||||||
@@ -137,16 +137,27 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'ID', dataIndex: 'id',
|
{ title: 'ID', dataIndex: 'id', width: 120,
|
||||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
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>,
|
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||||
},
|
},
|
||||||
{ title: '应用密钥', dataIndex: 'secret',
|
// { title: '应用密钥', dataIndex: 'secret', width: 120,
|
||||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
// 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) => {
|
render: (v: number) => {
|
||||||
const typeMap: Record<number, string> = {
|
const typeMap: Record<number, string> = {
|
||||||
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
|
1: '千川', 2: '广告', 3: '本地推', 4: '星图', 5: '快手代理商',
|
||||||
@@ -155,29 +166,40 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
return <Typography.Text>{typeMap[v] || v}</Typography.Text>;
|
return <Typography.Text>{typeMap[v] || v}</Typography.Text>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ title: '归属公司', dataIndex: 'company',
|
{ title: '归属公司', dataIndex: 'company', width: 120,
|
||||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
||||||
},
|
},
|
||||||
|
|
||||||
{ title: '授权次数', dataIndex: 'count',
|
{ title: '授权次数', dataIndex: 'maxCount', width: 90,
|
||||||
render: (v: number) => <Typography.Text>{v}</Typography.Text>,
|
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>,
|
render: (v: number) => <Tag color={v === 1 ? 'green' : 'red'}>{v === 1 ? '正常' : '禁用'}</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '授权URL', dataIndex: 'authUrl',
|
// { title: '授权URL', dataIndex: 'authUrl', width: 180,
|
||||||
render: (v: string) => <Typography.Text>{v}</Typography.Text>,
|
// 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>,
|
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>,
|
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>,
|
render: (v: string) => <Typography.Text>{formatDate(v)}</Typography.Text>,
|
||||||
},
|
},
|
||||||
{ title: '操作',
|
{ title: '操作', width: 120, fixed: 'right' as const,
|
||||||
render: (_: any, record: OAuthApp) => (
|
render: (_: any, record: OAuthApp) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
<Button
|
||||||
@@ -226,7 +248,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
showTotal: (t) => `共 ${t} 条记录`,
|
showTotal: (t) => `共 ${t} 条记录`,
|
||||||
onChange: (p) => { setPage(p); load(p); },
|
onChange: (p) => { setPage(p); load(p); },
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 800 }}
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -276,7 +298,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="count"
|
name="max_count"
|
||||||
label="最大授权用户数"
|
label="最大授权用户数"
|
||||||
initialValue={100}
|
initialValue={100}
|
||||||
>
|
>
|
||||||
@@ -322,7 +344,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
return typeMap[currentApp.openType] || currentApp.openType;
|
return typeMap[currentApp.openType] || currentApp.openType;
|
||||||
})()}</p>
|
})()}</p>
|
||||||
<p><strong>归属公司:</strong> {currentApp.company || '-'}</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>状态:</strong> {currentApp.status === 1 ? '正常' : '禁用'}</p>
|
||||||
<p><strong>授权URL:</strong> {currentApp.authUrl || '-'}</p>
|
<p><strong>授权URL:</strong> {currentApp.authUrl || '-'}</p>
|
||||||
<p><strong>创建人:</strong> {currentApp.createBy}</p>
|
<p><strong>创建人:</strong> {currentApp.createBy}</p>
|
||||||
@@ -347,7 +369,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Form form={updateForm} layout="vertical">
|
<Form form={updateForm} layout="vertical">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="app_id"
|
name="appId"
|
||||||
label="应用ID"
|
label="应用ID"
|
||||||
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
|
rules={[{ required: true, message: '请输入应用ID' }, { max: 64, message: '应用ID不能超过64个字符' }]}
|
||||||
>
|
>
|
||||||
@@ -361,7 +383,7 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
<Input placeholder="请输入应用密钥" />
|
<Input placeholder="请输入应用密钥" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="open_type"
|
name="openType"
|
||||||
label="开户方式"
|
label="开户方式"
|
||||||
rules={[{ required: true, message: '请选择开户方式' }]}
|
rules={[{ required: true, message: '请选择开户方式' }]}
|
||||||
>
|
>
|
||||||
@@ -379,13 +401,13 @@ const AdminOauthAppList: React.FC = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="count"
|
name="maxCount"
|
||||||
label="最大授权用户数"
|
label="最大授权用户数"
|
||||||
>
|
>
|
||||||
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
|
<InputNumber min={1} placeholder="应用最大可以授权多少个用户" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="auth_url"
|
name="authUrl"
|
||||||
label="应用授权链接"
|
label="应用授权链接"
|
||||||
>
|
>
|
||||||
<Input placeholder="请输入应用授权链接" />
|
<Input placeholder="请输入应用授权链接" />
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const AdminOperationLogs: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
<HistoryOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Form, Input, message, Switch, Typography, InputNumber,
|
Button, Card, Form, Input, message, Switch, Typography, InputNumber, Space,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SaveOutlined, WechatOutlined, AlipayCircleOutlined, DollarOutlined, ClockCircleOutlined,
|
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={{ display: 'flex', gap: 24, flexWrap: 'wrap', marginBottom: 24 }}>
|
||||||
{/* 通用设置:订单超时时间 */}
|
{/* 通用设置:订单超时时间 */}
|
||||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
<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', justifyContent: 'space-between' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -100,14 +100,16 @@ const AdminPaymentConfig: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<Form form={form} layout="inline">
|
<Form form={form} layout="inline">
|
||||||
<Form.Item name="order_timeout" style={{ margin: 0 }}>
|
<Form.Item name="order_timeout" style={{ margin: 0 }}>
|
||||||
<InputNumber
|
<Space.Compact style={{ width: 140 }}>
|
||||||
min={30}
|
<InputNumber
|
||||||
max={86400}
|
min={30}
|
||||||
placeholder="180"
|
max={86400}
|
||||||
style={{ width: 140 }}
|
placeholder="180"
|
||||||
size="middle"
|
style={{ width: '100%' }}
|
||||||
addonAfter="秒"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
|
<Typography.Text>秒</Typography.Text>
|
||||||
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +118,7 @@ const AdminPaymentConfig: React.FC = () => {
|
|||||||
|
|
||||||
{/* 模拟支付模式 */}
|
{/* 模拟支付模式 */}
|
||||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
<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', justifyContent: 'space-between' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -139,7 +141,7 @@ const AdminPaymentConfig: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||||
{/* 微信支付 */}
|
{/* 微信支付 */}
|
||||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
<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', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -216,7 +218,7 @@ const AdminPaymentConfig: React.FC = () => {
|
|||||||
|
|
||||||
{/* 支付宝 */}
|
{/* 支付宝 */}
|
||||||
<div style={{ flex: '1 1 400px', minWidth: 400 }}>
|
<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', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
<Col xs={24} sm={12} lg={12}>
|
<Col xs={24} sm={12} lg={12}>
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="今日收入"
|
title="今日收入"
|
||||||
value={stats.today.paidAmount}
|
value={stats.today.paidAmount}
|
||||||
@@ -186,7 +186,7 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={12}>
|
<Col xs={24} sm={12} lg={12}>
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="本月累计"
|
title="本月累计"
|
||||||
value={monthInfo.paidAmount}
|
value={monthInfo.paidAmount}
|
||||||
@@ -203,7 +203,7 @@ const AdminPaymentStats: React.FC = () => {
|
|||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Status breakdown */}
|
{/* Status breakdown */}
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}
|
||||||
title={<Space><DollarOutlined />订单状态分布</Space>}>
|
title={<Space><DollarOutlined />订单状态分布</Space>}>
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
|
{['paid', 'pending', 'cancelled', 'refunded'].map(s => {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -177,7 +177,7 @@ const AdminRechargePackages: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
<GiftOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
|
|||||||
@@ -251,7 +251,14 @@ const AdminReplicationProjectDetail: React.FC<AdminReplicationProjectDetailProps
|
|||||||
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
|
<Descriptions.Item label="提词参数" span={3}><JsonBlock value={detail.videoGeneration?.promptParams || {}} maxHeight={160} /></Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{renderErrorAlert(videoPromptStep?.errorMessage || detail.videoGeneration?.errorMessage)}
|
{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} />
|
<StepRawJson step={videoPromptStep} />
|
||||||
</Space>
|
</Space>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Form, Input, message, Space, Switch, Typography, Upload,
|
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined,
|
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { getSystemConfigs, updateSystemConfig, uploadPdf, uploadLogo } from '../api';
|
import {
|
||||||
import type { SystemConfig } from '../types';
|
getGlobalResourceCapacity,
|
||||||
|
getSystemConfigs,
|
||||||
|
saveGlobalResourceCapacity,
|
||||||
|
updateSystemConfig,
|
||||||
|
uploadLogo,
|
||||||
|
uploadPdf,
|
||||||
|
} from '../api';
|
||||||
|
import type { ResourceCapacityUnit, SystemConfig } 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 AdminSettings: React.FC = () => {
|
||||||
const [configs, setConfigs] = useState<SystemConfig[]>([]);
|
const [configs, setConfigs] = useState<SystemConfig[]>([]);
|
||||||
@@ -14,7 +27,6 @@ const AdminSettings: React.FC = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [uploading, setUploading] = useState('');
|
const [uploading, setUploading] = useState('');
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [logoPreview, setLogoPreview] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -22,12 +34,23 @@ const AdminSettings: React.FC = () => {
|
|||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getSystemConfigs();
|
try {
|
||||||
setConfigs(data);
|
const [data, capacity] = await Promise.all([
|
||||||
const formValues: Record<string, string> = {};
|
getSystemConfigs(),
|
||||||
data.forEach(c => { formValues[c.key] = c.value; });
|
getGlobalResourceCapacity(),
|
||||||
form.setFieldsValue(formValues);
|
]);
|
||||||
setLoading(false);
|
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 () => {
|
const handleSave = async () => {
|
||||||
@@ -36,17 +59,21 @@ const AdminSettings: React.FC = () => {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
for (const config of configs) {
|
for (const config of configs) {
|
||||||
const newVal = values[config.key];
|
const newVal = values[config.key];
|
||||||
if (newVal !== undefined && newVal !== config.value) {
|
if (newVal !== undefined && String(newVal) !== config.value) {
|
||||||
await updateSystemConfig(config.id, newVal ?? '');
|
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('系统配置已保存');
|
message.success('系统配置已保存');
|
||||||
const data = await getSystemConfigs();
|
await load();
|
||||||
setConfigs(data);
|
|
||||||
setSaving(false);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setSaving(false);
|
|
||||||
message.error(e?.message || '保存失败');
|
message.error(e?.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,84 +81,68 @@ const AdminSettings: React.FC = () => {
|
|||||||
setUploading(configKey);
|
setUploading(configKey);
|
||||||
try {
|
try {
|
||||||
const res = await uploadPdf(file, configKey);
|
const res = await uploadPdf(file, configKey);
|
||||||
// Update local state
|
|
||||||
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
|
setConfigs(prev => prev.map(c => c.key === configKey ? { ...c, value: res.url } : c));
|
||||||
form.setFieldsValue({ [configKey]: res.url });
|
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 {
|
} catch {
|
||||||
message.error('上传失败');
|
message.error('上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading('');
|
setUploading('');
|
||||||
}
|
}
|
||||||
return false; // prevent default upload
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLogoUpload = async (file: File) => {
|
const handleLogoUpload = async (file: File) => {
|
||||||
setUploading('site_logo');
|
setUploading('site_logo');
|
||||||
try {
|
try {
|
||||||
const res = await uploadLogo(file);
|
const res = await uploadLogo(file);
|
||||||
// Update local state
|
|
||||||
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
setConfigs(prev => prev.map(c => c.key === 'site_logo' ? { ...c, value: res.url } : c));
|
||||||
form.setFieldsValue({ site_logo: res.url });
|
form.setFieldsValue({ site_logo: res.url });
|
||||||
setLogoPreview(res.url);
|
const config = configs.find(c => c.key === 'site_logo');
|
||||||
message.success('Logo上传成功');
|
if (config) {
|
||||||
|
await updateSystemConfig(config.id, res.url);
|
||||||
|
}
|
||||||
|
message.success('Logo上传成功并已保存');
|
||||||
} catch {
|
} catch {
|
||||||
message.error('上传失败');
|
message.error('上传失败');
|
||||||
} finally {
|
} finally {
|
||||||
setUploading('');
|
setUploading('');
|
||||||
}
|
}
|
||||||
return false; // prevent default upload
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupedConfigs: Record<string, SystemConfig[]> = {
|
const groupedConfigs: Record<string, SystemConfig[]> = {
|
||||||
'站点信息': configs.filter(c => c.key.startsWith('site_')),
|
'站点信息': 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 === 'user_agreement_privacy_url'),
|
||||||
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
'SEO 设置': configs.filter(c => c.key.startsWith('seo_')),
|
||||||
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
'用户积分配置': configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')),
|
||||||
|
'其他配置': configs.filter(c => c.key === 'operation_manual'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFieldDescription = (config: SystemConfig): string => {
|
const getFieldDescription = (config: SystemConfig): string => {
|
||||||
const descMap: Record<string, string> = {
|
const descMap: Record<string, string> = {
|
||||||
site_name: '平台显示名称,将展示在页面标题和导航栏',
|
site_name: '平台显示名称,将展示在页面标题和导航栏',
|
||||||
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
|
site_logo: '平台Logo图片URL,建议尺寸 200x40px',
|
||||||
user_agreement_url: '用户注册/登录时需同意的用户协议PDF文件',
|
site_copyright: '显示在前台登录页底部的版权信息,例如:© 2024 民众智创 版权所有',
|
||||||
privacy_policy_url: '用户注册/登录时需同意的隐私政策PDF文件',
|
user_agreement_privacy_url: '用户登录时需同意的用户协议及隐私政策PDF文件',
|
||||||
seo_title: '搜索引擎结果中显示的标题',
|
seo_title: '搜索引擎结果中显示的标题',
|
||||||
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
|
seo_description: '搜索引擎结果中显示的描述文字,建议150字以内',
|
||||||
seo_keywords: '用逗号分隔的关键词列表',
|
seo_keywords: '用逗号分隔的关键词列表',
|
||||||
user_register_credits: '用户注册时赠送的初始积分,默认100',
|
user_register_credits: '用户注册时赠送的初始积分,默认100',
|
||||||
user_login_credits: '用户每日登录赠送的积分数量',
|
user_login_credits: '用户每日登录赠送的积分数量',
|
||||||
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
|
user_login_credits_enabled: '是否启用每日登录赠送积分功能',
|
||||||
|
operation_manual: '操作手册链接,前台用户菜单将展示该入口,点击跳转此链接',
|
||||||
};
|
};
|
||||||
return descMap[config.key] || config.description || '';
|
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 LogoUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
|
||||||
const hasLogo = config.value && config.value.startsWith('/uploads/');
|
const hasLogo = config.value && config.value.startsWith('/uploads/');
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
@@ -198,8 +209,9 @@ const AdminSettings: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const PdfUploadField: React.FC<{ config: SystemConfig }> = ({ config }) => {
|
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 hasFile = config.value && config.value.startsWith('/uploads/');
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '16px', borderRadius: 10,
|
padding: '16px', borderRadius: 10,
|
||||||
@@ -214,7 +226,7 @@ const AdminSettings: React.FC = () => {
|
|||||||
<Space>
|
<Space>
|
||||||
{hasFile && (
|
{hasFile && (
|
||||||
<Button size="small" icon={<EyeOutlined />}
|
<Button size="small" icon={<EyeOutlined />}
|
||||||
onClick={() => window.open(`http://localhost:8000${config.value}`, '_blank')}>
|
onClick={() => window.open(`${baseUrl}${config.value}`, '_blank')}>
|
||||||
预览
|
预览
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -236,13 +248,39 @@ 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={0} placeholder={config.description} size="large" />;
|
||||||
|
}
|
||||||
|
return <Input placeholder={config.description} size="large" />;
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <Card loading bordered={false} style={{ borderRadius: 12 }} />;
|
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 720 }}>
|
<div style={{ maxWidth: 720 }}>
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 44, height: 44, borderRadius: 10,
|
width: 44, height: 44, borderRadius: 10,
|
||||||
@@ -282,6 +320,49 @@ const AdminSettings: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: 4 }}>
|
||||||
|
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
|
||||||
|
资源空间管控
|
||||||
|
</Typography.Text>
|
||||||
|
<div style={{ padding: 16, border: '1px solid #f0f0f5', borderRadius: 10, background: '#fafbfc' }}>
|
||||||
|
<Space align="start" style={{ marginBottom: 16 }}>
|
||||||
|
<DatabaseOutlined style={{ color: '#6366f1', fontSize: 18, marginTop: 2 }} />
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>全局生成资源容量上限</Typography.Text>
|
||||||
|
<div style={{ color: '#64748b', fontSize: 13, marginTop: 4 }}>
|
||||||
|
开启后会按用户当前有效资源占用量进行提交前拦截;用户个人配置存在时优先级高于全局配置。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Form.Item
|
||||||
|
name="resource_capacity_enabled"
|
||||||
|
label="启用全局容量管控"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="关闭时全局不限制;若用户设置了个人配置,则仍按用户个人配置优先判断。"
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="resource_capacity_limit_value"
|
||||||
|
label="容量数值"
|
||||||
|
extra="最小为1,不能为负数,最多支持3位小数。"
|
||||||
|
rules={[{ required: true, message: '请输入容量数值' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="resource_capacity_limit_unit"
|
||||||
|
label="容量单位"
|
||||||
|
extra="MB / GB / TB 固定枚举"
|
||||||
|
rules={[{ required: true, message: '请选择容量单位' }]}
|
||||||
|
>
|
||||||
|
<Select size="large" options={capacityUnitOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Table, Tag, Typography } from 'antd';
|
||||||
|
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SettingOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import { deleteAdminTeam, getAdminTeams, getTeamMembersForAdmin, saveAdminTeam, setTeamManager } from '../api';
|
||||||
|
import type { AdminTeam, AdminUser } from '../types';
|
||||||
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: '', label: '全部状态' },
|
||||||
|
{ value: 'active', label: '启用' },
|
||||||
|
{ value: 'disabled', label: '禁用' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const editStatusOptions = [
|
||||||
|
{ value: 'active', label: '启用' },
|
||||||
|
{ value: 'disabled', label: '禁用' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusLabel(status?: string): string {
|
||||||
|
if (status === 'active') return '启用';
|
||||||
|
if (status === 'disabled') return '禁用';
|
||||||
|
return status || '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminTeams: React.FC = () => {
|
||||||
|
const [items, setItems] = useState<AdminTeam[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = 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('');
|
||||||
|
const [modal, setModal] = useState<{ open: boolean; item: AdminTeam | null }>({ open: false, item: null });
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
// 管理人弹窗状态
|
||||||
|
const [managerModal, setManagerModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
|
||||||
|
const [managerMembers, setManagerMembers] = useState<AdminUser[]>([]);
|
||||||
|
const [managerLoading, setManagerLoading] = useState(false);
|
||||||
|
const [selectedManagerId, setSelectedManagerId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 查看成员弹窗状态
|
||||||
|
const [membersModal, setMembersModal] = useState<{ open: boolean; team: AdminTeam | null }>({ open: false, team: null });
|
||||||
|
const [teamMembers, setTeamMembers] = useState<AdminUser[]>([]);
|
||||||
|
const [membersLoading, setMembersLoading] = useState(false);
|
||||||
|
|
||||||
|
const query = useMemo(() => ({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
}), [page, pageSize, keyword, status]);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getAdminTeams(query);
|
||||||
|
setItems(res.items || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载团队列表失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [query]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ status: 'active', sortOrder: 0 });
|
||||||
|
setModal({ open: true, item: null });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (item: AdminTeam) => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: item.name,
|
||||||
|
code: item.code || '',
|
||||||
|
description: item.description || '',
|
||||||
|
status: item.status || 'active',
|
||||||
|
sortOrder: item.sortOrder || 0,
|
||||||
|
});
|
||||||
|
setModal({ open: true, item });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
setSaving(true);
|
||||||
|
await saveAdminTeam({
|
||||||
|
id: modal.item?.id,
|
||||||
|
name: values.name,
|
||||||
|
code: values.code || null,
|
||||||
|
description: values.description || null,
|
||||||
|
status: values.status || 'active',
|
||||||
|
sort_order: values.sortOrder || 0,
|
||||||
|
});
|
||||||
|
message.success(modal.item ? '团队已更新' : '团队已创建');
|
||||||
|
setModal({ open: false, item: null });
|
||||||
|
form.resetFields();
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e?.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (item: AdminTeam) => {
|
||||||
|
try {
|
||||||
|
await deleteAdminTeam(item.id);
|
||||||
|
message.success('团队已删除');
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTeamMembers = async (teamId: string): Promise<AdminUser[]> => {
|
||||||
|
try {
|
||||||
|
const res = await getTeamMembersForAdmin(teamId);
|
||||||
|
return res.items || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载成员失败');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openManagerModal = async (team: AdminTeam) => {
|
||||||
|
setManagerModal({ open: true, team });
|
||||||
|
setSelectedManagerId(team.managerId || null);
|
||||||
|
setManagerLoading(true);
|
||||||
|
const data = await loadTeamMembers(team.id);
|
||||||
|
setManagerMembers(data);
|
||||||
|
setManagerLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openMembersModal = async (team: AdminTeam) => {
|
||||||
|
setMembersModal({ open: true, team });
|
||||||
|
setMembersLoading(true);
|
||||||
|
const data = await loadTeamMembers(team.id);
|
||||||
|
setTeamMembers(data);
|
||||||
|
setMembersLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetManager = async () => {
|
||||||
|
if (!managerModal.team) return;
|
||||||
|
try {
|
||||||
|
setManagerLoading(true);
|
||||||
|
await setTeamManager(managerModal.team.id, selectedManagerId);
|
||||||
|
message.success(selectedManagerId ? '已设置管理人' : '已取消管理人');
|
||||||
|
setManagerModal({ open: false, team: null });
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '设置失败');
|
||||||
|
} finally {
|
||||||
|
setManagerLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '团队名称',
|
||||||
|
dataIndex: 'name',
|
||||||
|
width: 220,
|
||||||
|
render: (v: string, r: AdminTeam) => (
|
||||||
|
<Space>
|
||||||
|
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'rgba(99,102,241,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6366f1' }}>
|
||||||
|
<TeamOutlined />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Typography.Text strong>{v}</Typography.Text>
|
||||||
|
<div style={{ fontSize: 12, color: '#94a3b8' }}>{r.code || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => <Tag color={v === 'active' ? 'green' : 'default'}>{statusLabel(v)}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成员数',
|
||||||
|
dataIndex: 'memberCount',
|
||||||
|
width: 100,
|
||||||
|
render: (v: number) => <Typography.Text strong>{Number(v || 0).toLocaleString()}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '管理人',
|
||||||
|
key: 'manager',
|
||||||
|
width: 140,
|
||||||
|
render: (_: any, r: AdminTeam) => (
|
||||||
|
<Typography.Text style={{ fontSize: 13 }}>
|
||||||
|
{r.managerName || <span style={{ color: '#94a3b8' }}>未设置</span>}
|
||||||
|
</Typography.Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '排序',
|
||||||
|
dataIndex: 'sortOrder',
|
||||||
|
width: 90,
|
||||||
|
render: (v: number) => v ?? 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'description',
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 230,
|
||||||
|
fixed: 'right' as const,
|
||||||
|
render: (_: any, r: AdminTeam) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Button type="link" size="small" icon={<UserOutlined />} onClick={() => openMembersModal(r)}>成员</Button>
|
||||||
|
<Button type="link" size="small" icon={<SettingOutlined />} onClick={() => openManagerModal(r)}>管理人</Button>
|
||||||
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(r)}>编辑</Button>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除该团队?"
|
||||||
|
description={r.memberCount > 0 ? '该团队下仍有成员,后端会拒绝删除。' : '删除后团队不再出现在设置下拉中。'}
|
||||||
|
onConfirm={() => handleDelete(r)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" danger 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, flexWrap: 'wrap', gap: 12 }}>
|
||||||
|
<Space wrap>
|
||||||
|
<Input
|
||||||
|
placeholder="搜索团队名称/编码/备注"
|
||||||
|
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => { setPage(1); setKeyword(e.target.value); }}
|
||||||
|
onPressEnter={load}
|
||||||
|
style={{ width: 260 }}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
<Select value={status} onChange={(v) => { setPage(1); setStatus(v); }} style={{ width: 130 }} options={statusOptions} />
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增团队</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={items}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (t) => `共 ${t} 个团队`,
|
||||||
|
}}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<Space><TeamOutlined />{modal.item ? '编辑团队' : '新增团队'}</Space>}
|
||||||
|
open={modal.open}
|
||||||
|
confirmLoading={saving}
|
||||||
|
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 maxLength={128} placeholder="请输入团队名称" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="code" label="团队编码" extra="选填,用于内部标识或后续外部系统对接。">
|
||||||
|
<Input maxLength={64} placeholder="例如 sales_a" size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态" rules={[{ required: true, message: '请选择状态' }]}>
|
||||||
|
<Select size="large" options={editStatusOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="sortOrder" label="排序" initialValue={0}>
|
||||||
|
<InputNumber min={0} max={999999} precision={0} style={{ width: '100%' }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="备注">
|
||||||
|
<Input.TextArea rows={3} maxLength={512} placeholder="选填" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 设置管理人弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={<Space><UserOutlined />设置团队管理人</Space>}
|
||||||
|
open={managerModal.open}
|
||||||
|
confirmLoading={managerLoading}
|
||||||
|
onOk={handleSetManager}
|
||||||
|
onCancel={() => { setManagerModal({ open: false, team: null }); setSelectedManagerId(null); }}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 8 }}>
|
||||||
|
团队:{managerModal.team?.name || '-'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text style={{ fontSize: 13, color: '#64748b', display: 'block', marginBottom: 12 }}>
|
||||||
|
选择该团队的一名成员作为管理人。管理人可在前台管理团队成员和分配积分。
|
||||||
|
</Typography.Text>
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="选择管理人(可清空取消)"
|
||||||
|
value={selectedManagerId}
|
||||||
|
onChange={(v) => setSelectedManagerId(v || null)}
|
||||||
|
allowClear
|
||||||
|
loading={managerLoading}
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={managerMembers.map((m) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: `${m.username}${m.phone ? ` (${m.phone})` : ''}`,
|
||||||
|
}))}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 查看成员弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title={<Space><UserOutlined />团队成员 - {membersModal.team?.name}</Space>}
|
||||||
|
open={membersModal.open}
|
||||||
|
onCancel={() => setMembersModal({ open: false, team: null })}
|
||||||
|
footer={null}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
{membersModal.team && (
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
rowKey="id"
|
||||||
|
loading={membersLoading}
|
||||||
|
dataSource={teamMembers}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 400 }}
|
||||||
|
columns={[
|
||||||
|
{ title: '用户名', dataIndex: 'username', width: 140, render: (v: string) => <Typography.Text strong>{v}</Typography.Text> },
|
||||||
|
{ title: '手机号', dataIndex: 'phone', width: 130, render: (v: string) => v || '-' },
|
||||||
|
{ title: '积分', dataIndex: 'credits', width: 100, render: (v: number) => <Typography.Text style={{ color: '#6366f1' }}>{(v ?? 0).toFixed(2)}</Typography.Text> },
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'isActive', width: 80,
|
||||||
|
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '启用' : '禁用'}</Tag>,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
locale={{ emptyText: '该团队暂无成员' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminTeams;
|
||||||
@@ -1,19 +1,70 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined,
|
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { getAdminUsers, adjustCredits, toggleUserStatus, createUser, updateUserMenus, getMenuConfigs, resetUserPassword, getSystemConfigs, updateSystemConfig } from '../api';
|
import {
|
||||||
import type { AdminUser, SystemConfig } from '../types';
|
adjustCredits,
|
||||||
|
adminGetPrivatePortraitConfig,
|
||||||
|
adminUpdatePrivatePortraitConfig,
|
||||||
|
createUser,
|
||||||
|
deleteUserResourceCapacity,
|
||||||
|
getAdminUsers,
|
||||||
|
getMenuConfigs,
|
||||||
|
getTeamOptions,
|
||||||
|
getSystemConfigs,
|
||||||
|
getUserResourceCapacity,
|
||||||
|
resetUserPassword,
|
||||||
|
saveUserResourceCapacity,
|
||||||
|
toggleUserStatus,
|
||||||
|
updateFrontendUserKind,
|
||||||
|
updateUserTeam,
|
||||||
|
updateSystemConfig,
|
||||||
|
updateUserMenus,
|
||||||
|
updateUserAdminStatus,
|
||||||
|
} from '../api';
|
||||||
|
import type { AdminTeamOption, AdminUser, AdminUserResourceCapacityOut, PrivatePortraitConfig, ResourceCapacityUnit, ResourceCapacityUsage, SystemConfig } from '../types';
|
||||||
import { formatDate } from '../utils/formatDate';
|
import { formatDate } from '../utils/formatDate';
|
||||||
|
|
||||||
|
const TEAM_UNASSIGNED_VALUE = '__none__';
|
||||||
|
|
||||||
|
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 字节)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatBytes(bytes?: number | null): string {
|
||||||
|
if (bytes === null || bytes === undefined) return '-';
|
||||||
|
const value = Number(bytes || 0);
|
||||||
|
if (value < 1024) return `${value} B`;
|
||||||
|
const units = ['KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
let size = value;
|
||||||
|
let unitIndex = -1;
|
||||||
|
do {
|
||||||
|
size /= 1024;
|
||||||
|
unitIndex += 1;
|
||||||
|
} while (size >= 1024 && unitIndex < units.length - 1);
|
||||||
|
return `${size.toFixed(size >= 100 ? 0 : size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function capacitySourceLabel(capacity?: ResourceCapacityUsage | null): string {
|
||||||
|
if (!capacity || !capacity.enabled) return '未开启';
|
||||||
|
if (capacity.source === 'user') return '个人';
|
||||||
|
if (capacity.source === 'global') return '全局';
|
||||||
|
return '未开启';
|
||||||
|
}
|
||||||
|
|
||||||
const AdminUsers: React.FC = () => {
|
const AdminUsers: React.FC = () => {
|
||||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<string>('frontend');
|
const [activeTab, setActiveTab] = useState<string>('frontend');
|
||||||
|
const [frontendKindFilter, setFrontendKindFilter] = useState<string>('');
|
||||||
|
const [teamFilter, setTeamFilter] = useState<string>('');
|
||||||
|
const [teamOptions, setTeamOptions] = useState<AdminTeamOption[]>([]);
|
||||||
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
const [creditModal, setCreditModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||||
const [createModal, setCreateModal] = useState(false);
|
const [createModal, setCreateModal] = useState(false);
|
||||||
const [createType, setCreateType] = useState<string>('frontend');
|
const [createType, setCreateType] = useState<string>('frontend');
|
||||||
@@ -21,9 +72,20 @@ const AdminUsers: React.FC = () => {
|
|||||||
const [allMenus, setAllMenus] = useState<any[]>([]);
|
const [allMenus, setAllMenus] = useState<any[]>([]);
|
||||||
const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
|
const [checkedMenus, setCheckedMenus] = useState<string[]>([]);
|
||||||
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
const [resetPwdModal, setResetPwdModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||||
|
const [capacityModal, setCapacityModal] = useState<{ open: boolean; user: AdminUser | null; detail: AdminUserResourceCapacityOut | null }>({ open: false, user: null, detail: null });
|
||||||
|
const [teamModal, setTeamModal] = useState<{ open: boolean; user: AdminUser | null }>({ open: false, user: null });
|
||||||
|
const [portraitModal, setPortraitModal] = useState<{ open: boolean; user: AdminUser | null; config: PrivatePortraitConfig | null }>({ open: false, user: null, config: null });
|
||||||
|
const [capacityLoading, setCapacityLoading] = useState(false);
|
||||||
|
const [capacitySaving, setCapacitySaving] = useState(false);
|
||||||
|
const [teamSaving, setTeamSaving] = useState(false);
|
||||||
|
const [portraitLoading, setPortraitLoading] = useState(false);
|
||||||
|
const [portraitSaving, setPortraitSaving] = useState(false);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [resetPwdForm] = Form.useForm();
|
const [resetPwdForm] = Form.useForm();
|
||||||
|
const [capacityForm] = Form.useForm();
|
||||||
|
const [teamForm] = Form.useForm();
|
||||||
|
const [portraitForm] = Form.useForm();
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
@@ -36,14 +98,25 @@ const AdminUsers: React.FC = () => {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await getAdminUsers(page, pageSize, search || undefined);
|
const data = await getAdminUsers(
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search || undefined,
|
||||||
|
activeTab,
|
||||||
|
activeTab === 'frontend' ? (frontendKindFilter || undefined) : undefined,
|
||||||
|
activeTab === 'frontend' ? (teamFilter || undefined) : undefined,
|
||||||
|
);
|
||||||
setUsers(data.items || []);
|
setUsers(data.items || []);
|
||||||
setTotal(data.total || 0);
|
setTotal(data.total || 0);
|
||||||
} catch { /* auth error handled by client */ }
|
} catch { /* auth error handled by client */ }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { load(); }, [page, pageSize, search]);
|
useEffect(() => { load(); }, [page, pageSize, search, activeTab, frontendKindFilter, teamFilter]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getTeamOptions(true).then(setTeamOptions).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCreditConfigs = async () => {
|
const loadCreditConfigs = async () => {
|
||||||
@@ -65,17 +138,17 @@ const AdminUsers: React.FC = () => {
|
|||||||
setConfigSaving(true);
|
setConfigSaving(true);
|
||||||
for (const config of creditConfigs) {
|
for (const config of creditConfigs) {
|
||||||
const newVal = values[config.key];
|
const newVal = values[config.key];
|
||||||
if (newVal !== undefined && newVal !== config.value) {
|
if (newVal !== undefined && String(newVal) !== config.value) {
|
||||||
await updateSystemConfig(config.id, newVal ?? '');
|
await updateSystemConfig(config.id, String(newVal ?? ''));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
message.success('积分配置已保存');
|
message.success('积分配置已保存');
|
||||||
const configs = await getSystemConfigs();
|
const configs = await getSystemConfigs();
|
||||||
setCreditConfigs(configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')));
|
setCreditConfigs(configs.filter(c => c.key.startsWith('user_') && c.key.includes('credits')));
|
||||||
setConfigSaving(false);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setConfigSaving(false);
|
|
||||||
message.error(e?.message || '保存失败');
|
message.error(e?.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setConfigSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,6 +189,9 @@ const AdminUsers: React.FC = () => {
|
|||||||
phone: userType === 'frontend' ? values.phone : (values.phone || undefined),
|
phone: userType === 'frontend' ? values.phone : (values.phone || undefined),
|
||||||
credits: values.credits || 0,
|
credits: values.credits || 0,
|
||||||
user_type: userType,
|
user_type: userType,
|
||||||
|
is_admin: userType === 'admin' ? (values.is_admin ?? false) : false,
|
||||||
|
frontend_user_kind: values.frontend_user_kind || 'external',
|
||||||
|
private_portrait_asset_limit: userType === 'frontend' ? Number(values.private_portrait_asset_limit ?? 50) : 0,
|
||||||
});
|
});
|
||||||
message.success('用户创建成功');
|
message.success('用户创建成功');
|
||||||
setCreateModal(false);
|
setCreateModal(false);
|
||||||
@@ -140,7 +216,125 @@ const AdminUsers: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build structured menu display: groups with children, and top-level pages
|
const openCapacityModal = async (user: AdminUser) => {
|
||||||
|
setCapacityLoading(true);
|
||||||
|
setCapacityModal({ open: true, user, detail: null });
|
||||||
|
try {
|
||||||
|
const detail = await getUserResourceCapacity(user.id);
|
||||||
|
const initialConfig = detail.userConfig;
|
||||||
|
capacityForm.setFieldsValue({
|
||||||
|
enabled: initialConfig?.enabled ?? false,
|
||||||
|
limitValue: initialConfig?.limitValue ?? detail.effective.limitValue ?? detail.globalConfig.limitValue ?? '1.000',
|
||||||
|
limitUnit: initialConfig?.limitUnit ?? detail.effective.limitUnit ?? detail.globalConfig.limitUnit ?? 'GB',
|
||||||
|
});
|
||||||
|
setCapacityModal({ open: true, user, detail });
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载容量配置失败');
|
||||||
|
setCapacityModal({ open: false, user: null, detail: null });
|
||||||
|
} finally {
|
||||||
|
setCapacityLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCapacity = async () => {
|
||||||
|
const { user } = capacityModal;
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const values = await capacityForm.validateFields();
|
||||||
|
setCapacitySaving(true);
|
||||||
|
await saveUserResourceCapacity(user.id, {
|
||||||
|
enabled: !!values.enabled,
|
||||||
|
limitValue: String(values.limitValue ?? '1.000'),
|
||||||
|
limitUnit: values.limitUnit || 'GB',
|
||||||
|
});
|
||||||
|
message.success('用户容量配置已保存');
|
||||||
|
setCapacityModal({ open: false, user: null, detail: null });
|
||||||
|
capacityForm.resetFields();
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setCapacitySaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreGlobalCapacity = async () => {
|
||||||
|
const { user } = capacityModal;
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
setCapacitySaving(true);
|
||||||
|
await deleteUserResourceCapacity(user.id);
|
||||||
|
message.success('已恢复为全局容量配置');
|
||||||
|
setCapacityModal({ open: false, user: null, detail: null });
|
||||||
|
capacityForm.resetFields();
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '恢复失败');
|
||||||
|
} finally {
|
||||||
|
setCapacitySaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPortraitModal = async (user: AdminUser) => {
|
||||||
|
setPortraitLoading(true);
|
||||||
|
setPortraitModal({ open: true, user, config: null });
|
||||||
|
portraitForm.setFieldsValue({ privatePortraitAssetLimit: user.privatePortraitAssetLimit ?? 5 });
|
||||||
|
try {
|
||||||
|
const config = await adminGetPrivatePortraitConfig(user.id);
|
||||||
|
portraitForm.setFieldsValue({ privatePortraitAssetLimit: config.assetLimit });
|
||||||
|
setPortraitModal({ open: true, user, config });
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '加载私域人像素材库配置失败');
|
||||||
|
setPortraitModal({ open: false, user: null, config: null });
|
||||||
|
} finally {
|
||||||
|
setPortraitLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePortraitConfig = async () => {
|
||||||
|
const { user } = portraitModal;
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const values = await portraitForm.validateFields();
|
||||||
|
const limit = Number(values.privatePortraitAssetLimit ?? 0);
|
||||||
|
setPortraitSaving(true);
|
||||||
|
const config = await adminUpdatePrivatePortraitConfig(user.id, limit);
|
||||||
|
message.success(limit > 0 ? `私域人像素材库已开启,限制 ${limit} 个` : '私域人像素材库已关闭');
|
||||||
|
setPortraitModal({ open: false, user: null, config });
|
||||||
|
portraitForm.resetFields();
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e?.message || '保存私域人像素材库配置失败');
|
||||||
|
} finally {
|
||||||
|
setPortraitSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTeamModal = (user: AdminUser) => {
|
||||||
|
teamForm.setFieldsValue({ teamId: user.teamId || '' });
|
||||||
|
setTeamModal({ open: true, user });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveTeam = async () => {
|
||||||
|
const { user } = teamModal;
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const values = await teamForm.validateFields();
|
||||||
|
setTeamSaving(true);
|
||||||
|
await updateUserTeam(user.id, values.teamId || null);
|
||||||
|
message.success('用户团队已更新');
|
||||||
|
setTeamModal({ open: false, user: null });
|
||||||
|
teamForm.resetFields();
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.errorFields) return;
|
||||||
|
message.error(e?.message || '保存团队失败');
|
||||||
|
} finally {
|
||||||
|
setTeamSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group');
|
const menuGroups = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) === 'group');
|
||||||
const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
|
const menuPages = allMenus.filter((m: any) => (m.menu_type ?? m.menuType) !== 'group');
|
||||||
const childMap: Record<string, any[]> = {};
|
const childMap: Record<string, any[]> = {};
|
||||||
@@ -176,7 +370,26 @@ const AdminUsers: React.FC = () => {
|
|||||||
} catch { /* validation */ }
|
} catch { /* validation */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredUsers = users.filter(u => u.userType === activeTab);
|
const handleUpdateFrontendKind = async (user: AdminUser, kind: 'internal' | 'external') => {
|
||||||
|
try {
|
||||||
|
await updateFrontendUserKind(user.id, kind);
|
||||||
|
message.success(kind === 'internal' ? '已设为前台内部用户' : '已设为前台外部用户');
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '设置失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleAdminStatus = async (user: AdminUser) => {
|
||||||
|
try {
|
||||||
|
await updateUserAdminStatus(user.id, !user.isAdmin);
|
||||||
|
message.success(user.isAdmin ? '已取消超级管理员' : '已设为超级管理员');
|
||||||
|
load();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '设置失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isAdminTab = activeTab === 'admin';
|
const isAdminTab = activeTab === 'admin';
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
@@ -195,7 +408,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 600 }}>
|
<div style={{ fontWeight: 600 }}>
|
||||||
{r.username}
|
{r.username}
|
||||||
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}>管理员</Tag>}
|
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}>超级管理员</Tag>}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
|
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -214,6 +427,51 @@ const AdminUsers: React.FC = () => {
|
|||||||
title: '手机号', dataIndex: 'phone', width: 130,
|
title: '手机号', dataIndex: 'phone', width: 130,
|
||||||
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
|
render: (v: string) => <Typography.Text type="secondary">{v || '-'}</Typography.Text>,
|
||||||
},
|
},
|
||||||
|
...(!isAdminTab ? [{
|
||||||
|
title: '前台归类', dataIndex: 'frontendUserKind', width: 110,
|
||||||
|
render: (v: string) => <Tag color={v === 'internal' ? 'geekblue' : 'default'}>{v === 'internal' ? '内部用户' : '外部用户'}</Tag>,
|
||||||
|
}] : []),
|
||||||
|
...(!isAdminTab ? [{
|
||||||
|
title: '团队', dataIndex: 'teamName', width: 140,
|
||||||
|
render: (v: string | null | undefined) => v ? <Tag color="blue">{v}</Tag> : <Typography.Text type="secondary">未分配</Typography.Text>,
|
||||||
|
}] : []),
|
||||||
|
...(!isAdminTab ? [{
|
||||||
|
title: '私域人像素材库', dataIndex: 'privatePortraitAssetLimit', width: 150,
|
||||||
|
render: (v: number) => {
|
||||||
|
const limit = Number(v || 0);
|
||||||
|
return limit > 0 ? <Tag color="purple">开启:{limit} 个</Tag> : <Tag>未开启</Tag>;
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
|
...(!isAdminTab ? [{
|
||||||
|
title: '资源容量', dataIndex: 'resourceCapacity', width: 230,
|
||||||
|
render: (capacity: ResourceCapacityUsage | null | undefined) => {
|
||||||
|
const usedText = formatBytes(capacity?.usedBytes || 0);
|
||||||
|
if (!capacity || !capacity.enabled) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space size={6} style={{ marginBottom: 4 }}>
|
||||||
|
<Tag>未开启</Tag>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>已用 {usedText}</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Progress percent={0} size="small" showInfo={false} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const percent = Math.min(Number(capacity.usagePercent || 0), 100);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space size={6} style={{ marginBottom: 4 }}>
|
||||||
|
<Tag color={capacity.source === 'user' ? 'blue' : 'purple'}>{capacitySourceLabel(capacity)}</Tag>
|
||||||
|
{capacity.exceeded && <Tag color="red">已超额</Tag>}
|
||||||
|
</Space>
|
||||||
|
<Progress percent={percent} size="small" status={capacity.exceeded ? 'exception' : 'active'} />
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{usedText} / {formatBytes(capacity.totalBytes)},可用 {formatBytes(capacity.availableBytes)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
{
|
{
|
||||||
title: '状态', dataIndex: 'isActive', width: 80,
|
title: '状态', dataIndex: 'isActive', width: 80,
|
||||||
render: (v: boolean) => (
|
render: (v: boolean) => (
|
||||||
@@ -229,15 +487,50 @@ const AdminUsers: React.FC = () => {
|
|||||||
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
render: (v: string) => <Typography.Text type="secondary" style={{ fontSize: 12 }}>{formatDate(v)}</Typography.Text>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作', key: 'action', width: 320, fixed: 'right' as const,
|
title: '操作', key: 'action', width: 560, fixed: 'right' as const,
|
||||||
render: (_: any, r: AdminUser) => (
|
render: (_: any, r: AdminUser) => (
|
||||||
<Space size={4}>
|
<Space size={4} wrap>
|
||||||
{!isAdminTab && (
|
{!isAdminTab && (
|
||||||
<Button type="link" size="small" icon={<WalletOutlined />}
|
<Button type="link" size="small" icon={<WalletOutlined />}
|
||||||
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
|
onClick={() => { setCreditModal({ open: true, user: r }); form.resetFields(); }}>
|
||||||
调整积分
|
调整积分
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{!isAdminTab && (
|
||||||
|
<Button type="link" size="small" icon={<DatabaseOutlined />}
|
||||||
|
onClick={() => openCapacityModal(r)}>
|
||||||
|
容量设置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isAdminTab && (
|
||||||
|
<Button type="link" size="small" icon={<PictureOutlined />}
|
||||||
|
onClick={() => openPortraitModal(r)}>
|
||||||
|
私域人像素材
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isAdminTab && (
|
||||||
|
<Button type="link" size="small" icon={<TeamOutlined />}
|
||||||
|
onClick={() => openTeamModal(r)}>
|
||||||
|
团队设置
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isAdminTab && r.frontendUserKind !== 'internal' && (
|
||||||
|
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'internal')}>设为内部</Button>
|
||||||
|
)}
|
||||||
|
{!isAdminTab && r.frontendUserKind === 'internal' && (
|
||||||
|
<Button type="link" size="small" onClick={() => handleUpdateFrontendKind(r, 'external')}>取消内部</Button>
|
||||||
|
)}
|
||||||
|
{isAdminTab && (
|
||||||
|
<Popconfirm
|
||||||
|
title={r.isAdmin ? '确定取消该用户的超级管理员权限?' : '确定将该用户设为超级管理员?'}
|
||||||
|
onConfirm={() => handleToggleAdminStatus(r)}
|
||||||
|
>
|
||||||
|
<Button type="link" size="small" style={{ color: r.isAdmin ? '#f59e0b' : '#6366f1' }}
|
||||||
|
icon={<SecurityScanOutlined />}>
|
||||||
|
{r.isAdmin ? '取消超级管理员' : '设为超级管理员'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
<Button type="link" size="small" icon={<MenuOutlined />}
|
<Button type="link" size="small" icon={<MenuOutlined />}
|
||||||
onClick={() => openMenuModal(r)}>
|
onClick={() => openMenuModal(r)}>
|
||||||
菜单权限
|
菜单权限
|
||||||
@@ -262,7 +555,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
{/* <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
width: 44, height: 44, borderRadius: 10,
|
width: 44, height: 44, borderRadius: 10,
|
||||||
@@ -314,10 +607,9 @@ const AdminUsers: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Card>
|
</Card> */}
|
||||||
|
|
||||||
<Card bordered={false} style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
|
||||||
{/* Search bar */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
<Input
|
<Input
|
||||||
@@ -329,6 +621,30 @@ const AdminUsers: React.FC = () => {
|
|||||||
style={{ width: 280, borderRadius: 8 }}
|
style={{ width: 280, borderRadius: 8 }}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
|
{!isAdminTab && (
|
||||||
|
<Select
|
||||||
|
value={frontendKindFilter}
|
||||||
|
onChange={(v) => { setPage(1); setFrontendKindFilter(v); }}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部前台用户' },
|
||||||
|
{ value: 'internal', label: '内部用户' },
|
||||||
|
{ value: 'external', label: '外部用户' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isAdminTab && (
|
||||||
|
<Select
|
||||||
|
value={teamFilter}
|
||||||
|
onChange={(v) => { setPage(1); setTeamFilter(v); }}
|
||||||
|
style={{ width: 170 }}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部团队' },
|
||||||
|
{ value: TEAM_UNASSIGNED_VALUE, label: '未分配团队' },
|
||||||
|
...teamOptions.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>搜索</Button>
|
<Button type="primary" onClick={handleSearch} style={{ borderRadius: 8 }}>搜索</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button type="primary" icon={<PlusOutlined />}
|
<Button type="primary" icon={<PlusOutlined />}
|
||||||
@@ -340,7 +656,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={setActiveTab}
|
onChange={(key) => { setActiveTab(key); setFrontendKindFilter(''); setTeamFilter(''); setPage(1); }}
|
||||||
items={[
|
items={[
|
||||||
{ key: 'frontend', label: '前台用户' },
|
{ key: 'frontend', label: '前台用户' },
|
||||||
{ key: 'admin', label: '后台用户' },
|
{ key: 'admin', label: '后台用户' },
|
||||||
@@ -349,7 +665,7 @@ const AdminUsers: React.FC = () => {
|
|||||||
|
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={filteredUsers}
|
dataSource={users}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={{
|
pagination={{
|
||||||
@@ -360,11 +676,10 @@ const AdminUsers: React.FC = () => {
|
|||||||
showSizeChanger: true,
|
showSizeChanger: true,
|
||||||
showTotal: (t) => `共 ${t} 个用户`,
|
showTotal: (t) => `共 ${t} 个用户`,
|
||||||
}}
|
}}
|
||||||
scroll={{ x: 1000 }}
|
scroll={{ x: 1280 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Adjust Credits Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
title={<Space><WalletOutlined />调整积分 - {creditModal.user?.username}</Space>}
|
title={<Space><WalletOutlined />调整积分 - {creditModal.user?.username}</Space>}
|
||||||
open={creditModal.open}
|
open={creditModal.open}
|
||||||
@@ -395,7 +710,133 @@ const AdminUsers: React.FC = () => {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Create User Modal */}
|
<Modal
|
||||||
|
title={<Space><TeamOutlined />团队设置 - {teamModal.user?.username}</Space>}
|
||||||
|
open={teamModal.open}
|
||||||
|
confirmLoading={teamSaving}
|
||||||
|
onOk={handleSaveTeam}
|
||||||
|
onCancel={() => { setTeamModal({ open: false, user: null }); teamForm.resetFields(); }}
|
||||||
|
okText="保存" cancelText="取消" width={460}
|
||||||
|
>
|
||||||
|
<Form form={teamForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
|
<Form.Item name="teamId" label="所属团队" extra="团队归属独立于前台内部/外部归类,不会改变用户内外部设置。">
|
||||||
|
<Select
|
||||||
|
size="large"
|
||||||
|
allowClear
|
||||||
|
placeholder="未分配团队"
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '未分配团队' },
|
||||||
|
...teamOptions
|
||||||
|
.filter(t => t.status === 'active' || t.id === teamModal.user?.teamId)
|
||||||
|
.map(t => ({ value: t.id, label: t.status === 'disabled' ? `${t.name}(禁用)` : t.name })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<Space><DatabaseOutlined />容量设置 - {capacityModal.user?.username}</Space>}
|
||||||
|
open={capacityModal.open}
|
||||||
|
confirmLoading={capacitySaving}
|
||||||
|
onOk={handleSaveCapacity}
|
||||||
|
onCancel={() => { setCapacityModal({ open: false, user: null, detail: null }); capacityForm.resetFields(); }}
|
||||||
|
okText="保存个人配置" cancelText="取消" width={560}
|
||||||
|
>
|
||||||
|
<Card loading={capacityLoading} variant="outlined" style={{ marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
当前生效来源:{capacitySourceLabel(capacityModal.detail?.effective)}
|
||||||
|
{capacityModal.detail?.effective.hasUserConfig ? '(已单独设置)' : '(未单独设置)'}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text>
|
||||||
|
已用:{formatBytes(capacityModal.detail?.effective.usedBytes)};
|
||||||
|
总量:{formatBytes(capacityModal.detail?.effective.totalBytes)};
|
||||||
|
可用:{formatBytes(capacityModal.detail?.effective.availableBytes)}
|
||||||
|
</Typography.Text>
|
||||||
|
{capacityModal.detail?.effective.enabled && (
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(Number(capacityModal.detail.effective.usagePercent || 0), 100)}
|
||||||
|
status={capacityModal.detail.effective.exceeded ? 'exception' : 'active'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
<Form form={capacityForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="enabled"
|
||||||
|
label="启用个人容量限制"
|
||||||
|
valuePropName="checked"
|
||||||
|
extra="保存后会生成用户个人配置,优先级高于全局;关闭并保存表示该用户个人明确不限制,不再走全局。"
|
||||||
|
>
|
||||||
|
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
|
||||||
|
<Form.Item
|
||||||
|
name="limitValue"
|
||||||
|
label="容量数值"
|
||||||
|
rules={[{ required: true, message: '请输入容量数值' }]}
|
||||||
|
extra="最小为1,不能为负数,最多支持3位小数。"
|
||||||
|
>
|
||||||
|
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="limitUnit"
|
||||||
|
label="容量单位"
|
||||||
|
rules={[{ required: true, message: '请选择容量单位' }]}
|
||||||
|
extra="MB / GB / TB 固定枚举"
|
||||||
|
>
|
||||||
|
<Select size="large" options={capacityUnitOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 }}>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
恢复全局设置会删除该用户个人配置,让用户重新按全局规则判断。
|
||||||
|
</Typography.Text>
|
||||||
|
<Popconfirm
|
||||||
|
title="确定恢复为全局容量配置?"
|
||||||
|
onConfirm={handleRestoreGlobalCapacity}
|
||||||
|
disabled={!capacityModal.detail?.hasUserConfig}
|
||||||
|
>
|
||||||
|
<Button disabled={!capacityModal.detail?.hasUserConfig} loading={capacitySaving}>
|
||||||
|
恢复全局设置
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<Space><PictureOutlined />私域人像素材库设置 - {portraitModal.user?.username}</Space>}
|
||||||
|
open={portraitModal.open}
|
||||||
|
confirmLoading={portraitSaving}
|
||||||
|
onOk={handleSavePortraitConfig}
|
||||||
|
onCancel={() => { setPortraitModal({ open: false, user: null, config: null }); portraitForm.resetFields(); }}
|
||||||
|
okText="保存" cancelText="取消" width={520}
|
||||||
|
>
|
||||||
|
<Card loading={portraitLoading} variant="outlined" style={{ marginBottom: 16 }}>
|
||||||
|
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||||
|
<Typography.Text>
|
||||||
|
当前状态:{portraitModal.config?.enabled ? <Tag color="purple">已开启</Tag> : <Tag>未开启</Tag>}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
已用:{portraitModal.config?.usedAssetCount ?? '-'} 个;
|
||||||
|
剩余:{portraitModal.config?.enabled ? portraitModal.config.remainingAssetCount : 0} 个
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
<Form form={portraitForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="privatePortraitAssetLimit"
|
||||||
|
label="私域人像素材总量上限"
|
||||||
|
extra="0 表示关闭私域人像素材库;大于 0 表示开启,并限制该用户所有私域人像素材总量。"
|
||||||
|
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={<Space><UserOutlined />创建用户</Space>}
|
title={<Space><UserOutlined />创建用户</Space>}
|
||||||
open={createModal}
|
open={createModal}
|
||||||
@@ -412,6 +853,14 @@ const AdminUsers: React.FC = () => {
|
|||||||
{ value: 'admin', label: '后台管理员' },
|
{ value: 'admin', label: '后台管理员' },
|
||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
{createType === 'frontend' && (
|
||||||
|
<Form.Item name="frontend_user_kind" label="前台归类" initialValue="external">
|
||||||
|
<Select size="large" options={[
|
||||||
|
{ value: 'external', label: '外部用户' },
|
||||||
|
{ value: 'internal', label: '内部用户' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
{createType === 'frontend' ? (
|
{createType === 'frontend' ? (
|
||||||
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
<Form.Item name="phone" label="手机号" rules={[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]}>
|
||||||
<Input placeholder="请输入手机号" maxLength={11} size="large" />
|
<Input placeholder="请输入手机号" maxLength={11} size="large" />
|
||||||
@@ -429,13 +878,31 @@ const AdminUsers: React.FC = () => {
|
|||||||
<InputNumber min={0} style={{ width: '100%' }} size="large" />
|
<InputNumber min={0} style={{ width: '100%' }} size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
|
{createType === 'frontend' && (
|
||||||
|
<Form.Item
|
||||||
|
name="private_portrait_asset_limit"
|
||||||
|
label="私域人像素材总量上限"
|
||||||
|
initialValue={50}
|
||||||
|
extra="0 表示关闭私域人像素材库;大于 0 表示开启并限制该用户所有私域人像素材总量。"
|
||||||
|
rules={[{ required: true, message: '请输入私域人像素材总量上限' }]}
|
||||||
|
>
|
||||||
|
<InputNumber min={0} max={9999} precision={0} style={{ width: '100%' }} size="large" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{createType === 'admin' && (
|
||||||
|
<Form.Item name="is_admin" label="超级管理员" valuePropName="checked" initialValue={false}>
|
||||||
|
<Switch
|
||||||
|
checkedChildren="是"
|
||||||
|
unCheckedChildren="否"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
<Form.Item name="email" label="邮箱">
|
<Form.Item name="email" label="邮箱">
|
||||||
<Input placeholder="选填" size="large" />
|
<Input placeholder="选填" size="large" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Menu Permission Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
title={<Space><MenuOutlined />菜单权限 - {menuModal.user?.username} ({menuModal.user?.userType === 'admin' ? '后台菜单' : '前台菜单'})</Space>}
|
title={<Space><MenuOutlined />菜单权限 - {menuModal.user?.username} ({menuModal.user?.userType === 'admin' ? '后台菜单' : '前台菜单'})</Space>}
|
||||||
open={menuModal.open}
|
open={menuModal.open}
|
||||||
@@ -451,14 +918,12 @@ const AdminUsers: React.FC = () => {
|
|||||||
<div style={{ padding: '12px 16px', background: '#f8fafc', borderRadius: 8, maxHeight: 400, overflow: 'auto' }}>
|
<div style={{ padding: '12px 16px', background: '#f8fafc', borderRadius: 8, maxHeight: 400, overflow: 'auto' }}>
|
||||||
<Checkbox.Group value={checkedMenus} onChange={(vals) => setCheckedMenus(vals as string[])}>
|
<Checkbox.Group value={checkedMenus} onChange={(vals) => setCheckedMenus(vals as string[])}>
|
||||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
{/* Top-level pages */}
|
|
||||||
{topLevelPages.map((m: any) => (
|
{topLevelPages.map((m: any) => (
|
||||||
<Checkbox key={m.path} value={m.path} style={{ width: '100%' }}>
|
<Checkbox key={m.path} value={m.path} style={{ width: '100%' }}>
|
||||||
{m.label}
|
{m.label}
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{m.path}</Typography.Text>
|
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>{m.path}</Typography.Text>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
))}
|
))}
|
||||||
{/* Groups with their children */}
|
|
||||||
{menuGroups.map((g: any) => {
|
{menuGroups.map((g: any) => {
|
||||||
const children = childMap[g.id] || [];
|
const children = childMap[g.id] || [];
|
||||||
if (children.length === 0) return null;
|
if (children.length === 0) return null;
|
||||||
@@ -483,7 +948,6 @@ const AdminUsers: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Reset Password Modal */}
|
|
||||||
<Modal
|
<Modal
|
||||||
title={<Space><LockOutlined />重置密码 - {resetPwdModal.user?.username}</Space>}
|
title={<Space><LockOutlined />重置密码 - {resetPwdModal.user?.username}</Space>}
|
||||||
open={resetPwdModal.open}
|
open={resetPwdModal.open}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ interface VideoEngine {
|
|||||||
supportedRatios: string[];
|
supportedRatios: string[];
|
||||||
supportedResolutions: string[];
|
supportedResolutions: string[];
|
||||||
supportedDurations: number[];
|
supportedDurations: number[];
|
||||||
|
maxDuration: number;
|
||||||
|
maxImageCount: number;
|
||||||
|
maxVideoCount: number;
|
||||||
|
maxAudioCount: number;
|
||||||
|
supportsFirstLastFrame: boolean;
|
||||||
|
supportsUniversalReference: boolean;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
}
|
}
|
||||||
@@ -66,6 +72,12 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
supported_ratios: JSON.stringify(values.supportedRatios || []),
|
supported_ratios: JSON.stringify(values.supportedRatios || []),
|
||||||
supported_resolutions: JSON.stringify(values.supportedResolutions || []),
|
supported_resolutions: JSON.stringify(values.supportedResolutions || []),
|
||||||
supported_durations: JSON.stringify(values.supportedDurations || []),
|
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,
|
is_active: values.isActive ?? true,
|
||||||
priority: values.priority ?? 0,
|
priority: values.priority ?? 0,
|
||||||
};
|
};
|
||||||
@@ -102,11 +114,17 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue({
|
form.setFieldsValue({
|
||||||
isActive: true, priority: 0,
|
isActive: true, priority: 0,
|
||||||
supportedRatios: ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'],
|
maxDuration: 30,
|
||||||
supportedResolutions: ['480p', '720p', '1080p'],
|
maxImageCount: 2,
|
||||||
supportedDurations: [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
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 +160,26 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
title: '支持时长', dataIndex: 'supportedDurations', width: 120,
|
title: '支持时长', dataIndex: 'supportedDurations', width: 120,
|
||||||
render: (d: number[]) => <Tag color="orange">{d?.length ? `${Math.min(...d)}-${Math.max(...d)}s` : '-'}</Tag>,
|
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: 'isActive', width: 80,
|
title: '状态', dataIndex: 'isActive', width: 80,
|
||||||
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
|
||||||
@@ -161,7 +199,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
<Space>
|
<Space>
|
||||||
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
<PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
|
||||||
@@ -179,7 +217,7 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: 900 }}
|
scroll={{ x: 1200 }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -235,7 +273,50 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 16 }}>
|
<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 表示不支持音频参考,最大 3 段"
|
||||||
|
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={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
|
||||||
<Select size="large" options={[
|
<Select size="large" options={[
|
||||||
{ value: 0, label: '0 (默认)' },
|
{ value: 0, label: '0 (默认)' },
|
||||||
{ value: 1, label: '1' },
|
{ value: 1, label: '1' },
|
||||||
@@ -245,9 +326,6 @@ const AdminVideoEngines: React.FC = () => {
|
|||||||
{ value: 10, label: '10 (最高)' },
|
{ value: 10, label: '10 (最高)' },
|
||||||
]} />
|
]} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="isActive" label="启用状态" valuePropName="checked" style={{ paddingTop: 30 }}>
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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;
|
||||||
+106
-249
@@ -1,171 +1,21 @@
|
|||||||
import React from 'react';
|
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 type { ColumnsType } from 'antd/es/table';
|
||||||
import { JsonBlock } from './JsonCollapse';
|
import { JsonBlock } from './JsonCollapse';
|
||||||
|
import type {
|
||||||
const FIELD_LABELS: Record<string, string> = {
|
VideoPromptSchemaFieldConfig,
|
||||||
schema_version: 'Schema 版本',
|
VideoPromptSchemaSectionConfig,
|
||||||
generation_type: '生成类型',
|
} from '../../../utils/videoPromptSchema';
|
||||||
business_type: '业务类型',
|
import {
|
||||||
usage: '用途',
|
hasSchemaConfigSnapshot,
|
||||||
product_type: '产品类型',
|
inferSchemaSections,
|
||||||
target_audience: '目标受众',
|
normalizeSchemaSections,
|
||||||
style: '风格',
|
stringifySchemaValue,
|
||||||
aspect_ratio: '画面比例',
|
} from '../../../utils/videoPromptSchema';
|
||||||
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: '最终提示词',
|
|
||||||
};
|
|
||||||
|
|
||||||
const isRecord = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
|
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 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 => {
|
const renderValue = (value: unknown): React.ReactNode => {
|
||||||
if (value === null || value === undefined || value === '') return <Typography.Text type="secondary">-</Typography.Text>;
|
if (value === null || value === undefined || value === '') return <Typography.Text type="secondary">-</Typography.Text>;
|
||||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||||
@@ -176,64 +26,55 @@ const renderValue = (value: unknown): React.ReactNode => {
|
|||||||
return <JsonBlock value={value} maxHeight={220} />;
|
return <JsonBlock value={value} maxHeight={220} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getArrayMode = (key: string): 'card' | 'table' => {
|
const tagOfEditable = (editable?: boolean) => editable ? <Tag color="processing">可编辑</Tag> : <Tag color="default">只读</Tag>;
|
||||||
const lower = key.toLowerCase();
|
|
||||||
if (
|
const readSectionValue = (schema: Record<string, any>, key: string): unknown => schema[key];
|
||||||
lower.includes('action') ||
|
|
||||||
lower.includes('motion') ||
|
const renderObjectSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||||
lower.includes('camera') ||
|
const value = readSectionValue(schema, section.key);
|
||||||
lower.includes('shot') ||
|
if (!isRecord(value)) return <JsonBlock value={value ?? {}} maxHeight={220} />;
|
||||||
lower.includes('lens') ||
|
const fields = section.children.filter((field) => field.enabled);
|
||||||
lower.includes('flow') ||
|
if (!fields.length) return <JsonBlock value={value} maxHeight={220} />;
|
||||||
lower.includes('动作') ||
|
|
||||||
lower.includes('镜头')
|
return (
|
||||||
) {
|
<Descriptions size="small" column={2} bordered>
|
||||||
return 'card';
|
{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}>
|
||||||
return 'table';
|
{renderValue(value[field.key])}
|
||||||
|
</Descriptions.Item>
|
||||||
|
))}
|
||||||
|
</Descriptions>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderCardArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => (
|
const buildFlowFields = (section: VideoPromptSchemaSectionConfig, rows: Record<string, unknown>[]): VideoPromptSchemaFieldConfig[] => {
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
const configured = section.itemFields.filter((field) => field.enabled);
|
||||||
{items.map((item, index) => {
|
if (configured.length) {
|
||||||
const displayItem = normalizeRecordForSection(sectionKey, item);
|
return [{ key: '时间段', label: '时间段', enabled: true, editable: false }, ...configured];
|
||||||
return (
|
}
|
||||||
<Card
|
const inferred = Array.from(new Set(rows.flatMap((row) => Object.keys(row))));
|
||||||
key={`${sectionKey}-${index}`}
|
return inferred.map((key) => ({ key, label: key, enabled: true, editable: false }));
|
||||||
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 renderTableArray = (sectionKey: string, items: Record<string, unknown>[]): React.ReactNode => {
|
const renderFlowSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||||
const displayItems = items.map(item => normalizeRecordForSection(sectionKey, item));
|
const value = readSectionValue(schema, section.key);
|
||||||
const fields = Array.from(new Set(displayItems.flatMap(item => Object.keys(item))));
|
if (!Array.isArray(value) || !value.every(isRecord)) return <JsonBlock value={value ?? []} maxHeight={260} />;
|
||||||
const columns: ColumnsType<Record<string, unknown>> = fields.map(field => ({
|
const rows = value as Record<string, unknown>[];
|
||||||
title: labelOf(field),
|
const fields = buildFlowFields(section, rows);
|
||||||
dataIndex: field,
|
const columns: ColumnsType<Record<string, unknown>> = fields.map((field) => ({
|
||||||
key: field,
|
title: <Space size={4}>{field.label}{tagOfEditable(field.editable)}</Space>,
|
||||||
width: isLongText(displayItems.find(item => item[field])?.[field]) ? 280 : 160,
|
dataIndex: field.key,
|
||||||
render: (value: unknown) => renderValue(value),
|
key: field.key,
|
||||||
|
width: isLongText(rows.find((row) => row[field.key])?.[field.key]) ? 320 : 160,
|
||||||
|
render: (cell: unknown) => renderValue(cell),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
size="small"
|
size="small"
|
||||||
rowKey={(_, index) => `${sectionKey}-${index}`}
|
rowKey={(_, index) => `${section.key}-${index}`}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={displayItems}
|
dataSource={rows}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
scroll={{ x: Math.max(900, fields.length * 180) }}
|
scroll={{ x: Math.max(900, fields.length * 180) }}
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
@@ -241,59 +82,64 @@ const renderTableArray = (sectionKey: string, items: Record<string, unknown>[]):
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderArray = (sectionKey: string, value: unknown[]): React.ReactNode => {
|
const renderPrimitiveSection = (schema: Record<string, any>, section: VideoPromptSchemaSectionConfig): React.ReactNode => {
|
||||||
if (!value.length) return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />;
|
const value = readSectionValue(schema, section.key);
|
||||||
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);
|
|
||||||
return renderValue(value);
|
return renderValue(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface VideoPromptSchemaViewerProps {
|
interface VideoPromptSchemaViewerProps {
|
||||||
schema?: Record<string, any> | null;
|
schema?: Record<string, any> | null;
|
||||||
finalPrompt?: string | 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;
|
const hasSchema = !!schema && Object.keys(schema).length > 0;
|
||||||
if (!hasSchema && !finalPrompt) {
|
if (!hasSchema && !finalPrompt) {
|
||||||
return <Empty description="暂无视频提词 schema" />;
|
return <Empty description="暂无视频提词 schema" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaItems = hasSchema
|
const safeSchema = hasSchema ? schema as Record<string, any> : {};
|
||||||
? Object.entries(schema || {}).map(([key, value]) => ({
|
const hasSnapshot = hasSchemaConfigSnapshot(schemaConfigSnapshot);
|
||||||
key,
|
const sections = hasSnapshot ? normalizeSchemaSections(schemaConfigSnapshot) : inferSchemaSections(safeSchema);
|
||||||
label: labelOf(key),
|
const schemaItems = sections
|
||||||
children: renderSection(key, value),
|
.filter((section) => section.key in safeSchema)
|
||||||
}))
|
.map((section) => ({
|
||||||
: [];
|
key: section.key,
|
||||||
|
label: (
|
||||||
const defaultKeys = finalPrompt ? ['final_prompt'] : [];
|
<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 (
|
return (
|
||||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
<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 ? (
|
{finalPrompt ? (
|
||||||
<Card size="small" title="最终视频提示词">
|
<Card size="small" title="最终视频提示词">
|
||||||
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
|
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{finalPrompt}</Typography.Paragraph>
|
||||||
@@ -301,10 +147,21 @@ const VideoPromptSchemaViewer: React.FC<VideoPromptSchemaViewerProps> = ({ schem
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{schemaItems.length ? (
|
{schemaItems.length ? (
|
||||||
|
<Collapse size="small" items={schemaItems} />
|
||||||
|
) : hasSchema ? (
|
||||||
|
<Card size="small" title="视频提词 Schema">
|
||||||
|
<JsonBlock value={safeSchema} maxHeight={360} />
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{hasSchema ? (
|
||||||
<Collapse
|
<Collapse
|
||||||
size="small"
|
size="small"
|
||||||
defaultActiveKey={defaultKeys}
|
items={[{
|
||||||
items={schemaItems}
|
key: 'raw_schema',
|
||||||
|
label: '原始 JSON / 历史脏字段排查',
|
||||||
|
children: <JsonBlock value={safeSchema} maxHeight={420} />,
|
||||||
|
}]}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</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: '民众普康 AI',
|
||||||
|
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: '民众普康 AI',
|
||||||
|
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,183 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Col, 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: '民众普康 AI',
|
||||||
|
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>
|
||||||
|
<Input value={textWatermark.color} onChange={(e) => patchText({ color: e.target.value || '#ffffff' })} placeholder="#ffffff" />
|
||||||
|
</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;
|
||||||
@@ -7,6 +7,8 @@ export interface User {
|
|||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
userType: string;
|
userType: string;
|
||||||
allowedMenus?: string[] | null;
|
allowedMenus?: string[] | null;
|
||||||
|
resourceCapacity?: ResourceCapacityUsage | null;
|
||||||
|
privatePortraitAssetLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreditRecord {
|
export interface CreditRecord {
|
||||||
@@ -93,8 +95,91 @@ export interface LoginParams {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type ResourceCapacityUnit = 'MB' | 'GB' | 'TB';
|
||||||
|
export type ResourceCapacitySource = 'user' | 'global' | 'disabled';
|
||||||
|
|
||||||
|
export interface ResourceCapacityUsage {
|
||||||
|
enabled: boolean;
|
||||||
|
source: ResourceCapacitySource;
|
||||||
|
hasUserConfig: boolean;
|
||||||
|
usedBytes: number;
|
||||||
|
availableBytes: number | null;
|
||||||
|
totalBytes: number | null;
|
||||||
|
usagePercent: number | null;
|
||||||
|
exceeded: boolean;
|
||||||
|
limitValue: string | null;
|
||||||
|
limitUnit: ResourceCapacityUnit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceCapacityConfigOut {
|
||||||
|
enabled: boolean;
|
||||||
|
limitValue: string;
|
||||||
|
limitUnit: ResourceCapacityUnit;
|
||||||
|
limitBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceCapacityConfigPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
limitValue?: string | number | null;
|
||||||
|
limitUnit?: ResourceCapacityUnit | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUserResourceCapacityOut {
|
||||||
|
hasUserConfig: boolean;
|
||||||
|
userConfig: ResourceCapacityConfigOut | null;
|
||||||
|
globalConfig: ResourceCapacityConfigOut;
|
||||||
|
effective: ResourceCapacityUsage;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Admin Types ──────────────────────────────────────
|
// ── Admin Types ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
export type AdminTeamStatus = 'active' | 'disabled' | string;
|
||||||
|
|
||||||
|
export interface AdminTeam {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
status: AdminTeamStatus;
|
||||||
|
sortOrder: number;
|
||||||
|
memberCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
managerId?: string | null;
|
||||||
|
managerName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTeamOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
status: AdminTeamStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTeamListResponse {
|
||||||
|
items: AdminTeam[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTeamQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTeamPayload {
|
||||||
|
name: string;
|
||||||
|
code?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
sort_order: number;
|
||||||
|
generation_prompt?: string | null;
|
||||||
|
media_references?: HomeMaterialMediaReference[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminUser {
|
export interface AdminUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -104,9 +189,14 @@ export interface AdminUser {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
userType: string;
|
userType: string;
|
||||||
|
frontendUserKind: 'internal' | 'external';
|
||||||
|
teamId?: string | null;
|
||||||
|
teamName?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastLoginAt?: string;
|
lastLoginAt?: string;
|
||||||
allowedMenus?: string[] | null;
|
allowedMenus?: string[] | null;
|
||||||
|
resourceCapacity?: ResourceCapacityUsage | null;
|
||||||
|
privatePortraitAssetLimit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
@@ -203,6 +293,11 @@ export interface GenerationAiVideoEngine {
|
|||||||
supportedResolutions: string[];
|
supportedResolutions: string[];
|
||||||
supportedDurations: number[];
|
supportedDurations: number[];
|
||||||
maxDuration: number;
|
maxDuration: number;
|
||||||
|
maxImageCount?: number;
|
||||||
|
maxVideoCount?: number;
|
||||||
|
maxAudioCount?: number;
|
||||||
|
supportsFirstLastFrame?: boolean;
|
||||||
|
supportsUniversalReference?: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +320,11 @@ export interface GenerationAiEngineOption {
|
|||||||
supportedSizes?: Record<string, Record<string, string>>;
|
supportedSizes?: Record<string, Record<string, string>>;
|
||||||
defaultSize?: string;
|
defaultSize?: string;
|
||||||
maxDuration?: number;
|
maxDuration?: number;
|
||||||
|
maxImageCount?: number;
|
||||||
|
maxVideoCount?: number;
|
||||||
|
maxAudioCount?: number;
|
||||||
|
supportsFirstLastFrame?: boolean;
|
||||||
|
supportsUniversalReference?: boolean;
|
||||||
priority: number;
|
priority: number;
|
||||||
genType: GenerationAiGenType;
|
genType: GenerationAiGenType;
|
||||||
}
|
}
|
||||||
@@ -235,6 +335,7 @@ export interface AdminGenerationRecord {
|
|||||||
username: string;
|
username: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
industry?: string;
|
||||||
originalPrompt: string;
|
originalPrompt: string;
|
||||||
optimizedPrompt: string;
|
optimizedPrompt: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
@@ -278,6 +379,7 @@ export interface GenerationAIEngineSnapshot {
|
|||||||
name?: string;
|
name?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
modelName?: string;
|
modelName?: string;
|
||||||
|
model?: string;
|
||||||
supportedModels?: string[];
|
supportedModels?: string[];
|
||||||
defaultSize?: string;
|
defaultSize?: string;
|
||||||
selectedSize?: string;
|
selectedSize?: string;
|
||||||
@@ -340,6 +442,9 @@ export interface GenerationAITaskQueryParams {
|
|||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
|
engineId?: string;
|
||||||
|
createdStart?: string;
|
||||||
|
createdEnd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -403,6 +508,10 @@ export interface ReplicationVideoGenerationOut {
|
|||||||
promptSchema?: Record<string, any> | null;
|
promptSchema?: Record<string, any> | null;
|
||||||
finalPrompt?: string | null;
|
finalPrompt?: string | null;
|
||||||
promptParams?: Record<string, any> | null;
|
promptParams?: Record<string, any> | null;
|
||||||
|
schemaConfigSnapshot?: Record<string, any> | null;
|
||||||
|
schemaConfigSource?: string | null;
|
||||||
|
schemaConfigVersion?: string | null;
|
||||||
|
schemaConfigIsFallback?: boolean | null;
|
||||||
engineId?: string | null;
|
engineId?: string | null;
|
||||||
engineName?: string | null;
|
engineName?: string | null;
|
||||||
params?: Record<string, any> | null;
|
params?: Record<string, any> | null;
|
||||||
@@ -581,3 +690,606 @@ export interface AdminShotSegmentQueryParams {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Video Prompt Schema Config (Admin) ─────────────────────
|
||||||
|
|
||||||
|
export type VideoPromptSchemaNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'flow';
|
||||||
|
|
||||||
|
export interface VideoPromptSchemaNode {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: VideoPromptSchemaNodeType | string;
|
||||||
|
enabled: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
value?: any;
|
||||||
|
children?: VideoPromptSchemaNode[];
|
||||||
|
contentKey?: string;
|
||||||
|
contentAliases?: string[];
|
||||||
|
itemFields?: VideoPromptFlowItemField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptFlowItemField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
enabled: boolean;
|
||||||
|
editable: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
value?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptTimePlanSegment {
|
||||||
|
stage: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptTimePlanRule {
|
||||||
|
minDuration: number;
|
||||||
|
maxDuration: number;
|
||||||
|
ratios: number[];
|
||||||
|
segments: VideoPromptTimePlanSegment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptSchemaConfigData {
|
||||||
|
version: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sections: VideoPromptSchemaNode[];
|
||||||
|
timePlanRules: VideoPromptTimePlanRule[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptSchemaConfigOut {
|
||||||
|
id?: string | null;
|
||||||
|
key: string;
|
||||||
|
description?: string | null;
|
||||||
|
isEnabled: boolean;
|
||||||
|
usingDefault: boolean;
|
||||||
|
data: VideoPromptSchemaConfigData;
|
||||||
|
defaultData: VideoPromptSchemaConfigData;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptSchemaConfigSavePayload {
|
||||||
|
is_enabled: boolean;
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptSchemaExportOut {
|
||||||
|
version: string;
|
||||||
|
key: string;
|
||||||
|
isEnabled: boolean;
|
||||||
|
data: VideoPromptSchemaConfigData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptSchemaPreviewPayload {
|
||||||
|
is_enabled: boolean;
|
||||||
|
data: any;
|
||||||
|
duration: number;
|
||||||
|
aspect_ratio: string;
|
||||||
|
resolution: string;
|
||||||
|
frame_rate: string;
|
||||||
|
supported_durations?: number[];
|
||||||
|
supported_ratios?: string[];
|
||||||
|
supported_resolutions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoPromptSchemaPreviewOut {
|
||||||
|
schemaConfigSnapshot: Record<string, any>;
|
||||||
|
runtimeSchema: Record<string, any>;
|
||||||
|
timePlan: Record<string, any>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface AdminCreditRecordSummary {
|
||||||
|
totalRecharge: number;
|
||||||
|
totalConsume: number;
|
||||||
|
totalRefund: number;
|
||||||
|
transactionCount: number;
|
||||||
|
generationCount: number;
|
||||||
|
generationAttemptCount: number;
|
||||||
|
imageGenerationCount: number;
|
||||||
|
videoGenerationCount: number;
|
||||||
|
imageConsume: number;
|
||||||
|
videoConsume: number;
|
||||||
|
textConsume: number;
|
||||||
|
analysisConsume: number;
|
||||||
|
totalTokens: number;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCreditRecord {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
username?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
userType?: string;
|
||||||
|
userTypeLabel?: string;
|
||||||
|
frontendUserKind?: string;
|
||||||
|
frontendUserKindLabel?: string;
|
||||||
|
teamIdSnapshot?: string | null;
|
||||||
|
teamNameSnapshot?: string | null;
|
||||||
|
type: string;
|
||||||
|
recordType: string;
|
||||||
|
recordTypeLabel?: string;
|
||||||
|
amount: number;
|
||||||
|
balanceAfter: number;
|
||||||
|
description?: string;
|
||||||
|
relatedId?: string;
|
||||||
|
bizKey?: string;
|
||||||
|
refundForBizKey?: string;
|
||||||
|
ownerType?: string;
|
||||||
|
ownerId?: string;
|
||||||
|
ownerDeleted?: boolean;
|
||||||
|
ownerDeletedAt?: string;
|
||||||
|
attemptNo?: number;
|
||||||
|
chargeKind?: string;
|
||||||
|
chargeKindLabel?: string;
|
||||||
|
chargeAction?: string;
|
||||||
|
creditSubject?: string;
|
||||||
|
creditSubjectLabel?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
mediaTypeLabel?: string;
|
||||||
|
billingScene?: string;
|
||||||
|
billingSceneLabel?: string;
|
||||||
|
sourceModule?: string;
|
||||||
|
sourceModuleLabel?: string;
|
||||||
|
sourceProjectId?: string;
|
||||||
|
sourceStepId?: string;
|
||||||
|
sourceStepCode?: string;
|
||||||
|
sourceStepCodeLabel?: string;
|
||||||
|
tokenUsageId?: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
engineType?: string;
|
||||||
|
engineId?: string;
|
||||||
|
engineName?: string;
|
||||||
|
engineProvider?: string;
|
||||||
|
engineModelName?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCreditRecordListResponse {
|
||||||
|
items: AdminCreditRecord[];
|
||||||
|
total: number;
|
||||||
|
summary: AdminCreditRecordSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminCreditRecordQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
userType?: string;
|
||||||
|
frontendUserKind?: string;
|
||||||
|
teamId?: string;
|
||||||
|
recordType?: string;
|
||||||
|
type?: string;
|
||||||
|
creditSubject?: string;
|
||||||
|
mediaType?: string;
|
||||||
|
chargeKind?: string;
|
||||||
|
sourceModule?: string;
|
||||||
|
sourceStepCode?: string;
|
||||||
|
billingScene?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 首页素材行业装修 ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
export type AdminUploadScene =
|
||||||
|
| 'system_logo'
|
||||||
|
| 'system_pdf'
|
||||||
|
| 'open_type_thumb'
|
||||||
|
| 'home_material_reference'
|
||||||
|
| 'admin_common';
|
||||||
|
|
||||||
|
export type AdminUploadResourceType = 'image' | 'video' | 'audio' | 'pdf' | 'file';
|
||||||
|
|
||||||
|
export interface HomeMaterialMediaReference {
|
||||||
|
url: string;
|
||||||
|
type: 'image' | 'video' | 'audio';
|
||||||
|
name?: string | null;
|
||||||
|
duration?: number | null;
|
||||||
|
source?: string | null;
|
||||||
|
uploadResourceId?: string | null;
|
||||||
|
displayUrl?: string | null;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUploadMediaReference {
|
||||||
|
url: string;
|
||||||
|
type: AdminUploadResourceType;
|
||||||
|
name?: string | null;
|
||||||
|
duration?: number | null;
|
||||||
|
source: string;
|
||||||
|
uploadResourceId?: string | null;
|
||||||
|
displayUrl?: string | null;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminUploadFileResult {
|
||||||
|
resourceId: string;
|
||||||
|
scene: AdminUploadScene;
|
||||||
|
module: string;
|
||||||
|
resourceType: AdminUploadResourceType;
|
||||||
|
url: string;
|
||||||
|
fileName: string;
|
||||||
|
originalFileName?: string | null;
|
||||||
|
fileSizeBytes: number;
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
mediaReference?: AdminUploadMediaReference | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HomeMaterialMediaType = 'image' | 'video';
|
||||||
|
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
|
||||||
|
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
|
||||||
|
export type HomeMaterialWatermarkPosition =
|
||||||
|
| 'top_left'
|
||||||
|
| 'top_center'
|
||||||
|
| 'top_right'
|
||||||
|
| 'middle_left'
|
||||||
|
| 'center'
|
||||||
|
| 'middle_right'
|
||||||
|
| 'bottom_left'
|
||||||
|
| 'bottom_center'
|
||||||
|
| 'bottom_right'
|
||||||
|
| 'custom';
|
||||||
|
export type HomeMaterialWatermarkSizeMode = 'ratio' | 'px';
|
||||||
|
export type HomeMaterialPublicResponseMode = 'grouped' | 'flat';
|
||||||
|
|
||||||
|
export interface HomeMaterialConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
showOriginalInAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialCategory {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
assetCount: number;
|
||||||
|
imageCount: number;
|
||||||
|
videoCount: number;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialCategoryPayload {
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
description?: string | null;
|
||||||
|
icon?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialWatermark {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fileUrl: string;
|
||||||
|
fileName?: string | null;
|
||||||
|
fileSizeBytes: number;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
isDefault: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialWatermarkPayload {
|
||||||
|
name: string;
|
||||||
|
is_default: boolean;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialTextWatermarkConfig {
|
||||||
|
text: string;
|
||||||
|
opacityLevel: number;
|
||||||
|
opacity?: number;
|
||||||
|
fontSizePx: number;
|
||||||
|
color: string;
|
||||||
|
rotateDeg: number;
|
||||||
|
gapX: number;
|
||||||
|
gapY: number;
|
||||||
|
staggered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialTextWatermarkPayload {
|
||||||
|
text: string;
|
||||||
|
opacity_level: number;
|
||||||
|
font_size_px: number;
|
||||||
|
color: string;
|
||||||
|
rotate_deg: number;
|
||||||
|
gap_x: number;
|
||||||
|
gap_y: number;
|
||||||
|
staggered: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialWatermarkConfig {
|
||||||
|
watermarkType: HomeMaterialWatermarkType;
|
||||||
|
watermarkId?: string | null;
|
||||||
|
opacityLevel: number;
|
||||||
|
opacity?: number;
|
||||||
|
position: HomeMaterialWatermarkPosition;
|
||||||
|
customXRatio?: number | null;
|
||||||
|
customYRatio?: number | null;
|
||||||
|
sizeMode: HomeMaterialWatermarkSizeMode;
|
||||||
|
widthRatio?: number | null;
|
||||||
|
widthPx?: number | null;
|
||||||
|
marginX: number;
|
||||||
|
marginY: number;
|
||||||
|
textWatermark?: HomeMaterialTextWatermarkConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialAsset {
|
||||||
|
id: string;
|
||||||
|
categoryId: string;
|
||||||
|
categoryName?: string | null;
|
||||||
|
categoryKey?: string | null;
|
||||||
|
title?: string | null;
|
||||||
|
mediaType: HomeMaterialMediaType;
|
||||||
|
status: HomeMaterialAssetStatus;
|
||||||
|
originalUrl?: string | null;
|
||||||
|
watermarkedUrl?: string | null;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
watermarkId?: string | null;
|
||||||
|
watermarkName?: string | null;
|
||||||
|
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
|
width?: number | null;
|
||||||
|
height?: number | null;
|
||||||
|
durationSeconds?: number | string | null;
|
||||||
|
fileSizeBytes: number;
|
||||||
|
watermarkedFileSizeBytes?: number | null;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
processedAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialAssetUpdatePayload {
|
||||||
|
category_id: string;
|
||||||
|
title?: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
generation_prompt?: string | null;
|
||||||
|
media_references?: HomeMaterialMediaReference[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialAssetStatusOut {
|
||||||
|
id: string;
|
||||||
|
status: HomeMaterialAssetStatus;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
originalUrl?: string | null;
|
||||||
|
watermarkedUrl?: string | null;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
processedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialUploadResult {
|
||||||
|
id: string;
|
||||||
|
categoryId: string;
|
||||||
|
title?: string | null;
|
||||||
|
mediaType: HomeMaterialMediaType;
|
||||||
|
status: HomeMaterialAssetStatus;
|
||||||
|
originalUrl?: string | null;
|
||||||
|
watermarkedUrl?: string | null;
|
||||||
|
coverUrl?: string | null;
|
||||||
|
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialListResponse<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialCategoryQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialWatermarkQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialAssetQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
categoryId?: string;
|
||||||
|
mediaType?: HomeMaterialMediaType;
|
||||||
|
status?: HomeMaterialAssetStatus;
|
||||||
|
isActive?: boolean;
|
||||||
|
keyword?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialUploadAssetParams {
|
||||||
|
categoryId: string;
|
||||||
|
file: File;
|
||||||
|
mediaType: HomeMaterialMediaType;
|
||||||
|
title?: string | null;
|
||||||
|
generationPrompt?: string | null;
|
||||||
|
mediaReferences?: HomeMaterialMediaReference[] | null;
|
||||||
|
watermarkType: HomeMaterialWatermarkType;
|
||||||
|
watermarkId?: string | null;
|
||||||
|
watermarkFile?: File | null;
|
||||||
|
opacityLevel: number;
|
||||||
|
position: HomeMaterialWatermarkPosition;
|
||||||
|
customXRatio?: number | null;
|
||||||
|
customYRatio?: number | null;
|
||||||
|
sizeMode: HomeMaterialWatermarkSizeMode;
|
||||||
|
widthRatio?: number | null;
|
||||||
|
widthPx?: number | null;
|
||||||
|
marginX: number;
|
||||||
|
marginY: number;
|
||||||
|
textWatermark?: HomeMaterialTextWatermarkConfig | null;
|
||||||
|
isActive: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
wait?: boolean;
|
||||||
|
waitTimeoutSeconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialRegeneratePayload {
|
||||||
|
watermark_type: HomeMaterialWatermarkType;
|
||||||
|
watermark_id?: string | null;
|
||||||
|
opacity_level: number;
|
||||||
|
position: HomeMaterialWatermarkPosition;
|
||||||
|
custom_x_ratio?: number | null;
|
||||||
|
custom_y_ratio?: number | null;
|
||||||
|
size_mode: HomeMaterialWatermarkSizeMode;
|
||||||
|
width_ratio?: number | null;
|
||||||
|
width_px?: number | null;
|
||||||
|
margin_x: number;
|
||||||
|
margin_y: number;
|
||||||
|
text_watermark?: HomeMaterialTextWatermarkPayload | null;
|
||||||
|
wait?: boolean;
|
||||||
|
wait_timeout_seconds?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialTextWatermarkPreviewRequest {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
text_watermark: HomeMaterialTextWatermarkPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeMaterialTextWatermarkPreviewResponse {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
previewLayerDataUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface PrivatePortraitConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
assetLimit: number;
|
||||||
|
usedAssetCount: number;
|
||||||
|
remainingAssetCount: number;
|
||||||
|
supportedAssetTypes?: string[];
|
||||||
|
unsupportedAssetTypes?: string[];
|
||||||
|
imageLimit?: number;
|
||||||
|
usedImageCount?: number;
|
||||||
|
remainingImageCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivatePortraitProject {
|
||||||
|
id: string;
|
||||||
|
userId?: string | null;
|
||||||
|
libraryType: string;
|
||||||
|
name: string;
|
||||||
|
nameSlug?: string | null;
|
||||||
|
remoteProjectName?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
status: string;
|
||||||
|
assetGroupCount: number;
|
||||||
|
assetCount: number;
|
||||||
|
imageAssetCount?: number;
|
||||||
|
videoAssetCount?: number;
|
||||||
|
activeAssetCount: number;
|
||||||
|
activeImageAssetCount?: number;
|
||||||
|
activeVideoAssetCount?: number;
|
||||||
|
lastUsedAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivatePortraitProjectListOut {
|
||||||
|
items: PrivatePortraitProject[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivatePortraitValidateSession {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
bytedToken?: string | null;
|
||||||
|
h5Link?: string | null;
|
||||||
|
callbackUrl?: string | null;
|
||||||
|
resultCode?: string | null;
|
||||||
|
status: string;
|
||||||
|
remoteGroupId?: string | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
expiredAt?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivatePortraitAsset {
|
||||||
|
id: string;
|
||||||
|
userId?: string | null;
|
||||||
|
projectId: string;
|
||||||
|
projectName?: string | null;
|
||||||
|
groupId: string;
|
||||||
|
libraryType: string;
|
||||||
|
remoteGroupId: string;
|
||||||
|
remoteAssetId?: string | null;
|
||||||
|
remoteProjectName?: string | null;
|
||||||
|
assetType: string;
|
||||||
|
name?: string | null;
|
||||||
|
sourceUrl: string;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
displayUrl?: string | null;
|
||||||
|
providerUrl?: string | null;
|
||||||
|
remoteUrl?: string | null;
|
||||||
|
videoDuration?: number | null;
|
||||||
|
videoCoverUrl?: string | null;
|
||||||
|
fileSize?: number | null;
|
||||||
|
mimeType?: string | null;
|
||||||
|
status: string;
|
||||||
|
pollCount: number;
|
||||||
|
remoteDeleteStatus: string;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
updatedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivatePortraitAssetListOut {
|
||||||
|
items: PrivatePortraitAsset[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivatePortraitSelectableAsset {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
libraryType: string;
|
||||||
|
name?: string | null;
|
||||||
|
assetType: string;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
displayUrl?: string | null;
|
||||||
|
providerUrl?: string | null;
|
||||||
|
videoDuration?: number | null;
|
||||||
|
videoCoverUrl?: string | null;
|
||||||
|
status: string;
|
||||||
|
createdAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PrivatePortraitSelectableAssetListOut {
|
||||||
|
items: PrivatePortraitSelectableAsset[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
@@ -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,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/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.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/adminlayout.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/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/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/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
|
||||||
+6
-1
@@ -5,10 +5,11 @@ DEBUG=false
|
|||||||
SECRET_KEY=local-dev-secret-key-not-for-production
|
SECRET_KEY=local-dev-secret-key-not-for-production
|
||||||
|
|
||||||
# Database (PostgreSQL)
|
# Database (PostgreSQL)
|
||||||
|
#DATABASE_URL=postgresql+asyncpg://videogen_test:Yr7kM7kDj75izCiA@180.184.42.66:5432/videogen_test
|
||||||
DATABASE_URL=postgresql+asyncpg://videogen:7k33pnXdPL62Yyb4@180.184.42.66:5432/videogen
|
DATABASE_URL=postgresql+asyncpg://videogen:7k33pnXdPL62Yyb4@180.184.42.66:5432/videogen
|
||||||
|
|
||||||
# Redis (leave empty to disable - rate limiting and captcha will use in-memory fallback)
|
# 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_BROKER_URL=redis://localhost:6379/5
|
||||||
CELERY_RESULT_BACKEND=redis://localhost:6379/6
|
CELERY_RESULT_BACKEND=redis://localhost:6379/6
|
||||||
|
|
||||||
@@ -56,6 +57,10 @@ VIDEO_COVER_WIDTH=600
|
|||||||
VIDEO_COVER_TIMEOUT_SECONDS=15
|
VIDEO_COVER_TIMEOUT_SECONDS=15
|
||||||
VIDEO_COVER_FORMAT=png
|
VIDEO_COVER_FORMAT=png
|
||||||
|
|
||||||
|
# VOLC
|
||||||
|
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||||
|
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||||
|
|
||||||
# VOLC_SMS
|
# VOLC_SMS
|
||||||
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
VOLC_SMS_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
|
||||||
VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
VOLC_SMS_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
|
||||||
|
|||||||
@@ -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.generated_resource import GeneratedResource # noqa: F401
|
||||||
from app.models.pre_test_template import PreTestTemplate # noqa: F401
|
from app.models.pre_test_template import PreTestTemplate # noqa: F401
|
||||||
from app.models.upload_task import UploadTask # noqa: F401
|
from app.models.upload_task import UploadTask # noqa: F401
|
||||||
|
from app.models.open_type import OpenType # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
config = context.config
|
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,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,29 @@
|
|||||||
|
"""新增开户方式管理表
|
||||||
|
|
||||||
|
Revision ID: 2bafebb4be14
|
||||||
|
Revises: 08749ddd1414
|
||||||
|
Create Date: 2026-06-23 13:50:26.577410
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '2bafebb4be14'
|
||||||
|
down_revision: Union[str, None] = '08749ddd1414'
|
||||||
|
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! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
pass
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""资源表新增文件名字段
|
||||||
|
|
||||||
|
Revision ID: 5e2c124e5484
|
||||||
|
Revises: 2bafebb4be14
|
||||||
|
Create Date: 2026-06-24 14:28:56.094105
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '5e2c124e5484'
|
||||||
|
down_revision: Union[str, None] = '2bafebb4be14'
|
||||||
|
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('generated_resources', sa.Column('file_name', sa.String(length=255), nullable=True, comment='文件名,平台素材名称'))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('generated_resources', 'file_name')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""add home material generation config
|
||||||
|
|
||||||
|
Revision ID: 6c1aaf036f43
|
||||||
|
Revises: 1475d11b1d74
|
||||||
|
Create Date: 2026-07-09 10:28:05.067550
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6c1aaf036f43'
|
||||||
|
down_revision: Union[str, None] = '1475d11b1d74'
|
||||||
|
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('home_material_assets', sa.Column('generation_prompt', sa.Text(), nullable=True, comment='生成提词'))
|
||||||
|
op.add_column('home_material_assets', sa.Column('media_references_json', sa.Text(), nullable=True, comment='附件/参考素材JSON字符串'))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('home_material_assets', 'media_references_json')
|
||||||
|
op.drop_column('home_material_assets', 'generation_prompt')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""add private portrait assets
|
||||||
|
|
||||||
|
Revision ID: 6fc75582f6f9
|
||||||
|
Revises: 054735900c23
|
||||||
|
Create Date: 2026-07-06 14:48:50.946515
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '6fc75582f6f9'
|
||||||
|
down_revision: Union[str, None] = '20260706_20260706'
|
||||||
|
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('private_portrait_projects',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False, comment='用户展示项目名'),
|
||||||
|
sa.Column('name_slug', sa.String(length=128), nullable=False, comment='项目名安全 slug'),
|
||||||
|
sa.Column('remote_project_name', sa.String(length=256), nullable=False, comment='火山 ProjectName 快照'),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
|
||||||
|
sa.Column('asset_group_count', sa.Integer(), server_default='0', nullable=False),
|
||||||
|
sa.Column('asset_count', sa.Integer(), server_default='0', nullable=False),
|
||||||
|
sa.Column('active_asset_count', sa.Integer(), server_default='0', nullable=False),
|
||||||
|
sa.Column('last_used_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(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_private_portrait_projects_remote_project_name', 'private_portrait_projects', ['remote_project_name'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_projects_user_deleted', 'private_portrait_projects', ['user_id', 'deleted_at'], unique=False, postgresql_where=sa.text('deleted_at IS NULL'))
|
||||||
|
op.create_index('idx_private_portrait_projects_user_status_created', 'private_portrait_projects', ['user_id', 'status', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_projects_deleted_at'), 'private_portrait_projects', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_projects_name'), 'private_portrait_projects', ['name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_projects_name_slug'), 'private_portrait_projects', ['name_slug'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_projects_remote_project_name'), 'private_portrait_projects', ['remote_project_name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_projects_status'), 'private_portrait_projects', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_projects_user_id'), 'private_portrait_projects', ['user_id'], unique=False)
|
||||||
|
op.create_table('private_portrait_asset_groups',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('remote_group_id', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('remote_group_name', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
|
||||||
|
sa.Column('group_type', sa.String(length=32), server_default='LivenessFace', nullable=False),
|
||||||
|
sa.Column('status', sa.String(length=32), server_default='active', nullable=False),
|
||||||
|
sa.Column('remote_delete_status', sa.String(length=32), server_default='none', nullable=False),
|
||||||
|
sa.Column('remote_deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('remote_delete_error', sa.Text(), nullable=True),
|
||||||
|
sa.Column('raw_response_json', 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(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_private_portrait_asset_groups_project_status', 'private_portrait_asset_groups', ['project_id', 'status'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_asset_groups_remote_delete_status', 'private_portrait_asset_groups', ['remote_delete_status'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_asset_groups_remote_project_name', 'private_portrait_asset_groups', ['remote_project_name'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_asset_groups_user_project', 'private_portrait_asset_groups', ['user_id', 'project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_asset_groups_deleted_at'), 'private_portrait_asset_groups', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_asset_groups_project_id'), 'private_portrait_asset_groups', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_asset_groups_remote_delete_status'), 'private_portrait_asset_groups', ['remote_delete_status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_asset_groups_remote_group_id'), 'private_portrait_asset_groups', ['remote_group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_asset_groups_remote_group_name'), 'private_portrait_asset_groups', ['remote_group_name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_asset_groups_remote_project_name'), 'private_portrait_asset_groups', ['remote_project_name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_asset_groups_status'), 'private_portrait_asset_groups', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_asset_groups_user_id'), 'private_portrait_asset_groups', ['user_id'], unique=False)
|
||||||
|
op.create_index('uq_private_portrait_asset_groups_remote_group_id', 'private_portrait_asset_groups', ['remote_group_id'], unique=True)
|
||||||
|
op.create_table('private_portrait_validate_sessions',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('byted_token', sa.String(length=256), nullable=True),
|
||||||
|
sa.Column('h5_link', sa.Text(), nullable=True),
|
||||||
|
sa.Column('callback_url', sa.Text(), nullable=True),
|
||||||
|
sa.Column('result_code', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('algorithm_base_resp_code', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('verify_type', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=32), server_default='created', nullable=False),
|
||||||
|
sa.Column('remote_group_id', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
|
||||||
|
sa.Column('expired_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('raw_callback_json', sa.Text(), nullable=True),
|
||||||
|
sa.Column('raw_response_json', 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.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_private_portrait_validate_sessions_byted_token', 'private_portrait_validate_sessions', ['byted_token'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_validate_sessions_status_created', 'private_portrait_validate_sessions', ['status', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_validate_sessions_user_project', 'private_portrait_validate_sessions', ['user_id', 'project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_validate_sessions_byted_token'), 'private_portrait_validate_sessions', ['byted_token'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_validate_sessions_project_id'), 'private_portrait_validate_sessions', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_validate_sessions_remote_group_id'), 'private_portrait_validate_sessions', ['remote_group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_validate_sessions_remote_project_name'), 'private_portrait_validate_sessions', ['remote_project_name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_validate_sessions_result_code'), 'private_portrait_validate_sessions', ['result_code'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_validate_sessions_status'), 'private_portrait_validate_sessions', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_validate_sessions_user_id'), 'private_portrait_validate_sessions', ['user_id'], unique=False)
|
||||||
|
op.create_table('private_portrait_assets',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('project_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('group_id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('remote_group_id', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('remote_asset_id', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('remote_project_name', sa.String(length=256), nullable=False),
|
||||||
|
sa.Column('asset_type', sa.String(length=16), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('source_url', sa.Text(), nullable=False),
|
||||||
|
sa.Column('preview_url', sa.Text(), nullable=True),
|
||||||
|
sa.Column('remote_url', sa.Text(), nullable=True),
|
||||||
|
sa.Column('remote_url_expired_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('status', sa.String(length=32), server_default='creating', nullable=False),
|
||||||
|
sa.Column('moderation_json', sa.Text(), nullable=True),
|
||||||
|
sa.Column('last_poll_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('poll_count', sa.Integer(), server_default='0', nullable=False),
|
||||||
|
sa.Column('remote_delete_status', sa.String(length=32), server_default='none', nullable=False),
|
||||||
|
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),
|
||||||
|
sa.Column('raw_response_json', 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(['group_id'], ['private_portrait_asset_groups.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['project_id'], ['private_portrait_projects.id'], ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_private_portrait_assets_group_status_created', 'private_portrait_assets', ['group_id', 'status', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_assets_next_poll_status', 'private_portrait_assets', ['next_poll_at', 'status'], unique=False, postgresql_where=sa.text('deleted_at IS NULL AND next_poll_at IS NOT NULL'))
|
||||||
|
op.create_index('idx_private_portrait_assets_project_status_created', 'private_portrait_assets', ['project_id', 'status', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_assets_remote_delete_status', 'private_portrait_assets', ['remote_delete_status'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_assets_remote_project_name', 'private_portrait_assets', ['remote_project_name'], unique=False)
|
||||||
|
op.create_index('idx_private_portrait_assets_user_status_created', 'private_portrait_assets', ['user_id', 'status', 'created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_asset_type'), 'private_portrait_assets', ['asset_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_deleted_at'), 'private_portrait_assets', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_group_id'), 'private_portrait_assets', ['group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_name'), 'private_portrait_assets', ['name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_next_poll_at'), 'private_portrait_assets', ['next_poll_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_project_id'), 'private_portrait_assets', ['project_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_remote_asset_id'), 'private_portrait_assets', ['remote_asset_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_remote_delete_status'), 'private_portrait_assets', ['remote_delete_status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_remote_group_id'), 'private_portrait_assets', ['remote_group_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_remote_project_name'), 'private_portrait_assets', ['remote_project_name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_status'), 'private_portrait_assets', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_private_portrait_assets_user_id'), 'private_portrait_assets', ['user_id'], unique=False)
|
||||||
|
op.create_index('uq_private_portrait_assets_remote_asset_id', 'private_portrait_assets', ['remote_asset_id'], unique=True)
|
||||||
|
op.add_column('users', sa.Column('private_portrait_image_limit', sa.Integer(), server_default='5', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('users', 'private_portrait_image_limit')
|
||||||
|
op.drop_index('uq_private_portrait_assets_remote_asset_id', table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_user_id'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_status'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_remote_project_name'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_remote_group_id'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_remote_delete_status'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_remote_asset_id'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_project_id'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_next_poll_at'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_name'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_group_id'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_deleted_at'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_assets_asset_type'), table_name='private_portrait_assets')
|
||||||
|
op.drop_index('idx_private_portrait_assets_user_status_created', table_name='private_portrait_assets')
|
||||||
|
op.drop_index('idx_private_portrait_assets_remote_project_name', table_name='private_portrait_assets')
|
||||||
|
op.drop_index('idx_private_portrait_assets_remote_delete_status', table_name='private_portrait_assets')
|
||||||
|
op.drop_index('idx_private_portrait_assets_project_status_created', table_name='private_portrait_assets')
|
||||||
|
op.drop_index('idx_private_portrait_assets_next_poll_status', table_name='private_portrait_assets', postgresql_where=sa.text('deleted_at IS NULL AND next_poll_at IS NOT NULL'))
|
||||||
|
op.drop_index('idx_private_portrait_assets_group_status_created', table_name='private_portrait_assets')
|
||||||
|
op.drop_table('private_portrait_assets')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_validate_sessions_user_id'), table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_validate_sessions_status'), table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_validate_sessions_result_code'), table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_validate_sessions_remote_project_name'), table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_validate_sessions_remote_group_id'), table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_validate_sessions_project_id'), table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_validate_sessions_byted_token'), table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index('idx_private_portrait_validate_sessions_user_project', table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index('idx_private_portrait_validate_sessions_status_created', table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_index('idx_private_portrait_validate_sessions_byted_token', table_name='private_portrait_validate_sessions')
|
||||||
|
op.drop_table('private_portrait_validate_sessions')
|
||||||
|
op.drop_index('uq_private_portrait_asset_groups_remote_group_id', table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_asset_groups_user_id'), table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_asset_groups_status'), table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_project_name'), table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_group_name'), table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_group_id'), table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_asset_groups_remote_delete_status'), table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_asset_groups_project_id'), table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_asset_groups_deleted_at'), table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index('idx_private_portrait_asset_groups_user_project', table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index('idx_private_portrait_asset_groups_remote_project_name', table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index('idx_private_portrait_asset_groups_remote_delete_status', table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_index('idx_private_portrait_asset_groups_project_status', table_name='private_portrait_asset_groups')
|
||||||
|
op.drop_table('private_portrait_asset_groups')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_projects_user_id'), table_name='private_portrait_projects')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_projects_status'), table_name='private_portrait_projects')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_projects_remote_project_name'), table_name='private_portrait_projects')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_projects_name_slug'), table_name='private_portrait_projects')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_projects_name'), table_name='private_portrait_projects')
|
||||||
|
op.drop_index(op.f('ix_private_portrait_projects_deleted_at'), table_name='private_portrait_projects')
|
||||||
|
op.drop_index('idx_private_portrait_projects_user_status_created', table_name='private_portrait_projects')
|
||||||
|
op.drop_index('idx_private_portrait_projects_user_deleted', table_name='private_portrait_projects', postgresql_where=sa.text('deleted_at IS NULL'))
|
||||||
|
op.drop_index('idx_private_portrait_projects_remote_project_name', table_name='private_portrait_projects')
|
||||||
|
op.drop_table('private_portrait_projects')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""6idufv2q1c_add_credits_ratio_增加上传视频积分规则
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f7
|
||||||
|
Revises: 6idufv2q1c
|
||||||
|
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 = '6idufv2q1c'
|
||||||
|
down_revision: Union[str, None] = 'a1b2c3d4e5f7'
|
||||||
|
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_video_ratio', sa.Float(), server_default='1.0', nullable=False))
|
||||||
|
op.add_column('credit_ratios', sa.Column('input_video_base_credits', sa.Float(), server_default='0.0', nullable=False))
|
||||||
|
op.add_column('credit_ratios', sa.Column('input_video_per_second_credits', sa.Float(), server_default='0.5', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ========================================
|
||||||
|
# 2026-07-01 - 积分规则表增加传入视频计费字段(回滚)
|
||||||
|
# ========================================
|
||||||
|
op.drop_column('credit_ratios', 'input_video_per_second_credits')
|
||||||
|
op.drop_column('credit_ratios', 'input_video_base_credits')
|
||||||
|
op.drop_column('credit_ratios', 'input_video_ratio')
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""add user resource capacity config
|
||||||
|
|
||||||
|
Revision ID: 78fb32c26a6e
|
||||||
|
Revises: n123456789ab
|
||||||
|
Create Date: 2026-06-29 13:05:45.888522
|
||||||
|
"""
|
||||||
|
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 = '78fb32c26a6e'
|
||||||
|
down_revision: Union[str, None] = 'n123456789ab'
|
||||||
|
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('user_resource_capacity_configs',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('user_id', sa.String(length=32), nullable=False, comment='用户ID'),
|
||||||
|
sa.Column('enabled', sa.Boolean(), server_default='false', nullable=False, comment='是否启用该用户个人容量限制'),
|
||||||
|
sa.Column('limit_value', sa.Numeric(precision=18, scale=3), server_default='1', nullable=False, comment='容量数值,最小1,最多3位小数'),
|
||||||
|
sa.Column('limit_unit', sa.String(length=8), server_default='GB', nullable=False, comment='容量单位:MB/GB/TB'),
|
||||||
|
sa.Column('limit_bytes', sa.BigInteger(), server_default='1073741824', 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.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', name='uq_user_resource_capacity_configs_user')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_user_resource_capacity_configs_user_id'), 'user_resource_capacity_configs', ['user_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_user_resource_capacity_configs_user_id'), table_name='user_resource_capacity_configs')
|
||||||
|
op.drop_table('user_resource_capacity_configs')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""add max image count to image engines
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: f7a3b2c1d4e5
|
||||||
|
Create Date: 2026-07-01 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'a1b2c3d4e5f7'
|
||||||
|
down_revision: Union[str, None] = 'f7a3b2c1d4e5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('image_engines', sa.Column('max_image_count', sa.Integer(), server_default='0', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('image_engines', 'max_image_count')
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""上传日志表增加前测信息字段
|
||||||
|
|
||||||
|
Revision ID: a65cf38abbb8
|
||||||
|
Revises: 5e2c124e5484
|
||||||
|
Create Date: 2026-06-25 11:03:40.134713
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a65cf38abbb8'
|
||||||
|
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:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('upload_task', sa.Column('other_info', sa.String(length=500), nullable=True, comment='其他信息'))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('upload_task', 'other_info')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""merge changes from remote add credit record and upload_task add other_info
|
||||||
|
|
||||||
|
Revision ID: c72a6f69e641
|
||||||
|
Revises: 0a8da2d3c091, a65cf38abbb8
|
||||||
|
Create Date: 2026-06-25 17:02:01.095460
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'c72a6f69e641'
|
||||||
|
down_revision: Union[str, None] = ('0a8da2d3c091', 'a65cf38abbb8')
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
"""rename private portrait asset limit and add library type
|
||||||
|
|
||||||
|
Revision ID: cd7688999204
|
||||||
|
Revises: e7038d02c355
|
||||||
|
Create Date: 2026-07-07 13:21:49.612465
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "cd7688999204"
|
||||||
|
down_revision: Union[str, None] = "e7038d02c355"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. users 字段改名:必须 rename,保留原有用户额度数据
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"private_portrait_image_limit",
|
||||||
|
new_column_name="private_portrait_asset_limit",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("5"),
|
||||||
|
comment="私域人像素材总量限制,真人/虚拟共用,图片/视频共用,0 表示关闭",
|
||||||
|
existing_comment="私域真人图片素材数量限制,0表示关闭",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. private_portrait_asset_groups
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_asset_groups",
|
||||||
|
sa.Column(
|
||||||
|
"library_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
server_default="real_person",
|
||||||
|
nullable=False,
|
||||||
|
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_private_portrait_asset_groups_library_type",
|
||||||
|
"private_portrait_asset_groups",
|
||||||
|
["library_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_private_portrait_asset_groups_user_library",
|
||||||
|
"private_portrait_asset_groups",
|
||||||
|
["user_id", "library_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. private_portrait_assets
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_assets",
|
||||||
|
sa.Column(
|
||||||
|
"library_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
server_default="real_person",
|
||||||
|
nullable=False,
|
||||||
|
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_assets",
|
||||||
|
sa.Column("video_duration", sa.Float(), nullable=True, comment="视频素材时长,秒"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_assets",
|
||||||
|
sa.Column("video_cover_url", sa.Text(), nullable=True, comment="视频素材封面预览地址"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_assets",
|
||||||
|
sa.Column("file_size", sa.Integer(), nullable=True, comment="素材文件大小,字节"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_assets",
|
||||||
|
sa.Column("mime_type", sa.String(length=128), nullable=True, comment="素材 MIME 类型"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果 private_portrait_assets.asset_type 已经存在索引,这条要删除
|
||||||
|
op.create_index(
|
||||||
|
"idx_private_portrait_assets_asset_type",
|
||||||
|
"private_portrait_assets",
|
||||||
|
["asset_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_private_portrait_assets_library_type",
|
||||||
|
"private_portrait_assets",
|
||||||
|
["library_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_private_portrait_assets_user_library_status_created",
|
||||||
|
"private_portrait_assets",
|
||||||
|
["user_id", "library_type", "status", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. private_portrait_projects
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_projects",
|
||||||
|
sa.Column(
|
||||||
|
"library_type",
|
||||||
|
sa.String(length=32),
|
||||||
|
server_default="real_person",
|
||||||
|
nullable=False,
|
||||||
|
comment="素材库类型:real_person 真人认证;aigc_virtual 虚拟人像",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_projects",
|
||||||
|
sa.Column("image_asset_count", sa.Integer(), server_default="0", nullable=False, comment="图片素材数量"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_projects",
|
||||||
|
sa.Column("video_asset_count", sa.Integer(), server_default="0", nullable=False, comment="视频素材数量"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_projects",
|
||||||
|
sa.Column("active_image_asset_count", sa.Integer(), server_default="0", nullable=False, comment="可用图片素材数量"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"private_portrait_projects",
|
||||||
|
sa.Column("active_video_asset_count", sa.Integer(), server_default="0", nullable=False, comment="可用视频素材数量"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_private_portrait_projects_library_type",
|
||||||
|
"private_portrait_projects",
|
||||||
|
["library_type"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_private_portrait_projects_user_library_status_created",
|
||||||
|
"private_portrait_projects",
|
||||||
|
["user_id", "library_type", "status", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 1. users 字段反向改名:保留数据
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"private_portrait_asset_limit",
|
||||||
|
new_column_name="private_portrait_image_limit",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("5"),
|
||||||
|
comment="私域真人图片素材数量限制,0表示关闭",
|
||||||
|
existing_comment="私域人像素材总量限制,真人/虚拟共用,图片/视频共用,0 表示关闭",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. private_portrait_projects
|
||||||
|
op.drop_index(
|
||||||
|
"idx_private_portrait_projects_user_library_status_created",
|
||||||
|
table_name="private_portrait_projects",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"idx_private_portrait_projects_library_type",
|
||||||
|
table_name="private_portrait_projects",
|
||||||
|
)
|
||||||
|
op.drop_column("private_portrait_projects", "active_video_asset_count")
|
||||||
|
op.drop_column("private_portrait_projects", "active_image_asset_count")
|
||||||
|
op.drop_column("private_portrait_projects", "video_asset_count")
|
||||||
|
op.drop_column("private_portrait_projects", "image_asset_count")
|
||||||
|
op.drop_column("private_portrait_projects", "library_type")
|
||||||
|
|
||||||
|
# 3. private_portrait_assets
|
||||||
|
op.drop_index(
|
||||||
|
"idx_private_portrait_assets_user_library_status_created",
|
||||||
|
table_name="private_portrait_assets",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"idx_private_portrait_assets_library_type",
|
||||||
|
table_name="private_portrait_assets",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"idx_private_portrait_assets_asset_type",
|
||||||
|
table_name="private_portrait_assets",
|
||||||
|
)
|
||||||
|
op.drop_column("private_portrait_assets", "mime_type")
|
||||||
|
op.drop_column("private_portrait_assets", "file_size")
|
||||||
|
op.drop_column("private_portrait_assets", "video_cover_url")
|
||||||
|
op.drop_column("private_portrait_assets", "video_duration")
|
||||||
|
op.drop_column("private_portrait_assets", "library_type")
|
||||||
|
|
||||||
|
# 4. private_portrait_asset_groups
|
||||||
|
op.drop_index(
|
||||||
|
"idx_private_portrait_asset_groups_user_library",
|
||||||
|
table_name="private_portrait_asset_groups",
|
||||||
|
)
|
||||||
|
op.drop_index(
|
||||||
|
"idx_private_portrait_asset_groups_library_type",
|
||||||
|
table_name="private_portrait_asset_groups",
|
||||||
|
)
|
||||||
|
op.drop_column("private_portrait_asset_groups", "library_type")
|
||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
"""d4e5f6a7b8c9 - 视频引擎增加首尾帧和全能参考支持字段
|
||||||
|
|
||||||
|
Revision ID: d4e5f6a7b8c9
|
||||||
|
Revises: 6idufv2q1c
|
||||||
|
Create Date: 2026-07-02 00:00:00.000000
|
||||||
|
|
||||||
|
该文件包含 2026-07-02 的数据库迁移内容:
|
||||||
|
1. 视频引擎表增加 supports_first_last_frame 字段(是否支持首尾帧模式)
|
||||||
|
2. 视频引擎表增加 supports_universal_reference 字段(是否支持全能参考模式)
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'd4e5f6a7b8c9'
|
||||||
|
down_revision: Union[str, None] = 'e2618c38eae5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'video_engines',
|
||||||
|
sa.Column(
|
||||||
|
'supports_first_last_frame',
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.text('false'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'video_engines',
|
||||||
|
sa.Column(
|
||||||
|
'supports_universal_reference',
|
||||||
|
sa.Boolean(),
|
||||||
|
server_default=sa.text('true'),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('video_engines', 'supports_universal_reference')
|
||||||
|
op.drop_column('video_engines', 'supports_first_last_frame')
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""merge changes main
|
||||||
|
|
||||||
|
Revision ID: e2618c38eae5
|
||||||
|
Revises: 6idufv2q1c, ee4b42e57960
|
||||||
|
Create Date: 2026-07-02 09:43:29.076528
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e2618c38eae5'
|
||||||
|
down_revision: Union[str, None] = ('6idufv2q1c', 'ee4b42e57960')
|
||||||
|
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,153 @@
|
|||||||
|
"""add home material decoration
|
||||||
|
|
||||||
|
Revision ID: e5f260ed1459
|
||||||
|
Revises: 170005531deb
|
||||||
|
Create Date: 2026-06-30 14:36:51.291905
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e5f260ed1459'
|
||||||
|
down_revision: Union[str, None] = '170005531deb'
|
||||||
|
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('home_material_assets',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('category_id', sa.String(length=32), nullable=False, comment='行业类别ID'),
|
||||||
|
sa.Column('title', sa.String(length=128), nullable=True, comment='素材标题'),
|
||||||
|
sa.Column('media_type', sa.String(length=16), server_default='image', nullable=False, comment='素材类型:image图片,video视频'),
|
||||||
|
sa.Column('original_url', sa.Text(), nullable=False, comment='原始素材URL'),
|
||||||
|
sa.Column('original_storage_path', sa.Text(), nullable=False, comment='原始素材本地路径'),
|
||||||
|
sa.Column('watermarked_url', sa.Text(), nullable=True, comment='水印素材URL'),
|
||||||
|
sa.Column('watermarked_storage_path', sa.Text(), nullable=True, comment='水印素材本地路径'),
|
||||||
|
sa.Column('cover_url', sa.Text(), nullable=True, comment='视频封面URL'),
|
||||||
|
sa.Column('cover_storage_path', sa.Text(), nullable=True, comment='视频封面本地路径'),
|
||||||
|
sa.Column('watermark_id', sa.String(length=32), nullable=True, comment='水印图片ID'),
|
||||||
|
sa.Column('watermark_config_json', sa.Text(), nullable=True, comment='水印配置快照JSON'),
|
||||||
|
sa.Column('status', sa.String(length=32), server_default='draft', nullable=False, comment='处理状态:draft/processing/success/failed'),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True, comment='处理失败原因'),
|
||||||
|
sa.Column('width', sa.Integer(), nullable=True, comment='素材宽度'),
|
||||||
|
sa.Column('height', sa.Integer(), nullable=True, comment='素材高度'),
|
||||||
|
sa.Column('duration_seconds', sa.Numeric(precision=10, scale=3), nullable=True, comment='视频时长,图片为空'),
|
||||||
|
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False, comment='原始文件大小'),
|
||||||
|
sa.Column('watermarked_file_size_bytes', sa.BigInteger(), nullable=True, comment='水印后文件大小'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否前台展示'),
|
||||||
|
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序,越小越靠前'),
|
||||||
|
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True, comment='处理完成时间'),
|
||||||
|
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||||
|
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||||
|
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('idx_home_material_assets_admin_list', 'home_material_assets', ['deleted_at', 'category_id', 'media_type', 'status', 'is_active', 'sort_order', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_home_material_assets_category_sort', 'home_material_assets', ['category_id', 'sort_order', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_home_material_assets_status', 'home_material_assets', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_category_id'), 'home_material_assets', ['category_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_created_by'), 'home_material_assets', ['created_by'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_deleted_at'), 'home_material_assets', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_is_active'), 'home_material_assets', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_media_type'), 'home_material_assets', ['media_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_processed_at'), 'home_material_assets', ['processed_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_sort_order'), 'home_material_assets', ['sort_order'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_status'), 'home_material_assets', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_title'), 'home_material_assets', ['title'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_updated_by'), 'home_material_assets', ['updated_by'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_watermark_id'), 'home_material_assets', ['watermark_id'], unique=False)
|
||||||
|
op.create_table('home_material_categories',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False, comment='行业名称'),
|
||||||
|
sa.Column('key', sa.String(length=64), nullable=False, comment='行业唯一标识,前台可按 key 查询'),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True, comment='行业描述'),
|
||||||
|
sa.Column('icon', sa.String(length=64), nullable=True, comment='前端图标名称'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否启用'),
|
||||||
|
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序,越小越靠前'),
|
||||||
|
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||||
|
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||||
|
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('idx_home_material_categories_active_sort', 'home_material_categories', ['deleted_at', 'is_active', 'sort_order', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_home_material_categories_key', 'home_material_categories', ['key'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_created_by'), 'home_material_categories', ['created_by'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_deleted_at'), 'home_material_categories', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_is_active'), 'home_material_categories', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_name'), 'home_material_categories', ['name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_sort_order'), 'home_material_categories', ['sort_order'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_updated_by'), 'home_material_categories', ['updated_by'], unique=False)
|
||||||
|
op.create_table('home_material_watermarks',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False, comment='水印名称'),
|
||||||
|
sa.Column('file_url', sa.Text(), nullable=False, comment='水印图片URL'),
|
||||||
|
sa.Column('storage_path', sa.Text(), nullable=False, comment='水印图片本地路径'),
|
||||||
|
sa.Column('file_name', sa.String(length=255), nullable=True, comment='原始文件名'),
|
||||||
|
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False, comment='文件大小'),
|
||||||
|
sa.Column('width', sa.Integer(), nullable=True, comment='水印图片宽度'),
|
||||||
|
sa.Column('height', sa.Integer(), nullable=True, comment='水印图片高度'),
|
||||||
|
sa.Column('is_default', sa.Boolean(), server_default='false', nullable=False, comment='是否默认水印'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否启用'),
|
||||||
|
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||||
|
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||||
|
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('idx_home_material_watermarks_active', 'home_material_watermarks', ['deleted_at', 'is_active', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_home_material_watermarks_default', 'home_material_watermarks', ['deleted_at', 'is_default'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_created_by'), 'home_material_watermarks', ['created_by'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_deleted_at'), 'home_material_watermarks', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_is_active'), 'home_material_watermarks', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_is_default'), 'home_material_watermarks', ['is_default'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_name'), 'home_material_watermarks', ['name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_updated_by'), 'home_material_watermarks', ['updated_by'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_updated_by'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_name'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_is_default'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_is_active'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_deleted_at'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_created_by'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index('idx_home_material_watermarks_default', table_name='home_material_watermarks')
|
||||||
|
op.drop_index('idx_home_material_watermarks_active', table_name='home_material_watermarks')
|
||||||
|
op.drop_table('home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_updated_by'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_sort_order'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_name'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_is_active'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_deleted_at'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_created_by'), table_name='home_material_categories')
|
||||||
|
op.drop_index('idx_home_material_categories_key', table_name='home_material_categories')
|
||||||
|
op.drop_index('idx_home_material_categories_active_sort', table_name='home_material_categories')
|
||||||
|
op.drop_table('home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_watermark_id'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_updated_by'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_title'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_status'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_sort_order'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_processed_at'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_media_type'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_is_active'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_deleted_at'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_created_by'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_category_id'), table_name='home_material_assets')
|
||||||
|
op.drop_index('idx_home_material_assets_status', table_name='home_material_assets')
|
||||||
|
op.drop_index('idx_home_material_assets_category_sort', table_name='home_material_assets')
|
||||||
|
op.drop_index('idx_home_material_assets_admin_list', table_name='home_material_assets')
|
||||||
|
op.drop_table('home_material_assets')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""private portrait validate once
|
||||||
|
|
||||||
|
Revision ID: e7038d02c355
|
||||||
|
Revises: 6fc75582f6f9
|
||||||
|
Create Date: 2026-07-07 10:47:00.436752
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e7038d02c355'
|
||||||
|
down_revision: Union[str, None] = '6fc75582f6f9'
|
||||||
|
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_index('uq_private_portrait_asset_groups_one_active_project', 'private_portrait_asset_groups', ['project_id'], unique=True, postgresql_where=sa.text("deleted_at IS NULL AND status = 'active'"))
|
||||||
|
op.create_index('uq_private_portrait_validate_sessions_one_group_active_project', 'private_portrait_validate_sessions', ['project_id'], unique=True, postgresql_where=sa.text("status = 'group_active'"))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('uq_private_portrait_validate_sessions_one_group_active_project', table_name='private_portrait_validate_sessions', postgresql_where=sa.text("status = 'group_active'"))
|
||||||
|
op.drop_index('uq_private_portrait_asset_groups_one_active_project', table_name='private_portrait_asset_groups', postgresql_where=sa.text("deleted_at IS NULL AND status = 'active'"))
|
||||||
|
# ### end Alembic commands ###
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
"""add chat generation poll schedule fields
|
||||||
|
|
||||||
|
Revision ID: ee4b42e57960
|
||||||
|
Revises: f7a3b2c1d4e5
|
||||||
|
Create Date: 2026-07-01 16:11:28.799227
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'ee4b42e57960'
|
||||||
|
down_revision: Union[str, None] = 'f7a3b2c1d4e5'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
'chat_generation_tasks',
|
||||||
|
sa.Column('poll_started_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'chat_generation_tasks',
|
||||||
|
sa.Column('next_poll_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
'chat_generation_tasks',
|
||||||
|
sa.Column(
|
||||||
|
'poll_interval_seconds',
|
||||||
|
sa.Integer(),
|
||||||
|
nullable=False,
|
||||||
|
server_default='0',
|
||||||
|
comment='当前轮询退避间隔秒数',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 修复历史“生成中视频任务”:
|
||||||
|
# 1. deadline_at 改为 created_at + 24 小时,避免沿用旧 30 分钟最终超时。
|
||||||
|
# 2. next_poll_at 设置为 now(),让 Beat dispatcher 能尽快接管。
|
||||||
|
# 3. poll_started_at 用 last_poll_at / created_at 兜底。
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE chat_generation_tasks
|
||||||
|
SET
|
||||||
|
poll_started_at = COALESCE(poll_started_at, last_poll_at, created_at, NOW()),
|
||||||
|
next_poll_at = COALESCE(next_poll_at, NOW()),
|
||||||
|
poll_interval_seconds = COALESCE(poll_interval_seconds, 0),
|
||||||
|
deadline_at = CASE
|
||||||
|
WHEN deadline_at IS NULL THEN created_at + INTERVAL '24 hours'
|
||||||
|
WHEN deadline_at < created_at + INTERVAL '24 hours' THEN created_at + INTERVAL '24 hours'
|
||||||
|
ELSE deadline_at
|
||||||
|
END
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND status = 'generating'
|
||||||
|
AND gen_type = 'video'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_index(
|
||||||
|
'idx_chat_generation_tasks_next_poll_at',
|
||||||
|
'chat_generation_tasks',
|
||||||
|
['next_poll_at'],
|
||||||
|
unique=False,
|
||||||
|
postgresql_where=sa.text(
|
||||||
|
"deleted_at IS NULL "
|
||||||
|
"AND status = 'generating' "
|
||||||
|
"AND gen_type = 'video' "
|
||||||
|
"AND next_poll_at IS NOT NULL"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(
|
||||||
|
'idx_chat_generation_tasks_next_poll_at',
|
||||||
|
table_name='chat_generation_tasks',
|
||||||
|
)
|
||||||
|
op.drop_column('chat_generation_tasks', 'poll_interval_seconds')
|
||||||
|
op.drop_column('chat_generation_tasks', 'next_poll_at')
|
||||||
|
op.drop_column('chat_generation_tasks', 'poll_started_at')
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""add max image and video count to video engines
|
||||||
|
|
||||||
|
Revision ID: f7a3b2c1d4e5
|
||||||
|
Revises: e5f260ed1459
|
||||||
|
Create Date: 2026-07-01 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'f7a3b2c1d4e5'
|
||||||
|
down_revision: Union[str, None] = 'e5f260ed1459'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('video_engines', sa.Column('max_image_count', sa.Integer(), server_default='2', nullable=False))
|
||||||
|
op.add_column('video_engines', sa.Column('max_video_count', sa.Integer(), server_default='0', nullable=False))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('video_engines', 'max_video_count')
|
||||||
|
op.drop_column('video_engines', 'max_image_count')
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""add core business indexes and contact submit_date
|
||||||
|
|
||||||
|
Revision ID: n123456789ab
|
||||||
|
Revises: c72a6f69e641
|
||||||
|
Create Date: 2026-06-29 14:00:00.000000
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "n123456789ab"
|
||||||
|
down_revision: Union[str, None] = "c72a6f69e641"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Add submit_date column to contact_requests
|
||||||
|
op.add_column(
|
||||||
|
"contact_requests",
|
||||||
|
sa.Column("submit_date", sa.String(10), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill submit_date from created_at for existing records
|
||||||
|
op.execute(
|
||||||
|
"UPDATE contact_requests SET submit_date = TO_CHAR(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') WHERE submit_date IS NULL"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set NOT NULL after backfill
|
||||||
|
op.alter_column("contact_requests", "submit_date", nullable=False)
|
||||||
|
|
||||||
|
# Create index on submit_date
|
||||||
|
op.create_index(
|
||||||
|
"ix_contact_requests_submit_date",
|
||||||
|
"contact_requests",
|
||||||
|
["submit_date"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Contact requests unique constraint: (user_id, submit_date)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_contact_user_date",
|
||||||
|
"contact_requests",
|
||||||
|
["user_id", "submit_date"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Contact requests composite indexes
|
||||||
|
op.create_index(
|
||||||
|
"idx_contact_handled_created",
|
||||||
|
"contact_requests",
|
||||||
|
["is_handled", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_contact_user_date_created",
|
||||||
|
"contact_requests",
|
||||||
|
["user_id", "submit_date", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Generation records composite indexes
|
||||||
|
op.create_index(
|
||||||
|
"idx_genrec_user_status_created",
|
||||||
|
"generation_records",
|
||||||
|
["user_id", "status", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_genrec_project_status",
|
||||||
|
"generation_records",
|
||||||
|
["project_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Payment orders composite indexes
|
||||||
|
op.create_index(
|
||||||
|
"idx_payorder_user_status_created",
|
||||||
|
"payment_orders",
|
||||||
|
["user_id", "status", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_payorder_status_created",
|
||||||
|
"payment_orders",
|
||||||
|
["status", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. Upload task composite indexes
|
||||||
|
op.create_index(
|
||||||
|
"idx_upload_user_status_created",
|
||||||
|
"upload_task",
|
||||||
|
["user_id", "status", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_upload_oauth_status",
|
||||||
|
"upload_task",
|
||||||
|
["oauth_id", "status"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 7. Menu configs indexes
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_menu_configs_parent_id"),
|
||||||
|
"menu_configs",
|
||||||
|
["parent_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_menu_target_active_sort",
|
||||||
|
"menu_configs",
|
||||||
|
["menu_target", "is_active", "sort_order"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8. Operation logs composite indexes
|
||||||
|
op.create_index(
|
||||||
|
"idx_oplog_user_created",
|
||||||
|
"operation_logs",
|
||||||
|
["user_id", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_oplog_created",
|
||||||
|
"operation_logs",
|
||||||
|
["created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 9. Notification indexes (from previous optimization)
|
||||||
|
op.create_index(
|
||||||
|
"idx_notif_user_isread_created",
|
||||||
|
"notifications",
|
||||||
|
["user_id", "is_read", "created_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 10. Notification reads indexes
|
||||||
|
op.create_index(
|
||||||
|
"idx_notif_read_user_notif",
|
||||||
|
"notification_reads",
|
||||||
|
["user_id", "notification_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_unique_constraint(
|
||||||
|
"uq_notif_read_user",
|
||||||
|
"notification_reads",
|
||||||
|
["notification_id", "user_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("uq_notif_read_user", "notification_reads", type_="unique")
|
||||||
|
op.drop_index("idx_notif_read_user_notif", table_name="notification_reads")
|
||||||
|
op.drop_index("idx_notif_user_isread_created", table_name="notifications")
|
||||||
|
op.drop_index("idx_oplog_created", table_name="operation_logs")
|
||||||
|
op.drop_index("idx_oplog_user_created", table_name="operation_logs")
|
||||||
|
op.drop_index("idx_menu_target_active_sort", table_name="menu_configs")
|
||||||
|
op.drop_index(op.f("ix_menu_configs_parent_id"), table_name="menu_configs")
|
||||||
|
op.drop_index("idx_upload_oauth_status", table_name="upload_task")
|
||||||
|
op.drop_index("idx_upload_user_status_created", table_name="upload_task")
|
||||||
|
op.drop_index("idx_payorder_status_created", table_name="payment_orders")
|
||||||
|
op.drop_index("idx_payorder_user_status_created", table_name="payment_orders")
|
||||||
|
op.drop_index("idx_genrec_project_status", table_name="generation_records")
|
||||||
|
op.drop_index("idx_genrec_user_status_created", table_name="generation_records")
|
||||||
|
op.drop_index("idx_contact_user_date_created", table_name="contact_requests")
|
||||||
|
op.drop_index("idx_contact_handled_created", table_name="contact_requests")
|
||||||
|
op.drop_constraint("uq_contact_user_date", "contact_requests", type_="unique")
|
||||||
|
op.drop_index("ix_contact_requests_submit_date", table_name="contact_requests")
|
||||||
|
op.drop_column("contact_requests", "submit_date")
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
||||||
|
from app.api.admin.resource_capacity import router as resource_capacity_router
|
||||||
|
from app.api.admin.team import router as team_router
|
||||||
|
from app.api.admin.home_material import router as home_material_router
|
||||||
|
from app.api.admin.private_portrait import router as private_portrait_router
|
||||||
|
from app.api.admin.recharge_package import router as recharge_package_router
|
||||||
|
from app.api.admin.menu_config import router as menu_config_router
|
||||||
|
from app.api.admin.upload import router as admin_upload_router
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
router.include_router(video_prompt_schema_config_router)
|
||||||
|
router.include_router(resource_capacity_router)
|
||||||
|
router.include_router(team_router)
|
||||||
|
router.include_router(home_material_router)
|
||||||
|
router.include_router(private_portrait_router)
|
||||||
|
router.include_router(recharge_package_router)
|
||||||
|
router.include_router(menu_config_router)
|
||||||
|
router.include_router(admin_upload_router)
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, Path, Query, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.enums.home_material import (
|
||||||
|
HomeMaterialAssetStatus,
|
||||||
|
HomeMaterialMediaType,
|
||||||
|
HomeMaterialOperationEnum,
|
||||||
|
HomeMaterialWatermarkPosition,
|
||||||
|
HomeMaterialWatermarkSizeMode,
|
||||||
|
HomeMaterialWatermarkType,
|
||||||
|
)
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.home_material import (
|
||||||
|
HomeMaterialAssetListOut,
|
||||||
|
HomeMaterialAssetOut,
|
||||||
|
HomeMaterialAssetStatusOut,
|
||||||
|
HomeMaterialAssetUpdate,
|
||||||
|
HomeMaterialCategoryCreate,
|
||||||
|
HomeMaterialCategoryListOut,
|
||||||
|
HomeMaterialCategoryOut,
|
||||||
|
HomeMaterialCategoryUpdate,
|
||||||
|
HomeMaterialConfigOut,
|
||||||
|
HomeMaterialConfigUpdate,
|
||||||
|
HomeMaterialRegenerateWatermarkRequest,
|
||||||
|
HomeMaterialTextWatermarkConfig,
|
||||||
|
HomeMaterialTextWatermarkPreviewRequest,
|
||||||
|
HomeMaterialTextWatermarkPreviewResponse,
|
||||||
|
HomeMaterialUploadResultOut,
|
||||||
|
HomeMaterialWatermarkConfig,
|
||||||
|
HomeMaterialWatermarkListOut,
|
||||||
|
HomeMaterialWatermarkOut,
|
||||||
|
HomeMaterialWatermarkUpdate,
|
||||||
|
)
|
||||||
|
from app.services.home_material import home_material_service
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/home-material", tags=["admin-home-material"])
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(**kwargs) -> str:
|
||||||
|
return json.dumps(kwargs, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/config",
|
||||||
|
response_model=HomeMaterialConfigOut,
|
||||||
|
summary="获取首页素材展示配置",
|
||||||
|
description="获取首页素材行业装修展示配置。配置存储于 system_configs,key=home_material_showcase_config。enabled=false 时前台不展示该模块。",
|
||||||
|
)
|
||||||
|
async def get_home_material_config(
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.get_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/config",
|
||||||
|
response_model=HomeMaterialConfigOut,
|
||||||
|
summary="保存首页素材展示配置",
|
||||||
|
description="保存首页素材展示开关、标题、副标题、后台是否展示原素材配置,并写入操作日志。",
|
||||||
|
)
|
||||||
|
async def save_home_material_config(
|
||||||
|
req: HomeMaterialConfigUpdate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.save_config(db, req)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.CONFIG_UPDATE.value,
|
||||||
|
"PUT",
|
||||||
|
"/admin/home-material/config",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/categories",
|
||||||
|
response_model=HomeMaterialCategoryListOut,
|
||||||
|
summary="后台首页素材行业列表",
|
||||||
|
description="分页查询首页素材行业。列表不会连表,会先查行业列表,再按 category_id 批量 group by 查询素材数量后 map 回填。",
|
||||||
|
)
|
||||||
|
async def list_home_material_categories(
|
||||||
|
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||||
|
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||||
|
keyword: str | None = Query(None, description="搜索行业名称或 key。"),
|
||||||
|
is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.list_categories(db, page=page, page_size=page_size, keyword=keyword, is_active=is_active)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/categories",
|
||||||
|
response_model=HomeMaterialCategoryOut,
|
||||||
|
summary="新增首页素材行业",
|
||||||
|
description="新增首页素材行业类别。key 只允许字母、数字、下划线、中划线,且未软删记录内唯一。",
|
||||||
|
)
|
||||||
|
async def create_home_material_category(
|
||||||
|
req: HomeMaterialCategoryCreate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, after = await home_material_service.create_category(db, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.CATEGORY_CREATE.value,
|
||||||
|
"POST",
|
||||||
|
"/admin/home-material/categories",
|
||||||
|
detail=_detail(after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/categories/{category_id}",
|
||||||
|
response_model=HomeMaterialCategoryOut,
|
||||||
|
summary="修改首页素材行业",
|
||||||
|
description="修改首页素材行业名称、key、描述、图标、启用状态和排序,并记录修改前后快照。",
|
||||||
|
)
|
||||||
|
async def update_home_material_category(
|
||||||
|
req: HomeMaterialCategoryUpdate,
|
||||||
|
category_id: str = Path(..., description="行业ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.update_category(db, category_id, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.CATEGORY_UPDATE.value,
|
||||||
|
"PUT",
|
||||||
|
f"/admin/home-material/categories/{category_id}",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/categories/{category_id}",
|
||||||
|
summary="删除首页素材行业",
|
||||||
|
description="软删首页素材行业。若行业下还有未删除素材,接口会拒绝删除,建议改为禁用。",
|
||||||
|
)
|
||||||
|
async def delete_home_material_category(
|
||||||
|
category_id: str = Path(..., description="行业ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
category, before = await home_material_service.delete_category(db, category_id, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.CATEGORY_DELETE.value,
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/home-material/categories/{category_id}",
|
||||||
|
detail=_detail(before=before, after={"deleted_at": category.deleted_at}),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/watermarks",
|
||||||
|
response_model=HomeMaterialWatermarkListOut,
|
||||||
|
summary="后台水印图片列表",
|
||||||
|
description="分页查询首页素材水印图片库。",
|
||||||
|
)
|
||||||
|
async def list_home_material_watermarks(
|
||||||
|
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||||
|
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||||
|
is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.list_watermarks(db, page=page, page_size=page_size, is_active=is_active)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/watermarks",
|
||||||
|
response_model=HomeMaterialWatermarkOut,
|
||||||
|
summary="上传水印图片",
|
||||||
|
description="上传水印图片到水印库。支持 png/webp/jpg/jpeg。is_default=true 时自动取消其他默认水印。",
|
||||||
|
)
|
||||||
|
async def upload_home_material_watermark(
|
||||||
|
file: UploadFile = File(..., description="水印图片文件,建议 png 或 webp。"),
|
||||||
|
name: str | None = Form(None, description="水印名称,不传使用文件名。"),
|
||||||
|
is_default: bool = Form(False, description="是否设为默认水印。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, after = await home_material_service.upload_watermark(db, file=file, name=name, is_default=is_default, admin_id=admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.WATERMARK_UPLOAD.value,
|
||||||
|
"POST",
|
||||||
|
"/admin/home-material/watermarks",
|
||||||
|
detail=_detail(after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/watermarks/{watermark_id}",
|
||||||
|
response_model=HomeMaterialWatermarkOut,
|
||||||
|
summary="修改水印图片信息",
|
||||||
|
description="修改水印名称、默认状态、启用状态。设为默认水印时自动取消其他默认水印。",
|
||||||
|
)
|
||||||
|
async def update_home_material_watermark(
|
||||||
|
req: HomeMaterialWatermarkUpdate,
|
||||||
|
watermark_id: str = Path(..., description="水印ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.update_watermark(db, watermark_id, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.WATERMARK_UPDATE.value,
|
||||||
|
"PUT",
|
||||||
|
f"/admin/home-material/watermarks/{watermark_id}",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/watermarks/{watermark_id}",
|
||||||
|
summary="删除水印图片",
|
||||||
|
description="软删水印图片记录,不物理删除文件;历史素材仍保留已生成的水印素材。",
|
||||||
|
)
|
||||||
|
async def delete_home_material_watermark(
|
||||||
|
watermark_id: str = Path(..., description="水印ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
watermark, before = await home_material_service.delete_watermark(db, watermark_id, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.WATERMARK_DELETE.value,
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/home-material/watermarks/{watermark_id}",
|
||||||
|
detail=_detail(before=before, after={"deleted_at": watermark.deleted_at}),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/text-watermark-preview",
|
||||||
|
response_model=HomeMaterialTextWatermarkPreviewResponse,
|
||||||
|
summary="生成重复文字水印精准预览层",
|
||||||
|
description=(
|
||||||
|
"根据素材真实宽高和重复文字水印配置,使用后端固定开源字体生成透明 PNG 预览层 data URL。"
|
||||||
|
"该接口和最终 FFmpeg 叠加前的文字水印层共用同一套渲染逻辑,用于保证预览和实际效果一致。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def preview_home_material_text_watermark(
|
||||||
|
req: HomeMaterialTextWatermarkPreviewRequest,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.preview_text_watermark_layer(req)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assets",
|
||||||
|
response_model=HomeMaterialAssetListOut,
|
||||||
|
summary="后台首页素材列表",
|
||||||
|
description="分页查询首页素材。不会连表,先查素材列表,再批量查询行业和水印 map 后回填 category_name/category_key/watermark_name。",
|
||||||
|
)
|
||||||
|
async def list_home_material_assets(
|
||||||
|
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||||
|
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||||
|
category_id: str | None = Query(None, description="行业ID。"),
|
||||||
|
media_type: HomeMaterialMediaType | None = Query(None, description="素材类型:image图片,video视频。"),
|
||||||
|
status: HomeMaterialAssetStatus | None = Query(None, description="处理状态:draft草稿,processing处理中,success成功,failed失败。"),
|
||||||
|
is_active: bool | None = Query(None, description="是否前台展示。"),
|
||||||
|
keyword: str | None = Query(None, description="素材标题搜索。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.list_assets(
|
||||||
|
db,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
category_id=category_id,
|
||||||
|
media_type=media_type,
|
||||||
|
status=status,
|
||||||
|
is_active=is_active,
|
||||||
|
keyword=keyword,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/assets",
|
||||||
|
response_model=HomeMaterialUploadResultOut,
|
||||||
|
summary="上传首页素材并生成水印",
|
||||||
|
description=(
|
||||||
|
"上传图片或视频素材,并使用水印图片生成水印版本。默认 wait=false 立即返回 processing,前端轮询 /assets/{asset_id}/status。"
|
||||||
|
"可传 watermark_id 使用水印库,也可传 watermark_file 临时上传水印;二者都不传时使用默认水印。"
|
||||||
|
"水印透明度 opacity_level 为 1-10;position 支持九宫格和 custom;custom 需要 custom_x_ratio/custom_y_ratio;"
|
||||||
|
"size_mode=ratio 使用 width_ratio,size_mode=px 使用 width_px。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def upload_home_material_asset(
|
||||||
|
category_id: str = Form(..., description="行业ID。"),
|
||||||
|
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
||||||
|
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
||||||
|
title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
|
||||||
|
generation_prompt: str | None = Form(None, description="生成提词,可为空。"),
|
||||||
|
media_references_json: str | None = Form(None, description="附件/参考素材JSON字符串,格式参考 generation_ai.create_task 的 media_references。"),
|
||||||
|
watermark_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"),
|
||||||
|
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
||||||
|
watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。watermark_type=image 时使用。"),
|
||||||
|
opacity_level: int = Form(6, ge=1, le=10, description="图片水印透明度档位,1-10。"),
|
||||||
|
position: HomeMaterialWatermarkPosition = Form(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="水印位置。"),
|
||||||
|
custom_x_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置X比例,position=custom时必填。"),
|
||||||
|
custom_y_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置Y比例,position=custom时必填。"),
|
||||||
|
size_mode: HomeMaterialWatermarkSizeMode = Form(HomeMaterialWatermarkSizeMode.RATIO, description="水印尺寸模式:ratio/px。"),
|
||||||
|
width_ratio: float | None = Form(0.18, ge=0.01, le=1, description="size_mode=ratio时使用,水印宽度占素材宽度比例。"),
|
||||||
|
width_px: int | None = Form(None, ge=1, le=10000, description="size_mode=px时使用,固定水印宽度。"),
|
||||||
|
margin_x: int = Form(24, ge=0, le=2000, description="图片水印横向边距。"),
|
||||||
|
margin_y: int = Form(24, ge=0, le=2000, description="图片水印纵向边距。"),
|
||||||
|
text_watermark_text: str | None = Form(None, description="重复文字水印内容。watermark_type=repeated_text 时必填。"),
|
||||||
|
text_watermark_opacity_level: int = Form(2, ge=1, le=10, description="重复文字透明度档位,1-10。"),
|
||||||
|
text_watermark_font_size_px: int = Form(28, ge=8, le=160, description="重复文字字号 px。"),
|
||||||
|
text_watermark_color: str = Form("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="重复文字颜色,#RRGGBB。"),
|
||||||
|
text_watermark_rotate_deg: int = Form(-30, ge=-90, le=90, description="重复文字旋转角度。"),
|
||||||
|
text_watermark_gap_x: int = Form(220, ge=20, le=2000, description="重复文字横向间距。"),
|
||||||
|
text_watermark_gap_y: int = Form(140, ge=20, le=2000, description="重复文字纵向间距。"),
|
||||||
|
text_watermark_staggered: bool = Form(True, description="重复文字是否交错排列。"),
|
||||||
|
is_active: bool = Form(True, description="是否前台展示。"),
|
||||||
|
sort_order: int = Form(0, ge=0, le=999999, description="排序值。"),
|
||||||
|
wait: bool = Form(False, description="是否等待处理完成。"),
|
||||||
|
wait_timeout_seconds: int = Form(30, ge=1, le=60, description="wait=true时最长等待秒数。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
text_config = None
|
||||||
|
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT:
|
||||||
|
text_config = HomeMaterialTextWatermarkConfig(
|
||||||
|
text=text_watermark_text or "",
|
||||||
|
opacity_level=text_watermark_opacity_level,
|
||||||
|
font_size_px=text_watermark_font_size_px,
|
||||||
|
color=text_watermark_color,
|
||||||
|
rotate_deg=text_watermark_rotate_deg,
|
||||||
|
gap_x=text_watermark_gap_x,
|
||||||
|
gap_y=text_watermark_gap_y,
|
||||||
|
staggered=text_watermark_staggered,
|
||||||
|
)
|
||||||
|
config = HomeMaterialWatermarkConfig(
|
||||||
|
watermark_type=watermark_type,
|
||||||
|
watermark_id=watermark_id,
|
||||||
|
opacity_level=opacity_level,
|
||||||
|
position=position,
|
||||||
|
custom_x_ratio=custom_x_ratio,
|
||||||
|
custom_y_ratio=custom_y_ratio,
|
||||||
|
size_mode=size_mode,
|
||||||
|
width_ratio=width_ratio,
|
||||||
|
width_px=width_px,
|
||||||
|
margin_x=margin_x,
|
||||||
|
margin_y=margin_y,
|
||||||
|
text_watermark=text_config,
|
||||||
|
)
|
||||||
|
result, after = await home_material_service.upload_asset(
|
||||||
|
db,
|
||||||
|
category_id=category_id,
|
||||||
|
file=file,
|
||||||
|
media_type=media_type,
|
||||||
|
title=title,
|
||||||
|
generation_prompt=generation_prompt,
|
||||||
|
media_references_json=media_references_json,
|
||||||
|
watermark_id=watermark_id,
|
||||||
|
watermark_file=watermark_file,
|
||||||
|
watermark_config=config,
|
||||||
|
is_active=is_active,
|
||||||
|
sort_order=sort_order,
|
||||||
|
admin_id=admin.id,
|
||||||
|
)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.ASSET_UPLOAD.value,
|
||||||
|
"POST",
|
||||||
|
"/admin/home-material/assets",
|
||||||
|
detail=_detail(after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
home_material_service.start_watermark_task(result.id)
|
||||||
|
if wait:
|
||||||
|
return await home_material_service.wait_for_asset_result(result.id, wait_timeout_seconds)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assets/{asset_id}",
|
||||||
|
response_model=HomeMaterialAssetOut,
|
||||||
|
summary="后台首页素材详情",
|
||||||
|
description="查询单个素材详情。详情仍不使用 ORM relationship,会按 category_id/watermark_id 批量查询 map 后组装。",
|
||||||
|
)
|
||||||
|
async def get_home_material_asset(
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.get_asset_detail(db, asset_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assets/{asset_id}/status",
|
||||||
|
response_model=HomeMaterialAssetStatusOut,
|
||||||
|
summary="查询首页素材水印处理状态",
|
||||||
|
description="前端上传或重新生成后轮询本接口,status=success 时展示 watermarked_url,status=failed 时展示 error_message 并允许重新生成。",
|
||||||
|
)
|
||||||
|
async def get_home_material_asset_status(
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.get_asset_status(db, asset_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/assets/{asset_id}",
|
||||||
|
response_model=HomeMaterialAssetOut,
|
||||||
|
summary="修改首页素材展示信息",
|
||||||
|
description="修改行业、标题、启用状态、排序、生成提词和附件JSON,不重新生成水印。",
|
||||||
|
)
|
||||||
|
async def update_home_material_asset(
|
||||||
|
req: HomeMaterialAssetUpdate,
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.update_asset(db, asset_id, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
(HomeMaterialOperationEnum.ASSET_GENERATION_CONFIG_UPDATE.value if (before.get("generation_prompt") != after.get("generation_prompt") or before.get("media_references_count") != after.get("media_references_count")) else HomeMaterialOperationEnum.ASSET_UPDATE.value),
|
||||||
|
"PUT",
|
||||||
|
f"/admin/home-material/assets/{asset_id}",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/assets/{asset_id}/regenerate-watermark",
|
||||||
|
response_model=HomeMaterialUploadResultOut,
|
||||||
|
summary="重新生成首页素材水印",
|
||||||
|
description="基于原始素材和新的水印配置重新生成水印文件。默认 wait=false,前端轮询状态接口。失败时保留旧 watermarked_url,但 status=failed。",
|
||||||
|
)
|
||||||
|
async def regenerate_home_material_watermark(
|
||||||
|
req: HomeMaterialRegenerateWatermarkRequest,
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.prepare_regenerate(db, asset_id, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.ASSET_REGENERATE_WATERMARK.value,
|
||||||
|
"POST",
|
||||||
|
f"/admin/home-material/assets/{asset_id}/regenerate-watermark",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
home_material_service.start_watermark_task(asset_id)
|
||||||
|
if req.wait:
|
||||||
|
return await home_material_service.wait_for_asset_result(asset_id, req.wait_timeout_seconds)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/assets/{asset_id}",
|
||||||
|
summary="删除首页素材",
|
||||||
|
description="软删素材记录,不物理删除原文件和水印文件,避免误删历史展示资源。",
|
||||||
|
)
|
||||||
|
async def delete_home_material_asset(
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
asset, before = await home_material_service.delete_asset(db, asset_id, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.ASSET_DELETE.value,
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/home-material/assets/{asset_id}",
|
||||||
|
detail=_detail(before=before, after={"deleted_at": asset.deleted_at}),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "ok"}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_db, get_admin_user, get_backend_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.menu_config import MenuConfig
|
||||||
|
from app.schemas.menu import MenuConfigCreate, MenuConfigOut
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/menu-configs", tags=["admin-menu-configs"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[MenuConfigOut])
|
||||||
|
async def admin_list_menu_configs(
|
||||||
|
admin: User = Depends(get_backend_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(MenuConfig).order_by(MenuConfig.sort_order))
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=MenuConfigOut)
|
||||||
|
async def create_menu_config(
|
||||||
|
req: MenuConfigCreate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
menu = MenuConfig(id=generate_id(), **req.model_dump())
|
||||||
|
db.add(menu)
|
||||||
|
await db.flush()
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"创建菜单 {menu.label}",
|
||||||
|
"POST",
|
||||||
|
"/admin/menu-configs",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"id": menu.id,
|
||||||
|
"label": menu.label,
|
||||||
|
"path": menu.path,
|
||||||
|
"menu_target": menu.menu_target,
|
||||||
|
"menu_type": menu.menu_type,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return menu
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{menu_id}", response_model=MenuConfigOut)
|
||||||
|
async def update_menu_config(
|
||||||
|
menu_id: str,
|
||||||
|
req: MenuConfigCreate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(MenuConfig).where(MenuConfig.id == menu_id).limit(1))
|
||||||
|
menu = result.scalar_one_or_none()
|
||||||
|
if not menu:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
|
||||||
|
before_label = menu.label
|
||||||
|
for k, v in req.model_dump().items():
|
||||||
|
setattr(menu, k, v)
|
||||||
|
await db.flush()
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"更新菜单 {before_label}",
|
||||||
|
"PUT",
|
||||||
|
f"/admin/menu-configs/{menu_id}",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"id": menu_id,
|
||||||
|
"before": {"label": before_label},
|
||||||
|
"after": {
|
||||||
|
"label": menu.label,
|
||||||
|
"path": menu.path,
|
||||||
|
"is_active": menu.is_active,
|
||||||
|
"sort_order": menu.sort_order,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return menu
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{menu_id}")
|
||||||
|
async def delete_menu_config(
|
||||||
|
menu_id: str,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(select(MenuConfig).where(MenuConfig.id == menu_id).limit(1))
|
||||||
|
menu = result.scalar_one_or_none()
|
||||||
|
if not menu:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="菜单不存在")
|
||||||
|
menu_label = menu.label
|
||||||
|
await db.delete(menu)
|
||||||
|
await db.flush()
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"删除菜单 {menu_label}",
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/menu-configs/{menu_id}",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"id": menu_id,
|
||||||
|
"label": menu_label,
|
||||||
|
"path": menu.path,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "ok"}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.private_portrait import (
|
||||||
|
PrivatePortraitAdminConfigUpdate,
|
||||||
|
PrivatePortraitAdminStatsOut,
|
||||||
|
PrivatePortraitAssetListOut,
|
||||||
|
PrivatePortraitConfigOut,
|
||||||
|
PrivatePortraitProjectListOut,
|
||||||
|
)
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
from app.services.private_portrait.admin.asset_query_service import admin_list_assets
|
||||||
|
from app.services.private_portrait.admin.project_query_service import admin_list_projects
|
||||||
|
from app.services.private_portrait.admin.stats_query_service import admin_get_private_portrait_stats
|
||||||
|
from app.services.private_portrait.quota_service import get_user_private_portrait_config, set_user_private_portrait_limit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/private-portrait", tags=["管理后台-私域人像素材库"])
|
||||||
|
|
||||||
|
|
||||||
|
def _json_detail(data: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(data, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:查看用户私域人像素材额度")
|
||||||
|
async def admin_get_private_portrait_config(user_id: str, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
config = await get_user_private_portrait_config(db, user_id=user_id)
|
||||||
|
await log_operation(db, admin.id, admin.username, "查看私域人像素材库配置", "GET", f"/admin/private-portrait/users/{user_id}/config", detail=_json_detail({"target_user_id": user_id, "config": config.model_dump(mode="json")}))
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/users/{user_id}/config", response_model=PrivatePortraitConfigOut, summary="管理后台:设置用户私域人像素材总量")
|
||||||
|
async def admin_update_private_portrait_config(user_id: str, payload: PrivatePortraitAdminConfigUpdate, admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
before = await get_user_private_portrait_config(db, user_id=user_id)
|
||||||
|
await set_user_private_portrait_limit(db, user_id=user_id, limit=payload.private_portrait_asset_limit)
|
||||||
|
after = await get_user_private_portrait_config(db, user_id=user_id)
|
||||||
|
await log_operation(db, admin.id, admin.username, f"设置私域人像素材总量限制:{before.asset_limit} -> {after.asset_limit}", "PUT", f"/admin/private-portrait/users/{user_id}/config", detail=_json_detail({"target_user_id": user_id, "before": before.model_dump(mode="json"), "after": after.model_dump(mode="json")}))
|
||||||
|
await db.commit()
|
||||||
|
return after
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=PrivatePortraitAdminStatsOut, summary="管理后台:私域人像素材统计")
|
||||||
|
async def admin_get_private_portrait_stats_api(user_id: str | None = Query(None), library_type: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
stats = await admin_get_private_portrait_stats(db, user_id=user_id, library_type=library_type)
|
||||||
|
await log_operation(db, admin.id, admin.username, "查看私域人像素材统计", "GET", "/admin/private-portrait/stats", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type}, "stats": stats.model_dump(mode="json")}))
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects", response_model=PrivatePortraitProjectListOut, summary="管理后台:查询私域人像项目列表")
|
||||||
|
async def admin_list_private_portrait_projects(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user_id: str | None = Query(None), library_type: str | None = Query(None), keyword: str | None = Query(None), status: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
items, total = await admin_list_projects(db, user_id=user_id, library_type=library_type, page=page, page_size=page_size, keyword=keyword, status=status)
|
||||||
|
await log_operation(db, admin.id, admin.username, "查看私域人像素材项目列表", "GET", "/admin/private-portrait/projects", detail=_json_detail({"filters": {"user_id": user_id, "library_type": library_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)}))
|
||||||
|
await db.commit()
|
||||||
|
return PrivatePortraitProjectListOut(items=items, total=total, page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/assets", response_model=PrivatePortraitAssetListOut, summary="管理后台:查询私域人像素材列表")
|
||||||
|
async def admin_list_private_portrait_assets(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), user_id: str | None = Query(None), project_id: str | None = Query(None), library_type: str | None = Query(None), asset_type: str | None = Query(None), keyword: str | None = Query(None), status: str | None = Query(None), admin: User = Depends(get_admin_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
items, total = await admin_list_assets(db, user_id=user_id, project_id=project_id, library_type=library_type, asset_type=asset_type, status=status, keyword=keyword, page=page, page_size=page_size)
|
||||||
|
await log_operation(db, admin.id, admin.username, "查看私域人像素材列表", "GET", "/admin/private-portrait/assets", detail=_json_detail({"filters": {"user_id": user_id, "project_id": project_id, "library_type": library_type, "asset_type": asset_type, "keyword": keyword, "status": status, "page": page, "page_size": page_size}, "total": total, "returned_count": len(items)}))
|
||||||
|
await db.commit()
|
||||||
|
return PrivatePortraitAssetListOut(items=items, total=total, page=page, page_size=page_size)
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_db, get_admin_user
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.recharge_package import RechargePackage
|
||||||
|
from app.schemas.recharge_package import (
|
||||||
|
RechargePackageCreate,
|
||||||
|
RechargePackageUpdate,
|
||||||
|
RechargePackageOut,
|
||||||
|
)
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/recharge-packages", tags=["admin-recharge-packages"])
|
||||||
|
|
||||||
|
|
||||||
|
def _to_out(pkg: RechargePackage) -> dict:
|
||||||
|
return {
|
||||||
|
"id": pkg.id,
|
||||||
|
"name": pkg.name,
|
||||||
|
"credits": round(pkg.credits, 2),
|
||||||
|
"price": round(pkg.price, 2),
|
||||||
|
"bonus_credits": round(pkg.bonus_credits, 2),
|
||||||
|
"total_credits": round(pkg.credits + pkg.bonus_credits, 2),
|
||||||
|
"description": pkg.description,
|
||||||
|
"package_type": pkg.package_type,
|
||||||
|
"is_gift": pkg.is_gift,
|
||||||
|
"is_active": pkg.is_active,
|
||||||
|
"sort_order": pkg.sort_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RechargePackageOut])
|
||||||
|
async def admin_list_packages(
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RechargePackage).order_by(RechargePackage.sort_order)
|
||||||
|
)
|
||||||
|
return [_to_out(p) for p in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RechargePackageOut)
|
||||||
|
async def create_package(
|
||||||
|
data: RechargePackageCreate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
pkg = RechargePackage(id=generate_id(), **data.model_dump())
|
||||||
|
db.add(pkg)
|
||||||
|
await db.flush()
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"创建充值套餐 {pkg.name}",
|
||||||
|
"POST",
|
||||||
|
"/admin/recharge-packages",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"id": pkg.id,
|
||||||
|
"name": pkg.name,
|
||||||
|
"price": round(pkg.price, 2),
|
||||||
|
"credits": round(pkg.credits, 2),
|
||||||
|
"bonus_credits": round(pkg.bonus_credits, 2),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return _to_out(pkg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{pkg_id}", response_model=RechargePackageOut)
|
||||||
|
async def update_package(
|
||||||
|
pkg_id: str,
|
||||||
|
data: RechargePackageUpdate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
|
||||||
|
)
|
||||||
|
pkg = result.scalar_one_or_none()
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||||
|
before = _to_out(pkg)
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(pkg, k, v)
|
||||||
|
await db.flush()
|
||||||
|
after = _to_out(pkg)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"更新充值套餐 {pkg.name}",
|
||||||
|
"PUT",
|
||||||
|
f"/admin/recharge-packages/{pkg_id}",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"id": pkg_id,
|
||||||
|
"before": before,
|
||||||
|
"after": after,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return _to_out(pkg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{pkg_id}")
|
||||||
|
async def delete_package(
|
||||||
|
pkg_id: str,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(RechargePackage).where(RechargePackage.id == pkg_id).limit(1)
|
||||||
|
)
|
||||||
|
pkg = result.scalar_one_or_none()
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||||
|
pkg_name = pkg.name
|
||||||
|
await db.delete(pkg)
|
||||||
|
await db.flush()
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"删除充值套餐 {pkg_name}",
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/recharge-packages/{pkg_id}",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"id": pkg_id,
|
||||||
|
"name": pkg_name,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Path
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.enums.resource_capacity import ResourceCapacityOperationEnum
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.resource_capacity import (
|
||||||
|
AdminUserResourceCapacityOut,
|
||||||
|
ResourceCapacityConfigOut,
|
||||||
|
ResourceCapacityConfigUpdate,
|
||||||
|
)
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
from app.services.resource_capacity_service import (
|
||||||
|
build_global_resource_capacity_operation_detail,
|
||||||
|
build_user_resource_capacity_operation_detail,
|
||||||
|
delete_user_resource_capacity_config,
|
||||||
|
get_admin_user_resource_capacity,
|
||||||
|
get_global_resource_capacity_config,
|
||||||
|
save_global_resource_capacity_config,
|
||||||
|
save_user_resource_capacity_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin-resource-capacity"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/resource-capacity/global",
|
||||||
|
response_model=ResourceCapacityConfigOut,
|
||||||
|
summary="获取全局资源空间容量配置",
|
||||||
|
description=(
|
||||||
|
"获取管理后台全局生成资源空间容量管控配置。"
|
||||||
|
"配置最终存储在 system_configs 表,key=resource_capacity_limit_config。"
|
||||||
|
"enabled=false 表示全局容量管控关闭;enabled=true 表示按 limit_value + limit_unit 换算出的 limit_bytes 进行限制。"
|
||||||
|
"单位枚举:MB=1048576字节,GB=1073741824字节,TB=1099511627776字节。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_global_resource_capacity(
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await get_global_resource_capacity_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/resource-capacity/global",
|
||||||
|
response_model=ResourceCapacityConfigOut,
|
||||||
|
summary="保存全局资源空间容量配置",
|
||||||
|
description=(
|
||||||
|
"保存管理后台全局生成资源空间容量管控配置。"
|
||||||
|
"enabled=true 时,limit_value 和 limit_unit 必填。"
|
||||||
|
"limit_value 最小为1,不能为负数,最多支持3位小数。"
|
||||||
|
"limit_unit 枚举明细:MB=1048576字节,GB=1073741824字节,TB=1099511627776字节。"
|
||||||
|
"limit_bytes 不允许前端传入,由后端统一换算后保存。"
|
||||||
|
"本接口会写入后台操作日志,action=更新全局资源容量配置,detail记录修改前后配置快照。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def update_global_resource_capacity(
|
||||||
|
req: ResourceCapacityConfigUpdate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
before = await get_global_resource_capacity_config(db)
|
||||||
|
result = await save_global_resource_capacity_config(db, req)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
ResourceCapacityOperationEnum.UPDATE_GLOBAL_CONFIG.value,
|
||||||
|
"PUT",
|
||||||
|
"/admin/resource-capacity/global",
|
||||||
|
detail=build_global_resource_capacity_operation_detail(before, result),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/users/{user_id}/resource-capacity",
|
||||||
|
response_model=AdminUserResourceCapacityOut,
|
||||||
|
summary="获取指定用户资源空间容量配置",
|
||||||
|
description=(
|
||||||
|
"获取指定用户的个人容量配置、全局容量配置以及最终生效容量数据。"
|
||||||
|
"优先级:用户个人配置存在时优先使用;用户个人配置不存在时使用全局配置。"
|
||||||
|
"注意:用户个人配置存在且 enabled=false,表示该用户个人明确关闭容量限制,不再回落到全局配置。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_user_resource_capacity(
|
||||||
|
user_id: str = Path(..., description="用户ID,用于查询该用户个人容量配置和最终生效容量数据"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await get_admin_user_resource_capacity(db, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/users/{user_id}/resource-capacity",
|
||||||
|
response_model=AdminUserResourceCapacityOut,
|
||||||
|
summary="保存指定用户个人资源空间容量配置",
|
||||||
|
description=(
|
||||||
|
"新增或更新指定用户个人资源空间容量配置。"
|
||||||
|
"保存后该用户配置优先级高于全局配置。"
|
||||||
|
"enabled=true 表示启用该用户个人容量限制;enabled=false 表示该用户个人明确关闭限制,不再回落到全局。"
|
||||||
|
"limit_value 最小为1,不能为负数,最多3位小数。"
|
||||||
|
"limit_unit 枚举明细:MB=1048576字节,GB=1073741824字节,TB=1099511627776字节。"
|
||||||
|
"本接口会写入后台操作日志:首次创建 action=新增用户资源容量配置;已有配置更新 action=更新用户资源容量配置;detail记录修改前后配置快照。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def update_user_resource_capacity(
|
||||||
|
req: ResourceCapacityConfigUpdate,
|
||||||
|
user_id: str = Path(..., description="用户ID,用于新增或更新该用户个人容量配置"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
before = await get_admin_user_resource_capacity(db, user_id)
|
||||||
|
result = await save_user_resource_capacity_config(db, user_id, req)
|
||||||
|
is_create = not before.has_user_config
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
(
|
||||||
|
ResourceCapacityOperationEnum.CREATE_USER_CONFIG.value
|
||||||
|
if is_create
|
||||||
|
else ResourceCapacityOperationEnum.UPDATE_USER_CONFIG.value
|
||||||
|
),
|
||||||
|
"PUT",
|
||||||
|
f"/admin/users/{user_id}/resource-capacity",
|
||||||
|
detail=build_user_resource_capacity_operation_detail(
|
||||||
|
target_user_id=user_id,
|
||||||
|
operation="create" if is_create else "update",
|
||||||
|
before=before,
|
||||||
|
after=result,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/users/{user_id}/resource-capacity",
|
||||||
|
response_model=AdminUserResourceCapacityOut,
|
||||||
|
summary="删除指定用户个人资源空间容量配置",
|
||||||
|
description=(
|
||||||
|
"删除指定用户个人容量配置。删除后该用户不再有个人覆盖配置,后续容量限制重新回落到全局配置。"
|
||||||
|
"本接口会写入后台操作日志,action=删除用户资源容量配置,detail记录删除前个人配置、删除后最终生效配置。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def remove_user_resource_capacity(
|
||||||
|
user_id: str = Path(..., description="用户ID,用于删除该用户个人容量配置并恢复走全局配置"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
before = await get_admin_user_resource_capacity(db, user_id)
|
||||||
|
result = await delete_user_resource_capacity_config(db, user_id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
ResourceCapacityOperationEnum.DELETE_USER_CONFIG.value,
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/users/{user_id}/resource-capacity",
|
||||||
|
detail=build_user_resource_capacity_operation_detail(
|
||||||
|
target_user_id=user_id,
|
||||||
|
operation="delete",
|
||||||
|
before=before,
|
||||||
|
after=result,
|
||||||
|
remark="删除用户个人容量配置,用户恢复使用全局资源容量配置。",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.models.team import Team
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.team import TeamCreate, TeamListOut, TeamOptionOut, TeamUpdate
|
||||||
|
from app.schemas.team_manager import SetManagerRequest
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
from app.services.team_manager_service import set_team_manager
|
||||||
|
from app.services.team_service import create_team, list_team_options, list_teams, soft_delete_team, update_team
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/teams", tags=["admin-teams"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _team_detail_payload(db: AsyncSession, team: Team) -> dict:
|
||||||
|
"""构造返回团队详情,包含 manager_name。"""
|
||||||
|
payload = {
|
||||||
|
"id": team.id,
|
||||||
|
"name": team.name,
|
||||||
|
"code": getattr(team, "code", None),
|
||||||
|
"description": getattr(team, "description", None),
|
||||||
|
"status": getattr(team, "status", "active"),
|
||||||
|
"sort_order": getattr(team, "sort_order", 0) or 0,
|
||||||
|
"member_count": 0,
|
||||||
|
"created_at": team.created_at,
|
||||||
|
"updated_at": team.updated_at,
|
||||||
|
"manager_id": getattr(team, "manager_id", None),
|
||||||
|
"manager_name": None,
|
||||||
|
}
|
||||||
|
# 查询成员数和管理人用户名
|
||||||
|
from sqlalchemy import func
|
||||||
|
from app.enums.user import UserType
|
||||||
|
member_count = (await db.execute(
|
||||||
|
select(func.count(User.id)).where(
|
||||||
|
User.user_type == UserType.FRONTEND.value,
|
||||||
|
User.team_id == team.id,
|
||||||
|
)
|
||||||
|
)).scalar() or 0
|
||||||
|
payload["member_count"] = int(member_count)
|
||||||
|
|
||||||
|
if getattr(team, "manager_id", None):
|
||||||
|
mgr = await db.execute(
|
||||||
|
select(User.username).where(User.id == team.manager_id).limit(1)
|
||||||
|
)
|
||||||
|
payload["manager_name"] = mgr.scalar_one_or_none()
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=TeamListOut)
|
||||||
|
async def list_admin_teams(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=500),
|
||||||
|
keyword: str | None = Query(None),
|
||||||
|
status: str | None = Query(None, pattern="^(active|disabled)$"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await list_teams(db, page=page, page_size=page_size, keyword=keyword, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/options", response_model=list[TeamOptionOut])
|
||||||
|
async def list_admin_team_options(
|
||||||
|
include_disabled: bool = Query(True),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await list_team_options(db, include_disabled=include_disabled)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", )
|
||||||
|
async def create_admin_team(
|
||||||
|
req: TeamCreate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
team = await create_team(db, req)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"创建团队 {team.name}",
|
||||||
|
"POST",
|
||||||
|
"/admin/teams",
|
||||||
|
detail=json.dumps({"after": req.model_dump()}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return await _team_detail_payload(db, team)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{team_id}", )
|
||||||
|
async def update_admin_team(
|
||||||
|
team_id: str,
|
||||||
|
req: TeamUpdate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
team, before, after = await update_team(db, team_id, req)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"修改团队 {team.name}",
|
||||||
|
"PUT",
|
||||||
|
f"/admin/teams/{team_id}",
|
||||||
|
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return await _team_detail_payload(db, team)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{team_id}/manager", )
|
||||||
|
async def set_team_manager_endpoint(
|
||||||
|
team_id: str,
|
||||||
|
req: SetManagerRequest = Body(...),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
team = await set_team_manager(db, team_id, req.user_id)
|
||||||
|
manager_name = None
|
||||||
|
# 使用 req.user_id 避免访问 team.manager_id 触发懒加载
|
||||||
|
if req.user_id:
|
||||||
|
mgr = await db.execute(
|
||||||
|
select(User.username).where(User.id == req.user_id).limit(1)
|
||||||
|
)
|
||||||
|
manager_name = mgr.scalar_one_or_none()
|
||||||
|
team_name = team.name
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"设置团队管理人 {team_name}: {manager_name or '取消'}",
|
||||||
|
"PUT",
|
||||||
|
f"/admin/teams/{team_id}/manager",
|
||||||
|
detail=json.dumps({"manager_id": req.user_id}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return await _team_detail_payload(db, team)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{team_id}")
|
||||||
|
async def delete_admin_team(
|
||||||
|
team_id: str,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
team, before = await soft_delete_team(db, team_id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
f"删除团队 {team.name}",
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/teams/{team_id}",
|
||||||
|
detail=json.dumps({"before": before, "after": {"deleted_at": str(team.deleted_at)}}, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return {"message": "ok"}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.enums.admin_upload import AdminUploadResourceTypeEnum, AdminUploadSceneEnum
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.admin_upload import AdminUploadFileOut
|
||||||
|
from app.services.admin_upload import admin_upload_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/uploads", tags=["admin-upload"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/files",
|
||||||
|
response_model=AdminUploadFileOut,
|
||||||
|
summary="管理后台全局文件上传",
|
||||||
|
description=(
|
||||||
|
"管理后台纯文件上传统一入口。通过 scene 区分业务场景并落到独立目录;"
|
||||||
|
"首页素材附件使用 scene=home_material_reference,系统Logo使用 system_logo,PDF使用 system_pdf,开户缩略图使用 open_type_thumb。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def upload_admin_file(
|
||||||
|
file: UploadFile = File(..., description="上传文件。"),
|
||||||
|
scene: AdminUploadSceneEnum = Form(..., description="上传场景。"),
|
||||||
|
resource_type: AdminUploadResourceTypeEnum = Form(..., description="资源类型:image/video/audio/pdf/file。"),
|
||||||
|
duration_seconds: float | None = Form(None, ge=0, description="音视频时长,单位秒。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await admin_upload_service.upload_file(
|
||||||
|
db,
|
||||||
|
file=file,
|
||||||
|
scene=scene,
|
||||||
|
resource_type=resource_type,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
admin=admin,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.video_prompt_schema_config import (
|
||||||
|
VideoPromptSchemaConfigImportRequest,
|
||||||
|
VideoPromptSchemaConfigOut,
|
||||||
|
VideoPromptSchemaConfigSaveRequest,
|
||||||
|
VideoPromptSchemaExportOut,
|
||||||
|
VideoPromptSchemaPreviewOut,
|
||||||
|
VideoPromptSchemaPreviewRequest,
|
||||||
|
)
|
||||||
|
from app.services.video_prompt_schema_config_service import (
|
||||||
|
export_video_prompt_schema_config,
|
||||||
|
get_video_prompt_schema_config,
|
||||||
|
import_video_prompt_schema_config,
|
||||||
|
preview_runtime_schema,
|
||||||
|
reset_video_prompt_schema_config,
|
||||||
|
save_video_prompt_schema_config,
|
||||||
|
)
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/video-prompt-schema-config", tags=["admin-video-prompt-schema-config"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=VideoPromptSchemaConfigOut, summary="获取视频提词 Schema 配置")
|
||||||
|
async def get_config(
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await get_video_prompt_schema_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=VideoPromptSchemaConfigOut, summary="保存视频提词 Schema 配置")
|
||||||
|
async def save_config(
|
||||||
|
req: VideoPromptSchemaConfigSaveRequest,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await save_video_prompt_schema_config(db, data=req.data, is_enabled=req.is_enabled)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
"保存视频提词 Schema 配置",
|
||||||
|
"PUT",
|
||||||
|
"/admin/video-prompt-schema-config",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"is_enabled": req.is_enabled,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-default", response_model=VideoPromptSchemaConfigOut, summary="恢复默认视频提词 Schema 配置")
|
||||||
|
async def reset_default(
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await reset_video_prompt_schema_config(db)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
"恢复默认视频提词 Schema 配置",
|
||||||
|
"POST",
|
||||||
|
"/admin/video-prompt-schema-config/reset-default",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"reset_to_default": True,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export", response_model=VideoPromptSchemaExportOut, summary="导出视频提词 Schema 配置")
|
||||||
|
async def export_config(
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await export_video_prompt_schema_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=VideoPromptSchemaConfigOut, summary="导入视频提词 Schema 配置")
|
||||||
|
async def import_config(
|
||||||
|
req: VideoPromptSchemaConfigImportRequest,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await import_video_prompt_schema_config(db, data=req.data, is_enabled=req.is_enabled)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
"导入视频提词 Schema 配置",
|
||||||
|
"POST",
|
||||||
|
"/admin/video-prompt-schema-config/import",
|
||||||
|
detail=json.dumps(
|
||||||
|
{
|
||||||
|
"is_enabled": req.is_enabled,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview", response_model=VideoPromptSchemaPreviewOut, summary="预览视频提词运行时 Schema")
|
||||||
|
async def preview_config(
|
||||||
|
req: VideoPromptSchemaPreviewRequest,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return preview_runtime_schema(data=req.data, is_enabled=req.is_enabled, video_config=req.to_video_config())
|
||||||
@@ -17,11 +17,24 @@ from app.api.v1.image_engines import router as image_engines_router
|
|||||||
from app.api.v1.generation_ai import router as generation_ai_router
|
from app.api.v1.generation_ai import router as generation_ai_router
|
||||||
from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router
|
from app.api.v1.hot_opening_replicate import router as hot_opening_replicate_router
|
||||||
from app.api.v1.shot_replicate import router as shot_replicate_router
|
from app.api.v1.shot_replicate import router as shot_replicate_router
|
||||||
|
from app.api.v1.recent_generation import router as recent_generation_router
|
||||||
from app.api.v1.test import router as test_router
|
from app.api.v1.test import router as test_router
|
||||||
from app.api.v1.user_oauth import router as user_oauth_router
|
from app.api.v1.user_oauth import router as user_oauth_router
|
||||||
from app.api.v1.user_oauth_app import router as user_oauth_app_router
|
from app.api.v1.user_oauth_app import router as user_oauth_app_router
|
||||||
|
from app.api.v1.user_oauth_account import router as user_oauth_account_router
|
||||||
from app.api.v1.upload_material import router as upload_material_router
|
from app.api.v1.upload_material import router as upload_material_router
|
||||||
from app.api.v1.pre_test_template import router as pre_test_template_router
|
from app.api.v1.pre_test_template import router as pre_test_template_router
|
||||||
|
from app.api.v1.material_consumption import router as material_consumption_router
|
||||||
|
from app.api.v1.open_type import router as open_type_router
|
||||||
|
from app.api.v1.resources_material import router as resources_material_router
|
||||||
|
from app.api.v1.contact import router as contact_router
|
||||||
|
from app.api.v1.team import router as team_router
|
||||||
|
from app.api.v1.home_materials import router as home_materials_router
|
||||||
|
from app.api.admin import router as admin_module_router
|
||||||
|
from app.api.v1.material_admin import router as material_admin_router
|
||||||
|
from app.api.v1.private_portrait import router as private_portrait_router
|
||||||
|
from app.api.v1.private_portrait_virtual import router as private_portrait_virtual_router
|
||||||
|
from app.api.v1.upload_resource import router as upload_resource_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
api_router.include_router(auth_router)
|
api_router.include_router(auth_router)
|
||||||
@@ -41,8 +54,21 @@ api_router.include_router(image_engines_router)
|
|||||||
api_router.include_router(generation_ai_router)
|
api_router.include_router(generation_ai_router)
|
||||||
api_router.include_router(hot_opening_replicate_router)
|
api_router.include_router(hot_opening_replicate_router)
|
||||||
api_router.include_router(shot_replicate_router)
|
api_router.include_router(shot_replicate_router)
|
||||||
|
api_router.include_router(recent_generation_router)
|
||||||
api_router.include_router(test_router)
|
api_router.include_router(test_router)
|
||||||
api_router.include_router(user_oauth_router)
|
api_router.include_router(user_oauth_router)
|
||||||
api_router.include_router(user_oauth_app_router)
|
api_router.include_router(user_oauth_app_router)
|
||||||
|
api_router.include_router(user_oauth_account_router)
|
||||||
api_router.include_router(upload_material_router)
|
api_router.include_router(upload_material_router)
|
||||||
api_router.include_router(pre_test_template_router)
|
api_router.include_router(pre_test_template_router)
|
||||||
|
api_router.include_router(material_consumption_router)
|
||||||
|
api_router.include_router(open_type_router)
|
||||||
|
api_router.include_router(resources_material_router)
|
||||||
|
api_router.include_router(contact_router)
|
||||||
|
api_router.include_router(team_router)
|
||||||
|
api_router.include_router(home_materials_router)
|
||||||
|
api_router.include_router(admin_module_router)
|
||||||
|
api_router.include_router(material_admin_router)
|
||||||
|
api_router.include_router(private_portrait_router)
|
||||||
|
api_router.include_router(private_portrait_virtual_router)
|
||||||
|
api_router.include_router(upload_resource_router)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,7 @@ from app.dependencies import (
|
|||||||
get_current_user_allow_password_pending,
|
get_current_user_allow_password_pending,
|
||||||
get_db,
|
get_db,
|
||||||
)
|
)
|
||||||
|
from app.models.credit_record import CreditRecord
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
@@ -29,6 +30,7 @@ from app.services.auth import (
|
|||||||
verify_password,
|
verify_password,
|
||||||
)
|
)
|
||||||
from app.services.sms import verify_sms_code
|
from app.services.sms import verify_sms_code
|
||||||
|
from app.services.resource_capacity_service import get_user_resource_capacity_usage
|
||||||
from app.utils.id_gen import generate_id
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -86,6 +88,20 @@ async def _get_register_credits(db: AsyncSession) -> int:
|
|||||||
return int(value) if value else 100
|
return int(value) if value else 100
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_register_credit_record(db: AsyncSession, user: User, credits: int) -> None:
|
||||||
|
if credits <= 0:
|
||||||
|
return
|
||||||
|
record = CreditRecord(
|
||||||
|
id=generate_id(),
|
||||||
|
user_id=user.id,
|
||||||
|
type="recharge",
|
||||||
|
amount=credits,
|
||||||
|
balance_after=user.credits,
|
||||||
|
description=f"注册赠送 {credits} 积分",
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
|
||||||
|
|
||||||
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
||||||
enabled_result = await db.execute(
|
enabled_result = await db.execute(
|
||||||
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
|
select(SystemConfig.value).where(SystemConfig.key == "user_login_credits_enabled").limit(1)
|
||||||
@@ -109,6 +125,16 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
|
|||||||
|
|
||||||
user.credits += credits
|
user.credits += credits
|
||||||
|
|
||||||
|
record = CreditRecord(
|
||||||
|
id=generate_id(),
|
||||||
|
user_id=user.id,
|
||||||
|
type="recharge",
|
||||||
|
amount=credits,
|
||||||
|
balance_after=user.credits,
|
||||||
|
description=f"每日登录赠送 {credits} 积分",
|
||||||
|
)
|
||||||
|
db.add(record)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/login",
|
"/login",
|
||||||
@@ -204,6 +230,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
|||||||
db.add(user)
|
db.add(user)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
await _add_register_credit_record(db, user, register_credits)
|
||||||
await _assign_default_frontend_menus(db, user)
|
await _assign_default_frontend_menus(db, user)
|
||||||
|
|
||||||
user.credits = round(user.credits, 2)
|
user.credits = round(user.credits, 2)
|
||||||
@@ -222,9 +249,32 @@ async def logout(current_user: User = Depends(get_current_user_allow_password_pe
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=UserOut)
|
@router.get("/me", response_model=UserOut)
|
||||||
async def get_me(current_user: User = Depends(get_current_user_allow_password_pending)):
|
async def get_me(
|
||||||
|
current_user: User = Depends(get_current_user_allow_password_pending),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
current_user.username = "用户"+current_user.username[-4:] if current_user.username == current_user.phone else current_user.username
|
||||||
current_user.credits = round(current_user.credits, 2)
|
current_user.credits = round(current_user.credits, 2)
|
||||||
return current_user
|
resource_capacity = await get_user_resource_capacity_usage(db, current_user.id)
|
||||||
|
|
||||||
|
# 计算 is_team_manager 和 team_name
|
||||||
|
is_team_manager = False
|
||||||
|
team_name = None
|
||||||
|
if current_user.team_id:
|
||||||
|
from app.services.team_manager_service import is_team_manager
|
||||||
|
from app.services.team_service import batch_get_team_name_map
|
||||||
|
is_team_manager = await is_team_manager(db, current_user.id, current_user.team_id)
|
||||||
|
name_map = await batch_get_team_name_map(db, [current_user.team_id])
|
||||||
|
team_name = name_map.get(current_user.team_id)
|
||||||
|
|
||||||
|
return UserOut.model_validate(current_user).model_copy(
|
||||||
|
update={
|
||||||
|
"resource_capacity": resource_capacity,
|
||||||
|
"is_team_manager": is_team_manager,
|
||||||
|
"team_id": current_user.team_id,
|
||||||
|
"team_name": team_name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
@@ -275,10 +325,10 @@ async def change_password(
|
|||||||
|
|
||||||
@router.get("/site-info")
|
@router.get("/site-info")
|
||||||
async def get_site_info(db: AsyncSession = Depends(get_db)):
|
async def get_site_info(db: AsyncSession = Depends(get_db)):
|
||||||
"""Public endpoint returning site name and logo."""
|
"""Public endpoint returning site name, logo, agreement and copyright info."""
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(SystemConfig).where(SystemConfig.key.in_([
|
select(SystemConfig).where(SystemConfig.key.in_([
|
||||||
"site_name", "site_logo", "user_agreement_url", "privacy_policy_url"
|
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual"
|
||||||
]))
|
]))
|
||||||
)
|
)
|
||||||
configs = result.scalars().all()
|
configs = result.scalars().all()
|
||||||
@@ -298,8 +348,9 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
|
|||||||
return {
|
return {
|
||||||
"site_name": info.get("site_name", "VideoGen.AI"),
|
"site_name": info.get("site_name", "VideoGen.AI"),
|
||||||
"site_logo": to_full_url(info.get("site_logo")),
|
"site_logo": to_full_url(info.get("site_logo")),
|
||||||
"user_agreement_url": to_full_url(info.get("user_agreement_url")),
|
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
|
||||||
"privacy_policy_url": to_full_url(info.get("privacy_policy_url")),
|
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
|
||||||
|
"operation_manual": info.get("operation_manual", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_db, get_current_user
|
||||||
|
from app.models.contact_request import ContactRequest
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.contact import ContactRequestCreate, ContactRequestListOut, ContactRequestOut
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/contact", tags=["contact"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/request", summary="提交联系请求", status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_contact_request(
|
||||||
|
request: ContactRequestCreate,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
async with db.begin_nested():
|
||||||
|
user_result = await db.execute(
|
||||||
|
select(User).where(User.id == user.id).with_for_update().limit(1)
|
||||||
|
)
|
||||||
|
locked_user = user_result.scalar_one_or_none()
|
||||||
|
if not locked_user:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||||
|
|
||||||
|
existing = await db.execute(
|
||||||
|
select(ContactRequest.id)
|
||||||
|
.where(ContactRequest.user_id == user.id)
|
||||||
|
.where(ContactRequest.submit_date == today_str)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="每个账号每天只能提交一次联系我们"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
contact_request = ContactRequest(
|
||||||
|
id=generate_id(),
|
||||||
|
user_id=user.id,
|
||||||
|
phone=request.phone,
|
||||||
|
company_name=request.company_name,
|
||||||
|
industry=request.industry,
|
||||||
|
name=request.name,
|
||||||
|
message=request.message,
|
||||||
|
submit_date=today_str,
|
||||||
|
)
|
||||||
|
db.add(contact_request)
|
||||||
|
await db.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="每个账号每天只能提交一次联系我们"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": "提交成功,我们会尽快与您联系"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests", summary="获取联系请求列表", response_model=ContactRequestListOut)
|
||||||
|
async def get_contact_requests(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
is_handled: bool | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||||
|
|
||||||
|
query = select(ContactRequest)
|
||||||
|
count_query = select(func.count(ContactRequest.id))
|
||||||
|
|
||||||
|
if is_handled is not None:
|
||||||
|
query = query.where(ContactRequest.is_handled == is_handled)
|
||||||
|
count_query = count_query.where(ContactRequest.is_handled == is_handled)
|
||||||
|
|
||||||
|
query = query.order_by(ContactRequest.created_at.desc())
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
result = await db.execute(query.offset(offset).limit(page_size))
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
total = (await db.execute(count_query)).scalar_one()
|
||||||
|
|
||||||
|
return {"items": items, "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/requests/{request_id}", summary="获取联系请求详情", response_model=ContactRequestOut)
|
||||||
|
async def get_contact_request(
|
||||||
|
request_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||||
|
|
||||||
|
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||||
|
contact_request = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not contact_request:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
||||||
|
|
||||||
|
return contact_request
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/requests/{request_id}/handle", summary="标记为已处理")
|
||||||
|
async def mark_as_handled(
|
||||||
|
request_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||||
|
|
||||||
|
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||||
|
contact_request = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not contact_request:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
||||||
|
|
||||||
|
contact_request.is_handled = True
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(contact_request)
|
||||||
|
|
||||||
|
return {"message": "已标记为处理"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/requests/{request_id}", summary="删除联系请求")
|
||||||
|
async def delete_contact_request(
|
||||||
|
request_id: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限")
|
||||||
|
|
||||||
|
result = await db.execute(select(ContactRequest).where(ContactRequest.id == request_id))
|
||||||
|
contact_request = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not contact_request:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
|
||||||
|
|
||||||
|
await db.delete(contact_request)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return {"message": "删除成功"}
|
||||||
@@ -33,15 +33,29 @@ from app.services.resource_accounting_service import (
|
|||||||
record_generation_record_generated_resource,
|
record_generation_record_generated_resource,
|
||||||
safe_file_size,
|
safe_file_size,
|
||||||
)
|
)
|
||||||
|
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
||||||
from app.services.resource_signed_url_service import build_resource_signed_url
|
from app.services.resource_signed_url_service import build_resource_signed_url
|
||||||
|
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||||
|
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
|
||||||
|
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
|
||||||
|
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
|
||||||
from app.services.generation_billing_service import (
|
from app.services.generation_billing_service import (
|
||||||
|
CHARGE_TEXT_PROMPT,
|
||||||
OWNER_GENERATION_RECORD,
|
OWNER_GENERATION_RECORD,
|
||||||
|
build_credit_biz_key,
|
||||||
charge_generation_media_by_params,
|
charge_generation_media_by_params,
|
||||||
charge_generation_media_for_record,
|
charge_generation_media_for_record,
|
||||||
get_next_credit_attempt_no,
|
get_next_credit_attempt_no,
|
||||||
)
|
)
|
||||||
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
|
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
|
||||||
|
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
|
||||||
|
from app.services.credit_record_meta_service import build_generation_record_prompt_meta
|
||||||
from app.services.video_cover_service import async_create_video_cover_for_local_video
|
from app.services.video_cover_service import async_create_video_cover_for_local_video
|
||||||
|
from app.enums.audio_reference import (
|
||||||
|
AUDIO_ALLOWED_EXTENSIONS,
|
||||||
|
AUDIO_ALLOWED_MIME_TYPES,
|
||||||
|
AUDIO_MAX_FILE_SIZE_MB,
|
||||||
|
)
|
||||||
from app.utils.id_gen import generate_id
|
from app.utils.id_gen import generate_id
|
||||||
from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError
|
from app.utils.exceptions import InsufficientCreditsError, RecordNotFoundError, InvalidStatusError
|
||||||
|
|
||||||
@@ -49,9 +63,9 @@ router = APIRouter(prefix="/generation-records", tags=["generation"])
|
|||||||
logger = logging.getLogger("videogen")
|
logger = logging.getLogger("videogen")
|
||||||
|
|
||||||
|
|
||||||
def _record_to_out(record: GenerationRecord, project_name: str) -> GenerationRecordOut:
|
def _record_to_out(record: GenerationRecord, project_name: str, refs_override: list[dict] | None = None) -> GenerationRecordOut:
|
||||||
refs = None
|
refs = refs_override
|
||||||
if record.media_references:
|
if refs is None and record.media_references:
|
||||||
try:
|
try:
|
||||||
refs = json.loads(record.media_references)
|
refs = json.loads(record.media_references)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
@@ -181,13 +195,18 @@ async def list_records(
|
|||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
refs_map = await batch_resolve_private_portrait_reference_display_urls(
|
||||||
|
db,
|
||||||
|
{record.id: json.loads(record.media_references) if record.media_references else None for record, _project_name in rows},
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": int(total),
|
"total": int(total),
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": page_size,
|
"page_size": page_size,
|
||||||
"items": [
|
"items": [
|
||||||
_record_to_out(record, project_name)
|
_record_to_out(record, project_name, refs_override=refs_map.get(record.id))
|
||||||
for record, project_name in rows
|
for record, project_name in rows
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -230,11 +249,12 @@ async def optimize(
|
|||||||
row = existing.first()
|
row = existing.first()
|
||||||
if row:
|
if row:
|
||||||
record, project_name = row
|
record, project_name = row
|
||||||
|
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
||||||
return OptimizeResult(
|
return OptimizeResult(
|
||||||
optimized_prompt=record.optimized_prompt or "",
|
optimized_prompt=record.optimized_prompt or "",
|
||||||
text_credits_cost=record.text_credits_cost or 0.00,
|
text_credits_cost=record.text_credits_cost or 0.00,
|
||||||
text_tokens_used=record.text_tokens_used or 0,
|
text_tokens_used=record.text_tokens_used or 0,
|
||||||
record=_record_to_out(record, project_name),
|
record=_record_to_out(record, project_name, refs_override=refs),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check project exists and belongs to user
|
# Check project exists and belongs to user
|
||||||
@@ -304,9 +324,27 @@ async def optimize(
|
|||||||
failed_record_id = record.id
|
failed_record_id = record.id
|
||||||
failed_user_id = current_user.id
|
failed_user_id = current_user.id
|
||||||
try:
|
try:
|
||||||
|
prompt_attempt_no = 1
|
||||||
|
prompt_biz_key = build_credit_biz_key(
|
||||||
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
|
owner_id=record.id,
|
||||||
|
attempt_no=prompt_attempt_no,
|
||||||
|
charge_kind=CHARGE_TEXT_PROMPT,
|
||||||
|
action="charge",
|
||||||
|
)
|
||||||
|
prompt_meta = await build_generation_record_prompt_meta(
|
||||||
|
db,
|
||||||
|
record_id=record.id,
|
||||||
|
attempt_no=prompt_attempt_no,
|
||||||
|
charge_kind=CHARGE_TEXT_PROMPT,
|
||||||
|
usage=token_usage,
|
||||||
|
)
|
||||||
await deduct_credits(
|
await deduct_credits(
|
||||||
db, current_user.id, text_credits,
|
db, current_user.id, text_credits,
|
||||||
f"提示词优化 - {project.name}",
|
f"提示词优化 - {project.name}",
|
||||||
|
related_id=record.id,
|
||||||
|
biz_key=prompt_biz_key,
|
||||||
|
record_meta=prompt_meta,
|
||||||
)
|
)
|
||||||
except InsufficientCreditsError as e:
|
except InsufficientCreditsError as e:
|
||||||
# /optimize 阶段只处理提示词优化扣费。
|
# /optimize 阶段只处理提示词优化扣费。
|
||||||
@@ -342,11 +380,12 @@ async def optimize(
|
|||||||
record.text_tokens_used = token_usage["total_tokens"]
|
record.text_tokens_used = token_usage["total_tokens"]
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
|
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
||||||
return OptimizeResult(
|
return OptimizeResult(
|
||||||
optimized_prompt=optimized,
|
optimized_prompt=optimized,
|
||||||
text_credits_cost=round(text_credits, 2),
|
text_credits_cost=round(text_credits, 2),
|
||||||
# text_tokens_used=token_usage["total_tokens"],
|
# text_tokens_used=token_usage["total_tokens"],
|
||||||
record=_record_to_out(record, project.name),
|
record=_record_to_out(record, project.name, refs_override=refs),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -376,6 +415,8 @@ async def generate(
|
|||||||
if record.status not in ("prompt_optimized", "failed"):
|
if record.status not in ("prompt_optimized", "failed"):
|
||||||
raise InvalidStatusError("当前状态不允许生成")
|
raise InvalidStatusError("当前状态不允许生成")
|
||||||
|
|
||||||
|
await assert_user_resource_capacity_available(db, current_user.id)
|
||||||
|
|
||||||
attempt_no = await get_next_credit_attempt_no(
|
attempt_no = await get_next_credit_attempt_no(
|
||||||
db,
|
db,
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
@@ -472,7 +513,8 @@ async def generate(
|
|||||||
)
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
return _record_to_out(record, project_name)
|
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
||||||
|
return _record_to_out(record, project_name, refs_override=refs)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{record_id}/retry")
|
@router.post("/{record_id}/retry")
|
||||||
@@ -500,6 +542,8 @@ async def retry_generation(
|
|||||||
if record.status != "failed":
|
if record.status != "failed":
|
||||||
raise InvalidStatusError("只有失败的记录可以重试")
|
raise InvalidStatusError("只有失败的记录可以重试")
|
||||||
|
|
||||||
|
await assert_user_resource_capacity_available(db, current_user.id)
|
||||||
|
|
||||||
attempt_no = await get_next_credit_attempt_no(
|
attempt_no = await get_next_credit_attempt_no(
|
||||||
db,
|
db,
|
||||||
owner_type=OWNER_GENERATION_RECORD,
|
owner_type=OWNER_GENERATION_RECORD,
|
||||||
@@ -546,7 +590,8 @@ async def retry_generation(
|
|||||||
)
|
)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|
||||||
return _record_to_out(record, project_name)
|
refs = await resolve_private_portrait_reference_display_urls(db, json.loads(record.media_references) if record.media_references else None, user_id=current_user.id)
|
||||||
|
return _record_to_out(record, project_name, refs_override=refs)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{record_id}/prompt")
|
@router.put("/{record_id}/prompt")
|
||||||
@@ -703,6 +748,7 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)
|
|||||||
usage = data.get("usage", {})
|
usage = data.get("usage", {})
|
||||||
if usage:
|
if usage:
|
||||||
record.video_tokens_used = usage.get("total_tokens", 0)
|
record.video_tokens_used = usage.get("total_tokens", 0)
|
||||||
|
await sync_generation_record_media_token_snapshot(db, record, provider_response=data)
|
||||||
# Log callback response
|
# Log callback response
|
||||||
from app.services.video_gen import _log_video_response
|
from app.services.video_gen import _log_video_response
|
||||||
_log_video_response(record.id, data)
|
_log_video_response(record.id, data)
|
||||||
@@ -737,90 +783,188 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)
|
|||||||
return {"message": "ok"}
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload-image")
|
@router.post(
|
||||||
|
"/upload-image",
|
||||||
|
summary="上传 AI 创作普通参考图片",
|
||||||
|
description=(
|
||||||
|
"上传普通 AI 创作参考图片,写入 UploadResource 资源账本并纳入用户上传容量统计。"
|
||||||
|
"返回 resource_id 和 url。该文件在未绑定业务记录前可通过 /generation-records/delete-file 单独删除,"
|
||||||
|
"也会出现在 /upload-resources/history 历史素材中供 AI 创作复用。"
|
||||||
|
),
|
||||||
|
responses={400: {"description": "文件类型、大小或容量校验失败"}, 401: {"description": "未登录或 Token 无效"}},
|
||||||
|
)
|
||||||
async def upload_image(
|
async def upload_image(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
gen_type: str = Query("video", description="生成类型:video-视频,image-图片"),
|
gen_type: str = Query("video", description="生成类型:video-视频,image-图片"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Upload an image for generation reference."""
|
"""上传普通参考图片,记录 UploadResource 并纳入用户容量统计。"""
|
||||||
import os
|
result = await upload_reference_file(
|
||||||
import uuid
|
db,
|
||||||
from app.config import settings
|
file=file,
|
||||||
from datetime import datetime
|
current_user=current_user,
|
||||||
|
module=UploadResourceModuleEnum.COMMON.value,
|
||||||
if not file.content_type or not file.content_type.startswith("image/"):
|
resource_type=UploadResourceTypeEnum.IMAGE.value,
|
||||||
raise HTTPException(status_code=400, detail="仅支持图片文件")
|
gen_type=gen_type,
|
||||||
|
)
|
||||||
ext = os.path.splitext(file.filename or ".png")[1] or ".png"
|
await db.commit()
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
return {
|
||||||
safe_name = f"{gen_type}_img_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
"url": result.url,
|
||||||
date_dir = datetime.now().strftime("%Y/%m/%d")
|
"filename": result.filename,
|
||||||
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "images", date_dir)
|
"type": "image",
|
||||||
os.makedirs(dir_path, exist_ok=True)
|
"gen_type": gen_type,
|
||||||
file_path = os.path.join(dir_path, safe_name)
|
"resource_id": result.resource_id,
|
||||||
|
"file_size_bytes": result.file_size_bytes,
|
||||||
content = await file.read()
|
}
|
||||||
if len(content) > 10 * 1024 * 1024:
|
|
||||||
raise HTTPException(status_code=400, detail="图片大小不能超过10MB")
|
|
||||||
|
|
||||||
with open(file_path, "wb") as f:
|
|
||||||
f.write(content)
|
|
||||||
|
|
||||||
url = f"/uploads/images/{date_dir}/{safe_name}"
|
|
||||||
return {"url": url, "filename": file.filename or safe_name, "type": "image", "gen_type": gen_type}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload-video")
|
@router.post(
|
||||||
|
"/upload-video",
|
||||||
|
summary="上传 AI 创作普通参考视频",
|
||||||
|
description=(
|
||||||
|
"上传普通 AI 创作参考视频,写入 UploadResource 资源账本并纳入用户上传容量统计。"
|
||||||
|
"duration_seconds 为前端识别的视频秒数,用于历史复用和 AI 创作视频总时长校验。"
|
||||||
|
"未绑定业务记录前可单独删除,并会出现在 /upload-resources/history 历史素材中。"
|
||||||
|
),
|
||||||
|
responses={400: {"description": "文件类型、大小、容量或视频参数校验失败"}, 401: {"description": "未登录或 Token 无效"}},
|
||||||
|
)
|
||||||
async def upload_video(
|
async def upload_video(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Upload a video for generation reference."""
|
"""上传普通参考视频,记录 UploadResource 并纳入用户容量统计。"""
|
||||||
|
result = await upload_reference_file(
|
||||||
|
db,
|
||||||
|
file=file,
|
||||||
|
current_user=current_user,
|
||||||
|
module=UploadResourceModuleEnum.COMMON.value,
|
||||||
|
resource_type=UploadResourceTypeEnum.VIDEO.value,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"url": result.url,
|
||||||
|
"filename": result.filename,
|
||||||
|
"type": "video",
|
||||||
|
"resource_id": result.resource_id,
|
||||||
|
"file_size_bytes": result.file_size_bytes,
|
||||||
|
"duration_seconds": result.duration_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/upload-audio",
|
||||||
|
summary="上传 AI 创作普通参考音频",
|
||||||
|
description=(
|
||||||
|
"上传普通 AI 创作参考音频,写入 UploadResource 资源账本并纳入用户上传容量统计。"
|
||||||
|
"当前仅支持 mp3、wav;单文件大小受 AUDIO_MAX_FILE_SIZE_MB 限制。"
|
||||||
|
"duration_seconds 为前端识别的音频秒数,用于 AI 创作音频总时长校验。"
|
||||||
|
"未绑定业务记录前可单独删除,并会出现在 /upload-resources/history 历史素材中。"
|
||||||
|
),
|
||||||
|
responses={400: {"description": "音频格式、MIME、大小或容量校验失败"}, 401: {"description": "未登录或 Token 无效"}},
|
||||||
|
)
|
||||||
|
async def upload_audio(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
duration_seconds: float | None = Query(None, description="前端识别的音频时长秒数,可选"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""上传普通参考音频,记录 UploadResource 并纳入用户容量统计。"""
|
||||||
import os
|
import os
|
||||||
import uuid
|
|
||||||
from app.config import settings
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
if not file.content_type or not file.content_type.startswith("video/"):
|
ext = os.path.splitext(file.filename or "")[1].lower().lstrip(".")
|
||||||
raise HTTPException(status_code=400, detail="仅支持视频文件")
|
if ext not in AUDIO_ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持 mp3、wav 音频文件")
|
||||||
|
|
||||||
ext = os.path.splitext(file.filename or ".mp4")[1] or ".mp4"
|
expected_mime = AUDIO_ALLOWED_MIME_TYPES.get(ext)
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
if expected_mime and file.content_type and file.content_type != expected_mime:
|
||||||
safe_name = f"video_ref_{current_user.id}_{timestamp}_{uuid.uuid4().hex[:8]}{ext}"
|
raise HTTPException(status_code=400, detail=f"音频 MIME 类型错误,{ext} 必须为 {expected_mime}")
|
||||||
date_dir = datetime.now().strftime("%Y/%m/%d")
|
|
||||||
dir_path = os.path.join(settings.UPLOAD_LOCAL_PATH, "videos", date_dir)
|
|
||||||
os.makedirs(dir_path, exist_ok=True)
|
|
||||||
file_path = os.path.join(dir_path, safe_name)
|
|
||||||
|
|
||||||
content = await file.read()
|
result = await upload_reference_file(
|
||||||
if len(content) > 100 * 1024 * 1024:
|
db,
|
||||||
raise HTTPException(status_code=400, detail="视频大小不能超过100MB")
|
file=file,
|
||||||
|
current_user=current_user,
|
||||||
with open(file_path, "wb") as f:
|
module=UploadResourceModuleEnum.COMMON.value,
|
||||||
f.write(content)
|
resource_type=UploadResourceTypeEnum.AUDIO.value,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
url = f"/uploads/videos/{date_dir}/{safe_name}"
|
max_bytes=AUDIO_MAX_FILE_SIZE_MB * 1024 * 1024,
|
||||||
return {"url": url, "filename": file.filename or safe_name, "type": "video"}
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {
|
||||||
|
"url": result.url,
|
||||||
|
"filename": result.filename,
|
||||||
|
"type": "audio",
|
||||||
|
"resource_id": result.resource_id,
|
||||||
|
"file_size_bytes": result.file_size_bytes,
|
||||||
|
"duration_seconds": result.duration_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/delete-file")
|
@router.post(
|
||||||
|
"/delete-file",
|
||||||
|
summary="删除未绑定上传文件",
|
||||||
|
description=(
|
||||||
|
"删除当前用户自己的未绑定上传文件,并释放 UploadResource 上传容量。"
|
||||||
|
"仅允许删除 bind_status=pending、delete_policy=user_deletable、未绑定 source_model/source_id 的资源。"
|
||||||
|
"删除顺序为主事务先 soft delete 并 commit,commit 成功后再清理真实文件。"
|
||||||
|
"该接口保留给单文件删除;批量删除请使用 DELETE /upload-resources/history/batch。"
|
||||||
|
),
|
||||||
|
responses={400: {"description": "文件路径无效、文件已被模块任务使用或不可单独删除"}, 401: {"description": "未登录或 Token 无效"}, 403: {"description": "无权删除此文件"}},
|
||||||
|
)
|
||||||
async def delete_upload(
|
async def delete_upload(
|
||||||
url: str = Query(..., description="文件URL,如 /uploads/images/2024/01/01/video_img_xxx.png"),
|
url: str = Query(..., description="文件URL,如 /uploads/images/2024/01/01/video_img_xxx.png"),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Delete an uploaded file by URL."""
|
"""删除未绑定业务记录的上传文件,并释放 UploadResource 容量。"""
|
||||||
import os
|
user_id = str(current_user.id)
|
||||||
from app.config import settings
|
pending_ids: list[str] = []
|
||||||
|
legacy_paths: list[str] = []
|
||||||
|
try:
|
||||||
|
result = await delete_unbound_upload_resource(db, user=current_user, url=url)
|
||||||
|
pending_ids = list(result.pop("_pending_physical_delete_resource_ids", []) or [])
|
||||||
|
legacy_paths = list(result.pop("_legacy_pending_delete_paths", []) or [])
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await safe_rollback_with_log(
|
||||||
|
db,
|
||||||
|
event_type=UploadResourceEventEnum.DELETE_UPLOAD_ROLLBACK_FAILED.value,
|
||||||
|
message="删除上传文件主事务回滚失败",
|
||||||
|
user_id=user_id,
|
||||||
|
detail={"url": url},
|
||||||
|
original_exc=exc,
|
||||||
|
)
|
||||||
|
log_upload_resource_exception(
|
||||||
|
event_type=UploadResourceEventEnum.DELETE_UPLOAD_FAILED.value,
|
||||||
|
message=f"删除上传文件失败: {exc}",
|
||||||
|
user_id=user_id,
|
||||||
|
detail={"url": url},
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
if not url.startswith("/uploads/"):
|
if pending_ids or legacy_paths:
|
||||||
raise HTTPException(status_code=400, detail="无效的文件路径")
|
try:
|
||||||
|
await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids, legacy_paths=legacy_paths)
|
||||||
if current_user.id not in url:
|
await db.commit()
|
||||||
raise HTTPException(status_code=403, detail="无权删除此文件")
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await safe_rollback_with_log(
|
||||||
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, url.replace("/uploads/", ""))
|
db,
|
||||||
if os.path.exists(file_path):
|
event_type=UploadResourceEventEnum.DELETE_UPLOAD_ROLLBACK_FAILED.value,
|
||||||
os.remove(file_path)
|
message="删除上传文件 cleanup 事务回滚失败",
|
||||||
|
user_id=user_id,
|
||||||
return {"message": "ok"}
|
detail={"url": url, "pending_ids": pending_ids, "legacy_paths": legacy_paths},
|
||||||
|
original_exc=exc,
|
||||||
|
)
|
||||||
|
log_upload_resource_exception(
|
||||||
|
event_type=UploadResourceEventEnum.DELETE_UPLOAD_CLEANUP_FAILED.value,
|
||||||
|
message=f"删除上传文件后清理真实文件失败: {exc}",
|
||||||
|
user_id=user_id,
|
||||||
|
resource_ids=pending_ids,
|
||||||
|
detail={"url": url, "legacy_paths": legacy_paths},
|
||||||
|
exc=exc,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dependencies import get_current_user, get_db
|
from app.dependencies import get_current_user, get_db
|
||||||
@@ -9,6 +9,8 @@ from app.models.chat_generation_task import ChatGenerationTask
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.generation_ai import (
|
from app.schemas.generation_ai import (
|
||||||
GenerationAIEngineOptionsOut,
|
GenerationAIEngineOptionsOut,
|
||||||
|
GenerationAIHistoryBatchDeleteOut,
|
||||||
|
GenerationAIHistoryBatchDeleteRequest,
|
||||||
GenerationAIHistoryDayItemsOut,
|
GenerationAIHistoryDayItemsOut,
|
||||||
GenerationAIHistoryGroupedOut,
|
GenerationAIHistoryGroupedOut,
|
||||||
GenerationAIRetryOut,
|
GenerationAIRetryOut,
|
||||||
@@ -31,8 +33,11 @@ from app.services.generation_billing_service import (
|
|||||||
charge_generation_media_by_params,
|
charge_generation_media_by_params,
|
||||||
get_next_credit_attempt_no,
|
get_next_credit_attempt_no,
|
||||||
)
|
)
|
||||||
|
from app.services.generation_history_delete_service import batch_delete_generation_history_items
|
||||||
from app.services.generation_log_service import log_task_event
|
from app.services.generation_log_service import log_task_event
|
||||||
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
|
||||||
|
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
|
||||||
|
from app.services.resource_capacity_service import assert_user_resource_capacity_available
|
||||||
from app.tasks.celery_app import celery_app
|
from app.tasks.celery_app import celery_app
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -86,6 +91,8 @@ router = APIRouter(
|
|||||||
"supported_durations": [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
"supported_durations": [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15],
|
||||||
"max_duration": 15,
|
"max_duration": 15,
|
||||||
"priority": 10,
|
"priority": 10,
|
||||||
|
"supports_first_last_frame": False,
|
||||||
|
"supports_universal_reference": False,
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -114,6 +121,9 @@ async def list_engines(
|
|||||||
"该接口用于 Chat 风格的图片/视频生成,不再绑定 project_id。"
|
"该接口用于 Chat 风格的图片/视频生成,不再绑定 project_id。"
|
||||||
"创建成功后会写入 chat_generation_tasks 表,并投递 Celery 异步任务。"
|
"创建成功后会写入 chat_generation_tasks 表,并投递 Celery 异步任务。"
|
||||||
"支持 image 图片生成和 video 视频生成。"
|
"支持 image 图片生成和 video 视频生成。"
|
||||||
|
"media_references 支持 image/video/audio;source 可为 upload_resource=历史上传素材、"
|
||||||
|
"private_portrait_asset=私域真人/虚拟素材、空=本次普通上传素材。"
|
||||||
|
"视频/音频参考素材必须携带 duration,并按 AI 创作原逻辑校验单段 2~15 秒、总时长不超过 15 秒。"
|
||||||
"建议前端传入 idempotency_key,用于防止按钮连点、网络重试导致重复创建任务和重复扣费。"
|
"建议前端传入 idempotency_key,用于防止按钮连点、网络重试导致重复创建任务和重复扣费。"
|
||||||
),
|
),
|
||||||
responses={
|
responses={
|
||||||
@@ -134,7 +144,12 @@ async def list_engines(
|
|||||||
async def create_task(
|
async def create_task(
|
||||||
req: GenerationAITaskCreate = Body(
|
req: GenerationAITaskCreate = Body(
|
||||||
...,
|
...,
|
||||||
description="AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数",
|
description=(
|
||||||
|
"AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。"
|
||||||
|
"枚举:gen_type=image/video;media_references[].type=image/video/audio;"
|
||||||
|
"media_references[].source=upload_resource/private_portrait_asset/空;"
|
||||||
|
"media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。"
|
||||||
|
),
|
||||||
),
|
),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -167,7 +182,8 @@ async def create_task(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
|
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
|
||||||
|
|
||||||
return record_to_out(task)
|
refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
|
||||||
|
return record_to_out(task, media_references=refs)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -178,7 +194,8 @@ async def create_task(
|
|||||||
"分页获取当前登录用户的AI生成任务列表。"
|
"分页获取当前登录用户的AI生成任务列表。"
|
||||||
"可按生成类型 gen_type 和任务状态 status 过滤。"
|
"可按生成类型 gen_type 和任务状态 status 过滤。"
|
||||||
"该接口返回的是普通任务列表,不按日期分组。"
|
"该接口返回的是普通任务列表,不按日期分组。"
|
||||||
"如果前端需要按生成日期分组展示历史记录,请使用 /generation-ai/history 接口。"
|
"如果前端需要按生成日期分组展示生成历史记录,请使用 /generation-ai/history 接口。"
|
||||||
|
"上传素材历史不是生成历史,请使用 /upload-resources/history。"
|
||||||
),
|
),
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
@@ -226,6 +243,21 @@ async def list_tasks(
|
|||||||
description="查询相关用户名的对应记录[管理后台]",
|
description="查询相关用户名的对应记录[管理后台]",
|
||||||
examples=["demo"],
|
examples=["demo"],
|
||||||
),
|
),
|
||||||
|
engine_id: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="按引擎ID筛选[管理后台]",
|
||||||
|
examples=["0019e1697667d0eff39"],
|
||||||
|
),
|
||||||
|
created_start: datetime | None = Query(
|
||||||
|
None,
|
||||||
|
description="创建时间起始(含),ISO 格式,例如 2026-07-03T00:00:00",
|
||||||
|
examples=["2026-07-03T00:00:00"],
|
||||||
|
),
|
||||||
|
created_end: datetime | None = Query(
|
||||||
|
None,
|
||||||
|
description="创建时间截止(含),ISO 格式,例如 2026-07-03T23:59:59",
|
||||||
|
examples=["2026-07-03T23:59:59"],
|
||||||
|
),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -244,6 +276,9 @@ async def list_tasks(
|
|||||||
page,
|
page,
|
||||||
page_size,
|
page_size,
|
||||||
is_admin,
|
is_admin,
|
||||||
|
engine_id=engine_id,
|
||||||
|
created_start=created_start,
|
||||||
|
created_end=created_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ====================== 在这里加排序(最新在前)======================
|
# ====================== 在这里加排序(最新在前)======================
|
||||||
@@ -256,7 +291,12 @@ async def list_tasks(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
items_sorted = items
|
items_sorted = items
|
||||||
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin) for i in items_sorted])
|
refs_map = await batch_resolve_private_portrait_reference_display_urls(
|
||||||
|
db,
|
||||||
|
{item.id: record_to_out(task=item, is_admin=is_admin).media_references for item in items_sorted},
|
||||||
|
user_id=None if is_admin else current_user.id,
|
||||||
|
)
|
||||||
|
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin, media_references=refs_map.get(i.id)) for i in items_sorted])
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
@@ -265,12 +305,16 @@ async def list_tasks(
|
|||||||
summary="获取AI生成历史日期分组",
|
summary="获取AI生成历史日期分组",
|
||||||
description=(
|
description=(
|
||||||
"按生成完成日期倒序返回当前用户的AI生成历史记录。"
|
"按生成完成日期倒序返回当前用户的AI生成历史记录。"
|
||||||
"默认查询 chat_generation_tasks / ChatGenerationTask 新任务历史。"
|
"默认查询 chat_generation_tasks / ChatGenerationTask 的 AI创作历史。"
|
||||||
"当 history_source=generation_record 时,查询 generation_records / GenerationRecord 旧历史。"
|
"history_source=chat_task 时查询 AI创作;"
|
||||||
|
"history_source=hot_opening_replicate 时查询爆款开头复刻生成素材;"
|
||||||
|
"history_source=shot_replicate 时查询拆镜复刻生成素材;"
|
||||||
|
"history_source=generation_record 时查询 generation_records / GenerationRecord 旧项目生成历史。"
|
||||||
"该接口只返回生成成功的任务,即 status=completed 且 generated_at 不为空的数据。"
|
"该接口只返回生成成功的任务,即 status=completed 且 generated_at 不为空的数据。"
|
||||||
"必须通过 gen_type 区分图片和视频。"
|
"必须通过 gen_type 区分图片和视频。"
|
||||||
"分页对象是生成日期,不是单条记录。"
|
"分页对象是生成日期,不是单条记录。"
|
||||||
"每页最多返回10个生成日期分组,每个日期分组内最多返回该日期下倒序前10条生成记录。"
|
"每页最多返回10个生成日期分组,每个日期分组内最多返回该日期下倒序前10条生成记录。"
|
||||||
|
"每条历史记录会返回 generated_resource_id,表示 generated_resources.id;历史脏数据可能为空。"
|
||||||
"如果某一天 total 大于10,前端可调用 /generation-ai/history/{generated_date} 加载该日期下的后续分页数据。"
|
"如果某一天 total 大于10,前端可调用 /generation-ai/history/{generated_date} 加载该日期下的后续分页数据。"
|
||||||
),
|
),
|
||||||
responses={
|
responses={
|
||||||
@@ -307,10 +351,17 @@ async def list_history_grouped_days(
|
|||||||
history_source: str | None = Query(
|
history_source: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description=(
|
description=(
|
||||||
"历史数据来源。默认不传或传 chat_task 查询 chat_generation_tasks / ChatGenerationTask;"
|
"历史数据来源。默认不传或传 chat_task 查询 AI创作;"
|
||||||
"传 generation_record 查询 generation_records / GenerationRecord 旧历史数据"
|
"传 generation_record 查询旧项目生成;"
|
||||||
|
"传 hot_opening_replicate 查询爆款开头复刻素材;"
|
||||||
|
"传 shot_replicate 查询拆镜复刻素材"
|
||||||
),
|
),
|
||||||
examples=["generation_record"],
|
examples=["shot_replicate"],
|
||||||
|
),
|
||||||
|
keyword: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="提示词搜索关键词,模糊匹配 original_prompt 字段",
|
||||||
|
examples=["猫咪"],
|
||||||
),
|
),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -322,6 +373,54 @@ async def list_history_grouped_days(
|
|||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
history_source=history_source,
|
history_source=history_source,
|
||||||
|
keyword=keyword,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/history/batch",
|
||||||
|
response_model=GenerationAIHistoryBatchDeleteOut,
|
||||||
|
summary="批量删除素材云历史记录",
|
||||||
|
description=(
|
||||||
|
"按 history_source 批量软删除素材云历史记录,单次最多30条。"
|
||||||
|
"generation_record 和 chat_task 入参 ids 为对应记录ID,且只有生成完成后才能删除;"
|
||||||
|
"hot_opening_replicate 入参 ids 为 module_project_id;"
|
||||||
|
"shot_replicate 入参 ids 为 shot_segment_id。"
|
||||||
|
"爆款开头复刻和拆镜复刻会联动软删 ModuleGenerationProject、ModuleGenerationStep、关联 ChatGenerationTask 和 generated_resources;"
|
||||||
|
"拆镜复刻还会联动软删 ShotReplicateSegment。"
|
||||||
|
"如果存在生成中、轮询中、下载中等任务,接口直接拦截,不做失败标记、不退款。"
|
||||||
|
),
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"description": "批量软删除成功",
|
||||||
|
},
|
||||||
|
400: {
|
||||||
|
"description": "参数错误,例如 history_source 不支持、ids 为空、超过30条或重复",
|
||||||
|
},
|
||||||
|
401: {
|
||||||
|
"description": "未登录或 Token 无效",
|
||||||
|
},
|
||||||
|
404: {
|
||||||
|
"description": "部分 ID 不存在、不属于当前用户或已删除",
|
||||||
|
},
|
||||||
|
409: {
|
||||||
|
"description": "存在未完成或生成中的记录,当前不能删除",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def batch_delete_history_items(
|
||||||
|
req: GenerationAIHistoryBatchDeleteRequest = Body(
|
||||||
|
...,
|
||||||
|
description="素材云历史批量删除参数。不同 history_source 对应不同 ID 语义,详见字段说明",
|
||||||
|
),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await batch_delete_generation_history_items(
|
||||||
|
db=db,
|
||||||
|
current_user=current_user,
|
||||||
|
history_source=req.history_source,
|
||||||
|
ids=req.ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -331,11 +430,15 @@ async def list_history_grouped_days(
|
|||||||
summary="获取指定日期下的AI生成历史分页",
|
summary="获取指定日期下的AI生成历史分页",
|
||||||
description=(
|
description=(
|
||||||
"获取某一个生成日期下的生成成功记录分页。"
|
"获取某一个生成日期下的生成成功记录分页。"
|
||||||
"默认查询 chat_generation_tasks / ChatGenerationTask 新任务历史。"
|
"默认查询 chat_generation_tasks / ChatGenerationTask 的 AI创作历史。"
|
||||||
"当 history_source=generation_record 时,查询 generation_records / GenerationRecord 旧历史。"
|
"history_source=chat_task 时查询 AI创作;"
|
||||||
|
"history_source=hot_opening_replicate 时查询爆款开头复刻生成素材;"
|
||||||
|
"history_source=shot_replicate 时查询拆镜复刻生成素材;"
|
||||||
|
"history_source=generation_record 时查询 generation_records / GenerationRecord 旧项目生成历史。"
|
||||||
"该接口用于前端在历史分组列表中继续加载某一天的后续记录。"
|
"该接口用于前端在历史分组列表中继续加载某一天的后续记录。"
|
||||||
"例如 /history 接口中某一天 total=18,但 items 只返回前10条,"
|
"例如 /history 接口中某一天 total=18,但 items 只返回前10条,"
|
||||||
"则前端可以调用本接口 page=2&page_size=10 获取该日期下剩余记录。"
|
"则前端可以调用本接口 page=2&page_size=10 获取该日期下剩余记录。"
|
||||||
|
"每条历史记录会返回 generated_resource_id,表示 generated_resources.id;历史脏数据可能为空。"
|
||||||
"该接口同样必须通过 gen_type 区分图片和视频。"
|
"该接口同样必须通过 gen_type 区分图片和视频。"
|
||||||
),
|
),
|
||||||
responses={
|
responses={
|
||||||
@@ -377,10 +480,17 @@ async def list_history_day_items(
|
|||||||
history_source: str | None = Query(
|
history_source: str | None = Query(
|
||||||
None,
|
None,
|
||||||
description=(
|
description=(
|
||||||
"历史数据来源。默认不传或传 chat_task 查询 chat_generation_tasks / ChatGenerationTask;"
|
"历史数据来源。默认不传或传 chat_task 查询 AI创作;"
|
||||||
"传 generation_record 查询 generation_records / GenerationRecord 旧历史数据"
|
"传 generation_record 查询旧项目生成;"
|
||||||
|
"传 hot_opening_replicate 查询爆款开头复刻素材;"
|
||||||
|
"传 shot_replicate 查询拆镜复刻素材"
|
||||||
),
|
),
|
||||||
examples=["generation_record"],
|
examples=["hot_opening_replicate"],
|
||||||
|
),
|
||||||
|
keyword: str | None = Query(
|
||||||
|
None,
|
||||||
|
description="提示词搜索关键词,模糊匹配 original_prompt 字段",
|
||||||
|
examples=["猫咪"],
|
||||||
),
|
),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
@@ -393,6 +503,7 @@ async def list_history_day_items(
|
|||||||
page=page,
|
page=page,
|
||||||
page_size=page_size,
|
page_size=page_size,
|
||||||
history_source=history_source,
|
history_source=history_source,
|
||||||
|
keyword=keyword,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -438,7 +549,8 @@ async def get_task(
|
|||||||
task = result.scalar_one_or_none()
|
task = result.scalar_one_or_none()
|
||||||
if not task:
|
if not task:
|
||||||
raise HTTPException(status_code=404, detail="任务不存在")
|
raise HTTPException(status_code=404, detail="任务不存在")
|
||||||
return record_to_out(task)
|
refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
|
||||||
|
return record_to_out(task, media_references=refs)
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
@@ -564,6 +676,8 @@ async def retry_task(
|
|||||||
if task.status != "failed":
|
if task.status != "failed":
|
||||||
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
|
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
|
||||||
|
|
||||||
|
await assert_user_resource_capacity_available(db, current_user.id)
|
||||||
|
|
||||||
attempt_no = await get_next_credit_attempt_no(
|
attempt_no = await get_next_credit_attempt_no(
|
||||||
db,
|
db,
|
||||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.enums.home_material import HomeMaterialMediaType, HomeMaterialPublicResponseMode
|
||||||
|
from app.schemas.home_material import (
|
||||||
|
HomeMaterialPublicCategoryListOut,
|
||||||
|
HomeMaterialPublicFlatOut,
|
||||||
|
HomeMaterialPublicGroupedOut,
|
||||||
|
)
|
||||||
|
from app.services.home_material import home_material_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/home-materials", tags=["home-materials"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/categories",
|
||||||
|
response_model=HomeMaterialPublicCategoryListOut,
|
||||||
|
summary="获取首页素材行业列表",
|
||||||
|
description=(
|
||||||
|
"获取首页素材行业列表,用于前端渲染行业 Tab/筛选。"
|
||||||
|
"只返回已启用、未软删行业;only_has_assets=true 时只返回存在 success + is_active + watermarked_url 的行业。"
|
||||||
|
"统计素材数量时不连表,先查行业列表,再按 category_id 批量 group by 查询素材数量并 map 回填。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_public_home_material_categories(
|
||||||
|
with_asset_count: bool = Query(True, description="是否返回每个行业下可展示素材数量。"),
|
||||||
|
media_type: HomeMaterialMediaType | None = Query(None, description="统计指定素材类型:image图片,video视频。不传表示全部。"),
|
||||||
|
only_has_assets: bool = Query(True, description="是否只返回有可展示素材的行业。"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await home_material_service.get_public_categories(
|
||||||
|
db,
|
||||||
|
with_asset_count=with_asset_count,
|
||||||
|
media_type=media_type,
|
||||||
|
only_has_assets=only_has_assets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut,
|
||||||
|
summary="获取首页素材展示数据",
|
||||||
|
description=(
|
||||||
|
"获取首页素材展示数据。支持不传行业返回全部行业分组;支持 category_id/category_key/category_ids/category_keys 按行业过滤,"
|
||||||
|
"优先级为 category_id > category_key > category_ids > category_keys。"
|
||||||
|
"response_mode=grouped 时按行业分组并限制每个行业 limit_per_category 条;"
|
||||||
|
"response_mode=flat 时返回平铺分页素材流。前台接口只返回水印素材 url,不返回原始素材 original_url。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_public_home_materials(
|
||||||
|
category_id: str | None = Query(None, description="单个行业ID。优先级最高。"),
|
||||||
|
category_key: str | None = Query(None, description="单个行业key。未传 category_id 时生效。"),
|
||||||
|
category_ids: str | None = Query(None, description="多个行业ID,英文逗号分隔。未传单行业参数时生效。"),
|
||||||
|
category_keys: str | None = Query(None, description="多个行业key,英文逗号分隔。优先级最低。"),
|
||||||
|
media_type: HomeMaterialMediaType | None = Query(None, description="素材类型筛选:image图片,video视频。不传表示全部。"),
|
||||||
|
limit_per_category: int = Query(8, ge=1, le=50, description="grouped 模式下每个行业最多返回几条素材,默认8,最大50。"),
|
||||||
|
include_empty_categories: bool = Query(False, description="grouped 模式下是否返回无素材行业。默认 false。"),
|
||||||
|
response_mode: HomeMaterialPublicResponseMode = Query(HomeMaterialPublicResponseMode.GROUPED, description="返回模式:grouped按行业分组,flat平铺分页。"),
|
||||||
|
page: int = Query(1, ge=1, description="flat 模式页码,默认1。"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="flat 模式每页数量,默认20,最大100。"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await home_material_service.get_public_home_materials(
|
||||||
|
db,
|
||||||
|
category_id=category_id,
|
||||||
|
category_key=category_key,
|
||||||
|
category_ids=category_ids,
|
||||||
|
category_keys=category_keys,
|
||||||
|
media_type=media_type,
|
||||||
|
limit_per_category=limit_per_category,
|
||||||
|
include_empty_categories=include_empty_categories,
|
||||||
|
response_mode=response_mode,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user