Compare commits
56 Commits
worktree-s
...
73ff5e8c5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73ff5e8c5e | ||
|
|
e3b93ff96d | ||
|
|
3b1a017761 | ||
|
|
4e3265a853 | ||
|
|
7d4d2b999b | ||
|
|
721451f6a7 | ||
|
|
4b9698034c | ||
|
|
4aa3f884ec | ||
|
|
f23f6c5f91 | ||
|
|
97698f54b2 | ||
|
|
a3bdf11d9a | ||
|
|
9905a8d0d5 | ||
|
|
2ff696289f | ||
|
|
6cae768401 | ||
|
|
3e5d64484e | ||
|
|
ee51d5abcd | ||
|
|
f79560a911 | ||
|
|
d0ae7d2770 | ||
|
|
8e6abc91e1 | ||
|
|
1d9283f335 | ||
|
|
49abd0fe89 | ||
|
|
c9b9c5231b | ||
|
|
9fb9c3204c | ||
|
|
3e57fadfc9 | ||
|
|
eb956d0dce | ||
|
|
6821df5f44 | ||
|
|
9d310e5a3c | ||
|
|
6529b67353 | ||
|
|
a0bbd4ba82 | ||
|
|
c2aff09811 | ||
|
|
e7b2d1c099 | ||
|
|
88aa4b1310 | ||
|
|
ecd7f2e928 | ||
|
|
544358764e | ||
|
|
ba2c6a6105 | ||
|
|
bc8c77e7fe | ||
|
|
834aa12076 | ||
|
|
813b49a986 | ||
|
|
d345e60a6a | ||
|
|
a7d33d0207 | ||
|
|
13c0b18bbc | ||
|
|
5595083b96 | ||
|
|
eed26a1ce4 | ||
|
|
f3f586efef | ||
|
|
6040d98b18 | ||
|
|
ee29b7b752 | ||
|
|
7e90cea117 | ||
|
|
09df242cf8 | ||
|
|
04c366fe8b | ||
|
|
7de294375b | ||
|
|
b7ec317d2c | ||
|
|
a0ca35c9dd | ||
|
|
77374121dd | ||
|
|
8b9d506893 | ||
|
|
5fdf96c3f5 | ||
|
|
9a5fad2b59 |
7
.cargo/config.toml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Reduce parallel compilation jobs to prevent compiler OOM.
|
||||||
|
# The desktop crate + its dependencies (tauri, sqlx, fantoccini, etc.)
|
||||||
|
# consume significant memory during borrow checking / type inference.
|
||||||
|
#
|
||||||
|
# If builds still OOM, try lowering further (e.g. 2 or 1).
|
||||||
|
[build]
|
||||||
|
jobs = 2
|
||||||
1
.claude/worktrees/saas-backend
Submodule
4
.gitignore
vendored
@@ -12,6 +12,10 @@ build/
|
|||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
|
# SaaS config (contains database credentials)
|
||||||
|
saas-config.toml
|
||||||
|
!saas-config.toml.example
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
463
65-90p
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
00000000: 2f2f 2120 5a43 4c41 5720 5361 6153 20e6 //! ZCLAW SaaS .
|
||||||
|
00000010: 9c8d e58a a1e5 85a5 e58f a30d 0a0d 0a75 ...............u
|
||||||
|
00000020: 7365 2061 7875 6d3a 3a65 7874 7261 6374 se axum::extract
|
||||||
|
00000030: 3a3a 5374 6174 653b 0d0a 7573 6520 746f ::State;..use to
|
||||||
|
00000040: 7765 725f 6874 7470 3a3a 7469 6d65 6f75 wer_http::timeou
|
||||||
|
00000050: 743a 3a54 696d 656f 7574 4c61 7965 723b t::TimeoutLayer;
|
||||||
|
00000060: 0d0a 7573 6520 7472 6163 696e 673a 3a69 ..use tracing::i
|
||||||
|
00000070: 6e66 6f3b 0d0a 7573 6520 7a63 6c61 775f nfo;..use zclaw_
|
||||||
|
00000080: 7361 6173 3a3a 7b63 6f6e 6669 673a 3a53 saas::{config::S
|
||||||
|
00000090: 6161 5343 6f6e 6669 672c 2064 623a 3a69 aaSConfig, db::i
|
||||||
|
000000a0: 6e69 745f 6462 2c20 7374 6174 653a 3a41 nit_db, state::A
|
||||||
|
000000b0: 7070 5374 6174 657d 3b0d 0a75 7365 207a ppState};..use z
|
||||||
|
000000c0: 636c 6177 5f73 6161 733a 3a77 6f72 6b65 claw_saas::worke
|
||||||
|
000000d0: 7273 3a3a 576f 726b 6572 4469 7370 6174 rs::WorkerDispat
|
||||||
|
000000e0: 6368 6572 3b0d 0a75 7365 207a 636c 6177 cher;..use zclaw
|
||||||
|
000000f0: 5f73 6161 733a 3a77 6f72 6b65 7273 3a3a _saas::workers::
|
||||||
|
00000100: 6c6f 675f 6f70 6572 6174 696f 6e3a 3a4c log_operation::L
|
||||||
|
00000110: 6f67 4f70 6572 6174 696f 6e57 6f72 6b65 ogOperationWorke
|
||||||
|
00000120: 723b 0d0a 7573 6520 7a63 6c61 775f 7361 r;..use zclaw_sa
|
||||||
|
00000130: 6173 3a3a 776f 726b 6572 733a 3a63 6c65 as::workers::cle
|
||||||
|
00000140: 616e 7570 5f72 6566 7265 7368 5f74 6f6b anup_refresh_tok
|
||||||
|
00000150: 656e 733a 3a43 6c65 616e 7570 5265 6672 ens::CleanupRefr
|
||||||
|
00000160: 6573 6854 6f6b 656e 7357 6f72 6b65 723b eshTokensWorker;
|
||||||
|
00000170: 0d0a 7573 6520 7a63 6c61 775f 7361 6173 ..use zclaw_saas
|
||||||
|
00000180: 3a3a 776f 726b 6572 733a 3a63 6c65 616e ::workers::clean
|
||||||
|
00000190: 7570 5f72 6174 655f 6c69 6d69 743a 3a43 up_rate_limit::C
|
||||||
|
000001a0: 6c65 616e 7570 5261 7465 4c69 6d69 7457 leanupRateLimitW
|
||||||
|
000001b0: 6f72 6b65 723b 0d0a 7573 6520 7a63 6c61 orker;..use zcla
|
||||||
|
000001c0: 775f 7361 6173 3a3a 776f 726b 6572 733a w_saas::workers:
|
||||||
|
000001d0: 3a72 6563 6f72 645f 7573 6167 653a 3a52 :record_usage::R
|
||||||
|
000001e0: 6563 6f72 6455 7361 6765 576f 726b 6572 ecordUsageWorker
|
||||||
|
000001f0: 3b0d 0a75 7365 207a 636c 6177 5f73 6161 ;..use zclaw_saa
|
||||||
|
00000200: 733a 3a77 6f72 6b65 7273 3a3a 7570 6461 s::workers::upda
|
||||||
|
00000210: 7465 5f6c 6173 745f 7573 6564 3a3a 5570 te_last_used::Up
|
||||||
|
00000220: 6461 7465 4c61 7374 5573 6564 576f 726b dateLastUsedWork
|
||||||
|
00000230: 6572 3b0d 0a0d 0a23 5b74 6f6b 696f 3a3a er;....#[tokio::
|
||||||
|
00000240: 6d61 696e 5d0d 0a61 7379 6e63 2066 6e20 main]..async fn
|
||||||
|
00000250: 6d61 696e 2829 202d 3e20 616e 7968 6f77 main() -> anyhow
|
||||||
|
00000260: 3a3a 5265 7375 6c74 3c28 293e 207b 0d0a ::Result<()> {..
|
||||||
|
00000270: 2020 2020 7472 6163 696e 675f 7375 6273 tracing_subs
|
||||||
|
00000280: 6372 6962 6572 3a3a 666d 7428 290d 0a20 criber::fmt()..
|
||||||
|
00000290: 2020 2020 2020 202e 7769 7468 5f65 6e76 .with_env
|
||||||
|
000002a0: 5f66 696c 7465 7228 0d0a 2020 2020 2020 _filter(..
|
||||||
|
000002b0: 2020 2020 2020 7472 6163 696e 675f 7375 tracing_su
|
||||||
|
000002c0: 6273 6372 6962 6572 3a3a 456e 7646 696c bscriber::EnvFil
|
||||||
|
000002d0: 7465 723a 3a74 7279 5f66 726f 6d5f 6465 ter::try_from_de
|
||||||
|
000002e0: 6661 756c 745f 656e 7628 290d 0a20 2020 fault_env()..
|
||||||
|
000002f0: 2020 2020 2020 2020 2020 2020 202e 756e .un
|
||||||
|
00000300: 7772 6170 5f6f 725f 656c 7365 287c 5f7c wrap_or_else(|_|
|
||||||
|
00000310: 2022 7a63 6c61 775f 7361 6173 3d64 6562 "zclaw_saas=deb
|
||||||
|
00000320: 7567 2c74 6f77 6572 5f68 7474 703d 6465 ug,tower_http=de
|
||||||
|
00000330: 6275 6722 2e69 6e74 6f28 2929 2c0d 0a20 bug".into()),..
|
||||||
|
00000340: 2020 2020 2020 2029 0d0a 2020 2020 2020 )..
|
||||||
|
00000350: 2020 2e69 6e69 7428 293b 0d0a 0d0a 2020 .init();....
|
||||||
|
00000360: 2020 6c65 7420 636f 6e66 6967 203d 2053 let config = S
|
||||||
|
00000370: 6161 5343 6f6e 6669 673a 3a6c 6f61 6428 aaSConfig::load(
|
||||||
|
00000380: 293f 3b0d 0a20 2020 2069 6e66 6f21 2822 )?;.. info!("
|
||||||
|
00000390: 5361 6153 2063 6f6e 6669 6720 6c6f 6164 SaaS config load
|
||||||
|
000003a0: 6564 3a20 7b7d 3a7b 7d22 2c20 636f 6e66 ed: {}:{}", conf
|
||||||
|
000003b0: 6967 2e73 6572 7665 722e 686f 7374 2c20 ig.server.host,
|
||||||
|
000003c0: 636f 6e66 6967 2e73 6572 7665 722e 706f config.server.po
|
||||||
|
000003d0: 7274 293b 0d0a 0d0a 2020 2020 6c65 7420 rt);.... let
|
||||||
|
000003e0: 6462 203d 2069 6e69 745f 6462 2826 636f db = init_db(&co
|
||||||
|
000003f0: 6e66 6967 2e64 6174 6162 6173 652e 7572 nfig.database.ur
|
||||||
|
00000400: 6c29 2e61 7761 6974 3f3b 0d0a 2020 2020 l).await?;..
|
||||||
|
00000410: 696e 666f 2128 2244 6174 6162 6173 6520 info!("Database
|
||||||
|
00000420: 696e 6974 6961 6c69 7a65 6422 293b 0d0a initialized");..
|
||||||
|
00000430: 0d0a 2020 2020 2f2f 20e5 889d e5a7 8be5 .. // .......
|
||||||
|
00000440: 8c96 2057 6f72 6b65 7220 e8b0 83e5 baa6 .. Worker ......
|
||||||
|
00000450: e599 a820 2b20 e6b3 a8e5 868c e689 80e6 ... + ..........
|
||||||
|
00000460: 9c89 2057 6f72 6b65 720d 0a20 2020 206c .. Worker.. l
|
||||||
|
00000470: 6574 206d 7574 2064 6973 7061 7463 6865 et mut dispatche
|
||||||
|
00000480: 7220 3d20 576f 726b 6572 4469 7370 6174 r = WorkerDispat
|
||||||
|
00000490: 6368 6572 3a3a 6e65 7728 6462 2e63 6c6f cher::new(db.clo
|
||||||
|
000004a0: 6e65 2829 293b 0d0a 2020 2020 6469 7370 ne());.. disp
|
||||||
|
000004b0: 6174 6368 6572 2e72 6567 6973 7465 7228 atcher.register(
|
||||||
|
000004c0: 4c6f 674f 7065 7261 7469 6f6e 576f 726b LogOperationWork
|
||||||
|
000004d0: 6572 293b 0d0a 2020 2020 6469 7370 6174 er);.. dispat
|
||||||
|
000004e0: 6368 6572 2e72 6567 6973 7465 7228 436c cher.register(Cl
|
||||||
|
000004f0: 6561 6e75 7052 6566 7265 7368 546f 6b65 eanupRefreshToke
|
||||||
|
00000500: 6e73 576f 726b 6572 293b 0d0a 2020 2020 nsWorker);..
|
||||||
|
00000510: 6469 7370 6174 6368 6572 2e72 6567 6973 dispatcher.regis
|
||||||
|
00000520: 7465 7228 436c 6561 6e75 7052 6174 654c ter(CleanupRateL
|
||||||
|
00000530: 696d 6974 576f 726b 6572 293b 0d0a 2020 imitWorker);..
|
||||||
|
00000540: 2020 6469 7370 6174 6368 6572 2e72 6567 dispatcher.reg
|
||||||
|
00000550: 6973 7465 7228 5265 636f 7264 5573 6167 ister(RecordUsag
|
||||||
|
00000560: 6557 6f72 6b65 7229 3b0d 0a20 2020 2064 eWorker);.. d
|
||||||
|
00000570: 6973 7061 7463 6865 722e 7265 6769 7374 ispatcher.regist
|
||||||
|
00000580: 6572 2855 7064 6174 654c 6173 7455 7365 er(UpdateLastUse
|
||||||
|
00000590: 6457 6f72 6b65 7229 3b0d 0a20 2020 2069 dWorker);.. i
|
||||||
|
000005a0: 6e66 6f21 2822 576f 726b 6572 2064 6973 nfo!("Worker dis
|
||||||
|
000005b0: 7061 7463 6865 7220 696e 6974 6961 6c69 patcher initiali
|
||||||
|
000005c0: 7a65 6420 2835 2077 6f72 6b65 7273 2072 zed (5 workers r
|
||||||
|
000005d0: 6567 6973 7465 7265 6429 2229 3b0d 0a0d egistered)");...
|
||||||
|
000005e0: 0a20 2020 206c 6574 2073 7461 7465 203d . let state =
|
||||||
|
000005f0: 2041 7070 5374 6174 653a 3a6e 6577 2864 AppState::new(d
|
||||||
|
00000600: 622e 636c 6f6e 6528 292c 2063 6f6e 6669 b.clone(), confi
|
||||||
|
00000610: 672e 636c 6f6e 6528 292c 2064 6973 7061 g.clone(), dispa
|
||||||
|
00000620: 7463 6865 7229 3f3b 0d0a 0d0a 2020 2020 tcher)?;....
|
||||||
|
00000630: 2f2f 20e5 90af e58a a8e5 a3b0 e698 8ee5 // .............
|
||||||
|
00000640: bc8f 2053 6368 6564 756c 6572 efbc 88e4 .. Scheduler....
|
||||||
|
00000650: bb8e 2054 4f4d 4c20 e985 8de7 bdae e8af .. TOML ........
|
||||||
|
00000660: bbe5 8f96 e5ae 9ae6 97b6 e4bb bbe5 8aa1 ................
|
||||||
|
00000670: efbc 890d 0a20 2020 206c 6574 2073 6368 ..... let sch
|
||||||
|
00000680: 6564 756c 6572 5f63 6f6e 6669 6720 3d20 eduler_config =
|
||||||
|
00000690: 2663 6f6e 6669 672e 7363 6865 6475 6c65 &config.schedule
|
||||||
|
000006a0: 723b 0d0a 2020 2020 7a63 6c61 775f 7361 r;.. zclaw_sa
|
||||||
|
000006b0: 6173 3a3a 7363 6865 6475 6c65 723a 3a73 as::scheduler::s
|
||||||
|
000006c0: 7461 7274 5f73 6368 6564 756c 6572 2873 tart_scheduler(s
|
||||||
|
000006d0: 6368 6564 756c 6572 5f63 6f6e 6669 672c cheduler_config,
|
||||||
|
000006e0: 2064 622e 636c 6f6e 6528 292c 2073 7461 db.clone(), sta
|
||||||
|
000006f0: 7465 2e77 6f72 6b65 725f 6469 7370 6174 te.worker_dispat
|
||||||
|
00000700: 6368 6572 2e63 6c6f 6e65 5f72 6566 2829 cher.clone_ref()
|
||||||
|
00000710: 293b 0d0a 2020 2020 696e 666f 2128 2253 );.. info!("S
|
||||||
|
00000720: 6368 6564 756c 6572 2073 7461 7274 6564 cheduler started
|
||||||
|
00000730: 2077 6974 6820 7b7d 206a 6f62 7322 2c20 with {} jobs",
|
||||||
|
00000740: 7363 6865 6475 6c65 725f 636f 6e66 6967 scheduler_config
|
||||||
|
00000750: 2e6a 6f62 732e 6c65 6e28 2929 3b0d 0a0d .jobs.len());...
|
||||||
|
00000760: 0a20 2020 202f 2f20 e590 afe5 8aa8 e586 . // ........
|
||||||
|
00000770: 85e7 bdae 2044 4220 e6b8 85e7 9086 e4bb .... DB ........
|
||||||
|
00000780: bbe5 8aa1 efbc 88e8 aebe e5a4 87e6 b885 ................
|
||||||
|
00000790: e790 86e7 ad89 e4b8 8de9 809a e8bf 8720 ...............
|
||||||
|
000007a0: 576f 726b 6572 20e7 9a84 e4bb bbe5 8aa1 Worker .........
|
||||||
|
000007b0: efbc 890d 0a20 2020 207a 636c 6177 5f73 ..... zclaw_s
|
||||||
|
000007c0: 6161 733a 3a73 6368 6564 756c 6572 3a3a aas::scheduler::
|
||||||
|
000007d0: 7374 6172 745f 6462 5f63 6c65 616e 7570 start_db_cleanup
|
||||||
|
000007e0: 5f74 6173 6b73 2864 622e 636c 6f6e 6528 _tasks(db.clone(
|
||||||
|
000007f0: 2929 3b0d 0a0d 0a20 2020 202f 2f20 e590 ));.... // ..
|
||||||
|
00000800: afe5 8aa8 e586 85e5 ad98 e4b8 ade7 9a84 ................
|
||||||
|
00000810: 2072 6174 6520 6c69 6d69 7420 e69d a1e7 rate limit ....
|
||||||
|
00000820: 9bae e6b8 85e7 9086 0d0a 2020 2020 6c65 .......... le
|
||||||
|
00000830: 7420 7261 7465 5f6c 696d 6974 5f73 7461 t rate_limit_sta
|
||||||
|
00000840: 7465 203d 2073 7461 7465 2e63 6c6f 6e65 te = state.clone
|
||||||
|
00000850: 2829 3b0d 0a20 2020 2074 6f6b 696f 3a3a ();.. tokio::
|
||||||
|
00000860: 7370 6177 6e28 6173 796e 6320 6d6f 7665 spawn(async move
|
||||||
|
00000870: 207b 0d0a 2020 2020 2020 2020 6c65 7420 {.. let
|
||||||
|
00000880: 6d75 7420 696e 7465 7276 616c 203d 2074 mut interval = t
|
||||||
|
00000890: 6f6b 696f 3a3a 7469 6d65 3a3a 696e 7465 okio::time::inte
|
||||||
|
000008a0: 7276 616c 2873 7464 3a3a 7469 6d65 3a3a rval(std::time::
|
||||||
|
000008b0: 4475 7261 7469 6f6e 3a3a 6672 6f6d 5f73 Duration::from_s
|
||||||
|
000008c0: 6563 7328 3330 3029 293b 0d0a 2020 2020 ecs(300));..
|
||||||
|
000008d0: 2020 2020 6c6f 6f70 207b 0d0a 2020 2020 loop {..
|
||||||
|
000008e0: 2020 2020 2020 2020 696e 7465 7276 616c interval
|
||||||
|
000008f0: 2e74 6963 6b28 292e 6177 6169 743b 0d0a .tick().await;..
|
||||||
|
00000900: 2020 2020 2020 2020 2020 2020 7261 7465 rate
|
||||||
|
00000910: 5f6c 696d 6974 5f73 7461 7465 2e63 6c65 _limit_state.cle
|
||||||
|
00000920: 616e 7570 5f72 6174 655f 6c69 6d69 745f anup_rate_limit_
|
||||||
|
00000930: 656e 7472 6965 7328 293b 0d0a 2020 2020 entries();..
|
||||||
|
00000940: 2020 2020 7d0d 0a20 2020 207d 293b 0d0a }.. });..
|
||||||
|
00000950: 0d0a 2020 2020 6c65 7420 6170 7020 3d20 .. let app =
|
||||||
|
00000960: 6275 696c 645f 726f 7574 6572 2873 7461 build_router(sta
|
||||||
|
00000970: 7465 292e 6177 6169 743b 0d0a 0d0a 2020 te).await;....
|
||||||
|
00000980: 2020 6c65 7420 6c69 7374 656e 6572 203d let listener =
|
||||||
|
00000990: 2074 6f6b 696f 3a3a 6e65 743a 3a54 6370 tokio::net::Tcp
|
||||||
|
000009a0: 4c69 7374 656e 6572 3a3a 6269 6e64 2866 Listener::bind(f
|
||||||
|
000009b0: 6f72 6d61 7421 2822 7b7d 3a7b 7d22 2c20 ormat!("{}:{}",
|
||||||
|
000009c0: 636f 6e66 6967 2e73 6572 7665 722e 686f config.server.ho
|
||||||
|
000009d0: 7374 2c20 636f 6e66 6967 2e73 6572 7665 st, config.serve
|
||||||
|
000009e0: 722e 706f 7274 2929 0d0a 2020 2020 2020 r.port))..
|
||||||
|
000009f0: 2020 2e61 7761 6974 3f3b 0d0a 2020 2020 .await?;..
|
||||||
|
00000a00: 696e 666f 2128 2253 6161 5320 7365 7276 info!("SaaS serv
|
||||||
|
00000a10: 6572 206c 6973 7465 6e69 6e67 206f 6e20 er listening on
|
||||||
|
00000a20: 7b7d 3a7b 7d22 2c20 636f 6e66 6967 2e73 {}:{}", config.s
|
||||||
|
00000a30: 6572 7665 722e 686f 7374 2c20 636f 6e66 erver.host, conf
|
||||||
|
00000a40: 6967 2e73 6572 7665 722e 706f 7274 293b ig.server.port);
|
||||||
|
00000a50: 0d0a 0d0a 2020 2020 6178 756d 3a3a 7365 .... axum::se
|
||||||
|
00000a60: 7276 6528 6c69 7374 656e 6572 2c20 6170 rve(listener, ap
|
||||||
|
00000a70: 702e 696e 746f 5f6d 616b 655f 7365 7276 p.into_make_serv
|
||||||
|
00000a80: 6963 655f 7769 7468 5f63 6f6e 6e65 6374 ice_with_connect
|
||||||
|
00000a90: 5f69 6e66 6f3a 3a3c 7374 643a 3a6e 6574 _info::<std::net
|
||||||
|
00000aa0: 3a3a 536f 636b 6574 4164 6472 3e28 2929 ::SocketAddr>())
|
||||||
|
00000ab0: 0d0a 2020 2020 2020 2020 2e77 6974 685f .. .with_
|
||||||
|
00000ac0: 6772 6163 6566 756c 5f73 6875 7464 6f77 graceful_shutdow
|
||||||
|
00000ad0: 6e28 7368 7574 646f 776e 5f73 6967 6e61 n(shutdown_signa
|
||||||
|
00000ae0: 6c28 2929 0d0a 2020 2020 2020 2020 2e61 l()).. .a
|
||||||
|
00000af0: 7761 6974 3f3b 0d0a 2020 2020 4f6b 2828 wait?;.. Ok((
|
||||||
|
00000b00: 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 2066 ))..}....async f
|
||||||
|
00000b10: 6e20 6865 616c 7468 5f68 616e 646c 6572 n health_handler
|
||||||
|
00000b20: 2853 7461 7465 2873 7461 7465 293a 2053 (State(state): S
|
||||||
|
00000b30: 7461 7465 3c41 7070 5374 6174 653e 2920 tate<AppState>)
|
||||||
|
00000b40: 2d3e 2061 7875 6d3a 3a4a 736f 6e3c 7365 -> axum::Json<se
|
||||||
|
00000b50: 7264 655f 6a73 6f6e 3a3a 5661 6c75 653e rde_json::Value>
|
||||||
|
00000b60: 207b 0d0a 2020 2020 2f2f 2068 6561 6c74 {.. // healt
|
||||||
|
00000b70: 6820 e5bf 85e9 a1bb e78b ace7 ab8b e5bf h ..............
|
||||||
|
00000b80: abe9 809f e8bf 94e5 9b9e efbc 8ce7 94a8 ................
|
||||||
|
00000b90: 2033 7320 e8b6 85e6 97b6 e981 bfe5 858d 3s ............
|
||||||
|
00000ba0: e8bf 9ee6 8ea5 e6b1 a0e6 bba1 e697 b6e9 ................
|
||||||
|
00000bb0: 98bb e5a1 9e0d 0a20 2020 206c 6574 2064 ....... let d
|
||||||
|
00000bc0: 625f 6865 616c 7468 7920 3d20 746f 6b69 b_healthy = toki
|
||||||
|
00000bd0: 6f3a 3a74 696d 653a 3a74 696d 656f 7574 o::time::timeout
|
||||||
|
00000be0: 280d 0a20 2020 2020 2020 2073 7464 3a3a (.. std::
|
||||||
|
00000bf0: 7469 6d65 3a3a 4475 7261 7469 6f6e 3a3a time::Duration::
|
||||||
|
00000c00: 6672 6f6d 5f73 6563 7328 3329 2c0d 0a20 from_secs(3),..
|
||||||
|
00000c10: 2020 2020 2020 2073 716c 783a 3a71 7565 sqlx::que
|
||||||
|
00000c20: 7279 5f73 6361 6c61 723a 3a3c 5f2c 2069 ry_scalar::<_, i
|
||||||
|
00000c30: 3332 3e28 2253 454c 4543 5420 3122 292e 32>("SELECT 1").
|
||||||
|
00000c40: 6665 7463 685f 6f6e 6528 2673 7461 7465 fetch_one(&state
|
||||||
|
00000c50: 2e64 6229 2c0d 0a20 2020 2029 0d0a 2020 .db),.. )..
|
||||||
|
00000c60: 2020 2e61 7761 6974 0d0a 2020 2020 2e6d .await.. .m
|
||||||
|
00000c70: 6170 287c 727c 2072 2e69 735f 6f6b 2829 ap(|r| r.is_ok()
|
||||||
|
00000c80: 290d 0a20 2020 202e 756e 7772 6170 5f6f ).. .unwrap_o
|
||||||
|
00000c90: 7228 6661 6c73 6529 3b0d 0a0d 0a20 2020 r(false);....
|
||||||
|
00000ca0: 206c 6574 2073 7461 7475 7320 3d20 6966 let status = if
|
||||||
|
00000cb0: 2064 625f 6865 616c 7468 7920 7b20 2268 db_healthy { "h
|
||||||
|
00000cc0: 6561 6c74 6879 2220 7d20 656c 7365 207b ealthy" } else {
|
||||||
|
00000cd0: 2022 6465 6772 6164 6564 2220 7d3b 0d0a "degraded" };..
|
||||||
|
00000ce0: 2020 2020 6c65 7420 5f63 6f64 6520 3d20 let _code =
|
||||||
|
00000cf0: 6966 2064 625f 6865 616c 7468 7920 7b20 if db_healthy {
|
||||||
|
00000d00: 3230 3020 7d20 656c 7365 207b 2035 3033 200 } else { 503
|
||||||
|
00000d10: 207d 3b0d 0a0d 0a20 2020 2061 7875 6d3a };.... axum:
|
||||||
|
00000d20: 3a4a 736f 6e28 7365 7264 655f 6a73 6f6e :Json(serde_json
|
||||||
|
00000d30: 3a3a 6a73 6f6e 2128 7b0d 0a20 2020 2020 ::json!({..
|
||||||
|
00000d40: 2020 2022 7374 6174 7573 223a 2073 7461 "status": sta
|
||||||
|
00000d50: 7475 732c 0d0a 2020 2020 2020 2020 2264 tus,.. "d
|
||||||
|
00000d60: 6174 6162 6173 6522 3a20 6462 5f68 6561 atabase": db_hea
|
||||||
|
00000d70: 6c74 6879 2c0d 0a20 2020 2020 2020 2022 lthy,.. "
|
||||||
|
00000d80: 7469 6d65 7374 616d 7022 3a20 6368 726f timestamp": chro
|
||||||
|
00000d90: 6e6f 3a3a 5574 633a 3a6e 6f77 2829 2e74 no::Utc::now().t
|
||||||
|
00000da0: 6f5f 7266 6333 3333 3928 292c 0d0a 2020 o_rfc3339(),..
|
||||||
|
00000db0: 2020 2020 2020 2276 6572 7369 6f6e 223a "version":
|
||||||
|
00000dc0: 2065 6e76 2128 2243 4152 474f 5f50 4b47 env!("CARGO_PKG
|
||||||
|
00000dd0: 5f56 4552 5349 4f4e 2229 2c0d 0a20 2020 _VERSION"),..
|
||||||
|
00000de0: 207d 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 }))..}....async
|
||||||
|
00000df0: 2066 6e20 6275 696c 645f 726f 7574 6572 fn build_router
|
||||||
|
00000e00: 2873 7461 7465 3a20 4170 7053 7461 7465 (state: AppState
|
||||||
|
00000e10: 2920 2d3e 2061 7875 6d3a 3a52 6f75 7465 ) -> axum::Route
|
||||||
|
00000e20: 7220 7b0d 0a20 2020 2075 7365 2061 7875 r {.. use axu
|
||||||
|
00000e30: 6d3a 3a6d 6964 646c 6577 6172 653b 0d0a m::middleware;..
|
||||||
|
00000e40: 2020 2020 7573 6520 746f 7765 725f 6874 use tower_ht
|
||||||
|
00000e50: 7470 3a3a 636f 7273 3a3a 7b41 6e79 2c20 tp::cors::{Any,
|
||||||
|
00000e60: 436f 7273 4c61 7965 727d 3b0d 0a20 2020 CorsLayer};..
|
||||||
|
00000e70: 2075 7365 2074 6f77 6572 5f68 7474 703a use tower_http:
|
||||||
|
00000e80: 3a74 7261 6365 3a3a 5472 6163 654c 6179 :trace::TraceLay
|
||||||
|
00000e90: 6572 3b0d 0a0d 0a20 2020 2075 7365 2061 er;.... use a
|
||||||
|
00000ea0: 7875 6d3a 3a68 7474 703a 3a48 6561 6465 xum::http::Heade
|
||||||
|
00000eb0: 7256 616c 7565 3b0d 0a20 2020 206c 6574 rValue;.. let
|
||||||
|
00000ec0: 2063 6f72 7320 3d20 7b0d 0a20 2020 2020 cors = {..
|
||||||
|
00000ed0: 2020 206c 6574 2063 6f6e 6669 6720 3d20 let config =
|
||||||
|
00000ee0: 7374 6174 652e 636f 6e66 6967 2e72 6561 state.config.rea
|
||||||
|
00000ef0: 6428 292e 6177 6169 743b 0d0a 2020 2020 d().await;..
|
||||||
|
00000f00: 2020 2020 6c65 7420 6973 5f64 6576 203d let is_dev =
|
||||||
|
00000f10: 2073 7464 3a3a 656e 763a 3a76 6172 2822 std::env::var("
|
||||||
|
00000f20: 5a43 4c41 575f 5341 4153 5f44 4556 2229 ZCLAW_SAAS_DEV")
|
||||||
|
00000f30: 0d0a 2020 2020 2020 2020 2020 2020 2e6d .. .m
|
||||||
|
00000f40: 6170 287c 767c 2076 203d 3d20 2274 7275 ap(|v| v == "tru
|
||||||
|
00000f50: 6522 207c 7c20 7620 3d3d 2022 3122 290d e" || v == "1").
|
||||||
|
00000f60: 0a20 2020 2020 2020 2020 2020 202e 756e . .un
|
||||||
|
00000f70: 7772 6170 5f6f 7228 6661 6c73 6529 3b0d wrap_or(false);.
|
||||||
|
00000f80: 0a20 2020 2020 2020 2069 6620 636f 6e66 . if conf
|
||||||
|
00000f90: 6967 2e73 6572 7665 722e 636f 7273 5f6f ig.server.cors_o
|
||||||
|
00000fa0: 7269 6769 6e73 2e69 735f 656d 7074 7928 rigins.is_empty(
|
||||||
|
00000fb0: 2920 7b0d 0a20 2020 2020 2020 2020 2020 ) {..
|
||||||
|
00000fc0: 2069 6620 6973 5f64 6576 207b 0d0a 2020 if is_dev {..
|
||||||
|
00000fd0: 2020 2020 2020 2020 2020 2020 2020 436f Co
|
||||||
|
00000fe0: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
|
||||||
|
00000ff0: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001000: 2020 2020 2e61 6c6c 6f77 5f6f 7269 6769 .allow_origi
|
||||||
|
00001010: 6e28 416e 7929 0d0a 2020 2020 2020 2020 n(Any)..
|
||||||
|
00001020: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
|
||||||
|
00001030: 6f77 5f6d 6574 686f 6473 2841 6e79 290d ow_methods(Any).
|
||||||
|
00001040: 0a20 2020 2020 2020 2020 2020 2020 2020 .
|
||||||
|
00001050: 2020 2020 202e 616c 6c6f 775f 6865 6164 .allow_head
|
||||||
|
00001060: 6572 7328 416e 7929 0d0a 2020 2020 2020 ers(Any)..
|
||||||
|
00001070: 2020 2020 2020 7d20 656c 7365 207b 0d0a } else {..
|
||||||
|
00001080: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001090: 7472 6163 696e 673a 3a65 7272 6f72 2128 tracing::error!(
|
||||||
|
000010a0: 22e7 949f e4ba a7e7 8eaf e5a2 83e5 bf85 "...............
|
||||||
|
000010b0: e9a1 bbe9 858d e7bd ae20 7365 7276 6572 ......... server
|
||||||
|
000010c0: 2e63 6f72 735f 6f72 6967 696e 73ef bc8c .cors_origins...
|
||||||
|
000010d0: e4b8 8de8 83bd e4bd bfe7 94a8 2061 6c6c ............ all
|
||||||
|
000010e0: 6f77 5f6f 7269 6769 6e28 416e 7929 2229 ow_origin(Any)")
|
||||||
|
000010f0: 3b0d 0a20 2020 2020 2020 2020 2020 2020 ;..
|
||||||
|
00001100: 2020 2070 616e 6963 2128 22e7 949f e4ba panic!(".....
|
||||||
|
00001110: a7e7 8eaf e5a2 83e5 bf85 e9a1 bbe9 858d ................
|
||||||
|
00001120: e7bd ae20 7365 7276 6572 2e63 6f72 735f ... server.cors_
|
||||||
|
00001130: 6f72 6967 696e 7320 e799 bde5 908d e58d origins ........
|
||||||
|
00001140: 95e3 8082 e5bc 80e5 8f91 e78e afe5 a283 ................
|
||||||
|
00001150: e58f afe8 aebe e7bd ae20 5a43 4c41 575f ......... ZCLAW_
|
||||||
|
00001160: 5341 4153 5f44 4556 3d74 7275 6520 e7bb SAAS_DEV=true ..
|
||||||
|
00001170: 95e8 bf87 e380 8222 293b 0d0a 2020 2020 .......");..
|
||||||
|
00001180: 2020 2020 2020 2020 7d0d 0a20 2020 2020 }..
|
||||||
|
00001190: 2020 207d 2065 6c73 6520 7b0d 0a20 2020 } else {..
|
||||||
|
000011a0: 2020 2020 2020 2020 206c 6574 206f 7269 let ori
|
||||||
|
000011b0: 6769 6e73 3a20 5665 633c 4865 6164 6572 gins: Vec<Header
|
||||||
|
000011c0: 5661 6c75 653e 203d 2063 6f6e 6669 672e Value> = config.
|
||||||
|
000011d0: 7365 7276 6572 2e63 6f72 735f 6f72 6967 server.cors_orig
|
||||||
|
000011e0: 696e 732e 6974 6572 2829 0d0a 2020 2020 ins.iter()..
|
||||||
|
000011f0: 2020 2020 2020 2020 2020 2020 2e66 696c .fil
|
||||||
|
00001200: 7465 725f 6d61 7028 7c6f 3a20 2653 7472 ter_map(|o: &Str
|
||||||
|
00001210: 696e 677c 206f 2e70 6172 7365 3a3a 3c48 ing| o.parse::<H
|
||||||
|
00001220: 6561 6465 7256 616c 7565 3e28 292e 6f6b eaderValue>().ok
|
||||||
|
00001230: 2829 290d 0a20 2020 2020 2020 2020 2020 ())..
|
||||||
|
00001240: 2020 2020 202e 636f 6c6c 6563 7428 293b .collect();
|
||||||
|
00001250: 0d0a 2020 2020 2020 2020 2020 2020 436f .. Co
|
||||||
|
00001260: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
|
||||||
|
00001270: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001280: 2e61 6c6c 6f77 5f6f 7269 6769 6e28 6f72 .allow_origin(or
|
||||||
|
00001290: 6967 696e 7329 0d0a 2020 2020 2020 2020 igins)..
|
||||||
|
000012a0: 2020 2020 2020 2020 2e61 6c6c 6f77 5f6d .allow_m
|
||||||
|
000012b0: 6574 686f 6473 285b 0d0a 2020 2020 2020 ethods([..
|
||||||
|
000012c0: 2020 2020 2020 2020 2020 2020 2020 6178 ax
|
||||||
|
000012d0: 756d 3a3a 6874 7470 3a3a 4d65 7468 6f64 um::http::Method
|
||||||
|
000012e0: 3a3a 4745 542c 0d0a 2020 2020 2020 2020 ::GET,..
|
||||||
|
000012f0: 2020 2020 2020 2020 2020 2020 6178 756d axum
|
||||||
|
00001300: 3a3a 6874 7470 3a3a 4d65 7468 6f64 3a3a ::http::Method::
|
||||||
|
00001310: 504f 5354 2c0d 0a20 2020 2020 2020 2020 POST,..
|
||||||
|
00001320: 2020 2020 2020 2020 2020 2061 7875 6d3a axum:
|
||||||
|
00001330: 3a68 7474 703a 3a4d 6574 686f 643a 3a50 :http::Method::P
|
||||||
|
00001340: 5554 2c0d 0a20 2020 2020 2020 2020 2020 UT,..
|
||||||
|
00001350: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
|
||||||
|
00001360: 7474 703a 3a4d 6574 686f 643a 3a50 4154 ttp::Method::PAT
|
||||||
|
00001370: 4348 2c0d 0a20 2020 2020 2020 2020 2020 CH,..
|
||||||
|
00001380: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
|
||||||
|
00001390: 7474 703a 3a4d 6574 686f 643a 3a44 454c ttp::Method::DEL
|
||||||
|
000013a0: 4554 452c 0d0a 2020 2020 2020 2020 2020 ETE,..
|
||||||
|
000013b0: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
|
||||||
|
000013c0: 6874 7470 3a3a 4d65 7468 6f64 3a3a 4f50 http::Method::OP
|
||||||
|
000013d0: 5449 4f4e 532c 0d0a 2020 2020 2020 2020 TIONS,..
|
||||||
|
000013e0: 2020 2020 2020 2020 5d29 0d0a 2020 2020 ])..
|
||||||
|
000013f0: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
|
||||||
|
00001400: 6f77 5f68 6561 6465 7273 285b 0d0a 2020 ow_headers([..
|
||||||
|
00001410: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001420: 2020 6178 756d 3a3a 6874 7470 3a3a 6865 axum::http::he
|
||||||
|
00001430: 6164 6572 3a3a 4155 5448 4f52 495a 4154 ader::AUTHORIZAT
|
||||||
|
00001440: 494f 4e2c 0d0a 2020 2020 2020 2020 2020 ION,..
|
||||||
|
00001450: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
|
||||||
|
00001460: 6874 7470 3a3a 6865 6164 6572 3a3a 434f http::header::CO
|
||||||
|
00001470: 4e54 454e 545f 5459 5045 2c0d 0a20 2020 NTENT_TYPE,..
|
||||||
|
00001480: 2020 2020 2020 2020 2020 2020 2020 2020
|
||||||
|
00001490: 2061 7875 6d3a 3a68 7474 703a 3a48 6561 axum::http::Hea
|
||||||
|
000014a0: 6465 724e 616d 653a 3a66 726f 6d5f 7374 derName::from_st
|
||||||
|
000014b0: 6174 6963 2822 782d 7265 7175 6573 742d atic("x-request-
|
||||||
|
000014c0: 6964 2229 2c0d 0a20 2020 2020 2020 2020 id"),..
|
||||||
|
000014d0: 2020 2020 2020 205d 290d 0a20 2020 2020 ])..
|
||||||
|
000014e0: 2020 207d 0d0a 2020 2020 7d3b 0d0a 0d0a }.. };....
|
||||||
|
000014f0: 2020 2020 6c65 7420 7075 626c 6963 5f72 let public_r
|
||||||
|
00001500: 6f75 7465 7320 3d20 7a63 6c61 775f 7361 outes = zclaw_sa
|
||||||
|
00001510: 6173 3a3a 6175 7468 3a3a 726f 7574 6573 as::auth::routes
|
||||||
|
00001520: 2829 0d0a 2020 2020 2020 2020 2e72 6f75 ().. .rou
|
||||||
|
00001530: 7465 2822 2f61 7069 2f68 6561 6c74 6822 te("/api/health"
|
||||||
|
00001540: 2c20 6178 756d 3a3a 726f 7574 696e 673a , axum::routing:
|
||||||
|
00001550: 3a67 6574 2868 6561 6c74 685f 6861 6e64 :get(health_hand
|
||||||
|
00001560: 6c65 7229 290d 0a20 2020 2020 2020 202e ler)).. .
|
||||||
|
00001570: 6c61 7965 7228 6d69 6464 6c65 7761 7265 layer(middleware
|
||||||
|
00001580: 3a3a 6672 6f6d 5f66 6e5f 7769 7468 5f73 ::from_fn_with_s
|
||||||
|
00001590: 7461 7465 280d 0a20 2020 2020 2020 2020 tate(..
|
||||||
|
000015a0: 2020 2073 7461 7465 2e63 6c6f 6e65 2829 state.clone()
|
||||||
|
000015b0: 2c0d 0a20 2020 2020 2020 2020 2020 207a ,.. z
|
||||||
|
000015c0: 636c 6177 5f73 6161 733a 3a6d 6964 646c claw_saas::middl
|
||||||
|
000015d0: 6577 6172 653a 3a70 7562 6c69 635f 7261 eware::public_ra
|
||||||
|
000015e0: 7465 5f6c 696d 6974 5f6d 6964 646c 6577 te_limit_middlew
|
||||||
|
000015f0: 6172 652c 0d0a 2020 2020 2020 2020 2929 are,.. ))
|
||||||
|
00001600: 3b0d 0a0d 0a20 2020 206c 6574 2070 726f ;.... let pro
|
||||||
|
00001610: 7465 6374 6564 5f72 6f75 7465 7320 3d20 tected_routes =
|
||||||
|
00001620: 7a63 6c61 775f 7361 6173 3a3a 6175 7468 zclaw_saas::auth
|
||||||
|
00001630: 3a3a 7072 6f74 6563 7465 645f 726f 7574 ::protected_rout
|
||||||
|
00001640: 6573 2829 0d0a 2020 2020 2020 2020 2e6d es().. .m
|
||||||
|
00001650: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
|
||||||
|
00001660: 3a61 6363 6f75 6e74 3a3a 726f 7574 6573 :account::routes
|
||||||
|
00001670: 2829 290d 0a20 2020 2020 2020 202e 6d65 ()).. .me
|
||||||
|
00001680: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
|
||||||
|
00001690: 6d6f 6465 6c5f 636f 6e66 6967 3a3a 726f model_config::ro
|
||||||
|
000016a0: 7574 6573 2829 290d 0a20 2020 2020 2020 utes())..
|
||||||
|
000016b0: 202e 6d65 7267 6528 7a63 6c61 775f 7361 .merge(zclaw_sa
|
||||||
|
000016c0: 6173 3a3a 7265 6c61 793a 3a72 6f75 7465 as::relay::route
|
||||||
|
000016d0: 7328 2929 0d0a 2020 2020 2020 2020 2e6d s()).. .m
|
||||||
|
000016e0: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
|
||||||
|
000016f0: 3a6d 6967 7261 7469 6f6e 3a3a 726f 7574 :migration::rout
|
||||||
|
00001700: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
|
||||||
|
00001710: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
|
||||||
|
00001720: 3a3a 726f 6c65 3a3a 726f 7574 6573 2829 ::role::routes()
|
||||||
|
00001730: 290d 0a20 2020 2020 2020 202e 6d65 7267 ).. .merg
|
||||||
|
00001740: 6528 7a63 6c61 775f 7361 6173 3a3a 7072 e(zclaw_saas::pr
|
||||||
|
00001750: 6f6d 7074 3a3a 726f 7574 6573 2829 290d ompt::routes()).
|
||||||
|
00001760: 0a20 2020 2020 2020 202e 6d65 7267 6528 . .merge(
|
||||||
|
00001770: 7a63 6c61 775f 7361 6173 3a3a 6167 656e zclaw_saas::agen
|
||||||
|
00001780: 745f 7465 6d70 6c61 7465 3a3a 726f 7574 t_template::rout
|
||||||
|
00001790: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
|
||||||
|
000017a0: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
|
||||||
|
000017b0: 3a3a 7465 6c65 6d65 7472 793a 3a72 6f75 ::telemetry::rou
|
||||||
|
000017c0: 7465 7328 2929 0d0a 2020 2020 2020 2020 tes())..
|
||||||
|
000017d0: 2e6c 6179 6572 286d 6964 646c 6577 6172 .layer(middlewar
|
||||||
|
000017e0: 653a 3a66 726f 6d5f 666e 5f77 6974 685f e::from_fn_with_
|
||||||
|
000017f0: 7374 6174 6528 0d0a 2020 2020 2020 2020 state(..
|
||||||
|
00001800: 2020 2020 7374 6174 652e 636c 6f6e 6528 state.clone(
|
||||||
|
00001810: 292c 0d0a 2020 2020 2020 2020 2020 2020 ),..
|
||||||
|
00001820: 7a63 6c61 775f 7361 6173 3a3a 6d69 6464 zclaw_saas::midd
|
||||||
|
00001830: 6c65 7761 7265 3a3a 6170 695f 7665 7273 leware::api_vers
|
||||||
|
00001840: 696f 6e5f 6d69 6464 6c65 7761 7265 2c0d ion_middleware,.
|
||||||
|
00001850: 0a20 2020 2020 2020 2029 290d 0a20 2020 . ))..
|
||||||
|
00001860: 2020 2020 202e 6c61 7965 7228 6d69 6464 .layer(midd
|
||||||
|
00001870: 6c65 7761 7265 3a3a 6672 6f6d 5f66 6e5f leware::from_fn_
|
||||||
|
00001880: 7769 7468 5f73 7461 7465 280d 0a20 2020 with_state(..
|
||||||
|
00001890: 2020 2020 2020 2020 2073 7461 7465 2e63 state.c
|
||||||
|
000018a0: 6c6f 6e65 2829 2c0d 0a20 2020 2020 2020 lone(),..
|
||||||
|
000018b0: 2020 2020 207a 636c 6177 5f73 6161 733a zclaw_saas:
|
||||||
|
000018c0: 3a6d 6964 646c 6577 6172 653a 3a72 6571 :middleware::req
|
||||||
|
000018d0: 7565 7374 5f69 645f 6d69 6464 6c65 7761 uest_id_middlewa
|
||||||
|
000018e0: 7265 2c0d 0a20 2020 2020 2020 2029 290d re,.. )).
|
||||||
|
000018f0: 0a20 2020 2020 2020 202e 6c61 7965 7228 . .layer(
|
||||||
|
00001900: 6d69 6464 6c65 7761 7265 3a3a 6672 6f6d middleware::from
|
||||||
|
00001910: 5f66 6e5f 7769 7468 5f73 7461 7465 280d _fn_with_state(.
|
||||||
|
00001920: 0a20 2020 2020 2020 2020 2020 2073 7461 . sta
|
||||||
|
00001930: 7465 2e63 6c6f 6e65 2829 2c0d 0a20 2020 te.clone(),..
|
||||||
|
00001940: 2020 2020 2020 2020 207a 636c 6177 5f73 zclaw_s
|
||||||
|
00001950: 6161 733a 3a6d 6964 646c 6577 6172 653a aas::middleware:
|
||||||
|
00001960: 3a72 6174 655f 6c69 6d69 745f 6d69 6464 :rate_limit_midd
|
||||||
|
00001970: 6c65 7761 7265 2c0d 0a20 2020 2020 2020 leware,..
|
||||||
|
00001980: 2029 290d 0a20 2020 2020 2020 202e 6c61 )).. .la
|
||||||
|
00001990: 7965 7228 6d69 6464 6c65 7761 7265 3a3a yer(middleware::
|
||||||
|
000019a0: 6672 6f6d 5f66 6e5f 7769 7468 5f73 7461 from_fn_with_sta
|
||||||
|
000019b0: 7465 280d 0a20 2020 2020 2020 2020 2020 te(..
|
||||||
|
000019c0: 2073 7461 7465 2e63 6c6f 6e65 2829 2c0d state.clone(),.
|
||||||
|
000019d0: 0a20 2020 2020 2020 2020 2020 207a 636c . zcl
|
||||||
|
000019e0: 6177 5f73 6161 733a 3a61 7574 683a 3a61 aw_saas::auth::a
|
||||||
|
000019f0: 7574 685f 6d69 6464 6c65 7761 7265 2c0d uth_middleware,.
|
||||||
|
00001a00: 0a20 2020 2020 2020 2029 293b 0d0a 0d0a . ));....
|
||||||
|
00001a10: 2020 2020 2f2f 20e9 9d9e e6b5 81e5 bc8f // .........
|
||||||
|
00001a20: e8b7 afe7 94b1 e5ba 94e7 94a8 e585 a8e5 ................
|
||||||
|
00001a30: b180 2031 3573 20e8 b685 e697 b6ef bc88 .. 15s .........
|
||||||
|
00001a40: 7265 6c61 7920 5353 4520 e7ab afe7 82b9 relay SSE ......
|
||||||
|
00001a50: e99c 80e8 a681 e69b b4e9 95bf e8b6 85e6 ................
|
||||||
|
00001a60: 97b6 efbc 890d 0a20 2020 206c 6574 206e ....... let n
|
||||||
|
00001a70: 6f6e 5f73 7472 6561 6d69 6e67 5f72 6f75 on_streaming_rou
|
||||||
|
00001a80: 7465 7320 3d20 6178 756d 3a3a 526f 7574 tes = axum::Rout
|
||||||
|
00001a90: 6572 3a3a 6e65 7728 290d 0a20 2020 2020 er::new()..
|
||||||
|
00001aa0: 2020 202e 6d65 7267 6528 7075 626c 6963 .merge(public
|
||||||
|
00001ab0: 5f72 6f75 7465 7329 0d0a 2020 2020 2020 _routes)..
|
||||||
|
00001ac0: 2020 2e6d 6572 6765 2870 726f 7465 6374 .merge(protect
|
||||||
|
00001ad0: 6564 5f72 6f75 7465 7329 0d0a 2020 2020 ed_routes)..
|
||||||
|
00001ae0: 2020 2020 2e6c 6179 6572 2854 696d 656f .layer(Timeo
|
||||||
|
00001af0: 7574 4c61 7965 723a 3a6e 6577 2873 7464 utLayer::new(std
|
||||||
|
00001b00: 3a3a 7469 6d65 3a3a 4475 7261 7469 6f6e ::time::Duration
|
||||||
|
00001b10: 3a3a 6672 6f6d 5f73 6563 7328 3135 2929 ::from_secs(15))
|
||||||
|
00001b20: 293b 0d0a 0d0a 2020 2020 6178 756d 3a3a );.... axum::
|
||||||
|
00001b30: 526f 7574 6572 3a3a 6e65 7728 290d 0a20 Router::new()..
|
||||||
|
00001b40: 2020 2020 2020 202e 6d65 7267 6528 6e6f .merge(no
|
||||||
|
00001b50: 6e5f 7374 7265 616d 696e 675f 726f 7574 n_streaming_rout
|
||||||
|
00001b60: 6573 290d 0a20 2020 2020 2020 202e 6d65 es).. .me
|
||||||
|
00001b70: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
|
||||||
|
00001b80: 7265 6c61 793a 3a72 6f75 7465 7328 2929 relay::routes())
|
||||||
|
00001b90: 0d0a 2020 2020 2020 2020 2e6c 6179 6572 .. .layer
|
||||||
|
00001ba0: 2854 7261 6365 4c61 7965 723a 3a6e 6577 (TraceLayer::new
|
||||||
|
00001bb0: 5f66 6f72 5f68 7474 7028 2929 0d0a 2020 _for_http())..
|
||||||
|
00001bc0: 2020 2020 2020 2e6c 6179 6572 2863 6f72 .layer(cor
|
||||||
|
00001bd0: 7329 0d0a 2020 2020 2020 2020 2e77 6974 s).. .wit
|
||||||
|
00001be0: 685f 7374 6174 6528 7374 6174 6529 0d0a h_state(state)..
|
||||||
|
00001bf0: 7d0d 0a0d 0a2f 2f2f 20e7 9b91 e590 ac20 }..../// ......
|
||||||
|
00001c00: 4374 726c 2b43 20e4 bfa1 e58f b7ef bc8c Ctrl+C .........
|
||||||
|
00001c10: e8a7 a6e5 8f91 2067 7261 6365 6675 6c20 ...... graceful
|
||||||
|
00001c20: 7368 7574 646f 776e 0d0a 6173 796e 6320 shutdown..async
|
||||||
|
00001c30: 666e 2073 6875 7464 6f77 6e5f 7369 676e fn shutdown_sign
|
||||||
|
00001c40: 616c 2829 207b 0d0a 2020 2020 746f 6b69 al() {.. toki
|
||||||
|
00001c50: 6f3a 3a73 6967 6e61 6c3a 3a63 7472 6c5f o::signal::ctrl_
|
||||||
|
00001c60: 6328 290d 0a20 2020 2020 2020 202e 6177 c().. .aw
|
||||||
|
00001c70: 6169 740d 0a20 2020 2020 2020 202e 6578 ait.. .ex
|
||||||
|
00001c80: 7065 6374 2822 4661 696c 6564 2074 6f20 pect("Failed to
|
||||||
|
00001c90: 696e 7374 616c 6c20 4374 726c 2b43 2068 install Ctrl+C h
|
||||||
|
00001ca0: 616e 646c 6572 2229 3b0d 0a20 2020 2069 andler");.. i
|
||||||
|
00001cb0: 6e66 6f21 2822 5265 6365 6976 6564 2073 nfo!("Received s
|
||||||
|
00001cc0: 6875 7464 6f77 6e20 7369 676e 616c 2c20 hutdown signal,
|
||||||
|
00001cd0: 6472 6169 6e69 6e67 2063 6f6e 6e65 6374 draining connect
|
||||||
|
00001ce0: 696f 6e73 2e2e 2e22 293b 0d0a 7d0d 0a ions...");..}..
|
||||||
0
Authorization
Normal file
98
CLAUDE.md
@@ -36,17 +36,20 @@ ZCLAW/
|
|||||||
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
|
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
|
||||||
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
||||||
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
||||||
│ ├── zclaw-channels/ # 通道适配器 (仅 ConsoleChannel 测试适配器)
|
│ ├── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
||||||
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
│ └── zclaw-saas/ # SaaS 后端 (账号, 模型配置, 中转, 配置同步)
|
||||||
|
├── admin/ # Next.js 管理后台
|
||||||
├── desktop/ # Tauri 桌面应用
|
├── desktop/ # Tauri 桌面应用
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # React UI 组件
|
│ │ ├── components/ # React UI 组件 (含 SaaS 集成)
|
||||||
│ │ ├── store/ # Zustand 状态管理
|
│ │ ├── store/ # Zustand 状态管理 (含 saasStore)
|
||||||
│ │ └── lib/ # 客户端通信 / 工具函数
|
│ │ └── lib/ # 客户端通信 / 工具函数 (含 saas-client)
|
||||||
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
|
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
|
||||||
├── skills/ # SKILL.md 技能定义
|
├── skills/ # SKILL.md 技能定义
|
||||||
├── hands/ # HAND.toml 自主能力配置
|
├── hands/ # HAND.toml 自主能力配置
|
||||||
├── config/ # TOML 配置文件
|
├── config/ # TOML 配置文件
|
||||||
|
├── saas-config.toml # SaaS 后端配置 (PostgreSQL 连接等)
|
||||||
|
├── docker-compose.yml # PostgreSQL 容器配置
|
||||||
├── docs/ # 架构文档和知识库
|
├── docs/ # 架构文档和知识库
|
||||||
└── tests/ # Vitest 回归测试
|
└── tests/ # Vitest 回归测试
|
||||||
```
|
```
|
||||||
@@ -66,7 +69,9 @@ ZCLAW/
|
|||||||
| 桌面框架 | Tauri 2.x |
|
| 桌面框架 | Tauri 2.x |
|
||||||
| 样式方案 | Tailwind CSS |
|
| 样式方案 | Tailwind CSS |
|
||||||
| 配置格式 | TOML |
|
| 配置格式 | TOML |
|
||||||
| 后端核心 | Rust Workspace (8 crates) |
|
| 后端核心 | Rust Workspace (9 crates) |
|
||||||
|
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
|
||||||
|
| 管理后台 | Next.js (admin/) |
|
||||||
|
|
||||||
### 2.3 Crate 依赖关系
|
### 2.3 Crate 依赖关系
|
||||||
|
|
||||||
@@ -79,7 +84,9 @@ zclaw-runtime (→ types, memory)
|
|||||||
↑
|
↑
|
||||||
zclaw-kernel (→ types, memory, runtime)
|
zclaw-kernel (→ types, memory, runtime)
|
||||||
↑
|
↑
|
||||||
desktop/src-tauri (→ kernel, skills, hands, channels, protocols)
|
zclaw-saas (→ types, 独立运行于 8080 端口)
|
||||||
|
↑
|
||||||
|
desktop/src-tauri (→ kernel, skills, hands, protocols)
|
||||||
```
|
```
|
||||||
|
|
||||||
***
|
***
|
||||||
@@ -191,10 +198,10 @@ ZCLAW 提供 11 个自主能力包:
|
|||||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||||
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
||||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||||
| Speech | 语音合成 | ✅ 可用 |
|
| Speech | 语音合成 | ✅ 可用(Browser TTS 前端集成完成) |
|
||||||
| Quiz | 测验生成 | ✅ 可用 |
|
| Quiz | 测验生成 | ✅ 可用 |
|
||||||
|
|
||||||
**触发 Hand 时:**
|
**触发 Hand 时:**
|
||||||
@@ -260,6 +267,18 @@ docs/
|
|||||||
- **面向未来** - 文档要帮助未来的开发者快速理解
|
- **面向未来** - 文档要帮助未来的开发者快速理解
|
||||||
- **中文优先** - 所有面向用户的文档使用中文
|
- **中文优先** - 所有面向用户的文档使用中文
|
||||||
|
|
||||||
|
### 8.3 完成工作后的文档同步(强制)
|
||||||
|
|
||||||
|
每次完成功能实现、架构变更、问题修复后,**必须**同步更新以下文档:
|
||||||
|
|
||||||
|
1. **CLAUDE.md** — 如果涉及项目结构、技术栈、工作流程、命令的变化
|
||||||
|
2. **docs/features/** — 如果涉及新功能、功能变更、功能状态更新
|
||||||
|
3. **docs/knowledge-base/** — 如果涉及新知识、故障排查经验、配置说明
|
||||||
|
4. **saas-config.toml 注释** — 如果涉及 SaaS 配置项变更
|
||||||
|
5. **CHANGELOG** — 如果涉及对外可见的行为变化
|
||||||
|
|
||||||
|
**执行时机:** 代码编译通过且验证成功后,在标记任务完成之前,立即执行文档更新。文档更新是任务完成的必要条件,不是可选步骤。
|
||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
## 9. 常见问题排查
|
## 9. 常见问题排查
|
||||||
@@ -336,10 +355,71 @@ refactor(store): 统一 Store 数据获取方式
|
|||||||
|
|
||||||
***
|
***
|
||||||
|
|
||||||
|
|
||||||
## 12. 安全注意事项
|
## 12. 安全注意事项
|
||||||
|
|
||||||
- 不在代码中硬编码密钥
|
- 不在代码中硬编码密钥
|
||||||
- 用户输入必须验证
|
- 用户输入必须验证
|
||||||
- 敏感操作需要确认
|
- 敏感操作需要确认
|
||||||
- 保留操作审计日志
|
- 保留操作审计日志
|
||||||
|
- 环境变量 `ZCLAW_SAAS_DEV` 模式放宽安全限制(开发环境设 `ZCLAW_SAAS_DEV=true`)
|
||||||
|
|
||||||
|
### 认证安全
|
||||||
|
|
||||||
|
- **JWT password_version**: 密码修改后自动使所有已签发的 JWT 失效(Claims 含 `pwv`,中间件比对 DB)
|
||||||
|
- **账户锁定**: 5 次登录失败后锁定 15 分钟
|
||||||
|
- **邮箱验证**: RFC 5322 正则 + 254 字符长度限制
|
||||||
|
- **JWT 密钥**: `#[cfg(debug_assertions)]` 保护 fallback,release 模式 `bail` 拒绝启动
|
||||||
|
- **TOTP 加密密钥**: 生产环境强制独立 `ZCLAW_TOTP_ENCRYPTION_KEY`(64 字符 hex),不从 JWT 密钥派生
|
||||||
|
- **TOTP/API Key 加密**: AES-256-GCM + 随机 Nonce
|
||||||
|
- **密码存储**: Argon2id + OsRng 随机盐
|
||||||
|
- **Refresh Token 轮换**: 单次使用,Logout 时撤销到 DB,rotation 校验已撤销的旧 token
|
||||||
|
|
||||||
|
### 网络安全
|
||||||
|
|
||||||
|
- **Cookie**: HttpOnly + Secure + SameSite=Strict + 路径作用域
|
||||||
|
- **Cookie Secure**: 开发环境 false,生产 true
|
||||||
|
- **CORS**: 生产强制白名单,缺失拒绝启动
|
||||||
|
- **TLS**: 反向代理(nginx/caddy)提供 HTTPS 终止,Axum 不负责 TLS
|
||||||
|
- **Docker**: SaaS 端口绑定 `127.0.0.1`,仅通过 nginx 反代访问
|
||||||
|
- **XFF**: 仅信任配置的代理 IP
|
||||||
|
|
||||||
|
### 限流
|
||||||
|
|
||||||
|
- `/api/auth/login` — 5次/分钟/IP(防暴力破解)+ 持久化到 PostgreSQL
|
||||||
|
- `/api/auth/register` — 3次/小时/IP(防刷注册)
|
||||||
|
- 公共端点默认 20次/分钟/IP(防滥用)
|
||||||
|
|
||||||
|
### 前端安全
|
||||||
|
|
||||||
|
- **Admin Token**: HttpOnly Cookie 传递,JS 不存储/读取 token
|
||||||
|
- **Tauri CSP**: 移除 `unsafe-inline` script,`connect-src` 限制为 `http://localhost:*` + `https://*`
|
||||||
|
- **Pipeline 日志**: Debug 日志截断 + 仅记录 keys 不记录 values
|
||||||
|
|
||||||
|
### 环境变量
|
||||||
|
|
||||||
|
| 变量 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `DB_PASSWORD` | 数据库密码 |
|
||||||
|
| `ZCLAW_DATABASE_URL` | 完整数据库连接 URL(优先级最高) |
|
||||||
|
| `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (>= 32 字符) |
|
||||||
|
| `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP/API Key 加密密钥 (64 hex) |
|
||||||
|
| `ZCLAW_ADMIN_USERNAME` | 初始管理员用户名 |
|
||||||
|
| `ZCLAW_ADMIN_PASSWORD` | 初始管理员密码 |
|
||||||
|
| `ZCLAW_SAAS_DEV` | 开发模式标志 (true=开发, false=生产) |
|
||||||
|
|
||||||
|
`saas-config.toml` 支持 `${ENV_VAR}` 模式环境变量插值。
|
||||||
|
|
||||||
|
### 生产环境清单
|
||||||
|
|
||||||
|
- [ ] nginx/caddy 配置反向代理 + HTTPS
|
||||||
|
- [ ] 确保设置 `ZCLAW_SAAS_DEV=false`(或不设置)
|
||||||
|
- [ ] 启用 CORS 白名单(`cors_origins` 配置实际域名)
|
||||||
|
- [ ] Cookie Secure=true + HttpOnly=true + SameSite=Strict
|
||||||
|
- [ ] JWT 签名密钥 >= 32 字符随机字符串
|
||||||
|
- [ ] `ZCLAW_TOTP_ENCRYPTION_KEY` 独立设置
|
||||||
|
- [ ] 数据库密码通过 `${DB_PASSWORD}` 引用
|
||||||
|
|
||||||
|
### 完整审计报告
|
||||||
|
|
||||||
|
参见 `docs/features/SECURITY_PENETRATION_TEST_V1.md`
|
||||||
|
|||||||
1009
Cargo.lock
generated
16
Cargo.toml
@@ -9,7 +9,6 @@ members = [
|
|||||||
# ZCLAW Extension Crates
|
# ZCLAW Extension Crates
|
||||||
"crates/zclaw-skills",
|
"crates/zclaw-skills",
|
||||||
"crates/zclaw-hands",
|
"crates/zclaw-hands",
|
||||||
"crates/zclaw-channels",
|
|
||||||
"crates/zclaw-protocols",
|
"crates/zclaw-protocols",
|
||||||
"crates/zclaw-pipeline",
|
"crates/zclaw-pipeline",
|
||||||
"crates/zclaw-growth",
|
"crates/zclaw-growth",
|
||||||
@@ -30,6 +29,7 @@ rust-version = "1.75"
|
|||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tokio-stream = "0.1"
|
tokio-stream = "0.1"
|
||||||
|
tokio-util = "0.7"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||||||
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
uuid = { version = "1", features = ["v4", "v5", "serde"] }
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres"] }
|
||||||
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
|
||||||
|
|
||||||
# HTTP client (for LLM drivers)
|
# HTTP client (for LLM drivers)
|
||||||
@@ -94,19 +94,26 @@ regex = "1"
|
|||||||
# Shell parsing
|
# Shell parsing
|
||||||
shlex = "1"
|
shlex = "1"
|
||||||
|
|
||||||
|
# WASM runtime
|
||||||
|
wasmtime = { version = "43", default-features = false, features = ["cranelift"] }
|
||||||
|
wasmtime-wasi = { version = "43" }
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
||||||
# SaaS dependencies
|
# SaaS dependencies
|
||||||
axum = { version = "0.7", features = ["macros"] }
|
axum = { version = "0.7", features = ["macros"] }
|
||||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
|
||||||
tower = { version = "0.4", features = ["util"] }
|
tower = { version = "0.4", features = ["util"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
|
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "timeout"] }
|
||||||
jsonwebtoken = "9"
|
jsonwebtoken = "9"
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
totp-rs = "5"
|
totp-rs = "5"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
|
||||||
|
# TCP socket configuration
|
||||||
|
socket2 = { version = "0.5", features = ["all"] }
|
||||||
|
|
||||||
# Internal crates
|
# Internal crates
|
||||||
zclaw-types = { path = "crates/zclaw-types" }
|
zclaw-types = { path = "crates/zclaw-types" }
|
||||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||||
@@ -114,7 +121,6 @@ zclaw-runtime = { path = "crates/zclaw-runtime" }
|
|||||||
zclaw-kernel = { path = "crates/zclaw-kernel" }
|
zclaw-kernel = { path = "crates/zclaw-kernel" }
|
||||||
zclaw-skills = { path = "crates/zclaw-skills" }
|
zclaw-skills = { path = "crates/zclaw-skills" }
|
||||||
zclaw-hands = { path = "crates/zclaw-hands" }
|
zclaw-hands = { path = "crates/zclaw-hands" }
|
||||||
zclaw-channels = { path = "crates/zclaw-channels" }
|
|
||||||
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
||||||
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
||||||
zclaw-growth = { path = "crates/zclaw-growth" }
|
zclaw-growth = { path = "crates/zclaw-growth" }
|
||||||
|
|||||||
106
Dockerfile
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# ============================================================
|
||||||
|
# ZCLAW SaaS Backend - Multi-stage Docker Build
|
||||||
|
# ============================================================
|
||||||
|
# Build: docker build -t zclaw-saas .
|
||||||
|
# Run: docker run --env-file saas-env.example zclaw-saas
|
||||||
|
# ============================================================
|
||||||
|
#
|
||||||
|
# .dockerignore recommended contents:
|
||||||
|
# target/
|
||||||
|
# node_modules/
|
||||||
|
# desktop/
|
||||||
|
# admin/
|
||||||
|
# admin-v2/
|
||||||
|
# docs/
|
||||||
|
# .git/
|
||||||
|
# .claude/
|
||||||
|
# *.md
|
||||||
|
# *.pen
|
||||||
|
# plans/
|
||||||
|
# dist/
|
||||||
|
# pencil-new.pen
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ---- Stage 1: Build ----
|
||||||
|
FROM rust:1.85-bookworm AS builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/zclaw
|
||||||
|
|
||||||
|
# Cache dependency builds by copying manifests first
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
|
# Create dummy lib.rs files so cargo can resolve the workspace
|
||||||
|
RUN mkdir -p crates/zclaw-types/src && echo "" > crates/zclaw-types/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-memory/src && echo "" > crates/zclaw-memory/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-runtime/src && echo "" > crates/zclaw-runtime/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-kernel/src && echo "" > crates/zclaw-kernel/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-skills/src && echo "" > crates/zclaw-skills/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-hands/src && echo "" > crates/zclaw-hands/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-protocols/src && echo "" > crates/zclaw-protocols/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-pipeline/src && echo "" > crates/zclaw-pipeline/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-growth/src && echo "" > crates/zclaw-growth/src/lib.rs
|
||||||
|
RUN mkdir -p crates/zclaw-saas/src && echo "fn main() {}" > crates/zclaw-saas/src/main.rs
|
||||||
|
RUN mkdir -p desktop/src-tauri/src && echo "" > desktop/src-tauri/src/lib.rs
|
||||||
|
|
||||||
|
# Copy all crate Cargo.toml files for dependency resolution
|
||||||
|
COPY crates/zclaw-types/Cargo.toml crates/zclaw-types/Cargo.toml
|
||||||
|
COPY crates/zclaw-memory/Cargo.toml crates/zclaw-memory/Cargo.toml
|
||||||
|
COPY crates/zclaw-runtime/Cargo.toml crates/zclaw-runtime/Cargo.toml
|
||||||
|
COPY crates/zclaw-kernel/Cargo.toml crates/zclaw-kernel/Cargo.toml
|
||||||
|
COPY crates/zclaw-skills/Cargo.toml crates/zclaw-skills/Cargo.toml
|
||||||
|
COPY crates/zclaw-hands/Cargo.toml crates/zclaw-hands/Cargo.toml
|
||||||
|
COPY crates/zclaw-protocols/Cargo.toml crates/zclaw-protocols/Cargo.toml
|
||||||
|
COPY crates/zclaw-pipeline/Cargo.toml crates/zclaw-pipeline/Cargo.toml
|
||||||
|
COPY crates/zclaw-growth/Cargo.toml crates/zclaw-growth/Cargo.toml
|
||||||
|
COPY crates/zclaw-saas/Cargo.toml crates/zclaw-saas/Cargo.toml
|
||||||
|
COPY desktop/src-tauri/Cargo.toml desktop/src-tauri/Cargo.toml
|
||||||
|
|
||||||
|
# Build dependencies only (cached layer)
|
||||||
|
RUN cargo build --release -p zclaw-saas 2>/dev/null || true
|
||||||
|
|
||||||
|
# Now copy the actual source code
|
||||||
|
COPY crates/ crates/
|
||||||
|
COPY desktop/src-tauri/src/ desktop/src-tauri/src/
|
||||||
|
|
||||||
|
# Touch source files to invalidate cache after dependency layer
|
||||||
|
RUN find crates/zclaw-saas/src -type f -exec touch {} +
|
||||||
|
|
||||||
|
# Build the final binary
|
||||||
|
RUN cargo build --release -p zclaw-saas
|
||||||
|
|
||||||
|
# ---- Stage 2: Runtime ----
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
# Install runtime dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd --gid 1000 zclaw && \
|
||||||
|
useradd --uid 1000 --gid zclaw --shell /bin/bash --create-home zclaw
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /usr/src/zclaw/target/release/zclaw-saas ./zclaw-saas
|
||||||
|
|
||||||
|
# Copy default config (can be overridden by env vars)
|
||||||
|
COPY saas-config.toml ./saas-config.toml
|
||||||
|
|
||||||
|
# Set ownership
|
||||||
|
RUN chown -R zclaw:zclaw /app
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER zclaw
|
||||||
|
|
||||||
|
# Expose SaaS backend port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["./zclaw-saas"]
|
||||||
1
admin-temp-dir/.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
1
admin-temp-dir/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
24
admin-temp-dir/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
admin-temp-dir/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
admin-temp-dir/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
admin-temp-dir/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>admin-v2</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
admin-temp-dir/package.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-v2",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/charts": "^2.6.7",
|
||||||
|
"@ant-design/icons": "^6.1.1",
|
||||||
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
|
"@ant-design/pro-layout": "^7.22.7",
|
||||||
|
"@tanstack/react-query": "^5.95.2",
|
||||||
|
"antd": "^6.3.4",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vite": "^8.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
5008
admin-temp-dir/pnpm-lock.yaml
generated
Normal file
1
admin-temp-dir/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
admin-temp-dir/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
admin-temp-dir/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
admin-temp-dir/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
admin-temp-dir/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
53
admin-temp-dir/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Component, type ReactNode } from 'react'
|
||||||
|
import { Result, Button } from 'antd'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary] Unhandled error:', error, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReload = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="页面出现错误"
|
||||||
|
subTitle={this.state.error?.message ?? '发生了未知错误,请刷新页面重试'}
|
||||||
|
extra={[
|
||||||
|
<Button key="retry" onClick={this.handleReset}>重试</Button>,
|
||||||
|
<Button key="reload" type="primary" onClick={this.handleReload}>刷新页面</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
103
admin-temp-dir/src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
// ============================================================
|
||||||
|
// AdminLayout — ProLayout 管理后台布局
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import ProLayout from '@ant-design/pro-layout'
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
KeyOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { Avatar, Dropdown, message } from 'antd'
|
||||||
|
import type { MenuDataItem } from '@ant-design/pro-layout'
|
||||||
|
|
||||||
|
const menuConfig: MenuDataItem[] = [
|
||||||
|
{ path: '/', name: '仪表盘', icon: <DashboardOutlined /> },
|
||||||
|
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin' },
|
||||||
|
{ path: '/providers', name: '服务商', icon: <CloudServerOutlined />, permission: 'provider:manage' },
|
||||||
|
{ path: '/models', name: '模型管理', icon: <ApiOutlined />, permission: 'model:read' },
|
||||||
|
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read' },
|
||||||
|
{ path: '/api-keys', name: 'API 密钥', icon: <KeyOutlined />, permission: 'admin:full' },
|
||||||
|
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full' },
|
||||||
|
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use' },
|
||||||
|
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read' },
|
||||||
|
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read' },
|
||||||
|
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function filterMenuByPermission(
|
||||||
|
items: MenuDataItem[],
|
||||||
|
hasPermission: (p: string) => boolean,
|
||||||
|
): MenuDataItem[] {
|
||||||
|
return items
|
||||||
|
.filter((item) => !item.permission || hasPermission(item.permission as string))
|
||||||
|
.map(({ permission, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { account, hasPermission, logout } = useAuthStore()
|
||||||
|
|
||||||
|
const menuData = filterMenuByPermission(menuConfig, hasPermission)
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
message.success('已退出登录')
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProLayout
|
||||||
|
title="ZCLAW"
|
||||||
|
logo={<div style={{ width: 28, height: 28, background: '#1677ff', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: 14 }}>Z</div>}
|
||||||
|
layout="mix"
|
||||||
|
fixSiderbar
|
||||||
|
fixedHeader
|
||||||
|
location={{ pathname: location.pathname }}
|
||||||
|
menuDataRender={() => menuData}
|
||||||
|
menuItemRender={(item, dom) => (
|
||||||
|
<div onClick={() => item.path && navigate(item.path)}>{dom}</div>
|
||||||
|
)}
|
||||||
|
avatarProps={{
|
||||||
|
src: undefined,
|
||||||
|
title: account?.display_name || account?.username || 'Admin',
|
||||||
|
size: 'small',
|
||||||
|
render: (_, dom) => (
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录',
|
||||||
|
onClick: handleLogout,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{dom}
|
||||||
|
</Dropdown>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
suppressSiderWhenMenuEmpty
|
||||||
|
contentStyle={{ padding: 24 }}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</ProLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
admin-temp-dir/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider } from 'react-router-dom'
|
||||||
|
import { ConfigProvider, App as AntApp } from 'antd'
|
||||||
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
import { router } from './router'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ConfigProvider locale={zhCN}>
|
||||||
|
<AntApp>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
</ErrorBoundary>,
|
||||||
|
)
|
||||||
170
admin-temp-dir/src/pages/Accounts.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 账号管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { accountService } from '@/services/accounts'
|
||||||
|
import type { AccountPublic } from '@/types'
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员',
|
||||||
|
admin: '管理员',
|
||||||
|
user: '用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleColors: Record<string, string> = {
|
||||||
|
super_admin: 'red',
|
||||||
|
admin: 'blue',
|
||||||
|
user: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
suspended: '已封禁',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'green',
|
||||||
|
disabled: 'default',
|
||||||
|
suspended: 'red',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Accounts() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['accounts'],
|
||||||
|
queryFn: ({ signal }) => accountService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
||||||
|
accountService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('更新成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: AccountPublic['status'] }) =>
|
||||||
|
accountService.updateStatus(id, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('状态更新成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '状态更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<AccountPublic>[] = [
|
||||||
|
{ title: '用户名', dataIndex: 'username', width: 120 },
|
||||||
|
{ title: '显示名', dataIndex: 'display_name', width: 120 },
|
||||||
|
{ title: '邮箱', dataIndex: 'email', width: 180 },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
width: 120,
|
||||||
|
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '2FA',
|
||||||
|
dataIndex: 'totp_enabled',
|
||||||
|
width: 80,
|
||||||
|
render: (_, record) => record.totp_enabled ? <Tag color="green">已启用</Tag> : <Tag>未启用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后登录',
|
||||||
|
dataIndex: 'last_login_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 200,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{record.status === 'active' ? (
|
||||||
|
<Popconfirm title="确定禁用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'disabled' })}>
|
||||||
|
<Button size="small" danger>禁用</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Popconfirm title="确定启用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'active' })}>
|
||||||
|
<Button size="small" type="primary">启用</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<AccountPublic>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => []}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="编辑账号"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="display_name" label="显示名">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label="邮箱">
|
||||||
|
<Input type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="角色">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'super_admin', label: '超级管理员' },
|
||||||
|
{ value: 'admin', label: '管理员' },
|
||||||
|
{ value: 'user', label: '用户' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
190
admin-temp-dir/src/pages/AgentTemplates.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Agent 模板管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { agentTemplateService } from '@/services/agent-templates'
|
||||||
|
import type { AgentTemplate } from '@/types'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
|
||||||
|
const visibilityLabels: Record<string, string> = { public: '公开', team: '团队', private: '私有' }
|
||||||
|
const statusLabels: Record<string, string> = { active: '活跃', archived: '已归档' }
|
||||||
|
const statusColors: Record<string, string> = { active: 'green', archived: 'default' }
|
||||||
|
|
||||||
|
export default function AgentTemplates() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [detailRecord, setDetailRecord] = useState<AgentTemplate | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['agent-templates'],
|
||||||
|
queryFn: ({ signal }) => agentTemplateService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof agentTemplateService.create>[0]) =>
|
||||||
|
agentTemplateService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => agentTemplateService.archive(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已归档')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '归档失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<AgentTemplate>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 160 },
|
||||||
|
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||||
|
{ title: '模型', dataIndex: 'model', width: 140, render: (_, r) => r.model || '-' },
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag>{sourceLabels[r.source] || r.source}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '可见性',
|
||||||
|
dataIndex: 'visibility',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag color="blue">{visibilityLabels[r.visibility] || r.visibility}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '版本', dataIndex: 'current_version', width: 70 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => setDetailRecord(record)}>详情</Button>
|
||||||
|
{record.status === 'active' && (
|
||||||
|
<Popconfirm title="确定归档此模板?" onConfirm={() => archiveMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>归档</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<AgentTemplate>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
|
||||||
|
新建模板
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建 Agent 模板"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类">
|
||||||
|
<Input placeholder="如 assistant, tool" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="model" label="默认模型">
|
||||||
|
<Input placeholder="如 gpt-4o" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="system_prompt" label="系统提示词">
|
||||||
|
<TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="temperature" label="Temperature">
|
||||||
|
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_tokens" label="最大 Token">
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="visibility" label="可见性">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'public', label: '公开' },
|
||||||
|
{ value: 'team', label: '团队' },
|
||||||
|
{ value: 'private', label: '私有' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="模板详情"
|
||||||
|
open={!!detailRecord}
|
||||||
|
onCancel={() => setDetailRecord(null)}
|
||||||
|
footer={null}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
{detailRecord && (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="系统提示词" span={2}>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
{detailRecord.system_prompt || '-'}
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="工具" span={2}>
|
||||||
|
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="能力" span={2}>
|
||||||
|
{detailRecord.capabilities?.map((c) => <Tag key={c} color="blue">{c}</Tag>) || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
165
admin-temp-dir/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
// ============================================================
|
||||||
|
// API 密钥管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Popconfirm, Space, Typography } from 'antd'
|
||||||
|
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { apiKeyService } from '@/services/api-keys'
|
||||||
|
import type { TokenInfo } from '@/types'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
export default function ApiKeys() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [newToken, setNewToken] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['api-keys'],
|
||||||
|
queryFn: ({ signal }) => apiKeyService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: { name: string; expires_days?: number; permissions: string[] }) =>
|
||||||
|
apiKeyService.create(data),
|
||||||
|
onSuccess: (result: TokenInfo) => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
if (result.token) {
|
||||||
|
setNewToken(result.token)
|
||||||
|
}
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => apiKeyService.revoke(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已撤销')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '撤销失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<TokenInfo>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 160 },
|
||||||
|
{ title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => <Text code>{r.token_prefix}...</Text> },
|
||||||
|
{
|
||||||
|
title: '权限',
|
||||||
|
dataIndex: 'permissions',
|
||||||
|
width: 200,
|
||||||
|
render: (_, r) => r.permissions?.map((p) => <Tag key={p}>{p}</Tag>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '过期时间',
|
||||||
|
dataIndex: 'expires_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => r.expires_at ? new Date(r.expires_at).toLocaleString('zh-CN') : '永不过期',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后使用',
|
||||||
|
dataIndex: 'last_used_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('zh-CN') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 100,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Popconfirm title="确定撤销此密钥?撤销后无法恢复。" onConfirm={() => revokeMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>撤销</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToken = () => {
|
||||||
|
if (newToken) {
|
||||||
|
navigator.clipboard.writeText(newToken)
|
||||||
|
message.success('已复制到剪贴板')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<TokenInfo>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
|
||||||
|
创建密钥
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="创建 API 密钥"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="给密钥起个名字" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="expires_days" label="有效期 (天)">
|
||||||
|
<InputNumber min={1} placeholder="留空则永不过期" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="permissions" label="权限" rules={[{ required: true }]}>
|
||||||
|
<Select mode="multiple" placeholder="选择权限" options={[
|
||||||
|
{ value: 'relay:use', label: '中转使用' },
|
||||||
|
{ value: 'model:read', label: '模型读取' },
|
||||||
|
{ value: 'config:read', label: '配置读取' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="密钥创建成功"
|
||||||
|
open={!!newToken}
|
||||||
|
onOk={() => setNewToken(null)}
|
||||||
|
onCancel={() => setNewToken(null)}
|
||||||
|
>
|
||||||
|
<p>请立即保存此密钥,关闭后将无法再次查看:</p>
|
||||||
|
<Input.TextArea
|
||||||
|
value={newToken || ''}
|
||||||
|
rows={3}
|
||||||
|
readOnly
|
||||||
|
addonAfter={<CopyOutlined onClick={copyToken} style={{ cursor: 'pointer' }} />}
|
||||||
|
/>
|
||||||
|
<Button type="primary" icon={<CopyOutlined />} onClick={copyToken} style={{ marginTop: 8 }}>
|
||||||
|
复制密钥
|
||||||
|
</Button>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
admin-temp-dir/src/pages/Config.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 系统配置
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Card, Tabs, message, Tag, Input, Button, Space, Typography } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { configService } from '@/services/config'
|
||||||
|
import type { ConfigItem } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
export default function Config() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [category, setCategory] = useState<string>('general')
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['config', category],
|
||||||
|
queryFn: ({ signal }) => configService.list({ category }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, value }: { id: string; value: string }) =>
|
||||||
|
configService.update(id, { value }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('配置已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['config', category] })
|
||||||
|
setEditingId(null)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<ConfigItem>[] = [
|
||||||
|
{ title: '配置路径', dataIndex: 'key_path', width: 200, render: (_, r) => <code>{r.key_path}</code> },
|
||||||
|
{
|
||||||
|
title: '当前值',
|
||||||
|
dataIndex: 'current_value',
|
||||||
|
width: 250,
|
||||||
|
render: (_, record) => {
|
||||||
|
if (editingId === record.id) {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
onPressEnter={() => updateMutation.mutate({ id: record.id, value: editValue })}
|
||||||
|
/>
|
||||||
|
<Button size="small" type="primary" onClick={() => updateMutation.mutate({ id: record.id, value: editValue })}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setEditingId(null)}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={() => { setEditingId(record.id); setEditValue(record.current_value || '') }}
|
||||||
|
style={{ cursor: 'pointer', color: '#1677ff' }}
|
||||||
|
>
|
||||||
|
{record.current_value || <Tag>未设置</Tag>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '默认值', dataIndex: 'default_value', width: 200, render: (_, r) => r.default_value || '-' },
|
||||||
|
{ title: '类型', dataIndex: 'value_type', width: 80, render: (_, r) => <Tag>{r.value_type}</Tag> },
|
||||||
|
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '需要重启',
|
||||||
|
dataIndex: 'requires_restart',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) => r.requires_restart ? <Tag color="orange">是</Tag> : <Tag>否</Tag>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginBottom: 24 }}>系统配置</Title>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
activeKey={category}
|
||||||
|
onChange={(key) => { setCategory(key); setEditingId(null) }}
|
||||||
|
items={[
|
||||||
|
{ key: 'general', label: '通用' },
|
||||||
|
{ key: 'auth', label: '认证' },
|
||||||
|
{ key: 'relay', label: '中转' },
|
||||||
|
{ key: 'model', label: '模型' },
|
||||||
|
{ key: 'rate_limit', label: '限流' },
|
||||||
|
{ key: 'log', label: '日志' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProTable<ConfigItem>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
121
admin-temp-dir/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 仪表盘页面
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin, Alert } from 'antd'
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
ColumnWidthOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { statsService } from '@/services/stats'
|
||||||
|
import { logService } from '@/services/logs'
|
||||||
|
import type { OperationLog } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
login: '登录', logout: '登出',
|
||||||
|
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
|
||||||
|
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
|
||||||
|
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
|
||||||
|
create_token: '创建密钥', revoke_token: '撤销密钥',
|
||||||
|
update_config: '更新配置',
|
||||||
|
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
|
||||||
|
desktop_audit: '桌面端审计',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
login: 'green', logout: 'default',
|
||||||
|
create_account: 'blue', update_account: 'orange', delete_account: 'red',
|
||||||
|
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
|
||||||
|
create_model: 'blue', update_model: 'orange', delete_model: 'red',
|
||||||
|
create_token: 'blue', revoke_token: 'red',
|
||||||
|
update_config: 'orange',
|
||||||
|
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
|
||||||
|
desktop_audit: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: ({ signal }) => statsService.dashboard(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||||
|
queryKey: ['recent-logs'],
|
||||||
|
queryFn: ({ signal }) => logService.list({ page: 1, page_size: 10 }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (statsError) {
|
||||||
|
return <Alert type="error" message="加载仪表盘数据失败" description={(statsError as Error).message} showIcon />
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#1677ff' },
|
||||||
|
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#52c41a' },
|
||||||
|
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#722ed1' },
|
||||||
|
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#fa8c16' },
|
||||||
|
{ title: '今日 Token', value: ((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0)), icon: <ColumnWidthOutlined />, color: '#eb2f96' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const logColumns = [
|
||||||
|
{
|
||||||
|
title: '操作类型',
|
||||||
|
dataIndex: 'action',
|
||||||
|
key: 'action',
|
||||||
|
width: 140,
|
||||||
|
render: (action: string) => (
|
||||||
|
<Tag color={actionColors[action] || 'default'}>
|
||||||
|
{actionLabels[action] || action}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 100, render: (v: string | null) => v || '-' },
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginBottom: 24 }}>仪表盘</Title>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<Col span={24}><Spin /></Col>
|
||||||
|
) : (
|
||||||
|
statCards.map((card) => (
|
||||||
|
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
|
||||||
|
<Card>
|
||||||
|
<Statistic
|
||||||
|
title={card.title}
|
||||||
|
value={card.value}
|
||||||
|
prefix={<span style={{ color: card.color }}>{card.icon}</span>}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card title="最近操作日志" size="small">
|
||||||
|
<Table<OperationLog>
|
||||||
|
columns={logColumns}
|
||||||
|
dataSource={logsData?.items ?? []}
|
||||||
|
loading={logsLoading}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
138
admin-temp-dir/src/pages/Login.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 登录页面
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { LoginForm, ProFormText } from '@ant-design/pro-components'
|
||||||
|
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||||
|
import { message, Divider, Typography } from 'antd'
|
||||||
|
import { authService } from '@/services/auth'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import type { LoginRequest } from '@/types'
|
||||||
|
|
||||||
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const loginStore = useAuthStore((s) => s.login)
|
||||||
|
const [needTotp, setNeedTotp] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (values: Record<string, string>) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data: LoginRequest = {
|
||||||
|
username: values.username?.trim() || '',
|
||||||
|
password: values.password || '',
|
||||||
|
totp_code: values.totp_code?.trim() || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await authService.login(data)
|
||||||
|
loginStore(res.token, res.refresh_token, res.account)
|
||||||
|
|
||||||
|
message.success('登录成功')
|
||||||
|
const from = searchParams.get('from') || '/'
|
||||||
|
navigate(from, { replace: true })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { message?: string; status?: number }
|
||||||
|
const msg = error.message || ''
|
||||||
|
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || error.status === 403) {
|
||||||
|
setNeedTotp(true)
|
||||||
|
message.warning(msg || '请输入两步验证码')
|
||||||
|
} else {
|
||||||
|
message.error(msg || '登录失败,请检查用户名和密码')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ minHeight: '100vh', display: 'flex' }}>
|
||||||
|
{/* 左侧品牌区 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'linear-gradient(135deg, #001529 0%, #003a70 50%, #001529 100%)',
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Title level={1} style={{ color: '#fff', marginBottom: 8, letterSpacing: 4 }}>
|
||||||
|
ZCLAW
|
||||||
|
</Title>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 16 }}>AI Agent 管理平台</Text>
|
||||||
|
<Divider style={{ borderColor: 'rgba(22,119,255,0.3)', width: 100, minWidth: 100 }} />
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13, maxWidth: 320, textAlign: 'center' }}>
|
||||||
|
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧登录表单 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '0 0 480px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||||
|
<Title level={3} style={{ marginBottom: 4 }}>登录</Title>
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginBottom: 32 }}>
|
||||||
|
输入您的账号信息以继续
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<LoginForm
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: { submitText: '登录' },
|
||||||
|
submitButtonProps: { loading, block: true },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="username"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <UserOutlined />,
|
||||||
|
autoComplete: 'username',
|
||||||
|
}}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <LockOutlined />,
|
||||||
|
autoComplete: 'current-password',
|
||||||
|
}}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
/>
|
||||||
|
{needTotp && (
|
||||||
|
<ProFormText
|
||||||
|
name="totp_code"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <SafetyOutlined />,
|
||||||
|
maxLength: 6,
|
||||||
|
autoComplete: 'one-time-code',
|
||||||
|
}}
|
||||||
|
placeholder="请输入 6 位验证码"
|
||||||
|
rules={[{ required: true, message: '请输入验证码' }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LoginForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
admin-temp-dir/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 操作日志
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag, Select, Typography } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { logService } from '@/services/logs'
|
||||||
|
import type { OperationLog } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
login: '登录', logout: '登出',
|
||||||
|
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
|
||||||
|
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
|
||||||
|
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
|
||||||
|
create_token: '创建密钥', revoke_token: '撤销密钥',
|
||||||
|
update_config: '更新配置',
|
||||||
|
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
|
||||||
|
desktop_audit: '桌面端审计',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
login: 'green', logout: 'default',
|
||||||
|
create_account: 'blue', update_account: 'orange', delete_account: 'red',
|
||||||
|
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
|
||||||
|
create_model: 'blue', update_model: 'orange', delete_model: 'red',
|
||||||
|
create_token: 'blue', revoke_token: 'red',
|
||||||
|
update_config: 'orange',
|
||||||
|
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
|
||||||
|
desktop_audit: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [actionFilter, setActionFilter] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['logs', page, actionFilter],
|
||||||
|
queryFn: ({ signal }) => logService.list({ page, page_size: 20, action: actionFilter }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<OperationLog>[] = [
|
||||||
|
{
|
||||||
|
title: '操作类型',
|
||||||
|
dataIndex: 'action',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={actionColors[r.action] || 'default'}>
|
||||||
|
{actionLabels[r.action] || r.action}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '目标类型', dataIndex: 'target_type', width: 100, render: (_, r) => r.target_type || '-' },
|
||||||
|
{ title: '目标 ID', dataIndex: 'target_id', width: 120, render: (_, r) => r.target_id ? <code>{r.target_id.substring(0, 8)}...</code> : '-' },
|
||||||
|
{
|
||||||
|
title: '详情',
|
||||||
|
dataIndex: 'details',
|
||||||
|
width: 250,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => {
|
||||||
|
if (!r.details) return '-'
|
||||||
|
if (typeof r.details === 'string') return r.details
|
||||||
|
return JSON.stringify(r.details)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: 'IP 地址', dataIndex: 'ip_address', width: 130, render: (_, r) => <code>{r.ip_address || '-'}</code> },
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>操作日志</Title>
|
||||||
|
<Select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(v) => { setActionFilter(v === 'all' ? undefined : v); setPage(1) }}
|
||||||
|
placeholder="操作类型筛选"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
allowClear
|
||||||
|
options={[{ value: 'all', label: '全部操作' }, ...actionOptions]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProTable<OperationLog>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: 20,
|
||||||
|
current: page,
|
||||||
|
onChange: setPage,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
186
admin-temp-dir/src/pages/Models.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 模型管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Select, Space, Popconfirm } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { modelService } from '@/services/models'
|
||||||
|
import { providerService } from '@/services/providers'
|
||||||
|
import type { Model } from '@/types'
|
||||||
|
|
||||||
|
export default function Models() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['models'],
|
||||||
|
queryFn: ({ signal }) => modelService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: providersData } = useQuery({
|
||||||
|
queryKey: ['providers-for-select'],
|
||||||
|
queryFn: ({ signal }) => providerService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['models'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
|
||||||
|
modelService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('更新成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['models'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => modelService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('删除成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['models'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<Model>[] = [
|
||||||
|
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <code>{r.model_id}</code> },
|
||||||
|
{ title: '别名', dataIndex: 'alias', width: 140 },
|
||||||
|
{
|
||||||
|
title: '服务商',
|
||||||
|
dataIndex: 'provider_id',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => {
|
||||||
|
const provider = providersData?.items?.find((p) => p.id === r.provider_id)
|
||||||
|
return provider?.display_name || r.provider_id.substring(0, 8)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, render: (_, r) => r.context_window?.toLocaleString() },
|
||||||
|
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, render: (_, r) => r.max_output_tokens?.toLocaleString() },
|
||||||
|
{
|
||||||
|
title: '流式',
|
||||||
|
dataIndex: 'supports_streaming',
|
||||||
|
width: 70,
|
||||||
|
render: (_, r) => r.supports_streaming ? <Tag color="green">是</Tag> : <Tag>否</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '视觉',
|
||||||
|
dataIndex: 'supports_vision',
|
||||||
|
width: 70,
|
||||||
|
render: (_, r) => r.supports_vision ? <Tag color="blue">是</Tag> : <Tag>否</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 70,
|
||||||
|
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 160,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<Model>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||||
|
新建模型
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingId ? '编辑模型' : '新建模型'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="provider_id" label="服务商" rules={[{ required: true }]}>
|
||||||
|
<Select
|
||||||
|
options={(providersData?.items ?? []).map((p) => ({ value: p.id, label: p.display_name }))}
|
||||||
|
placeholder="选择服务商"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 gpt-4o" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="alias" label="别名">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="context_window" label="上下文窗口">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_output_tokens" label="最大输出 Token">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="pricing_input" label="输入价格 (每百万 Token)">
|
||||||
|
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="pricing_output" label="输出价格 (每百万 Token)">
|
||||||
|
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
228
admin-temp-dir/src/pages/Prompts.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 提示词管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm, Descriptions, Tabs, Typography } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { promptService } from '@/services/prompts'
|
||||||
|
import type { PromptTemplate, PromptVersion } from '@/types'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
|
||||||
|
const statusLabels: Record<string, string> = { active: '活跃', deprecated: '已废弃', archived: '已归档' }
|
||||||
|
const statusColors: Record<string, string> = { active: 'green', deprecated: 'orange', archived: 'default' }
|
||||||
|
|
||||||
|
export default function Prompts() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [detailName, setDetailName] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['prompts'],
|
||||||
|
queryFn: ({ signal }) => promptService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: detailData } = useQuery({
|
||||||
|
queryKey: ['prompt-detail', detailName],
|
||||||
|
queryFn: ({ signal }) => promptService.get(detailName!, signal),
|
||||||
|
enabled: !!detailName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: versionsData } = useQuery({
|
||||||
|
queryKey: ['prompt-versions', detailName],
|
||||||
|
queryFn: ({ signal }) => promptService.listVersions(detailName!, signal),
|
||||||
|
enabled: !!detailName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof promptService.create>[0]) => promptService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
setCreateOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => promptService.archive(name),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已归档')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '归档失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rollbackMutation = useMutation({
|
||||||
|
mutationFn: ({ name, version }: { name: string; version: number }) =>
|
||||||
|
promptService.rollback(name, version),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('回滚成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompt-detail', detailName] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompt-versions', detailName] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '回滚失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<PromptTemplate>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 200, render: (_, r) => <Text code>{r.name}</Text> },
|
||||||
|
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||||
|
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag>{sourceLabels[r.source]}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '版本', dataIndex: 'current_version', width: 70 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status]}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => setDetailName(record.name)}>详情</Button>
|
||||||
|
{record.status === 'active' && (
|
||||||
|
<Popconfirm title="确定归档此提示词?" onConfirm={() => archiveMutation.mutate(record.name)}>
|
||||||
|
<Button size="small" danger>归档</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionColumns: ProColumns<PromptVersion>[] = [
|
||||||
|
{ title: '版本', dataIndex: 'version', width: 60 },
|
||||||
|
{ title: '更新说明', dataIndex: 'changelog', width: 200, ellipsis: true },
|
||||||
|
{ title: '最低版本', dataIndex: 'min_app_version', width: 100, render: (_, r) => r.min_app_version || '-' },
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={`确定回滚到版本 ${record.version}?`}
|
||||||
|
onConfirm={() => detailName && rollbackMutation.mutate({ name: detailName, version: record.version })}
|
||||||
|
>
|
||||||
|
<Button size="small">回滚</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<PromptTemplate>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setCreateOpen(true) }}>
|
||||||
|
新建提示词
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建提示词"
|
||||||
|
open={createOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setCreateOpen(false); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="唯一标识" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 system, tool" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="system_prompt" label="系统提示词" rules={[{ required: true }]}>
|
||||||
|
<TextArea rows={6} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="user_prompt_template" label="用户提示词模板">
|
||||||
|
<TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`提示词详情: ${detailName || ''}`}
|
||||||
|
open={!!detailName}
|
||||||
|
onCancel={() => setDetailName(null)}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<Tabs items={[
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: '基本信息',
|
||||||
|
children: detailData ? (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="名称">{detailData.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">{detailData.category}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{sourceLabels[detailData.source]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{statusLabels[detailData.status]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="当前版本">{detailData.current_version}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'versions',
|
||||||
|
label: '版本历史',
|
||||||
|
children: (
|
||||||
|
<ProTable<PromptVersion>
|
||||||
|
columns={versionColumns}
|
||||||
|
dataSource={versionsData ?? []}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
loading={!versionsData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
188
admin-temp-dir/src/pages/Providers.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 服务商管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Typography } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { providerService } from '@/services/providers'
|
||||||
|
import type { Provider, ProviderKey } from '@/types'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
export default function Providers() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [keyModalProviderId, setKeyModalProviderId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['providers'],
|
||||||
|
queryFn: ({ signal }) => providerService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: keysData, isLoading: keysLoading } = useQuery({
|
||||||
|
queryKey: ['provider-keys', keyModalProviderId],
|
||||||
|
queryFn: ({ signal }) => providerService.listKeys(keyModalProviderId!, signal),
|
||||||
|
enabled: !!keyModalProviderId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
|
||||||
|
providerService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
|
||||||
|
providerService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('更新成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => providerService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('删除成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<Provider>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'display_name', width: 140 },
|
||||||
|
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
|
||||||
|
{ title: '协议', dataIndex: 'api_protocol', width: 100 },
|
||||||
|
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, render: (_, r) => r.rate_limit_rpm ?? '-' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 260,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setKeyModalProviderId(record.id)}>
|
||||||
|
Key Pool
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const keyColumns: ProColumns<ProviderKey>[] = [
|
||||||
|
{ title: '标签', dataIndex: 'key_label', width: 120 },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', width: 80 },
|
||||||
|
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
|
||||||
|
{ title: 'Token 数', dataIndex: 'total_tokens', width: 100 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'is_active',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => r.is_active ? <Tag color="green">活跃</Tag> : <Tag>冷却</Tag>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<Provider>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||||
|
新建服务商
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingId ? '编辑服务商' : '新建服务商'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
|
||||||
|
<Input disabled={!!editingId} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="api_protocol" label="API 协议">
|
||||||
|
<Input placeholder="openai" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="rate_limit_rpm" label="RPM 限制">
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Key Pool"
|
||||||
|
open={!!keyModalProviderId}
|
||||||
|
onCancel={() => setKeyModalProviderId(null)}
|
||||||
|
footer={null}
|
||||||
|
width={700}
|
||||||
|
>
|
||||||
|
<ProTable<ProviderKey>
|
||||||
|
columns={keyColumns}
|
||||||
|
dataSource={keysData ?? []}
|
||||||
|
loading={keysLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
109
admin-temp-dir/src/pages/Relay.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 中转任务
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag, Select, Typography } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { relayService } from '@/services/relay'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import type { RelayTask } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
running: '运行中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
queued: 'default',
|
||||||
|
running: 'processing',
|
||||||
|
completed: 'green',
|
||||||
|
failed: 'red',
|
||||||
|
cancelled: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Relay() {
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['relay-tasks', page, statusFilter],
|
||||||
|
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<RelayTask>[] = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => <code>{r.id.substring(0, 8)}...</code> },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => <Tag color={statusColors[r.status] || 'default'}>{statusLabels[r.status] || r.status}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '模型', dataIndex: 'model_id', width: 160 },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
||||||
|
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
|
||||||
|
{
|
||||||
|
title: 'Token',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`,
|
||||||
|
},
|
||||||
|
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '排队时间',
|
||||||
|
dataIndex: 'queued_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.queued_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '完成时间',
|
||||||
|
dataIndex: 'completed_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>中转任务</Title>
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(v) => { setStatusFilter(v === 'all' ? undefined : v); setPage(1) }}
|
||||||
|
placeholder="状态筛选"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
allowClear
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'queued', label: '排队中' },
|
||||||
|
{ value: 'running', label: '运行中' },
|
||||||
|
{ value: 'completed', label: '已完成' },
|
||||||
|
{ value: 'failed', label: '失败' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProTable<RelayTask>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: 20,
|
||||||
|
current: page,
|
||||||
|
onChange: setPage,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
admin-temp-dir/src/pages/Usage.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 用量统计
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Card, Row, Col, Select, Spin, Alert, Statistic, Typography } from 'antd'
|
||||||
|
import { ColumnWidthOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { usageService } from '@/services/usage'
|
||||||
|
import { telemetryService } from '@/services/telemetry'
|
||||||
|
import type { DailyUsageStat, ModelUsageStat } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
export default function Usage() {
|
||||||
|
const [days, setDays] = useState(30)
|
||||||
|
|
||||||
|
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = useQuery({
|
||||||
|
queryKey: ['usage-daily', days],
|
||||||
|
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: modelData, isLoading: modelLoading } = useQuery({
|
||||||
|
queryKey: ['usage-model', days],
|
||||||
|
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dailyError) {
|
||||||
|
return <Alert type="error" message="加载用量数据失败" description={(dailyError as Error).message} showIcon />
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
|
||||||
|
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
|
||||||
|
|
||||||
|
const dailyColumns: ProColumns<DailyUsageStat>[] = [
|
||||||
|
{ title: '日期', dataIndex: 'day', width: 120 },
|
||||||
|
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
|
||||||
|
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
|
||||||
|
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
|
||||||
|
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const modelColumns: ProColumns<ModelUsageStat>[] = [
|
||||||
|
{ title: '模型', dataIndex: 'model_id', width: 200 },
|
||||||
|
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
|
||||||
|
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
|
||||||
|
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
|
||||||
|
{
|
||||||
|
title: '平均延迟',
|
||||||
|
dataIndex: 'avg_latency_ms',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成功率',
|
||||||
|
dataIndex: 'success_rate',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => `${(r.success_rate * 100).toFixed(1)}%`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>用量统计</Title>
|
||||||
|
<Select
|
||||||
|
value={days}
|
||||||
|
onChange={setDays}
|
||||||
|
options={[
|
||||||
|
{ value: 7, label: '最近 7 天' },
|
||||||
|
{ value: 30, label: '最近 30 天' },
|
||||||
|
{ value: 90, label: '最近 90 天' },
|
||||||
|
]}
|
||||||
|
style={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="总请求数" value={totalRequests} prefix={<ThunderboltOutlined />} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Card>
|
||||||
|
<Statistic title="总 Token 数" value={totalTokens} prefix={<ColumnWidthOutlined />} />
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Card title="每日统计" style={{ marginBottom: 24 }} size="small">
|
||||||
|
<ProTable<DailyUsageStat>
|
||||||
|
columns={dailyColumns}
|
||||||
|
dataSource={dailyData ?? []}
|
||||||
|
loading={dailyLoading}
|
||||||
|
rowKey="day"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="按模型统计" size="small">
|
||||||
|
<ProTable<ModelUsageStat>
|
||||||
|
columns={modelColumns}
|
||||||
|
dataSource={modelData ?? []}
|
||||||
|
loading={modelLoading}
|
||||||
|
rowKey="model_id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
admin-temp-dir/src/router/AuthGuard.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 路由守卫 — 未登录重定向到 /login
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const token = useAuthStore((s) => s.token)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
35
admin-temp-dir/src/router/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 路由定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { createBrowserRouter } from 'react-router-dom'
|
||||||
|
import { AuthGuard } from './AuthGuard'
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout'
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
lazy: () => import('@/pages/Login').then((m) => ({ Component: m.default })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: (
|
||||||
|
<AuthGuard>
|
||||||
|
<AdminLayout />
|
||||||
|
</AuthGuard>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'accounts', lazy: () => import('@/pages/Accounts').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'providers', lazy: () => import('@/pages/Providers').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'models', lazy: () => import('@/pages/Models').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
16
admin-temp-dir/src/services/accounts.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AccountPublic, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const accountService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<AccountPublic>>('/accounts', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<AccountPublic>(`/accounts/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<AccountPublic>(`/accounts/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
updateStatus: (id: string, data: { status: AccountPublic['status'] }, signal?: AbortSignal) =>
|
||||||
|
request.patch(`/accounts/${id}/status`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
28
admin-temp-dir/src/services/agent-templates.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AgentTemplate, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const agentTemplateService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: {
|
||||||
|
name: string; description?: string; category?: string; source?: string
|
||||||
|
model?: string; system_prompt?: string; tools?: string[]
|
||||||
|
capabilities?: string[]; temperature?: number; max_tokens?: number
|
||||||
|
visibility?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: {
|
||||||
|
description?: string; model?: string; system_prompt?: string
|
||||||
|
tools?: string[]; capabilities?: string[]; temperature?: number
|
||||||
|
max_tokens?: number; visibility?: string; status?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<AgentTemplate>(`/agent-templates/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
archive: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
13
admin-temp-dir/src/services/api-keys.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const apiKeyService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<TokenInfo>>('/keys', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
|
||||||
|
request.post<TokenInfo>('/keys', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
revoke: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/keys/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
10
admin-temp-dir/src/services/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AccountPublic, LoginRequest, LoginResponse } from '@/types'
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
login: (data: LoginRequest, signal?: AbortSignal) =>
|
||||||
|
request.post<LoginResponse>('/auth/login', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
me: (signal?: AbortSignal) =>
|
||||||
|
request.get<AccountPublic>('/auth/me', withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
11
admin-temp-dir/src/services/config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { ConfigItem, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const configService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<ConfigItem>>('/config/items', withSignal({ params }, signal))
|
||||||
|
.then((r) => r.data.items),
|
||||||
|
|
||||||
|
update: (id: string, data: { value: string | number | boolean }, signal?: AbortSignal) =>
|
||||||
|
request.patch<ConfigItem>(`/config/items/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
7
admin-temp-dir/src/services/logs.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { OperationLog, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const logService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<OperationLog>>('/logs/operations', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
16
admin-temp-dir/src/services/models.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { Model, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const modelService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<Model>>('/models', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
|
||||||
|
request.post<Model>('/models', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<Model>(`/models/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/models/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
35
admin-temp-dir/src/services/prompts.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { PromptTemplate, PromptVersion, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const promptService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<PromptTemplate>>('/prompts', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: {
|
||||||
|
name: string; category: string; description?: string; source?: string
|
||||||
|
system_prompt: string; user_prompt_template?: string
|
||||||
|
variables?: unknown[]; min_app_version?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptTemplate>('/prompts', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (name: string, data: { description?: string; status?: string }, signal?: AbortSignal) =>
|
||||||
|
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
archive: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
listVersions: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
createVersion: (name: string, data: {
|
||||||
|
system_prompt: string; user_prompt_template?: string
|
||||||
|
variables?: unknown[]; changelog?: string; min_app_version?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
rollback: (name: string, version: number, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`, undefined, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
31
admin-temp-dir/src/services/providers.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { Provider, ProviderKey, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const providerService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<Provider>>('/providers', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
|
||||||
|
request.post<Provider>('/providers', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<Provider>(`/providers/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/providers/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
listKeys: (providerId: string, signal?: AbortSignal) =>
|
||||||
|
request.get<ProviderKey[]>(`/providers/${providerId}/keys`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
addKey: (providerId: string, data: {
|
||||||
|
key_label: string; key_value: string; priority?: number
|
||||||
|
max_rpm?: number; max_tpm?: number; quota_reset_interval?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
toggleKey: (providerId: string, keyId: string, active: boolean, signal?: AbortSignal) =>
|
||||||
|
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteKey: (providerId: string, keyId: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
10
admin-temp-dir/src/services/relay.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { RelayTask, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const relayService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<RelayTask>>('/relay/tasks', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<RelayTask>(`/relay/tasks/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
127
admin-temp-dir/src/services/request.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// 认证策略: 主路径使用 HttpOnly cookie(浏览器自动附加),
|
||||||
|
// Authorization header 作为 fallback 保留用于 API 客户端。
|
||||||
|
|
||||||
|
import axios from 'axios'
|
||||||
|
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||||
|
import type { AxiosRequestConfig } from 'axios'
|
||||||
|
import type { ApiError } from '@/types'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
|
||||||
|
const TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
/** API 业务错误 */
|
||||||
|
export class ApiRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
public body: ApiError,
|
||||||
|
) {
|
||||||
|
super(body.message || `Request failed with status ${status}`)
|
||||||
|
this.name = 'ApiRequestError'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
withCredentials: true, // 发送 HttpOnly cookies
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 请求拦截器:附加 Authorization header fallback ──────────
|
||||||
|
|
||||||
|
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = useAuthStore.getState().token
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 响应拦截器:401 自动刷新 ──────────────────────────────
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let pendingRequests: Array<(token: string) => void> = []
|
||||||
|
|
||||||
|
function onTokenRefreshed(newToken: string) {
|
||||||
|
pendingRequests.forEach((cb) => cb(newToken))
|
||||||
|
pendingRequests = []
|
||||||
|
}
|
||||||
|
|
||||||
|
request.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError<{ error?: string; message?: string }>) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||||
|
|
||||||
|
// 401 → 尝试刷新 Token
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
const store = useAuthStore.getState()
|
||||||
|
if (!store.refreshToken) {
|
||||||
|
store.logout()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
pendingRequests.push((newToken: string) => {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
resolve(request(originalRequest))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true
|
||||||
|
isRefreshing = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
|
||||||
|
headers: { Authorization: `Bearer ${store.refreshToken}` },
|
||||||
|
withCredentials: true, // 发送 refresh cookie
|
||||||
|
})
|
||||||
|
const newToken = res.data.token as string
|
||||||
|
const newRefreshToken = res.data.refresh_token as string
|
||||||
|
// 更新内存中的 token(实际认证通过 HttpOnly cookie,浏览器已自动更新)
|
||||||
|
store.setToken(newToken)
|
||||||
|
if (newRefreshToken) {
|
||||||
|
store.setRefreshToken(newRefreshToken)
|
||||||
|
}
|
||||||
|
onTokenRefreshed(newToken)
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||||
|
return request(originalRequest)
|
||||||
|
} catch {
|
||||||
|
store.logout()
|
||||||
|
window.location.href = '/login'
|
||||||
|
return Promise.reject(error)
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造 ApiRequestError
|
||||||
|
if (error.response) {
|
||||||
|
const body: ApiError = {
|
||||||
|
error: error.response.data?.error || 'unknown',
|
||||||
|
message: error.response.data?.message || `请求失败 (${error.response.status})`,
|
||||||
|
status: error.response.status,
|
||||||
|
}
|
||||||
|
return Promise.reject(new ApiRequestError(error.response.status, body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export default request
|
||||||
|
|
||||||
|
/** 将 AbortSignal 注入 Axios config,用于 TanStack Query 的请求取消 */
|
||||||
|
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
|
||||||
|
if (signal) {
|
||||||
|
return { ...config, signal }
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
7
admin-temp-dir/src/services/stats.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { DashboardStats } from '@/types'
|
||||||
|
|
||||||
|
export const statsService = {
|
||||||
|
dashboard: (signal?: AbortSignal) =>
|
||||||
|
request.get<DashboardStats>('/stats/dashboard', withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
10
admin-temp-dir/src/services/telemetry.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { ModelUsageStat, DailyUsageStat } from '@/types'
|
||||||
|
|
||||||
|
export const telemetryService = {
|
||||||
|
modelStats: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<ModelUsageStat[]>('/telemetry/stats', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
dailyStats: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||||
|
request.get<DailyUsageStat[]>('/telemetry/daily', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
12
admin-temp-dir/src/services/usage.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { UsageRecord, UsageByModel } from '@/types'
|
||||||
|
|
||||||
|
export const usageService = {
|
||||||
|
daily: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||||
|
request.get<{ by_day: UsageRecord[] }>('/usage', withSignal({ params: { ...params, group_by: 'day' } }, signal))
|
||||||
|
.then((r) => r.data.by_day || []),
|
||||||
|
|
||||||
|
byModel: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||||
|
request.get<{ by_model: UsageByModel[] }>('/usage', withSignal({ params: { ...params, group_by: 'model' } }, signal))
|
||||||
|
.then((r) => r.data.by_model || []),
|
||||||
|
}
|
||||||
96
admin-temp-dir/src/stores/authStore.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW Admin V2 — Zustand 认证状态管理
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
|
||||||
|
// account 信息(显示名/角色)仍存 localStorage 用于页面刷新后恢复 UI。
|
||||||
|
// 内存中的 token/refreshToken 仅用于 Authorization header fallback(API 客户端兼容)。
|
||||||
|
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import type { AccountPublic } from '@/types'
|
||||||
|
|
||||||
|
/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */
|
||||||
|
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
|
super_admin: [
|
||||||
|
'admin:full', 'account:admin', 'provider:manage', 'model:manage',
|
||||||
|
'relay:admin', 'config:write', 'prompt:read', 'prompt:write',
|
||||||
|
'prompt:publish', 'prompt:admin',
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
'account:read', 'account:admin', 'provider:manage', 'model:read',
|
||||||
|
'model:manage', 'relay:use', 'config:read',
|
||||||
|
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
|
||||||
|
],
|
||||||
|
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||||
|
|
||||||
|
/** 从 localStorage 恢复 account 信息(token 通过 HttpOnly cookie 管理) */
|
||||||
|
function loadFromStorage(): { account: AccountPublic | null } {
|
||||||
|
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||||
|
let account: AccountPublic | null = null
|
||||||
|
if (raw) {
|
||||||
|
try { account = JSON.parse(raw) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
return { account }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
token: string | null
|
||||||
|
refreshToken: string | null
|
||||||
|
account: AccountPublic | null
|
||||||
|
permissions: string[]
|
||||||
|
|
||||||
|
setToken: (token: string) => void
|
||||||
|
setRefreshToken: (refreshToken: string) => void
|
||||||
|
login: (token: string, refreshToken: string, account: AccountPublic) => void
|
||||||
|
logout: () => void
|
||||||
|
hasPermission: (permission: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>((set, get) => {
|
||||||
|
const stored = loadFromStorage()
|
||||||
|
const perms = stored.account?.role
|
||||||
|
? (ROLE_PERMISSIONS[stored.account.role] ?? [])
|
||||||
|
: []
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: null,
|
||||||
|
refreshToken: null,
|
||||||
|
account: stored.account,
|
||||||
|
permissions: perms,
|
||||||
|
|
||||||
|
setToken: (token: string) => {
|
||||||
|
set({ token })
|
||||||
|
},
|
||||||
|
|
||||||
|
setRefreshToken: (refreshToken: string) => {
|
||||||
|
set({ refreshToken })
|
||||||
|
},
|
||||||
|
|
||||||
|
login: (token: string, refreshToken: string, account: AccountPublic) => {
|
||||||
|
// account 保留 localStorage(仅用于 UI 显示,非敏感)
|
||||||
|
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||||
|
// token 仅存内存(实际认证通过 HttpOnly cookie)
|
||||||
|
set({
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
account,
|
||||||
|
permissions: ROLE_PERMISSIONS[account.role] ?? [],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem(ACCOUNT_KEY)
|
||||||
|
set({ token: null, refreshToken: null, account: null, permissions: [] })
|
||||||
|
// 调用后端 logout 清除 HttpOnly cookies(fire-and-forget)
|
||||||
|
fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||||
|
},
|
||||||
|
|
||||||
|
hasPermission: (permission: string) => {
|
||||||
|
const { permissions } = get()
|
||||||
|
return permissions.includes(permission) || permissions.includes('admin:full')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@ export interface AccountPublic {
|
|||||||
role: 'super_admin' | 'admin' | 'user'
|
role: 'super_admin' | 'admin' | 'user'
|
||||||
status: 'active' | 'disabled' | 'suspended'
|
status: 'active' | 'disabled' | 'suspended'
|
||||||
totp_enabled: boolean
|
totp_enabled: boolean
|
||||||
|
last_login_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,11 +19,13 @@ export interface AccountPublic {
|
|||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
|
totp_code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录响应 */
|
/** 登录响应 */
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
token: string
|
token: string
|
||||||
|
refresh_token: string
|
||||||
account: AccountPublic
|
account: AccountPublic
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,10 +52,10 @@ export interface Provider {
|
|||||||
display_name: string
|
display_name: string
|
||||||
api_key?: string
|
api_key?: string
|
||||||
base_url: string
|
base_url: string
|
||||||
api_protocol: 'openai' | 'anthropic'
|
api_protocol: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
rate_limit_rpm?: number
|
rate_limit_rpm: number | null
|
||||||
rate_limit_tpm?: number
|
rate_limit_tpm: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -97,15 +100,16 @@ export interface RelayTask {
|
|||||||
account_id: string
|
account_id: string
|
||||||
provider_id: string
|
provider_id: string
|
||||||
model_id: string
|
model_id: string
|
||||||
status: 'queued' | 'processing' | 'completed' | 'failed'
|
status: string
|
||||||
priority: number
|
priority: number
|
||||||
attempt_count: number
|
attempt_count: number
|
||||||
|
max_attempts: number
|
||||||
input_tokens: number
|
input_tokens: number
|
||||||
output_tokens: number
|
output_tokens: number
|
||||||
error_message?: string
|
error_message: string | null
|
||||||
queued_at?: string
|
queued_at: string
|
||||||
started_at?: string
|
started_at: string | null
|
||||||
completed_at?: string
|
completed_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,23 +134,25 @@ export interface ConfigItem {
|
|||||||
id: string
|
id: string
|
||||||
category: string
|
category: string
|
||||||
key_path: string
|
key_path: string
|
||||||
value_type: 'string' | 'number' | 'boolean'
|
value_type: string
|
||||||
current_value?: string | number | boolean
|
current_value: string | null
|
||||||
default_value?: string | number | boolean
|
default_value: string | null
|
||||||
source: 'default' | 'env' | 'db'
|
source: string
|
||||||
description?: string
|
description: string | null
|
||||||
requires_restart: boolean
|
requires_restart: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 操作日志 */
|
/** 操作日志 */
|
||||||
export interface OperationLog {
|
export interface OperationLog {
|
||||||
id: string
|
id: number
|
||||||
account_id: string
|
account_id: string | null
|
||||||
action: string
|
action: string
|
||||||
target_type: string
|
target_type: string | null
|
||||||
target_id: string
|
target_id: string | null
|
||||||
details?: string
|
details: Record<string, unknown> | null
|
||||||
ip_address?: string
|
ip_address: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,3 +173,95 @@ export interface ApiError {
|
|||||||
message: string
|
message: string
|
||||||
status?: number
|
status?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提示词模板 */
|
||||||
|
export interface PromptTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
category: string
|
||||||
|
description?: string
|
||||||
|
source: 'builtin' | 'custom'
|
||||||
|
current_version: number
|
||||||
|
status: 'active' | 'deprecated' | 'archived'
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提示词版本 */
|
||||||
|
export interface PromptVersion {
|
||||||
|
id: string
|
||||||
|
template_id: string
|
||||||
|
version: number
|
||||||
|
system_prompt: string
|
||||||
|
user_prompt_template?: string
|
||||||
|
variables: PromptVariable[]
|
||||||
|
changelog?: string
|
||||||
|
min_app_version?: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提示词变量定义 */
|
||||||
|
export interface PromptVariable {
|
||||||
|
name: string
|
||||||
|
type: 'string' | 'number' | 'select' | 'boolean'
|
||||||
|
default_value?: string
|
||||||
|
description?: string
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent 模板 */
|
||||||
|
export interface AgentTemplate {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
category: string
|
||||||
|
source: 'builtin' | 'custom'
|
||||||
|
model?: string
|
||||||
|
system_prompt?: string
|
||||||
|
tools: string[]
|
||||||
|
capabilities: string[]
|
||||||
|
temperature?: number
|
||||||
|
max_tokens?: number
|
||||||
|
visibility: 'public' | 'team' | 'private'
|
||||||
|
status: 'active' | 'archived'
|
||||||
|
current_version: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provider Key */
|
||||||
|
export interface ProviderKey {
|
||||||
|
id: string
|
||||||
|
provider_id: string
|
||||||
|
key_label: string
|
||||||
|
priority: number
|
||||||
|
max_rpm?: number
|
||||||
|
max_tpm?: number
|
||||||
|
quota_reset_interval?: string
|
||||||
|
is_active: boolean
|
||||||
|
last_429_at?: string
|
||||||
|
cooldown_until?: string
|
||||||
|
total_requests: number
|
||||||
|
total_tokens: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按模型聚合的用量统计 */
|
||||||
|
export interface ModelUsageStat {
|
||||||
|
model_id: string
|
||||||
|
request_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
avg_latency_ms: number | null
|
||||||
|
success_rate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按天的用量统计 */
|
||||||
|
export interface DailyUsageStat {
|
||||||
|
day: string
|
||||||
|
request_count: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
unique_devices: number
|
||||||
|
}
|
||||||
34
admin-temp-dir/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path alias */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
admin-temp-dir/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
admin-temp-dir/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
38
admin-temp-dir/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
// SSE relay 端点需要长超时(流式响应可持续数分钟)
|
||||||
|
'/api/v1/relay/chat/completions': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
timeout: 600_000,
|
||||||
|
proxyTimeout: 600_000,
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
timeout: 30_000,
|
||||||
|
proxyTimeout: 30_000,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
|
proxyReq.setTimeout(30_000)
|
||||||
|
})
|
||||||
|
proxy.on('proxyRes', (proxyRes) => {
|
||||||
|
proxyRes.setTimeout(30_000)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
1
admin-v2/.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
1
admin-v2/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=/api/v1
|
||||||
24
admin-v2/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
admin-v2/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
admin-v2/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
17
admin-v2/index.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ZCLAW Admin</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a href="#main-content" class="skip-to-content">跳转到主要内容</a>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
48
admin-v2/package.json
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{
|
||||||
|
"name": "admin-v2",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.1.1",
|
||||||
|
"@ant-design/pro-components": "^2.8.10",
|
||||||
|
"@ant-design/pro-layout": "^7.22.7",
|
||||||
|
"@tanstack/react-query": "^5.95.2",
|
||||||
|
"antd": "^6.3.4",
|
||||||
|
"axios": "^1.14.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.13.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"jsdom": "^29.0.1",
|
||||||
|
"msw": "^2.12.14",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vite": "^8.0.1",
|
||||||
|
"vitest": "^4.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
4900
admin-v2/pnpm-lock.yaml
generated
Normal file
1
admin-v2/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
24
admin-v2/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
BIN
admin-v2/src/assets/hero.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
1
admin-v2/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
admin-v2/src/assets/vite.svg
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
53
admin-v2/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Component, type ReactNode } from 'react'
|
||||||
|
import { Result, Button } from 'antd'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||||
|
console.error('[ErrorBoundary] Unhandled error:', error, info.componentStack)
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReload = () => {
|
||||||
|
window.location.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="页面出现错误"
|
||||||
|
subTitle={this.state.error?.message ?? '发生了未知错误,请刷新页面重试'}
|
||||||
|
extra={[
|
||||||
|
<Button key="retry" onClick={this.handleReset}>重试</Button>,
|
||||||
|
<Button key="reload" type="primary" onClick={this.handleReload}>刷新页面</Button>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
51
admin-v2/src/components/ErrorState.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Button, Result } from 'antd'
|
||||||
|
import type { FallbackProps } from 'react-error-boundary'
|
||||||
|
|
||||||
|
interface ErrorStateProps {
|
||||||
|
title?: string
|
||||||
|
message?: string
|
||||||
|
onRetry?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorState({
|
||||||
|
title = '加载失败',
|
||||||
|
message,
|
||||||
|
onRetry,
|
||||||
|
}: ErrorStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px] p-8">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title={title}
|
||||||
|
subTitle={message}
|
||||||
|
extra={
|
||||||
|
onRetry ? (
|
||||||
|
<Button type="primary" onClick={onRetry}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[200px] p-8">
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="页面出现错误"
|
||||||
|
subTitle={error.message}
|
||||||
|
extra={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={resetErrorBoundary}>重试</Button>
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
admin-v2/src/components/PageHeader.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({ title, description, actions }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
15
admin-v2/src/components/StatusTag.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Tag } from 'antd'
|
||||||
|
|
||||||
|
interface StatusTagProps {
|
||||||
|
status: string
|
||||||
|
labels: Record<string, string>
|
||||||
|
colors: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusTag({ status, labels, colors }: StatusTagProps) {
|
||||||
|
return (
|
||||||
|
<Tag color={colors[status] || 'default'}>
|
||||||
|
{labels[status] || status}
|
||||||
|
</Tag>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
admin-v2/src/constants/status.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 操作日志状态映射 — Dashboard 与 Logs 共用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export const actionLabels: Record<string, string> = {
|
||||||
|
login: '登录',
|
||||||
|
logout: '登出',
|
||||||
|
create_account: '创建账号',
|
||||||
|
update_account: '更新账号',
|
||||||
|
delete_account: '删除账号',
|
||||||
|
create_provider: '创建服务商',
|
||||||
|
update_provider: '更新服务商',
|
||||||
|
delete_provider: '删除服务商',
|
||||||
|
create_model: '创建模型',
|
||||||
|
update_model: '更新模型',
|
||||||
|
delete_model: '删除模型',
|
||||||
|
create_token: '创建密钥',
|
||||||
|
revoke_token: '撤销密钥',
|
||||||
|
update_config: '更新配置',
|
||||||
|
create_prompt: '创建提示词',
|
||||||
|
update_prompt: '更新提示词',
|
||||||
|
archive_prompt: '归档提示词',
|
||||||
|
desktop_audit: '桌面端审计',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionColors: Record<string, string> = {
|
||||||
|
login: 'green',
|
||||||
|
logout: 'default',
|
||||||
|
create_account: 'blue',
|
||||||
|
update_account: 'orange',
|
||||||
|
delete_account: 'red',
|
||||||
|
create_provider: 'blue',
|
||||||
|
update_provider: 'orange',
|
||||||
|
delete_provider: 'red',
|
||||||
|
create_model: 'blue',
|
||||||
|
update_model: 'orange',
|
||||||
|
delete_model: 'red',
|
||||||
|
create_token: 'blue',
|
||||||
|
revoke_token: 'red',
|
||||||
|
update_config: 'orange',
|
||||||
|
create_prompt: 'blue',
|
||||||
|
update_prompt: 'orange',
|
||||||
|
archive_prompt: 'red',
|
||||||
|
desktop_audit: 'default',
|
||||||
|
}
|
||||||
385
admin-v2/src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||||
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
RobotOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
SunOutlined,
|
||||||
|
MoonOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { Avatar, Dropdown, Tooltip, Drawer } from 'antd'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { useThemeStore, setThemeMode } from '@/stores/themeStore'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Navigation Configuration
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
icon: ReactNode
|
||||||
|
permission?: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ path: '/', name: '仪表盘', icon: <DashboardOutlined />, group: '核心' },
|
||||||
|
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin', group: '资源管理' },
|
||||||
|
{ path: '/model-services', name: '模型服务', icon: <CloudServerOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
|
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read', group: '资源管理' },
|
||||||
|
{ path: '/api-keys', name: 'API 密钥', icon: <ApiOutlined />, permission: 'provider:manage', group: '资源管理' },
|
||||||
|
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
|
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use', group: '运维' },
|
||||||
|
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full', group: '运维' },
|
||||||
|
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read', group: '系统' },
|
||||||
|
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read', group: '系统' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Sidebar Component
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
collapsed,
|
||||||
|
onNavigate,
|
||||||
|
activePath,
|
||||||
|
}: {
|
||||||
|
collapsed: boolean
|
||||||
|
onNavigate: (path: string) => void
|
||||||
|
activePath: string
|
||||||
|
}) {
|
||||||
|
const { hasPermission } = useAuthStore()
|
||||||
|
const visibleItems = navItems.filter(
|
||||||
|
(item) => !item.permission || hasPermission(item.permission),
|
||||||
|
)
|
||||||
|
|
||||||
|
const groups = useMemo(() => {
|
||||||
|
const map = new Map<string, NavItem[]>()
|
||||||
|
for (const item of visibleItems) {
|
||||||
|
const list = map.get(item.group) || []
|
||||||
|
list.push(item)
|
||||||
|
map.set(item.group, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}, [visibleItems])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex flex-col h-full" aria-label="主导航">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center h-14 px-4 border-b border-neutral-200 dark:border-neutral-800">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-lg shrink-0"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
|
>
|
||||||
|
<span className="text-white font-bold text-sm">Z</span>
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<span className="ml-3 text-lg font-bold text-neutral-900 dark:text-neutral-100 tracking-tight">
|
||||||
|
ZCLAW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Items */}
|
||||||
|
<div className="flex-1 overflow-y-auto py-3 px-2">
|
||||||
|
{Array.from(groups.entries()).map(([groupName, items]) => (
|
||||||
|
<div key={groupName} className="mb-3">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="px-3 mb-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-400 dark:text-neutral-600">
|
||||||
|
{groupName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
item.path === '/'
|
||||||
|
? activePath === '/'
|
||||||
|
: activePath.startsWith(item.path)
|
||||||
|
|
||||||
|
const btn = (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => onNavigate(item.path)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium
|
||||||
|
transition-all duration-150 ease-in-out cursor-pointer border-none bg-transparent
|
||||||
|
${
|
||||||
|
isActive
|
||||||
|
? 'text-brand-purple bg-brand-purple/8 dark:text-brand-purple dark:bg-brand-purple/12'
|
||||||
|
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||||
|
}
|
||||||
|
${collapsed ? 'justify-center' : ''}
|
||||||
|
`}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-base ${isActive ? 'text-brand-purple' : ''}`}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
{!collapsed && <span>{item.name}</span>}
|
||||||
|
{isActive && (
|
||||||
|
<span className="ml-auto w-1.5 h-1.5 rounded-full bg-brand-purple" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
|
||||||
|
return collapsed ? (
|
||||||
|
<Tooltip key={item.path} title={item.name} placement="right">
|
||||||
|
{btn}
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<div key={item.path}>{btn}</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom section */}
|
||||||
|
<div className="border-t border-neutral-200 dark:border-neutral-800 p-3">
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="text-[11px] text-neutral-400 dark:text-neutral-600 text-center">
|
||||||
|
ZCLAW Admin v2
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Mobile Drawer Sidebar
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
function MobileDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
activePath,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onNavigate: (path: string) => void
|
||||||
|
activePath: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
placement="left"
|
||||||
|
onClose={onClose}
|
||||||
|
open={open}
|
||||||
|
width={280}
|
||||||
|
styles={{
|
||||||
|
body: { padding: 0 },
|
||||||
|
header: { display: 'none' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sidebar collapsed={false} onNavigate={onNavigate} activePath={activePath} />
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Breadcrumb
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const breadcrumbMap: Record<string, string> = {
|
||||||
|
'/': '仪表盘',
|
||||||
|
'/accounts': '账号管理',
|
||||||
|
'/model-services': '模型服务',
|
||||||
|
'/providers': '模型服务',
|
||||||
|
'/models': '模型服务',
|
||||||
|
'/api-keys': 'API 密钥',
|
||||||
|
'/agent-templates': 'Agent 模板',
|
||||||
|
'/usage': '用量统计',
|
||||||
|
'/relay': '中转任务',
|
||||||
|
'/config': '系统配置',
|
||||||
|
'/prompts': '提示词管理',
|
||||||
|
'/logs': '操作日志',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Layout
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { account, logout } = useAuthStore()
|
||||||
|
const themeState = useThemeStore()
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
|
|
||||||
|
// Responsive detection
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia('(max-width: 768px)')
|
||||||
|
setIsMobile(mq.matches)
|
||||||
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
|
||||||
|
mq.addEventListener('change', handler)
|
||||||
|
return () => mq.removeEventListener('change', handler)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleNavigate = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
navigate(path)
|
||||||
|
setMobileOpen(false)
|
||||||
|
},
|
||||||
|
[navigate],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
logout()
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}, [logout, navigate])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setThemeMode(themeState.resolved === 'dark' ? 'light' : 'dark')
|
||||||
|
}, [themeState.resolved])
|
||||||
|
|
||||||
|
const currentPage = breadcrumbMap[location.pathname] || '页面'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-neutral-50 dark:bg-neutral-950">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
{!isMobile && (
|
||||||
|
<aside
|
||||||
|
className={`
|
||||||
|
shrink-0 border-r border-neutral-200 dark:border-neutral-800
|
||||||
|
bg-white dark:bg-neutral-900
|
||||||
|
transition-all duration-200 ease-in-out
|
||||||
|
${collapsed ? 'w-12' : 'w-64'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Sidebar
|
||||||
|
collapsed={collapsed}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
activePath={location.pathname}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile Drawer */}
|
||||||
|
{isMobile && (
|
||||||
|
<MobileDrawer
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
activePath={location.pathname}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Area */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="h-14 shrink-0 flex items-center justify-between px-4 border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
{isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
aria-label="打开菜单"
|
||||||
|
>
|
||||||
|
<MenuUnfoldOutlined className="text-lg" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapse toggle (desktop) */}
|
||||||
|
{!isMobile && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
aria-label={collapsed ? '展开侧边栏' : '收起侧边栏'}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<MenuUnfoldOutlined className="text-lg" />
|
||||||
|
) : (
|
||||||
|
<MenuFoldOutlined className="text-lg" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span className="text-neutral-400 dark:text-neutral-600">ZCLAW</span>
|
||||||
|
<span className="text-neutral-300 dark:text-neutral-700">/</span>
|
||||||
|
<span className="text-neutral-900 dark:text-neutral-100 font-medium">
|
||||||
|
{currentPage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<Tooltip title={themeState.resolved === 'dark' ? '切换亮色' : '切换暗色'}>
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className="p-2 rounded-lg text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
aria-label="切换主题"
|
||||||
|
>
|
||||||
|
{themeState.resolved === 'dark' ? (
|
||||||
|
<SunOutlined className="text-lg" />
|
||||||
|
) : (
|
||||||
|
<MoonOutlined className="text-lg" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* User avatar */}
|
||||||
|
<Dropdown
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: '退出登录',
|
||||||
|
onClick: handleLogout,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors">
|
||||||
|
<Avatar
|
||||||
|
size={28}
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(135deg, #863bff, #47bfff)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(account?.display_name || account?.username || 'A')[0].toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
{!isMobile && (
|
||||||
|
<span className="text-sm text-neutral-700 dark:text-neutral-300 font-medium">
|
||||||
|
{account?.display_name || account?.username || 'Admin'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main
|
||||||
|
id="main-content"
|
||||||
|
className="flex-1 overflow-y-auto p-6"
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
admin-v2/src/main.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { RouterProvider } from 'react-router-dom'
|
||||||
|
import { ConfigProvider, App as AntApp, theme } from 'antd'
|
||||||
|
import zhCN from 'antd/locale/zh_CN'
|
||||||
|
import { router } from './router'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
|
import { useThemeStore } from './stores/themeStore'
|
||||||
|
import './styles/globals.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function ThemedApp() {
|
||||||
|
const resolved = useThemeStore((s) => s.resolved)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider
|
||||||
|
locale={zhCN}
|
||||||
|
theme={{
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#863bff',
|
||||||
|
colorBgContainer: resolved === 'dark' ? '#292524' : '#ffffff',
|
||||||
|
colorBgElevated: resolved === 'dark' ? '#1c1917' : '#ffffff',
|
||||||
|
colorBgLayout: resolved === 'dark' ? '#0c0a09' : '#fafaf9',
|
||||||
|
colorBorder: resolved === 'dark' ? '#44403c' : '#e7e5e4',
|
||||||
|
colorBorderSecondary: resolved === 'dark' ? '#44403c' : '#f5f5f4',
|
||||||
|
colorText: resolved === 'dark' ? '#fafaf9' : '#1c1917',
|
||||||
|
colorTextSecondary: resolved === 'dark' ? '#a8a29e' : '#78716c',
|
||||||
|
colorTextTertiary: resolved === 'dark' ? '#78716c' : '#a8a29e',
|
||||||
|
borderRadius: 8,
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
fontFamily:
|
||||||
|
'"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||||
|
fontSize: 14,
|
||||||
|
controlHeight: 36,
|
||||||
|
},
|
||||||
|
algorithm: resolved === 'dark' ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||||
|
components: {
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
headerBg: resolved === 'dark' ? '#1c1917' : '#fafaf9',
|
||||||
|
headerColor: resolved === 'dark' ? '#a8a29e' : '#78716c',
|
||||||
|
rowHoverBg: resolved === 'dark' ? 'rgba(134,59,255,0.06)' : 'rgba(134,59,255,0.04)',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadius: 8,
|
||||||
|
controlHeight: 36,
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
Tag: {
|
||||||
|
borderRadiusSM: 9999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AntApp>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</AntApp>
|
||||||
|
</ConfigProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<ErrorBoundary>
|
||||||
|
<ThemedApp />
|
||||||
|
</ErrorBoundary>,
|
||||||
|
)
|
||||||
222
admin-v2/src/pages/Accounts.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 账号管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { accountService } from '@/services/accounts'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import type { AccountPublic } from '@/types'
|
||||||
|
|
||||||
|
const roleLabels: Record<string, string> = {
|
||||||
|
super_admin: '超级管理员',
|
||||||
|
admin: '管理员',
|
||||||
|
user: '用户',
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleColors: Record<string, string> = {
|
||||||
|
super_admin: 'red',
|
||||||
|
admin: 'blue',
|
||||||
|
user: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: '正常',
|
||||||
|
disabled: '已禁用',
|
||||||
|
suspended: '已封禁',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'green',
|
||||||
|
disabled: 'default',
|
||||||
|
suspended: 'red',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Accounts() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [searchParams, setSearchParams] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['accounts', searchParams],
|
||||||
|
queryFn: ({ signal }) => accountService.list(searchParams, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
|
||||||
|
accountService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('更新成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: string; status: AccountPublic['status'] }) =>
|
||||||
|
accountService.updateStatus(id, { status }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('状态更新成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['accounts'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '状态更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<AccountPublic>[] = [
|
||||||
|
{ title: '用户名', dataIndex: 'username', width: 120, tooltip: '搜索用户名、邮箱或显示名' },
|
||||||
|
{ title: '显示名', dataIndex: 'display_name', width: 120, hideInSearch: true },
|
||||||
|
{ title: '邮箱', dataIndex: 'email', width: 180 },
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'role',
|
||||||
|
width: 120,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
super_admin: { text: '超级管理员' },
|
||||||
|
admin: { text: '管理员' },
|
||||||
|
user: { text: '用户' },
|
||||||
|
},
|
||||||
|
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
active: { text: '正常', status: 'Success' },
|
||||||
|
disabled: { text: '已禁用', status: 'Default' },
|
||||||
|
suspended: { text: '已封禁', status: 'Error' },
|
||||||
|
},
|
||||||
|
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '2FA',
|
||||||
|
dataIndex: 'totp_enabled',
|
||||||
|
width: 80,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => record.totp_enabled ? <Tag color="green">已启用</Tag> : <Tag>未启用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'LLM 路由',
|
||||||
|
dataIndex: 'llm_routing',
|
||||||
|
width: 120,
|
||||||
|
hideInSearch: true,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
relay: { text: 'SaaS 中转', status: 'Success' },
|
||||||
|
local: { text: '本地直连', status: 'Default' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '最后登录',
|
||||||
|
dataIndex: 'last_login_at',
|
||||||
|
width: 180,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 200,
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
{record.status === 'active' ? (
|
||||||
|
<Popconfirm title="确定禁用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'disabled' })}>
|
||||||
|
<Button size="small" danger>禁用</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : (
|
||||||
|
<Popconfirm title="确定启用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'active' })}>
|
||||||
|
<Button size="small" type="primary">启用</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="账号管理" description="管理系统用户账号、角色与权限" />
|
||||||
|
|
||||||
|
<ProTable<AccountPublic>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={{}}
|
||||||
|
toolBarRender={() => []}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
const filtered: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
if (v !== undefined && v !== null && v !== '') {
|
||||||
|
// Map 'username' search field to backend 'search' param
|
||||||
|
if (k === 'username') {
|
||||||
|
filtered.search = String(v)
|
||||||
|
} else {
|
||||||
|
filtered[k] = String(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSearchParams(filtered)
|
||||||
|
}}
|
||||||
|
onReset={() => setSearchParams({})}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={<span className="text-base font-semibold">编辑账号</span>}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" className="mt-4">
|
||||||
|
<Form.Item name="display_name" label="显示名">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="email" label="邮箱">
|
||||||
|
<Input type="email" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="role" label="角色">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'super_admin', label: '超级管理员' },
|
||||||
|
{ value: 'admin', label: '管理员' },
|
||||||
|
{ value: 'user', label: '用户' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="llm_routing" label="LLM 路由模式">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'local', label: '本地直连' },
|
||||||
|
{ value: 'relay', label: 'SaaS 中转 (Token 池)' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
258
admin-v2/src/pages/AgentTemplates.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Agent 模板管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
|
||||||
|
import { PlusOutlined, MinusCircleOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { agentTemplateService } from '@/services/agent-templates'
|
||||||
|
import type { AgentTemplate } from '@/types'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
|
||||||
|
const visibilityLabels: Record<string, string> = { public: '公开', team: '团队', private: '私有' }
|
||||||
|
const statusLabels: Record<string, string> = { active: '活跃', archived: '已归档' }
|
||||||
|
const statusColors: Record<string, string> = { active: 'green', archived: 'default' }
|
||||||
|
|
||||||
|
export default function AgentTemplates() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [detailRecord, setDetailRecord] = useState<AgentTemplate | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['agent-templates'],
|
||||||
|
queryFn: ({ signal }) => agentTemplateService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof agentTemplateService.create>[0]) =>
|
||||||
|
agentTemplateService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => agentTemplateService.archive(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已归档')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '归档失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<AgentTemplate>[] = [
|
||||||
|
{ title: '图标', dataIndex: 'emoji', width: 60 },
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 160 },
|
||||||
|
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||||
|
{ title: '模型', dataIndex: 'model', width: 140, render: (_, r) => r.model || '-' },
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag>{sourceLabels[r.source] || r.source}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '可见性',
|
||||||
|
dataIndex: 'visibility',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag color="blue">{visibilityLabels[r.visibility] || r.visibility}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '版本', dataIndex: 'current_version', width: 70 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => setDetailRecord(record)}>详情</Button>
|
||||||
|
{record.status === 'active' && (
|
||||||
|
<Popconfirm title="确定归档此模板?" onConfirm={() => archiveMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>归档</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<AgentTemplate>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
|
||||||
|
新建模板
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建 Agent 模板"
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setModalOpen(false); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类">
|
||||||
|
<Input placeholder="如 assistant, tool" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="model" label="默认模型">
|
||||||
|
<Input placeholder="如 gpt-4o" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="system_prompt" label="系统提示词">
|
||||||
|
<TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="temperature" label="Temperature">
|
||||||
|
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_tokens" label="最大 Token">
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="visibility" label="可见性">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'public', label: '公开' },
|
||||||
|
{ value: 'team', label: '团队' },
|
||||||
|
{ value: 'private', label: '私有' },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="emoji" label="图标">
|
||||||
|
<Input placeholder="如 🏥" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="personality" label="人格预设">
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'professional', label: '专业' },
|
||||||
|
{ value: 'friendly', label: '友好' },
|
||||||
|
{ value: 'creative', label: '创意' },
|
||||||
|
{ value: 'concise', label: '简洁' },
|
||||||
|
]} allowClear placeholder="选择人格预设" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="soul_content" label="SOUL.md 人格配置">
|
||||||
|
<TextArea rows={8} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="welcome_message" label="欢迎语">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="communication_style" label="沟通风格">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="source_id" label="模板标识">
|
||||||
|
<Input placeholder="如 medical-assistant-v1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="scenarios" label="使用场景">
|
||||||
|
<Select mode="tags" placeholder="输入场景标签后按回车" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.List name="quick_commands">
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: 8, fontWeight: 500 }}>快捷命令</div>
|
||||||
|
{fields.map(({ key, name, ...restField }) => (
|
||||||
|
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
|
||||||
|
<Form.Item {...restField} name={[name, 'label']} rules={[{ required: true, message: '请输入标签' }]}>
|
||||||
|
<Input placeholder="标签" style={{ width: 140 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item {...restField} name={[name, 'command']} rules={[{ required: true, message: '请输入命令' }]}>
|
||||||
|
<Input placeholder="命令/提示词" style={{ width: 280 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||||
|
</Space>
|
||||||
|
))}
|
||||||
|
<Button type="dashed" onClick={() => add()} block icon={<PlusOutlined />}>
|
||||||
|
添加快捷命令
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="模板详情"
|
||||||
|
open={!!detailRecord}
|
||||||
|
onCancel={() => setDetailRecord(null)}
|
||||||
|
footer={null}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
{detailRecord && (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="图标">{detailRecord.emoji || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="版本">{detailRecord.version ?? detailRecord.current_version}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="人格预设">{detailRecord.personality || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="沟通风格">{detailRecord.communication_style || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="模板标识" span={2}>{detailRecord.source_id || '-'}</Descriptions.Item>
|
||||||
|
{detailRecord.welcome_message && (
|
||||||
|
<Descriptions.Item label="欢迎语" span={2}>{detailRecord.welcome_message}</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
{detailRecord.scenarios && detailRecord.scenarios.length > 0 && (
|
||||||
|
<Descriptions.Item label="使用场景" span={2}>
|
||||||
|
{detailRecord.scenarios.map((s) => <Tag key={s}>{s}</Tag>)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="系统提示词" span={2}>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
{detailRecord.system_prompt || '-'}
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{detailRecord.soul_content && (
|
||||||
|
<Descriptions.Item label="SOUL.md 人格配置" span={2}>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
{detailRecord.soul_content}
|
||||||
|
</div>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="工具" span={2}>
|
||||||
|
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="能力" span={2}>
|
||||||
|
{detailRecord.capabilities?.map((c) => <Tag key={c} color="blue">{c}</Tag>) || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
admin-v2/src/pages/Config.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 系统配置
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Card, Tabs, message, Tag, Input, Button, Space, Typography } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { configService } from '@/services/config'
|
||||||
|
import type { ConfigItem } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
export default function Config() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [category, setCategory] = useState<string>('general')
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['config', category],
|
||||||
|
queryFn: ({ signal }) => configService.list({ category }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, value }: { id: string; value: string }) =>
|
||||||
|
configService.update(id, { value }),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('配置已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['config', category] })
|
||||||
|
setEditingId(null)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<ConfigItem>[] = [
|
||||||
|
{ title: '配置路径', dataIndex: 'key_path', width: 200, render: (_, r) => <code>{r.key_path}</code> },
|
||||||
|
{
|
||||||
|
title: '当前值',
|
||||||
|
dataIndex: 'current_value',
|
||||||
|
width: 250,
|
||||||
|
render: (_, record) => {
|
||||||
|
if (editingId === record.id) {
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Input
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
style={{ width: 180 }}
|
||||||
|
onPressEnter={() => updateMutation.mutate({ id: record.id, value: editValue })}
|
||||||
|
/>
|
||||||
|
<Button size="small" type="primary" onClick={() => updateMutation.mutate({ id: record.id, value: editValue })}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => setEditingId(null)}>取消</Button>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={() => { setEditingId(record.id); setEditValue(record.current_value || '') }}
|
||||||
|
style={{ cursor: 'pointer', color: '#1677ff' }}
|
||||||
|
>
|
||||||
|
{record.current_value || <Tag>未设置</Tag>}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: '默认值', dataIndex: 'default_value', width: 200, render: (_, r) => r.default_value || '-' },
|
||||||
|
{ title: '类型', dataIndex: 'value_type', width: 80, render: (_, r) => <Tag>{r.value_type}</Tag> },
|
||||||
|
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '需要重启',
|
||||||
|
dataIndex: 'requires_restart',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) => r.requires_restart ? <Tag color="orange">是</Tag> : <Tag>否</Tag>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ marginBottom: 24 }}>系统配置</Title>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
activeKey={category}
|
||||||
|
onChange={(key) => { setCategory(key); setEditingId(null) }}
|
||||||
|
items={[
|
||||||
|
{ key: 'general', label: '通用' },
|
||||||
|
{ key: 'auth', label: '认证' },
|
||||||
|
{ key: 'relay', label: '中转' },
|
||||||
|
{ key: 'model', label: '模型' },
|
||||||
|
{ key: 'rate_limit', label: '限流' },
|
||||||
|
{ key: 'log', label: '日志' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProTable<ConfigItem>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
admin-v2/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 仪表盘页面
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Card, Col, Row, Statistic, Table, Tag, Spin } from 'antd'
|
||||||
|
import {
|
||||||
|
TeamOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ApiOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
ColumnWidthOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { statsService } from '@/services/stats'
|
||||||
|
import { logService } from '@/services/logs'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
import { actionLabels, actionColors } from '@/constants/status'
|
||||||
|
import type { OperationLog } from '@/types'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading: statsLoading,
|
||||||
|
error: statsError,
|
||||||
|
refetch: refetchStats,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['dashboard-stats'],
|
||||||
|
queryFn: ({ signal }) => statsService.dashboard(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||||
|
queryKey: ['recent-logs'],
|
||||||
|
queryFn: ({ signal }) => logService.list({ page: 1, page_size: 10 }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (statsError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="仪表盘" description="系统概览与最近活动" />
|
||||||
|
<ErrorState
|
||||||
|
message={(statsError as Error).message}
|
||||||
|
onRetry={() => refetchStats()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#863bff' },
|
||||||
|
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#47bfff' },
|
||||||
|
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#22c55e' },
|
||||||
|
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#f59e0b' },
|
||||||
|
{
|
||||||
|
title: '今日 Token',
|
||||||
|
value: (stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0),
|
||||||
|
icon: <ColumnWidthOutlined />,
|
||||||
|
color: '#ef4444',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const logColumns = [
|
||||||
|
{
|
||||||
|
title: '操作类型',
|
||||||
|
dataIndex: 'action',
|
||||||
|
key: 'action',
|
||||||
|
width: 140,
|
||||||
|
render: (action: string) => (
|
||||||
|
<Tag color={actionColors[action] || 'default'}>
|
||||||
|
{actionLabels[action] || action}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '目标类型',
|
||||||
|
dataIndex: 'target_type',
|
||||||
|
key: 'target_type',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string | null) => v || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
key: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="仪表盘" description="系统概览与最近活动" />
|
||||||
|
|
||||||
|
{/* Stat Cards */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
|
{statsLoading ? (
|
||||||
|
<Col span={24}>
|
||||||
|
<div className="flex justify-center py-8">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
) : (
|
||||||
|
statCards.map((card) => (
|
||||||
|
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
|
||||||
|
<Card
|
||||||
|
className="hover:shadow-md transition-shadow duration-200"
|
||||||
|
styles={{ body: { padding: '20px 24px' } }}
|
||||||
|
>
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
{card.title}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={card.value}
|
||||||
|
valueStyle={{ fontSize: 28, fontWeight: 600, color: card.color }}
|
||||||
|
prefix={
|
||||||
|
<span style={{ color: card.color, marginRight: 4 }}>{card.icon}</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Recent Logs */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
最近操作日志
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<Table<OperationLog>
|
||||||
|
columns={logColumns}
|
||||||
|
dataSource={logsData?.items ?? []}
|
||||||
|
loading={logsLoading}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
160
admin-v2/src/pages/Login.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 登录页面
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { LoginForm, ProFormText } from '@ant-design/pro-components'
|
||||||
|
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
|
||||||
|
import { message } from 'antd'
|
||||||
|
import { authService } from '@/services/auth'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import type { LoginRequest } from '@/types'
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const loginStore = useAuthStore((s) => s.login)
|
||||||
|
const [needTotp, setNeedTotp] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async (values: Record<string, string>) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data: LoginRequest = {
|
||||||
|
username: values.username?.trim() || '',
|
||||||
|
password: values.password || '',
|
||||||
|
totp_code: values.totp_code?.trim() || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await authService.login(data)
|
||||||
|
loginStore(res.account)
|
||||||
|
|
||||||
|
message.success('登录成功')
|
||||||
|
const from = searchParams.get('from') || '/'
|
||||||
|
navigate(from, { replace: true })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { message?: string; status?: number }
|
||||||
|
const msg = error.message || ''
|
||||||
|
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || error.status === 403) {
|
||||||
|
setNeedTotp(true)
|
||||||
|
message.warning(msg || '请输入两步验证码')
|
||||||
|
} else {
|
||||||
|
message.error(msg || '登录失败,请检查用户名和密码')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex">
|
||||||
|
{/* Left Brand Panel — hidden on mobile */}
|
||||||
|
<div className="hidden md:flex flex-1 flex-col items-center justify-center relative overflow-hidden"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #0c0a09 0%, #1c1917 40%, #292524 100%)' }}
|
||||||
|
>
|
||||||
|
{/* Decorative gradient orb */}
|
||||||
|
<div
|
||||||
|
className="absolute w-[400px] h-[400px] rounded-full opacity-20 blur-3xl"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)', top: '20%', left: '10%' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute w-[300px] h-[300px] rounded-full opacity-10 blur-3xl"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #47bfff, #863bff)', bottom: '10%', right: '15%' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Brand content */}
|
||||||
|
<div className="relative z-10 text-center px-8">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center justify-center w-16 h-16 rounded-2xl mb-6"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
|
>
|
||||||
|
<span className="text-white text-2xl font-bold">Z</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold text-white mb-3 tracking-tight">ZCLAW</h1>
|
||||||
|
<p className="text-white/50 text-base mb-8">AI Agent 管理平台</p>
|
||||||
|
<div className="w-16 h-px mx-auto mb-8" style={{ background: 'linear-gradient(90deg, transparent, #863bff, #47bfff, transparent)' }} />
|
||||||
|
<p className="text-white/30 text-sm max-w-sm mx-auto leading-relaxed">
|
||||||
|
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Login Form */}
|
||||||
|
<div className="flex-1 md:flex-none md:w-[480px] flex items-center justify-center p-8 bg-white dark:bg-neutral-950">
|
||||||
|
<div className="w-full max-w-[360px]">
|
||||||
|
{/* Mobile logo (visible only on mobile) */}
|
||||||
|
<div className="md:hidden flex items-center gap-3 mb-10">
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center w-10 h-10 rounded-xl"
|
||||||
|
style={{ background: 'linear-gradient(135deg, #863bff, #47bfff)' }}
|
||||||
|
>
|
||||||
|
<span className="text-white font-bold">Z</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-neutral-900 dark:text-white">ZCLAW</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-semibold text-neutral-900 dark:text-white mb-1">
|
||||||
|
登录
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-8">
|
||||||
|
输入您的账号信息以继续
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<LoginForm
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: { submitText: '登录' },
|
||||||
|
submitButtonProps: {
|
||||||
|
loading,
|
||||||
|
block: true,
|
||||||
|
style: {
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 8,
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 15,
|
||||||
|
background: 'linear-gradient(135deg, #863bff, #47bfff)',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="username"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <UserOutlined />,
|
||||||
|
autoComplete: 'username',
|
||||||
|
}}
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
rules={[{ required: true, message: '请输入用户名' }]}
|
||||||
|
/>
|
||||||
|
<ProFormText.Password
|
||||||
|
name="password"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <LockOutlined />,
|
||||||
|
autoComplete: 'current-password',
|
||||||
|
}}
|
||||||
|
placeholder="请输入密码"
|
||||||
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
|
/>
|
||||||
|
{needTotp && (
|
||||||
|
<ProFormText
|
||||||
|
name="totp_code"
|
||||||
|
fieldProps={{
|
||||||
|
size: 'large',
|
||||||
|
prefix: <SafetyOutlined />,
|
||||||
|
maxLength: 6,
|
||||||
|
autoComplete: 'one-time-code',
|
||||||
|
}}
|
||||||
|
placeholder="请输入 6 位验证码"
|
||||||
|
rules={[{ required: true, message: '请输入验证码' }]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</LoginForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
admin-v2/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 操作日志
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag, Select, Typography } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { logService } from '@/services/logs'
|
||||||
|
import type { OperationLog } from '@/types'
|
||||||
|
|
||||||
|
const { Title } = Typography
|
||||||
|
|
||||||
|
const actionLabels: Record<string, string> = {
|
||||||
|
login: '登录', logout: '登出',
|
||||||
|
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
|
||||||
|
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
|
||||||
|
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
|
||||||
|
create_token: '创建密钥', revoke_token: '撤销密钥',
|
||||||
|
update_config: '更新配置',
|
||||||
|
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
|
||||||
|
desktop_audit: '桌面端审计',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
login: 'green', logout: 'default',
|
||||||
|
create_account: 'blue', update_account: 'orange', delete_account: 'red',
|
||||||
|
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
|
||||||
|
create_model: 'blue', update_model: 'orange', delete_model: 'red',
|
||||||
|
create_token: 'blue', revoke_token: 'red',
|
||||||
|
update_config: 'orange',
|
||||||
|
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
|
||||||
|
desktop_audit: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
|
||||||
|
|
||||||
|
export default function Logs() {
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [actionFilter, setActionFilter] = useState<string | undefined>(undefined)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['logs', page, actionFilter],
|
||||||
|
queryFn: ({ signal }) => logService.list({ page, page_size: 20, action: actionFilter }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<OperationLog>[] = [
|
||||||
|
{
|
||||||
|
title: '操作类型',
|
||||||
|
dataIndex: 'action',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={actionColors[r.action] || 'default'}>
|
||||||
|
{actionLabels[r.action] || r.action}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '目标类型', dataIndex: 'target_type', width: 100, render: (_, r) => r.target_type || '-' },
|
||||||
|
{ title: '目标 ID', dataIndex: 'target_id', width: 120, render: (_, r) => r.target_id ? <code>{r.target_id.substring(0, 8)}...</code> : '-' },
|
||||||
|
{
|
||||||
|
title: '详情',
|
||||||
|
dataIndex: 'details',
|
||||||
|
width: 250,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, r) => {
|
||||||
|
if (!r.details) return '-'
|
||||||
|
if (typeof r.details === 'string') return r.details
|
||||||
|
return JSON.stringify(r.details)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ title: 'IP 地址', dataIndex: 'ip_address', width: 130, render: (_, r) => <code>{r.ip_address || '-'}</code> },
|
||||||
|
{
|
||||||
|
title: '时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>操作日志</Title>
|
||||||
|
<Select
|
||||||
|
value={actionFilter}
|
||||||
|
onChange={(v) => { setActionFilter(v === 'all' ? undefined : v); setPage(1) }}
|
||||||
|
placeholder="操作类型筛选"
|
||||||
|
style={{ width: 160 }}
|
||||||
|
allowClear
|
||||||
|
options={[{ value: 'all', label: '全部操作' }, ...actionOptions]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProTable<OperationLog>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: 20,
|
||||||
|
current: page,
|
||||||
|
onChange: setPage,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
427
admin-v2/src/pages/ModelServices.tsx
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Tabs, Table, Typography } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { providerService } from '@/services/providers'
|
||||||
|
import { modelService } from '@/services/models'
|
||||||
|
import type { Provider, ProviderKey, Model } from '@/types'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 子组件: 模型表格
|
||||||
|
// ============================================================
|
||||||
|
function ProviderModelsTable({ providerId }: { providerId: string }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['provider-models', providerId],
|
||||||
|
queryFn: ({ signal }) => modelService.list({ provider_id: providerId! }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
|
||||||
|
modelService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => modelService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('模型已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-models', providerId] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate({ ...values, provider_id: providerId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<Model>[] = [
|
||||||
|
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <Text code>{r.model_id}</Text> },
|
||||||
|
{ title: '别名', dataIndex: 'alias', width: 120 },
|
||||||
|
{ title: '上下文窗口', dataIndex: 'context_window', width: 100, render: (_, r) => r.context_window?.toLocaleString() },
|
||||||
|
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 90, render: (_, r) => r.max_output_tokens?.toLocaleString() },
|
||||||
|
{ title: '流式', dataIndex: 'supports_streaming', width: 60, render: (_, r) => r.supports_streaming ? <Tag color="green">是</Tag> : <Tag>否</Tag> },
|
||||||
|
{ title: '视觉', dataIndex: 'supports_vision', width: 60, render: (_, r) => r.supports_vision ? <Tag color="blue">是</Tag> : <Tag>否</Tag> },
|
||||||
|
{ title: '状态', dataIndex: 'enabled', width: 60, render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag> },
|
||||||
|
{
|
||||||
|
title: '操作', width: 120, render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const models = data?.items ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||||
|
添加模型
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table<Model>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={models}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={editingId ? '编辑模型' : '添加模型'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 gpt-4o" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="alias" label="别名">
|
||||||
|
<Input placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="context_window" label="上下文窗口" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} placeholder="128000" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_output_tokens" label="最大输出 Token" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} placeholder="4096" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch defaultChecked />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="pricing_input" label="输入价格 (每百万Token)" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="pricing_output" label="输出价格 (每百万Token)" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} step={0.01} placeholder="0" style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 子组件: Key Pool 表格
|
||||||
|
// ============================================================
|
||||||
|
function ProviderKeysTable({ providerId }: { providerId: string }) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [addKeyForm] = Form.useForm()
|
||||||
|
const [addKeyOpen, setAddKeyOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['provider-keys', providerId],
|
||||||
|
queryFn: ({ signal }) => providerService.listKeys(providerId!, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const addKeyMutation = useMutation({
|
||||||
|
mutationFn: (data: { key_label: string; key_value: string; priority?: number; max_rpm?: number; max_tpm?: number }) =>
|
||||||
|
providerService.addKey(providerId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
|
||||||
|
message.success('密钥已添加')
|
||||||
|
setAddKeyOpen(false)
|
||||||
|
addKeyForm.resetFields()
|
||||||
|
},
|
||||||
|
onError: () => message.error('添加失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleKeyMutation = useMutation({
|
||||||
|
mutationFn: ({ keyId, active }: { keyId: string; active: boolean }) =>
|
||||||
|
providerService.toggleKey(providerId, keyId, active),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
|
||||||
|
message.success('状态已切换')
|
||||||
|
},
|
||||||
|
onError: () => message.error('切换失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteKeyMutation = useMutation({
|
||||||
|
mutationFn: (keyId: string) => providerService.deleteKey(providerId, keyId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['provider-keys', providerId] })
|
||||||
|
message.success('密钥已删除')
|
||||||
|
},
|
||||||
|
onError: () => message.error('删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const keyColumns: ProColumns<ProviderKey>[] = [
|
||||||
|
{ title: '标签', dataIndex: 'key_label', width: 120 },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
||||||
|
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
|
||||||
|
{ title: 'Token 数', dataIndex: 'total_tokens', width: 90 },
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'is_active', width: 70,
|
||||||
|
render: (_, r) => r.is_active ? <Tag color="green">活跃</Tag> : <Tag color="orange">冷却</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', width: 120,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Popconfirm
|
||||||
|
title={record.is_active ? '确定禁用此密钥?' : '确定启用此密钥?'}
|
||||||
|
onConfirm={() => toggleKeyMutation.mutate({ keyId: record.id, active: !record.is_active })}
|
||||||
|
>
|
||||||
|
<Button size="small" type={record.is_active ? 'default' : 'primary'}>
|
||||||
|
{record.is_active ? '禁用' : '启用'}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
<Popconfirm title="确定删除此密钥?此操作不可恢复。" onConfirm={() => deleteKeyMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const keys = data ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => { addKeyForm.resetFields(); setAddKeyOpen(true) }}>
|
||||||
|
添加密钥
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table<ProviderKey>
|
||||||
|
columns={keyColumns}
|
||||||
|
dataSource={keys}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="添加密钥"
|
||||||
|
open={addKeyOpen}
|
||||||
|
onOk={() => {
|
||||||
|
addKeyForm.validateFields().then((v) => addKeyMutation.mutate(v))
|
||||||
|
}}
|
||||||
|
onCancel={() => setAddKeyOpen(false)}
|
||||||
|
confirmLoading={addKeyMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={addKeyForm} layout="vertical">
|
||||||
|
<Form.Item name="key_label" label="标签" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如: my-openai-key" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="key_value" label="API Key" rules={[{ required: true }]}>
|
||||||
|
<Input.Password placeholder="sk-..." />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="priority" label="优先级" initialValue={0} style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_rpm" label="最大 RPM" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="max_tpm" label="最大 TPM" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 主页面: 模型服务
|
||||||
|
// ============================================================
|
||||||
|
export default function ModelServices() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['providers'],
|
||||||
|
queryFn: ({ signal }) => providerService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
|
||||||
|
providerService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('服务商已创建')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
|
||||||
|
providerService.update(id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('服务商已更新')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
setModalOpen(false)
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '更新失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => providerService.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('服务商已删除')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['providers'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '删除失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
if (editingId) {
|
||||||
|
updateMutation.mutate({ id: editingId, data: values })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<Provider>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'display_name', width: 150 },
|
||||||
|
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
|
||||||
|
{ title: 'Base URL', dataIndex: 'base_url', width: 260, ellipsis: true },
|
||||||
|
{ title: '协议', dataIndex: 'api_protocol', width: 90, hideInSearch: true },
|
||||||
|
{ title: 'RPM', dataIndex: 'rate_limit_rpm', width: 80, hideInSearch: true, render: (_, r) => r.rate_limit_rpm ?? '-' },
|
||||||
|
{
|
||||||
|
title: '状态', dataIndex: 'enabled', width: 70, hideInSearch: true,
|
||||||
|
render: (_, r) => r.enabled ? <Tag color="green">启用</Tag> : <Tag>禁用</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作', width: 140, hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<Provider>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={{}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
|
||||||
|
新建服务商
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
expandable={{
|
||||||
|
expandedRowRender: (record) => (
|
||||||
|
<Tabs
|
||||||
|
size="small"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'models',
|
||||||
|
label: `模型`,
|
||||||
|
children: <ProviderModelsTable providerId={record.id} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'keys',
|
||||||
|
label: 'Key Pool',
|
||||||
|
children: <ProviderKeysTable providerId={record.id} />,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingId? '编辑服务商' : '新建服务商'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSave}
|
||||||
|
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
|
width={560}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
|
||||||
|
<Input disabled={!!editingId} placeholder="如 openai, anthropic" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 OpenAI" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="https://api.openai.com/v1" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="api_protocol" label="API 协议">
|
||||||
|
<Input placeholder="openai" />
|
||||||
|
</Form.Item>
|
||||||
|
<div style={{ display: 'flex', gap: 16 }}>
|
||||||
|
<Form.Item name="enabled" label="启用" valuePropName="checked" style={{ flex: 1 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="rate_limit_rpm" label="RPM 限制" style={{ flex: 1 }}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
228
admin-v2/src/pages/Prompts.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 提示词管理
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm, Descriptions, Tabs, Typography } from 'antd'
|
||||||
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { promptService } from '@/services/prompts'
|
||||||
|
import type { PromptTemplate, PromptVersion } from '@/types'
|
||||||
|
|
||||||
|
const { TextArea } = Input
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
|
||||||
|
const statusLabels: Record<string, string> = { active: '活跃', deprecated: '已废弃', archived: '已归档' }
|
||||||
|
const statusColors: Record<string, string> = { active: 'green', deprecated: 'orange', archived: 'default' }
|
||||||
|
|
||||||
|
export default function Prompts() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [detailName, setDetailName] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['prompts'],
|
||||||
|
queryFn: ({ signal }) => promptService.list(signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: detailData } = useQuery({
|
||||||
|
queryKey: ['prompt-detail', detailName],
|
||||||
|
queryFn: ({ signal }) => promptService.get(detailName!, signal),
|
||||||
|
enabled: !!detailName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: versionsData } = useQuery({
|
||||||
|
queryKey: ['prompt-versions', detailName],
|
||||||
|
queryFn: ({ signal }) => promptService.listVersions(detailName!, signal),
|
||||||
|
enabled: !!detailName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: Parameters<typeof promptService.create>[0]) => promptService.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('创建成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
setCreateOpen(false)
|
||||||
|
form.resetFields()
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '创建失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const archiveMutation = useMutation({
|
||||||
|
mutationFn: (name: string) => promptService.archive(name),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('已归档')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '归档失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rollbackMutation = useMutation({
|
||||||
|
mutationFn: ({ name, version }: { name: string; version: number }) =>
|
||||||
|
promptService.rollback(name, version),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('回滚成功')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompts'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompt-detail', detailName] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['prompt-versions', detailName] })
|
||||||
|
},
|
||||||
|
onError: (err: Error) => message.error(err.message || '回滚失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const columns: ProColumns<PromptTemplate>[] = [
|
||||||
|
{ title: '名称', dataIndex: 'name', width: 200, render: (_, r) => <Text code>{r.name}</Text> },
|
||||||
|
{ title: '分类', dataIndex: 'category', width: 100 },
|
||||||
|
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '来源',
|
||||||
|
dataIndex: 'source',
|
||||||
|
width: 80,
|
||||||
|
render: (_, r) => <Tag>{sourceLabels[r.source]}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '版本', dataIndex: 'current_version', width: 70 },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 90,
|
||||||
|
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status]}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 180,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => setDetailName(record.name)}>详情</Button>
|
||||||
|
{record.status === 'active' && (
|
||||||
|
<Popconfirm title="确定归档此提示词?" onConfirm={() => archiveMutation.mutate(record.name)}>
|
||||||
|
<Button size="small" danger>归档</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
createMutation.mutate(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
const versionColumns: ProColumns<PromptVersion>[] = [
|
||||||
|
{ title: '版本', dataIndex: 'version', width: 60 },
|
||||||
|
{ title: '更新说明', dataIndex: 'changelog', width: 200, ellipsis: true },
|
||||||
|
{ title: '最低版本', dataIndex: 'min_app_version', width: 100, render: (_, r) => r.min_app_version || '-' },
|
||||||
|
{
|
||||||
|
title: '创建时间',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Popconfirm
|
||||||
|
title={`确定回滚到版本 ${record.version}?`}
|
||||||
|
onConfirm={() => detailName && rollbackMutation.mutate({ name: detailName, version: record.version })}
|
||||||
|
>
|
||||||
|
<Button size="small">回滚</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<ProTable<PromptTemplate>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setCreateOpen(true) }}>
|
||||||
|
新建提示词
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: data?.page_size ?? 20,
|
||||||
|
current: data?.page ?? 1,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="新建提示词"
|
||||||
|
open={createOpen}
|
||||||
|
onOk={handleCreate}
|
||||||
|
onCancel={() => { setCreateOpen(false); form.resetFields() }}
|
||||||
|
confirmLoading={createMutation.isPending}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="唯一标识" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="如 system, tool" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="描述">
|
||||||
|
<TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="system_prompt" label="系统提示词" rules={[{ required: true }]}>
|
||||||
|
<TextArea rows={6} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="user_prompt_template" label="用户提示词模板">
|
||||||
|
<TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`提示词详情: ${detailName || ''}`}
|
||||||
|
open={!!detailName}
|
||||||
|
onCancel={() => setDetailName(null)}
|
||||||
|
footer={null}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<Tabs items={[
|
||||||
|
{
|
||||||
|
key: 'info',
|
||||||
|
label: '基本信息',
|
||||||
|
children: detailData ? (
|
||||||
|
<Descriptions column={2} bordered size="small">
|
||||||
|
<Descriptions.Item label="名称">{detailData.name}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分类">{detailData.category}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="来源">{sourceLabels[detailData.source]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">{statusLabels[detailData.status]}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="当前版本">{detailData.current_version}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'versions',
|
||||||
|
label: '版本历史',
|
||||||
|
children: (
|
||||||
|
<ProTable<PromptVersion>
|
||||||
|
columns={versionColumns}
|
||||||
|
dataSource={versionsData ?? []}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
loading={!versionsData}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]} />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
146
admin-v2/src/pages/Relay.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 中转任务
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Tag, Select } from 'antd'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { relayService } from '@/services/relay'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
import type { RelayTask } from '@/types'
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
queued: '排队中',
|
||||||
|
running: '运行中',
|
||||||
|
completed: '已完成',
|
||||||
|
failed: '失败',
|
||||||
|
cancelled: '已取消',
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
queued: 'default',
|
||||||
|
running: 'processing',
|
||||||
|
completed: 'green',
|
||||||
|
failed: 'red',
|
||||||
|
cancelled: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Relay() {
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['relay-tasks', page, statusFilter],
|
||||||
|
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="中转任务" description="查看和管理 AI 模型中转请求" />
|
||||||
|
<ErrorState message={(error as Error).message} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ProColumns<RelayTask>[] = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'id',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => (
|
||||||
|
<code className="text-xs px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800">
|
||||||
|
{r.id.substring(0, 8)}...
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => (
|
||||||
|
<Tag color={statusColors[r.status] || 'default'}>
|
||||||
|
{statusLabels[r.status] || r.status}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '模型', dataIndex: 'model_id', width: 160 },
|
||||||
|
{ title: '优先级', dataIndex: 'priority', width: 70 },
|
||||||
|
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
|
||||||
|
{
|
||||||
|
title: 'Token (入/出)',
|
||||||
|
width: 140,
|
||||||
|
render: (_, r) => (
|
||||||
|
<span className="text-sm">
|
||||||
|
{r.input_tokens.toLocaleString()} / {r.output_tokens.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '排队时间',
|
||||||
|
dataIndex: 'queued_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => new Date(r.queued_at).toLocaleString('zh-CN'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '完成时间',
|
||||||
|
dataIndex: 'completed_at',
|
||||||
|
width: 180,
|
||||||
|
render: (_, r) => (r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="中转任务"
|
||||||
|
description="查看和管理 AI 模型中转请求"
|
||||||
|
actions={
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(v) => {
|
||||||
|
setStatusFilter(v === 'all' ? undefined : v)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
placeholder="状态筛选"
|
||||||
|
className="w-36"
|
||||||
|
allowClear
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: '全部' },
|
||||||
|
{ value: 'queued', label: '排队中' },
|
||||||
|
{ value: 'running', label: '运行中' },
|
||||||
|
{ value: 'completed', label: '已完成' },
|
||||||
|
{ value: 'failed', label: '失败' },
|
||||||
|
{ value: 'cancelled', label: '已取消' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProTable<RelayTask>
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
rowKey="id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={{
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
pageSize: 20,
|
||||||
|
current: page,
|
||||||
|
onChange: setPage,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
200
admin-v2/src/pages/Usage.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 用量统计
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Card, Col, Row, Select, Statistic } from 'antd'
|
||||||
|
import { ThunderboltOutlined, ColumnWidthOutlined } from '@ant-design/icons'
|
||||||
|
import type { ProColumns } from '@ant-design/pro-components'
|
||||||
|
import { ProTable } from '@ant-design/pro-components'
|
||||||
|
import { telemetryService } from '@/services/telemetry'
|
||||||
|
import { PageHeader } from '@/components/PageHeader'
|
||||||
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
|
import type { DailyUsageStat, ModelUsageStat } from '@/types'
|
||||||
|
|
||||||
|
export default function Usage() {
|
||||||
|
const [days, setDays] = useState(30)
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: dailyData,
|
||||||
|
isLoading: dailyLoading,
|
||||||
|
error: dailyError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['usage-daily', days],
|
||||||
|
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: modelData, isLoading: modelLoading } = useQuery({
|
||||||
|
queryKey: ['usage-model', days],
|
||||||
|
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (dailyError) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="用量统计" description="查看模型使用情况和 Token 消耗" />
|
||||||
|
<ErrorState message={(dailyError as Error).message} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
|
||||||
|
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
|
||||||
|
|
||||||
|
const dailyColumns: ProColumns<DailyUsageStat>[] = [
|
||||||
|
{ title: '日期', dataIndex: 'day', width: 120 },
|
||||||
|
{
|
||||||
|
title: '请求数',
|
||||||
|
dataIndex: 'request_count',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => r.request_count.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输入 Token',
|
||||||
|
dataIndex: 'input_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.input_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输出 Token',
|
||||||
|
dataIndex: 'output_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.output_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const modelColumns: ProColumns<ModelUsageStat>[] = [
|
||||||
|
{ title: '模型', dataIndex: 'model_id', width: 200 },
|
||||||
|
{
|
||||||
|
title: '请求数',
|
||||||
|
dataIndex: 'request_count',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => r.request_count.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输入 Token',
|
||||||
|
dataIndex: 'input_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.input_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '输出 Token',
|
||||||
|
dataIndex: 'output_tokens',
|
||||||
|
width: 120,
|
||||||
|
render: (_, r) => r.output_tokens.toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '平均延迟',
|
||||||
|
dataIndex: 'avg_latency_ms',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => (r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '成功率',
|
||||||
|
dataIndex: 'success_rate',
|
||||||
|
width: 100,
|
||||||
|
render: (_, r) => `${(r.success_rate * 100).toFixed(1)}%`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="用量统计"
|
||||||
|
description="查看模型使用情况和 Token 消耗"
|
||||||
|
actions={
|
||||||
|
<Select
|
||||||
|
value={days}
|
||||||
|
onChange={setDays}
|
||||||
|
options={[
|
||||||
|
{ value: 7, label: '最近 7 天' },
|
||||||
|
{ value: 30, label: '最近 30 天' },
|
||||||
|
{ value: 90, label: '最近 90 天' },
|
||||||
|
]}
|
||||||
|
className="w-36"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
总请求数
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={totalRequests}
|
||||||
|
prefix={<ThunderboltOutlined style={{ color: '#863bff' }} />}
|
||||||
|
valueStyle={{ fontWeight: 600, color: '#863bff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} sm={12}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow duration-200">
|
||||||
|
<Statistic
|
||||||
|
title={
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 text-xs font-medium uppercase tracking-wide">
|
||||||
|
总 Token 数
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
value={totalTokens}
|
||||||
|
prefix={<ColumnWidthOutlined style={{ color: '#47bfff' }} />}
|
||||||
|
valueStyle={{ fontWeight: 600, color: '#47bfff' }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Daily Stats */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
每日统计
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className="mb-6"
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<ProTable<DailyUsageStat>
|
||||||
|
columns={dailyColumns}
|
||||||
|
dataSource={dailyData ?? []}
|
||||||
|
loading={dailyLoading}
|
||||||
|
rowKey="day"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Model Stats */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<span className="text-sm font-semibold text-neutral-700 dark:text-neutral-300">
|
||||||
|
按模型统计
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
size="small"
|
||||||
|
styles={{ body: { padding: 0 } }}
|
||||||
|
>
|
||||||
|
<ProTable<ModelUsageStat>
|
||||||
|
columns={modelColumns}
|
||||||
|
dataSource={modelData ?? []}
|
||||||
|
loading={modelLoading}
|
||||||
|
rowKey="model_id"
|
||||||
|
search={false}
|
||||||
|
toolBarRender={false}
|
||||||
|
pagination={false}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
63
admin-v2/src/router/AuthGuard.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ZCLAW Admin V2 — Auth Guard with session restore
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// Auth strategy:
|
||||||
|
// 1. If Zustand has isAuthenticated=true (normal flow after login) -> authenticated
|
||||||
|
// 2. If isAuthenticated=false but account in localStorage -> call GET /auth/me
|
||||||
|
// to validate HttpOnly cookie and restore session
|
||||||
|
// 3. If cookie invalid -> clean up and redirect to /login
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { Spin } from 'antd'
|
||||||
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
|
import { authService } from '@/services/auth'
|
||||||
|
|
||||||
|
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
const account = useAuthStore((s) => s.account)
|
||||||
|
const login = useAuthStore((s) => s.login)
|
||||||
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Track restore attempt to avoid double-calling
|
||||||
|
const restoreAttempted = useRef(false)
|
||||||
|
const [restoring, setRestoring] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoreAttempted.current) return
|
||||||
|
restoreAttempted.current = true
|
||||||
|
|
||||||
|
// If not authenticated but account exists in localStorage,
|
||||||
|
// try to validate the HttpOnly cookie via /auth/me
|
||||||
|
if (!isAuthenticated && account) {
|
||||||
|
setRestoring(true)
|
||||||
|
authService.me()
|
||||||
|
.then((meAccount) => {
|
||||||
|
// Cookie is valid — restore session
|
||||||
|
login(meAccount)
|
||||||
|
setRestoring(false)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Cookie expired or invalid — clean up stale data
|
||||||
|
logout()
|
||||||
|
setRestoring(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
if (restoring) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
36
admin-v2/src/router/index.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// ============================================================
|
||||||
|
// 路由定义
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { createBrowserRouter } from 'react-router-dom'
|
||||||
|
import { AuthGuard } from './AuthGuard'
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout'
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
lazy: () => import('@/pages/Login').then((m) => ({ Component: m.default })),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
element: (
|
||||||
|
<AuthGuard>
|
||||||
|
<AdminLayout />
|
||||||
|
</AuthGuard>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'accounts', lazy: () => import('@/pages/Accounts').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'model-services', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'providers', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'models', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'api-keys', lazy: () => import('@/pages/ModelServices').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
|
||||||
|
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
16
admin-v2/src/services/accounts.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AccountPublic, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const accountService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<AccountPublic>>('/accounts', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<AccountPublic>(`/accounts/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<AccountPublic>(`/accounts/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
updateStatus: (id: string, data: { status: AccountPublic['status'] }, signal?: AbortSignal) =>
|
||||||
|
request.patch(`/accounts/${id}/status`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
35
admin-v2/src/services/agent-templates.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AgentTemplate, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const agentTemplateService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
getFull: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.get<AgentTemplate>(`/agent-templates/${id}/full`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: {
|
||||||
|
name: string; description?: string; category?: string; source?: string
|
||||||
|
model?: string; system_prompt?: string; tools?: string[]
|
||||||
|
capabilities?: string[]; temperature?: number; max_tokens?: number
|
||||||
|
visibility?: string; emoji?: string; personality?: string
|
||||||
|
soul_content?: string; welcome_message?: string
|
||||||
|
communication_style?: string; source_id?: string
|
||||||
|
scenarios?: string[]
|
||||||
|
quick_commands?: Array<{ label: string; command: string }>
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: {
|
||||||
|
description?: string; model?: string; system_prompt?: string
|
||||||
|
tools?: string[]; capabilities?: string[]; temperature?: number
|
||||||
|
max_tokens?: number; visibility?: string; status?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<AgentTemplate>(`/agent-templates/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
archive: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
13
admin-v2/src/services/api-keys.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const apiKeyService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<TokenInfo>>('/keys', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
|
||||||
|
request.post<TokenInfo>('/keys', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
revoke: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/keys/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
10
admin-v2/src/services/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { AccountPublic, LoginRequest, LoginResponse } from '@/types'
|
||||||
|
|
||||||
|
export const authService = {
|
||||||
|
login: (data: LoginRequest, signal?: AbortSignal) =>
|
||||||
|
request.post<LoginResponse>('/auth/login', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
me: (signal?: AbortSignal) =>
|
||||||
|
request.get<AccountPublic>('/auth/me', withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
11
admin-v2/src/services/config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { ConfigItem, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const configService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<ConfigItem>>('/config/items', withSignal({ params }, signal))
|
||||||
|
.then((r) => r.data.items),
|
||||||
|
|
||||||
|
update: (id: string, data: { value: string | number | boolean }, signal?: AbortSignal) =>
|
||||||
|
request.patch<ConfigItem>(`/config/items/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
7
admin-v2/src/services/logs.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { OperationLog, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const logService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<OperationLog>>('/logs/operations', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
16
admin-v2/src/services/models.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { Model, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const modelService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<Model>>('/models', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
|
||||||
|
request.post<Model>('/models', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<Model>(`/models/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/models/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
35
admin-v2/src/services/prompts.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { PromptTemplate, PromptVersion, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const promptService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<PromptTemplate>>('/prompts', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: {
|
||||||
|
name: string; category: string; description?: string; source?: string
|
||||||
|
system_prompt: string; user_prompt_template?: string
|
||||||
|
variables?: unknown[]; min_app_version?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptTemplate>('/prompts', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (name: string, data: { description?: string; status?: string }, signal?: AbortSignal) =>
|
||||||
|
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
archive: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
listVersions: (name: string, signal?: AbortSignal) =>
|
||||||
|
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
createVersion: (name: string, data: {
|
||||||
|
system_prompt: string; user_prompt_template?: string
|
||||||
|
variables?: unknown[]; changelog?: string; min_app_version?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
rollback: (name: string, version: number, signal?: AbortSignal) =>
|
||||||
|
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`, undefined, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||
31
admin-v2/src/services/providers.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import request, { withSignal } from './request'
|
||||||
|
import type { Provider, ProviderKey, PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
export const providerService = {
|
||||||
|
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||||
|
request.get<PaginatedResponse<Provider>>('/providers', withSignal({ params }, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
|
||||||
|
request.post<Provider>('/providers', data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
|
||||||
|
request.patch<Provider>(`/providers/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string, signal?: AbortSignal) =>
|
||||||
|
request.delete(`/providers/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
listKeys: (providerId: string, signal?: AbortSignal) =>
|
||||||
|
request.get<ProviderKey[]>(`/providers/${providerId}/keys`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
addKey: (providerId: string, data: {
|
||||||
|
key_label: string; key_value: string; priority?: number
|
||||||
|
max_rpm?: number; max_tpm?: number; quota_reset_interval?: string
|
||||||
|
}, signal?: AbortSignal) =>
|
||||||
|
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
toggleKey: (providerId: string, keyId: string, active: boolean, signal?: AbortSignal) =>
|
||||||
|
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
|
||||||
|
deleteKey: (providerId: string, keyId: string, signal?: AbortSignal) =>
|
||||||
|
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`, withSignal({}, signal)).then((r) => r.data),
|
||||||
|
}
|
||||||