feat(saas): 合并 SaaS 后端、Admin 管理后台、桌面端集成
- 14 commits from worktree-saas-backend - crates/zclaw-saas: Axum 后端 (auth, accounts, models, relay, config-sync) - admin/: Next.js 管理后台 - desktop/: SaaS 客户端集成 (saasStore, 2FA, relay, config sync) - saas-config.toml, docker-compose.yml, Dockerfile - 84 files, 15558 insertions
This commit is contained in:
349
Cargo.lock
generated
349
Cargo.lock
generated
@@ -110,6 +110,18 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -315,6 +327,7 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum-core",
|
||||
"axum-macros",
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
@@ -335,7 +348,7 @@ dependencies = [
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -362,6 +375,47 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-extra"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"axum-core",
|
||||
"bytes",
|
||||
"fastrand",
|
||||
"futures-util",
|
||||
"headers",
|
||||
"http 1.4.0",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"mime",
|
||||
"multer",
|
||||
"pin-project-lite",
|
||||
"serde",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-macros"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base32"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
@@ -410,6 +464,15 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
@@ -654,6 +717,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -896,6 +965,12 @@ dependencies = [
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -1168,6 +1243,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1894,6 +1978,30 @@ dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"headers-core",
|
||||
"http 1.4.0",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"sha1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "headers-core"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
|
||||
dependencies = [
|
||||
"http 1.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2433,6 +2541,21 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "9.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"js-sys",
|
||||
"pem",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.7.0"
|
||||
@@ -2625,6 +2748,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matches"
|
||||
version = "0.1.10"
|
||||
@@ -2716,6 +2848,23 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"httparse",
|
||||
"memchr",
|
||||
"mime",
|
||||
"spin",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
@@ -2785,6 +2934,25 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint-dig"
|
||||
version = "0.8.6"
|
||||
@@ -3120,6 +3288,17 @@ dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "paste"
|
||||
version = "1.0.15"
|
||||
@@ -3132,6 +3311,16 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
|
||||
|
||||
[[package]]
|
||||
name = "pem"
|
||||
version = "3.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -3334,6 +3523,26 @@ dependencies = [
|
||||
"siphasher 1.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -3860,7 +4069,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
"tower-service",
|
||||
"url",
|
||||
@@ -3895,7 +4104,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-http 0.6.8",
|
||||
"tower-service",
|
||||
"url",
|
||||
@@ -4399,6 +4608,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -4431,6 +4649,18 @@ version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||
|
||||
[[package]]
|
||||
name = "simple_asn1"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "0.3.11"
|
||||
@@ -5261,6 +5491,15 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -5505,6 +5744,34 @@ version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
||||
|
||||
[[package]]
|
||||
name = "totp-rs"
|
||||
version = "5.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba"
|
||||
dependencies = [
|
||||
"base32",
|
||||
"constant_time_eq",
|
||||
"hmac",
|
||||
"sha1",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.3"
|
||||
@@ -5535,6 +5802,7 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5550,7 +5818,7 @@ dependencies = [
|
||||
"http-body",
|
||||
"iri-string",
|
||||
"pin-project-lite",
|
||||
"tower",
|
||||
"tower 0.5.3",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
@@ -5597,6 +5865,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5814,6 +6112,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -7124,6 +7428,43 @@ dependencies = [
|
||||
"zclaw-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zclaw-saas"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"data-encoding",
|
||||
"futures",
|
||||
"hex",
|
||||
"jsonwebtoken",
|
||||
"libsqlite3-sys",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"secrecy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"totp-rs",
|
||||
"tower 0.4.13",
|
||||
"tower-http 0.5.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"zclaw-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zclaw-skills"
|
||||
version = "0.1.0"
|
||||
|
||||
13
Cargo.toml
13
Cargo.toml
@@ -15,6 +15,8 @@ members = [
|
||||
"crates/zclaw-growth",
|
||||
# Desktop Application
|
||||
"desktop/src-tauri",
|
||||
# SaaS Backend
|
||||
"crates/zclaw-saas",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -95,6 +97,16 @@ shlex = "1"
|
||||
# Testing
|
||||
tempfile = "3"
|
||||
|
||||
# SaaS dependencies
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
tower = { version = "0.4", features = ["util"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace", "limit"] }
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
totp-rs = "5"
|
||||
hex = "0.4"
|
||||
|
||||
# Internal crates
|
||||
zclaw-types = { path = "crates/zclaw-types" }
|
||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||
@@ -106,6 +118,7 @@ zclaw-channels = { path = "crates/zclaw-channels" }
|
||||
zclaw-protocols = { path = "crates/zclaw-protocols" }
|
||||
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
|
||||
zclaw-growth = { path = "crates/zclaw-growth" }
|
||||
zclaw-saas = { path = "crates/zclaw-saas" }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
|
||||
2
admin/.gitignore
vendored
Normal file
2
admin/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.next/
|
||||
node_modules/
|
||||
5
admin/next-env.d.ts
vendored
Normal file
5
admin/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
4
admin/next.config.js
Normal file
4
admin/next.config.js
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
37
admin/package.json
Normal file
37
admin/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "zclaw-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.484.0",
|
||||
"next": "14.2.29",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.2"
|
||||
}
|
||||
2171
admin/pnpm-lock.yaml
generated
Normal file
2171
admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
admin/postcss.config.js
Normal file
6
admin/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
393
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
393
admin/src/app/(dashboard)/accounts/page.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { AccountPublic } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
super_admin: '超级管理员',
|
||||
admin: '管理员',
|
||||
user: '普通用户',
|
||||
}
|
||||
|
||||
const statusColors: Record<string, 'success' | 'destructive' | 'warning'> = {
|
||||
active: 'success',
|
||||
disabled: 'destructive',
|
||||
suspended: 'warning',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '已禁用',
|
||||
suspended: '已暂停',
|
||||
}
|
||||
|
||||
export default function AccountsPage() {
|
||||
const [accounts, setAccounts] = useState<AccountPublic[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 编辑 Dialog
|
||||
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
|
||||
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' })
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
|
||||
// 确认 Dialog
|
||||
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
|
||||
const [confirmSaving, setConfirmSaving] = useState(false)
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (search.trim()) params.search = search.trim()
|
||||
if (roleFilter !== 'all') params.role = roleFilter
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
|
||||
const res = await api.accounts.list(params)
|
||||
setAccounts(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message)
|
||||
} else {
|
||||
setError('加载失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, roleFilter, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
function openEditDialog(account: AccountPublic) {
|
||||
setEditTarget(account)
|
||||
setEditForm({
|
||||
display_name: account.display_name,
|
||||
email: account.email,
|
||||
role: account.role,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editTarget) return
|
||||
setEditSaving(true)
|
||||
try {
|
||||
await api.accounts.update(editTarget.id, {
|
||||
display_name: editForm.display_name,
|
||||
email: editForm.email,
|
||||
role: editForm.role as AccountPublic['role'],
|
||||
})
|
||||
setEditTarget(null)
|
||||
fetchAccounts()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message)
|
||||
}
|
||||
} finally {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirmDialog(account: AccountPublic) {
|
||||
const newStatus = account.status === 'active' ? 'disabled' : 'active'
|
||||
setConfirmTarget({
|
||||
id: account.id,
|
||||
action: newStatus === 'disabled' ? '禁用' : '启用',
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleConfirmSave() {
|
||||
if (!confirmTarget) return
|
||||
setConfirmSaving(true)
|
||||
try {
|
||||
await api.accounts.updateStatus(confirmTarget.id, {
|
||||
status: confirmTarget.status as AccountPublic['status'],
|
||||
})
|
||||
setConfirmTarget(null)
|
||||
fetchAccounts()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message)
|
||||
}
|
||||
} finally {
|
||||
setConfirmSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名 / 邮箱 / 显示名..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="角色筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="active">正常</SelectItem>
|
||||
<SelectItem value="disabled">已禁用</SelectItem>
|
||||
<SelectItem value="suspended">已暂停</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格 */}
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>显示名</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.id}>
|
||||
<TableCell className="font-medium">{account.username}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{account.email}</TableCell>
|
||||
<TableCell>{account.display_name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={account.role === 'super_admin' ? 'default' : account.role === 'admin' ? 'info' : 'secondary'}>
|
||||
{roleLabels[account.role] || account.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[account.status] || 'secondary'}>
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{statusLabels[account.status] || account.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(account.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(account)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openConfirmDialog(account)}
|
||||
title={account.status === 'active' ? '禁用' : '启用'}
|
||||
>
|
||||
{account.status === 'active' ? (
|
||||
<Ban className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 编辑 Dialog */}
|
||||
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑账号</DialogTitle>
|
||||
<DialogDescription>修改账号信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>显示名</Label>
|
||||
<Input
|
||||
value={editForm.display_name}
|
||||
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>角色</Label>
|
||||
<Select value={editForm.role} onValueChange={(v) => setEditForm({ ...editForm, role: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={editSaving}>
|
||||
{editSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 确认 Dialog */}
|
||||
<Dialog open={!!confirmTarget} onOpenChange={() => setConfirmTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认{confirmTarget?.action}</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要{confirmTarget?.action}该账号吗?此操作将立即生效。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmTarget?.status === 'disabled' ? 'destructive' : 'default'}
|
||||
onClick={handleConfirmSave}
|
||||
disabled={confirmSaving}
|
||||
>
|
||||
{confirmSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
确认{confirmTarget?.action}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
351
admin/src/app/(dashboard)/api-keys/page.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { TokenInfo } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const allPermissions = [
|
||||
{ key: 'chat', label: '对话' },
|
||||
{ key: 'relay', label: '中转' },
|
||||
{ key: 'admin', label: '管理' },
|
||||
]
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [tokens, setTokens] = useState<TokenInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 创建 Dialog
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ name: '', expires_days: '', permissions: ['chat'] as string[] })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// 创建成功显示 token
|
||||
const [createdToken, setCreatedToken] = useState<TokenInfo | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// 撤销确认
|
||||
const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null)
|
||||
const [revoking, setRevoking] = useState(false)
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.tokens.list({ page, page_size: PAGE_SIZE })
|
||||
setTokens(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTokens()
|
||||
}, [fetchTokens])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
function togglePermission(perm: string) {
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes(perm)
|
||||
? prev.permissions.filter((p) => p !== perm)
|
||||
: [...prev.permissions, perm],
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.name.trim() || createForm.permissions.length === 0) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: createForm.name.trim(),
|
||||
expires_days: createForm.expires_days ? parseInt(createForm.expires_days, 10) : undefined,
|
||||
permissions: createForm.permissions,
|
||||
}
|
||||
const res = await api.tokens.create(payload)
|
||||
setCreateOpen(false)
|
||||
setCreatedToken(res)
|
||||
setCreateForm({ name: '', expires_days: '', permissions: ['chat'] })
|
||||
fetchTokens()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!revokeTarget) return
|
||||
setRevoking(true)
|
||||
try {
|
||||
await api.tokens.revoke(revokeTarget.id)
|
||||
setRevokeTarget(null)
|
||||
fetchTokens()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setRevoking(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!createdToken?.token) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken.token)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = createdToken.token
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div />
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建密钥
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>前缀</TableHead>
|
||||
<TableHead>权限</TableHead>
|
||||
<TableHead>最后使用</TableHead>
|
||||
<TableHead>过期时间</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tokens.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.token_prefix}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{t.permissions.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-xs">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.last_used_at ? formatDate(t.last_used_at) : '未使用'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.expires_at ? formatDate(t.expires_at) : '永不过期'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(t.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => setRevokeTarget(t)} title="撤销">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建 Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建 API 密钥</DialogTitle>
|
||||
<DialogDescription>创建新的 API 密钥用于接口调用</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
placeholder="例如: 生产环境"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>过期天数 (留空则永不过期)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={createForm.expires_days}
|
||||
onChange={(e) => setCreateForm({ ...createForm, expires_days: e.target.value })}
|
||||
placeholder="365"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>权限 *</Label>
|
||||
<div className="flex flex-wrap gap-3 mt-1">
|
||||
{allPermissions.map((perm) => (
|
||||
<label
|
||||
key={perm.key}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.permissions.includes(perm.key)}
|
||||
onChange={() => togglePermission(perm.key)}
|
||||
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{perm.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !createForm.name.trim() || createForm.permissions.length === 0}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 创建成功 Dialog */}
|
||||
<Dialog open={!!createdToken} onOpenChange={() => setCreatedToken(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-400" />
|
||||
密钥已创建
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
请立即复制并安全保存此密钥,关闭后将无法再次查看完整密钥。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md bg-muted p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">完整密钥</p>
|
||||
<p className="font-mono text-sm break-all text-foreground">
|
||||
{createdToken?.token}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-3 text-sm text-yellow-400">
|
||||
此密钥仅显示一次。请确保已保存到安全的位置。
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={copyToken} variant="outline">
|
||||
{copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||
{copied ? '已复制' : '复制密钥'}
|
||||
</Button>
|
||||
<Button onClick={() => setCreatedToken(null)}>我已保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 撤销确认 */}
|
||||
<Dialog open={!!revokeTarget} onOpenChange={() => setRevokeTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认撤销</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要撤销密钥 "{revokeTarget?.name}" 吗?使用此密钥的应用将立即失去访问权限。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleRevoke} disabled={revoking}>
|
||||
{revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
撤销
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
270
admin/src/app/(dashboard)/config/page.tsx
Normal file
270
admin/src/app/(dashboard)/config/page.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import type { ConfigItem } from '@/lib/types'
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
default: '默认值',
|
||||
env: '环境变量',
|
||||
db: '数据库',
|
||||
}
|
||||
|
||||
const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
|
||||
default: 'secondary',
|
||||
env: 'info',
|
||||
db: 'default',
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<ConfigItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
|
||||
// 编辑 Dialog
|
||||
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchConfigs = useCallback(async (category?: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = {}
|
||||
if (category && category !== 'all') params.category = category
|
||||
const res = await api.config.list(params)
|
||||
setConfigs(res)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs(activeTab)
|
||||
}, [fetchConfigs, activeTab])
|
||||
|
||||
function openEditDialog(config: ConfigItem) {
|
||||
setEditTarget(config)
|
||||
setEditValue(config.current_value !== undefined ? String(config.current_value) : '')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editTarget) return
|
||||
setSaving(true)
|
||||
try {
|
||||
let parsedValue: string | number | boolean = editValue
|
||||
if (editTarget.value_type === 'number') {
|
||||
parsedValue = parseFloat(editValue) || 0
|
||||
} else if (editTarget.value_type === 'boolean') {
|
||||
parsedValue = editValue === 'true'
|
||||
}
|
||||
await api.config.update(editTarget.id, { value: parsedValue })
|
||||
setEditTarget(null)
|
||||
fetchConfigs(activeTab)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return '-'
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const categories = ['all', 'auth', 'relay', 'model', 'system']
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 分类 Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
{categories.map((cat) => (
|
||||
<TabsTrigger key={cat} value={cat}>
|
||||
{cat === 'all' ? '全部' : cat}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无配置项
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>当前值</TableHead>
|
||||
<TableHead>默认值</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>需重启</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{configs.map((config) => (
|
||||
<TableRow key={config.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{config.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{config.key_path}</TableCell>
|
||||
<TableCell className="font-mono text-sm max-w-[200px] truncate">
|
||||
{formatValue(config.current_value)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{formatValue(config.default_value)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={sourceVariants[config.source] || 'secondary'}>
|
||||
{sourceLabels[config.source] || config.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{config.requires_restart ? (
|
||||
<Badge variant="warning">是</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">否</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
|
||||
{config.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(config)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* 编辑 Dialog */}
|
||||
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改 {editTarget?.key_path} 的值
|
||||
{editTarget?.requires_restart && (
|
||||
<span className="block mt-1 text-yellow-400 text-xs">
|
||||
注意: 修改此配置需要重启服务才能生效
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Key</Label>
|
||||
<Input value={editTarget?.key_path || ''} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>类型</Label>
|
||||
<Input value={editTarget?.value_type || ''} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
新值 {editTarget?.default_value !== undefined && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
(默认: {formatValue(editTarget.default_value)})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{editTarget?.value_type === 'boolean' ? (
|
||||
<Select value={editValue} onValueChange={setEditValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">true</SelectItem>
|
||||
<SelectItem value="false">false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type={editTarget?.value_type === 'number' ? 'number' : 'text'}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (editTarget?.default_value !== undefined) {
|
||||
setEditValue(String(editTarget.default_value))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setEditTarget(null)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
218
admin/src/app/(dashboard)/layout.tsx
Normal file
218
admin/src/app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Server,
|
||||
Cpu,
|
||||
Key,
|
||||
BarChart3,
|
||||
ArrowLeftRight,
|
||||
Settings,
|
||||
FileText,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
Menu,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
||||
import { logout } from '@/lib/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ href: '/accounts', label: '账号管理', icon: Users },
|
||||
{ href: '/providers', label: '服务商', icon: Server },
|
||||
{ href: '/models', label: '模型管理', icon: Cpu },
|
||||
{ href: '/api-keys', label: 'API 密钥', icon: Key },
|
||||
{ href: '/usage', label: '用量统计', icon: BarChart3 },
|
||||
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight },
|
||||
{ href: '/config', label: '系统配置', icon: Settings },
|
||||
{ href: '/logs', label: '操作日志', icon: FileText },
|
||||
]
|
||||
|
||||
function Sidebar({
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { account } = useAuth()
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
||||
collapsed ? 'w-16' : 'w-64',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center border-b border-border px-4">
|
||||
<Link href="/" className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold text-sm">
|
||||
Z
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-foreground">ZCLAW</span>
|
||||
<span className="text-[10px] text-muted-foreground">Admin</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href)
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-200 cursor-pointer',
|
||||
isActive
|
||||
? 'bg-muted text-green-400'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
collapsed && 'justify-center px-2',
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部折叠按钮 */}
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
collapsed && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 用户信息 */}
|
||||
{!collapsed && (
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
||||
{account?.display_name?.[0] || account?.username?.[0] || 'A'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{account?.display_name || account?.username || 'Admin'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{account?.role || 'admin'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const pathname = usePathname()
|
||||
const currentNav = navItems.find(
|
||||
(item) =>
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href),
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-border bg-background/80 backdrop-blur-sm px-6">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<MobileMenuButton />
|
||||
|
||||
{/* 页面标题 */}
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
{currentNav?.label || '仪表盘'}
|
||||
</h1>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* 通知 */}
|
||||
<button
|
||||
className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenuButton() {
|
||||
// Placeholder for mobile menu toggle
|
||||
return (
|
||||
<button
|
||||
className="mr-3 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 lg:hidden cursor-pointer"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 flex-col transition-all duration-300',
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
436
admin/src/app/(dashboard)/models/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
import type { Model, Provider } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
interface ModelForm {
|
||||
provider_id: string
|
||||
model_id: string
|
||||
alias: string
|
||||
context_window: string
|
||||
max_output_tokens: string
|
||||
supports_streaming: boolean
|
||||
supports_vision: boolean
|
||||
enabled: boolean
|
||||
pricing_input: string
|
||||
pricing_output: string
|
||||
}
|
||||
|
||||
const emptyForm: ModelForm = {
|
||||
provider_id: '',
|
||||
model_id: '',
|
||||
alias: '',
|
||||
context_window: '4096',
|
||||
max_output_tokens: '4096',
|
||||
supports_streaming: true,
|
||||
supports_vision: false,
|
||||
enabled: true,
|
||||
pricing_input: '',
|
||||
pricing_output: '',
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [models, setModels] = useState<Model[]>([])
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [providerFilter, setProviderFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<Model | null>(null)
|
||||
const [form, setForm] = useState<ModelForm>(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除
|
||||
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (providerFilter !== 'all') params.provider_id = providerFilter
|
||||
const res = await api.models.list(params)
|
||||
setModels(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, providerFilter])
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.providers.list({ page: 1, page_size: 100 })
|
||||
setProviders(res.items)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels()
|
||||
fetchProviders()
|
||||
}, [fetchModels, fetchProviders])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditTarget(null)
|
||||
setForm(emptyForm)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(model: Model) {
|
||||
setEditTarget(model)
|
||||
setForm({
|
||||
provider_id: model.provider_id,
|
||||
model_id: model.model_id,
|
||||
alias: model.alias,
|
||||
context_window: model.context_window.toString(),
|
||||
max_output_tokens: model.max_output_tokens.toString(),
|
||||
supports_streaming: model.supports_streaming,
|
||||
supports_vision: model.supports_vision,
|
||||
enabled: model.enabled,
|
||||
pricing_input: model.pricing_input.toString(),
|
||||
pricing_output: model.pricing_output.toString(),
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.model_id.trim() || !form.provider_id) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
provider_id: form.provider_id,
|
||||
model_id: form.model_id.trim(),
|
||||
alias: form.alias.trim(),
|
||||
context_window: parseInt(form.context_window, 10) || 4096,
|
||||
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
|
||||
supports_streaming: form.supports_streaming,
|
||||
supports_vision: form.supports_vision,
|
||||
enabled: form.enabled,
|
||||
pricing_input: parseFloat(form.pricing_input) || 0,
|
||||
pricing_output: parseFloat(form.pricing_output) || 0,
|
||||
}
|
||||
if (editTarget) {
|
||||
await api.models.update(editTarget.id, payload)
|
||||
} else {
|
||||
await api.models.create(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchModels()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.models.delete(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
fetchModels()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="按服务商筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部服务商</SelectItem>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.display_name || p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模型 ID</TableHead>
|
||||
<TableHead>别名</TableHead>
|
||||
<TableHead>服务商</TableHead>
|
||||
<TableHead>上下文窗口</TableHead>
|
||||
<TableHead>最大输出</TableHead>
|
||||
<TableHead>流式</TableHead>
|
||||
<TableHead>视觉</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{models.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
|
||||
<TableCell>{m.alias || '-'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(m.context_window)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(m.max_output_tokens)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
|
||||
{m.supports_streaming ? '是' : '否'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
|
||||
{m.supports_vision ? '是' : '否'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.enabled ? 'success' : 'destructive'}>
|
||||
{m.enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑 Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="space-y-2">
|
||||
<Label>服务商 *</Label>
|
||||
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择服务商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.display_name || p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>模型 ID *</Label>
|
||||
<Input
|
||||
value={form.model_id}
|
||||
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
|
||||
placeholder="gpt-4o"
|
||||
disabled={!!editTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>别名</Label>
|
||||
<Input
|
||||
value={form.alias}
|
||||
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
||||
placeholder="GPT-4o"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>上下文窗口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.context_window}
|
||||
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>最大输出 Tokens</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.max_output_tokens}
|
||||
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Input 定价 ($/1M tokens)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.pricing_input}
|
||||
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Output 定价 ($/1M tokens)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.pricing_output}
|
||||
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
|
||||
<Label>流式</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
|
||||
<Label>视觉</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
||||
<Label>启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
336
admin/src/app/(dashboard)/page.tsx
Normal file
336
admin/src/app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Users,
|
||||
Server,
|
||||
ArrowLeftRight,
|
||||
Zap,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { formatNumber, formatDate } from '@/lib/utils'
|
||||
import type {
|
||||
DashboardStats,
|
||||
UsageRecord,
|
||||
OperationLog,
|
||||
} from '@/lib/types'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color, subtitle }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-foreground">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${color}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const variantMap: Record<string, 'success' | 'destructive' | 'warning' | 'info' | 'secondary'> = {
|
||||
active: 'success',
|
||||
completed: 'success',
|
||||
disabled: 'destructive',
|
||||
failed: 'destructive',
|
||||
processing: 'info',
|
||||
queued: 'warning',
|
||||
suspended: 'destructive',
|
||||
}
|
||||
return (
|
||||
<Badge variant={variantMap[status] || 'secondary'}>{status}</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [usageData, setUsageData] = useState<UsageRecord[]>([])
|
||||
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [statsRes, usageRes, logsRes] = await Promise.allSettled([
|
||||
api.stats.dashboard(),
|
||||
api.usage.daily({ days: 30 }),
|
||||
api.logs.list({ page: 1, page_size: 5 }),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||
if (usageRes.status === 'fulfilled') setUsageData(usageRes.value)
|
||||
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value.items)
|
||||
} catch (err) {
|
||||
setError('加载数据失败,请检查后端服务是否启动')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 text-sm text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = usageData.map((r) => ({
|
||||
day: r.day.slice(5), // MM-DD
|
||||
请求量: r.count,
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="总账号数"
|
||||
value={stats?.total_accounts ?? '-'}
|
||||
icon={<Users className="h-5 w-5 text-blue-400" />}
|
||||
color="bg-blue-500/10"
|
||||
subtitle={`活跃 ${stats?.active_accounts ?? 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
title="活跃服务商"
|
||||
value={stats?.active_providers ?? '-'}
|
||||
icon={<Server className="h-5 w-5 text-green-400" />}
|
||||
color="bg-green-500/10"
|
||||
subtitle={`模型 ${stats?.active_models ?? 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
title="今日请求"
|
||||
value={stats?.tasks_today ?? '-'}
|
||||
icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />}
|
||||
color="bg-purple-500/10"
|
||||
subtitle="中转任务"
|
||||
/>
|
||||
<StatCard
|
||||
title="今日 Token"
|
||||
value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))}
|
||||
icon={<Zap className="h-5 w-5 text-orange-400" />}
|
||||
color="bg-orange-500/10"
|
||||
subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 图表 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* 请求趋势 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
请求趋势 (30 天)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#22C55E" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0F172A',
|
||||
border: '1px solid #1E293B',
|
||||
borderRadius: '8px',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="请求量"
|
||||
stroke="#22C55E"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorRequests)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Token 用量 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-orange-400" />
|
||||
Token 用量 (30 天)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0F172A',
|
||||
border: '1px solid #1E293B',
|
||||
borderRadius: '8px',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }}
|
||||
/>
|
||||
<Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 最近操作日志 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">最近操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentLogs.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>账号 ID</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>目标类型</TableHead>
|
||||
<TableHead>目标 ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{log.account_id.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.action}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{log.target_type}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{log.target_id.slice(0, 8)}...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无操作日志
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
386
admin/src/app/(dashboard)/providers/page.tsx
Normal file
386
admin/src/app/(dashboard)/providers/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate, maskApiKey } from '@/lib/utils'
|
||||
import type { Provider } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
interface ProviderForm {
|
||||
name: string
|
||||
display_name: string
|
||||
base_url: string
|
||||
api_protocol: 'openai' | 'anthropic'
|
||||
api_key: string
|
||||
enabled: boolean
|
||||
rate_limit_rpm: string
|
||||
rate_limit_tpm: string
|
||||
}
|
||||
|
||||
const emptyForm: ProviderForm = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
base_url: '',
|
||||
api_protocol: 'openai',
|
||||
api_key: '',
|
||||
enabled: true,
|
||||
rate_limit_rpm: '',
|
||||
rate_limit_tpm: '',
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 创建/编辑 Dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<Provider | null>(null)
|
||||
const [form, setForm] = useState<ProviderForm>(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除确认 Dialog
|
||||
const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.providers.list({ page, page_size: PAGE_SIZE })
|
||||
setProviders(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders()
|
||||
}, [fetchProviders])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditTarget(null)
|
||||
setForm(emptyForm)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(provider: Provider) {
|
||||
setEditTarget(provider)
|
||||
setForm({
|
||||
name: provider.name,
|
||||
display_name: provider.display_name,
|
||||
base_url: provider.base_url,
|
||||
api_protocol: provider.api_protocol,
|
||||
api_key: provider.api_key || '',
|
||||
enabled: provider.enabled,
|
||||
rate_limit_rpm: provider.rate_limit_rpm?.toString() || '',
|
||||
rate_limit_tpm: provider.rate_limit_tpm?.toString() || '',
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name.trim() || !form.base_url.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
base_url: form.base_url.trim(),
|
||||
api_protocol: form.api_protocol,
|
||||
api_key: form.api_key.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
rate_limit_rpm: form.rate_limit_rpm ? parseInt(form.rate_limit_rpm, 10) : undefined,
|
||||
rate_limit_tpm: form.rate_limit_tpm ? parseInt(form.rate_limit_tpm, 10) : undefined,
|
||||
}
|
||||
if (editTarget) {
|
||||
await api.providers.update(editTarget.id, payload)
|
||||
} else {
|
||||
await api.providers.create(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchProviders()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.providers.delete(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
fetchProviders()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div />
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建服务商
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>显示名</TableHead>
|
||||
<TableHead>Base URL</TableHead>
|
||||
<TableHead>协议</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead>RPM 限制</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{providers.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell>{p.display_name || '-'}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{p.base_url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={p.api_protocol === 'openai' ? 'default' : 'info'}>
|
||||
{p.api_protocol}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{maskApiKey(p.api_key)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={p.enabled ? 'success' : 'secondary'}>
|
||||
{p.enabled ? '是' : '否'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.rate_limit_rpm ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(p.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(p)} title="删除">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑 Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? '编辑服务商' : '新建服务商'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editTarget ? '修改服务商配置' : '添加新的 AI 服务商'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="例如: openai"
|
||||
disabled={!!editTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>显示名</Label>
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
placeholder="例如: OpenAI"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Base URL *</Label>
|
||||
<Input
|
||||
value={form.base_url}
|
||||
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API 协议</Label>
|
||||
<Select value={form.api_protocol} onValueChange={(v) => setForm({ ...form, api_protocol: v as 'openai' | 'anthropic' })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
placeholder={editTarget ? '留空则不修改' : 'sk-...'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
|
||||
/>
|
||||
<Label>启用</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>RPM 限制</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.rate_limit_rpm}
|
||||
onChange={(e) => setForm({ ...form, rate_limit_rpm: e.target.value })}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>TPM 限制</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.rate_limit_tpm}
|
||||
onChange={(e) => setForm({ ...form, rate_limit_tpm: e.target.value })}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form.name.trim() || !form.base_url.trim()}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 Dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除服务商 "{deleteTarget?.display_name || deleteTarget?.name}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
245
admin/src/app/(dashboard)/relay/page.tsx
Normal file
245
admin/src/app/(dashboard)/relay/page.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate, formatNumber } from '@/lib/utils'
|
||||
import type { RelayTask } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const statusVariants: Record<string, 'success' | 'info' | 'warning' | 'destructive' | 'secondary'> = {
|
||||
queued: 'warning',
|
||||
processing: 'info',
|
||||
completed: 'success',
|
||||
failed: 'destructive',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
export default function RelayPage() {
|
||||
const [tasks, setTasks] = useState<RelayTask[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
const res = await api.relay.list(params)
|
||||
setTasks(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks()
|
||||
}, [fetchTasks])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 筛选 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="queued">排队中</SelectItem>
|
||||
<SelectItem value="processing">处理中</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>任务 ID</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>重试次数</TableHead>
|
||||
<TableHead>Input Tokens</TableHead>
|
||||
<TableHead>Output Tokens</TableHead>
|
||||
<TableHead>错误信息</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<>
|
||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggleExpand(task.id)}>
|
||||
<TableCell>
|
||||
{expandedId === task.id ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{task.id.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{task.model_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariants[task.status] || 'secondary'}>
|
||||
{statusLabels[task.status] || task.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{task.priority}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{task.attempt_count}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(task.input_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(task.output_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-xs text-destructive">
|
||||
{task.error_message || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(task.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedId === task.id && (
|
||||
<TableRow key={`${task.id}-detail`}>
|
||||
<TableCell colSpan={10} className="bg-muted/20 px-8 py-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">任务 ID</p>
|
||||
<p className="font-mono text-xs">{task.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">账号 ID</p>
|
||||
<p className="font-mono text-xs">{task.account_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">服务商 ID</p>
|
||||
<p className="font-mono text-xs">{task.provider_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">模型 ID</p>
|
||||
<p className="font-mono text-xs">{task.model_id}</p>
|
||||
</div>
|
||||
{task.queued_at && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">排队时间</p>
|
||||
<p className="font-mono text-xs">{formatDate(task.queued_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.started_at && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">开始时间</p>
|
||||
<p className="font-mono text-xs">{formatDate(task.started_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.completed_at && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">完成时间</p>
|
||||
<p className="font-mono text-xs">{formatDate(task.completed_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.error_message && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">错误信息</p>
|
||||
<p className="text-xs text-destructive mt-1">{task.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
235
admin/src/app/(dashboard)/usage/page.tsx
Normal file
235
admin/src/app/(dashboard)/usage/page.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Loader2, Zap } from 'lucide-react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
import type { UsageRecord, UsageByModel } from '@/lib/types'
|
||||
|
||||
export default function UsagePage() {
|
||||
const [days, setDays] = useState(7)
|
||||
const [dailyData, setDailyData] = useState<UsageRecord[]>([])
|
||||
const [modelData, setModelData] = useState<UsageByModel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [dailyRes, modelRes] = await Promise.allSettled([
|
||||
api.usage.daily({ days }),
|
||||
api.usage.byModel({ days }),
|
||||
])
|
||||
if (dailyRes.status === 'fulfilled') setDailyData(dailyRes.value)
|
||||
else throw new Error('Failed to fetch daily usage')
|
||||
if (modelRes.status === 'fulfilled') setModelData(modelRes.value)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [days])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const lineChartData = dailyData.map((r) => ({
|
||||
day: r.day.slice(5),
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
const barChartData = modelData.map((r) => ({
|
||||
model: r.model_id,
|
||||
请求量: r.count,
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
const totalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
|
||||
const totalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
|
||||
const totalRequests = dailyData.reduce((s, r) => s + r.count, 0)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button onClick={() => fetchData()} className="mt-4 text-sm text-primary hover:underline cursor-pointer">
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 时间范围 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">时间范围:</span>
|
||||
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">最近 7 天</SelectItem>
|
||||
<SelectItem value="30">最近 30 天</SelectItem>
|
||||
<SelectItem value="90">最近 90 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 汇总统计 */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">总请求数</p>
|
||||
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||
{formatNumber(totalRequests)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">Input Tokens</p>
|
||||
<p className="mt-1 text-2xl font-bold text-blue-400">
|
||||
{formatNumber(totalInput)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">Output Tokens</p>
|
||||
<p className="mt-1 text-2xl font-bold text-orange-400">
|
||||
{formatNumber(totalOutput)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Token 用量趋势 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
Token 用量趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{lineChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={lineChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0F172A',
|
||||
border: '1px solid #1E293B',
|
||||
borderRadius: '8px',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 按模型分布 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">按模型分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{barChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={barChartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="model"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0F172A',
|
||||
border: '1px solid #1E293B',
|
||||
borderRadius: '8px',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||
<Bar dataKey="Input" fill="#3B82F6" radius={[0, 2, 2, 0]} />
|
||||
<Bar dataKey="Output" fill="#F97316" radius={[0, 2, 2, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
admin/src/app/globals.css
Normal file
66
admin/src/app/globals.css
Normal file
@@ -0,0 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 222 47% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 142 71% 45%;
|
||||
--primary-foreground: 222 47% 5%;
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 215 28% 23%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 142 71% 45%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted)) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-card {
|
||||
@apply bg-card/80 backdrop-blur-sm border border-border rounded-lg;
|
||||
}
|
||||
}
|
||||
27
admin/src/app/layout.tsx
Normal file
27
admin/src/app/layout.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ZCLAW Admin',
|
||||
description: 'ZCLAW AI Agent 管理平台',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN" className="dark">
|
||||
<head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
199
admin/src/app/login/page.tsx
Normal file
199
admin/src/app/login/page.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Lock, User, Loader2, Eye, EyeOff } from 'lucide-react'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { login } from '@/lib/auth'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!username.trim()) {
|
||||
setError('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (!password.trim()) {
|
||||
setError('请输入密码')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.auth.login({ username: username.trim(), password })
|
||||
login(res.token, res.account)
|
||||
router.replace('/')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message || '登录失败,请检查用户名和密码')
|
||||
} else {
|
||||
setError('网络错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* 左侧品牌区域 */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* 装饰性背景 */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-green-500/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-green-500/8 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-green-500/10 rounded-full" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] border border-green-500/10 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* 品牌内容 */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold tracking-tight text-foreground mb-4">
|
||||
ZCLAW
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground font-light">
|
||||
AI Agent 管理平台
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<div className="h-px w-12 bg-green-500/50" />
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<div className="h-px w-12 bg-green-500/50" />
|
||||
</div>
|
||||
<p className="mt-6 text-sm text-muted-foreground/60 max-w-sm">
|
||||
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧登录表单 */}
|
||||
<div className="flex w-full lg:w-1/2 items-center justify-center p-8">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
{/* 移动端 Logo */}
|
||||
<div className="lg:hidden text-center">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground mb-2">
|
||||
ZCLAW
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">AI Agent 管理平台</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-foreground">登录</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
输入您的账号信息以继续
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 用户名 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 记住我 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="remember"
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember"
|
||||
className="text-sm text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
记住我
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex h-10 w-full items-center justify-center rounded-md bg-primary text-primary-foreground font-medium text-sm shadow-sm transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
admin/src/components/auth-guard.tsx
Normal file
48
admin/src/components/auth-guard.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated, getAccount } from '@/lib/auth'
|
||||
import type { AccountPublic } from '@/lib/types'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const [authorized, setAuthorized] = useState(false)
|
||||
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
setAccount(getAccount())
|
||||
setAuthorized(true)
|
||||
}, [router])
|
||||
|
||||
if (!authorized) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const acc = getAccount()
|
||||
setAccount(acc)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return { account, loading, isAuthenticated: isAuthenticated() }
|
||||
}
|
||||
42
admin/src/components/ui/badge.tsx
Normal file
42
admin/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary/15 text-primary',
|
||||
secondary:
|
||||
'border-transparent bg-muted text-muted-foreground',
|
||||
destructive:
|
||||
'border-transparent bg-destructive/15 text-destructive',
|
||||
outline:
|
||||
'text-foreground border-border',
|
||||
success:
|
||||
'border-transparent bg-green-500/15 text-green-400',
|
||||
warning:
|
||||
'border-transparent bg-yellow-500/15 text-yellow-400',
|
||||
info:
|
||||
'border-transparent bg-blue-500/15 text-blue-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
56
admin/src/components/ui/button.tsx
Normal file
56
admin/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm',
|
||||
secondary:
|
||||
'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-red-600 shadow-sm',
|
||||
outline:
|
||||
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
link:
|
||||
'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
75
admin/src/components/ui/card.tsx
Normal file
75
admin/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
118
admin/src/components/ui/dialog.tsx
Normal file
118
admin/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||
'gap-4 border border-border bg-card p-6 shadow-lg duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
28
admin/src/components/ui/input.tsx
Normal file
28
admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors duration-200',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
23
admin/src/components/ui/label.tsx
Normal file
23
admin/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LabelProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
export { Label }
|
||||
100
admin/src/components/ui/select.tsx
Normal file
100
admin/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-1 focus:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
}
|
||||
30
admin/src/components/ui/separator.tsx
Normal file
30
admin/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
32
admin/src/components/ui/switch.tsx
Normal file
32
admin/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200',
|
||||
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
119
admin/src/components/ui/table.tsx
Normal file
119
admin/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto scrollbar-thin">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors duration-200 hover:bg-muted/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
57
admin/src/components/ui/tabs.tsx
Normal file
57
admin/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
31
admin/src/components/ui/tooltip.tsx
Normal file
31
admin/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-card border border-border px-3 py-1.5 text-sm text-foreground shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
284
admin/src/lib/api-client.ts
Normal file
284
admin/src/lib/api-client.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
// ============================================================
|
||||
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
||||
// ============================================================
|
||||
|
||||
import { getToken, logout } from './auth'
|
||||
import type {
|
||||
AccountPublic,
|
||||
ApiError,
|
||||
ConfigItem,
|
||||
CreateTokenRequest,
|
||||
DashboardStats,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
Model,
|
||||
OperationLog,
|
||||
PaginatedResponse,
|
||||
Provider,
|
||||
RelayTask,
|
||||
TokenInfo,
|
||||
UsageByModel,
|
||||
UsageRecord,
|
||||
} from './types'
|
||||
|
||||
// ── 错误类 ────────────────────────────────────────────────
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: ApiError,
|
||||
) {
|
||||
super(body.message || `Request failed with status ${status}`)
|
||||
this.name = 'ApiRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 基础请求 ──────────────────────────────────────────────
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
logout()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let errorBody: ApiError
|
||||
try {
|
||||
errorBody = await res.json()
|
||||
} catch {
|
||||
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
|
||||
}
|
||||
throw new ApiRequestError(res.status, errorBody)
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// ── API 客户端 ────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
// ── 认证 ──────────────────────────────────────────────
|
||||
auth: {
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('POST', '/api/auth/login', data)
|
||||
},
|
||||
|
||||
async register(data: {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
display_name?: string
|
||||
}): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('POST', '/api/auth/register', data)
|
||||
},
|
||||
|
||||
async me(): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('GET', '/api/auth/me')
|
||||
},
|
||||
},
|
||||
|
||||
// ── 账号管理 ──────────────────────────────────────────
|
||||
accounts: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
role?: string
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<AccountPublic>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<AccountPublic>>('GET', `/api/accounts${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('GET', `/api/accounts/${id}`)
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
||||
): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data)
|
||||
},
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
data: { status: AccountPublic['status'] },
|
||||
): Promise<void> {
|
||||
return request<void>('PATCH', `/api/accounts/${id}/status`, data)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 服务商管理 ────────────────────────────────────────
|
||||
providers: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<Provider>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<Provider>>('GET', `/api/providers${qs}`)
|
||||
},
|
||||
|
||||
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
||||
return request<Provider>('POST', '/api/providers', data)
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
||||
): Promise<Provider> {
|
||||
return request<Provider>('PATCH', `/api/providers/${id}`, data)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/providers/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 模型管理 ──────────────────────────────────────────
|
||||
models: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
provider_id?: string
|
||||
}): Promise<PaginatedResponse<Model>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`)
|
||||
},
|
||||
|
||||
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||
return request<Model>('POST', '/api/models', data)
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||
return request<Model>('PATCH', `/api/models/${id}`, data)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/models/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── API 密钥 ──────────────────────────────────────────
|
||||
tokens: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<TokenInfo>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`)
|
||||
},
|
||||
|
||||
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||
return request<TokenInfo>('POST', '/api/tokens', data)
|
||||
},
|
||||
|
||||
async revoke(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/tokens/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 用量统计 ──────────────────────────────────────────
|
||||
usage: {
|
||||
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<UsageRecord[]>('GET', `/api/usage/daily${qs}`)
|
||||
},
|
||||
|
||||
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<UsageByModel[]>('GET', `/api/usage/by-model${qs}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 中转任务 ──────────────────────────────────────────
|
||||
relay: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<RelayTask>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<RelayTask> {
|
||||
return request<RelayTask>('GET', `/api/relay/tasks/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 系统配置 ──────────────────────────────────────────
|
||||
config: {
|
||||
async list(params?: {
|
||||
category?: string
|
||||
}): Promise<ConfigItem[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<ConfigItem[]>('GET', `/api/config${qs}`)
|
||||
},
|
||||
|
||||
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
|
||||
return request<ConfigItem>('PATCH', `/api/config/${id}`, data)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 操作日志 ──────────────────────────────────────────
|
||||
logs: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
action?: string
|
||||
}): Promise<PaginatedResponse<OperationLog>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<OperationLog>>('GET', `/api/logs${qs}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 仪表盘 ────────────────────────────────────────────
|
||||
stats: {
|
||||
async dashboard(): Promise<DashboardStats> {
|
||||
return request<DashboardStats>('GET', '/api/stats/dashboard')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────
|
||||
|
||||
function buildQueryString(params?: Record<string, unknown>): string {
|
||||
if (!params) return ''
|
||||
const entries = Object.entries(params).filter(
|
||||
([, v]) => v !== undefined && v !== null && v !== '',
|
||||
)
|
||||
if (entries.length === 0) return ''
|
||||
const qs = entries
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&')
|
||||
return `?${qs}`
|
||||
}
|
||||
45
admin/src/lib/auth.ts
Normal file
45
admin/src/lib/auth.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ============================================================
|
||||
// ZCLAW SaaS Admin — JWT Token 管理
|
||||
// ============================================================
|
||||
|
||||
import type { AccountPublic } from './types'
|
||||
|
||||
const TOKEN_KEY = 'zclaw_admin_token'
|
||||
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||
|
||||
/** 保存登录凭证 */
|
||||
export function login(token: string, account: AccountPublic): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||
}
|
||||
|
||||
/** 清除登录凭证 */
|
||||
export function logout(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(ACCOUNT_KEY)
|
||||
}
|
||||
|
||||
/** 获取 JWT token */
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息 */
|
||||
export function getAccount(): AccountPublic | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as AccountPublic
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否已认证 */
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
169
admin/src/lib/types.ts
Normal file
169
admin/src/lib/types.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
// ============================================================
|
||||
// ZCLAW SaaS Admin — 全局类型定义
|
||||
// ============================================================
|
||||
|
||||
/** 公共账号信息 */
|
||||
export interface AccountPublic {
|
||||
id: string
|
||||
username: string
|
||||
email: string
|
||||
display_name: string
|
||||
role: 'super_admin' | 'admin' | 'user'
|
||||
status: 'active' | 'disabled' | 'suspended'
|
||||
totp_enabled: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 登录请求 */
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
account: AccountPublic
|
||||
}
|
||||
|
||||
/** 注册请求 */
|
||||
export interface RegisterRequest {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
display_name?: string
|
||||
}
|
||||
|
||||
/** 分页响应 */
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/** 服务商 (Provider) */
|
||||
export interface Provider {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
api_key?: string
|
||||
base_url: string
|
||||
api_protocol: 'openai' | 'anthropic'
|
||||
enabled: boolean
|
||||
rate_limit_rpm?: number
|
||||
rate_limit_tpm?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 模型 */
|
||||
export interface Model {
|
||||
id: string
|
||||
provider_id: string
|
||||
model_id: string
|
||||
alias: string
|
||||
context_window: number
|
||||
max_output_tokens: number
|
||||
supports_streaming: boolean
|
||||
supports_vision: boolean
|
||||
enabled: boolean
|
||||
pricing_input: number
|
||||
pricing_output: number
|
||||
}
|
||||
|
||||
/** API 密钥信息 */
|
||||
export interface TokenInfo {
|
||||
id: string
|
||||
name: string
|
||||
token_prefix: string
|
||||
permissions: string[]
|
||||
last_used_at?: string
|
||||
expires_at?: string
|
||||
created_at: string
|
||||
token?: string
|
||||
}
|
||||
|
||||
/** 创建 Token 请求 */
|
||||
export interface CreateTokenRequest {
|
||||
name: string
|
||||
expires_days?: number
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
/** 中转任务 */
|
||||
export interface RelayTask {
|
||||
id: string
|
||||
account_id: string
|
||||
provider_id: string
|
||||
model_id: string
|
||||
status: 'queued' | 'processing' | 'completed' | 'failed'
|
||||
priority: number
|
||||
attempt_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
error_message?: string
|
||||
queued_at?: string
|
||||
started_at?: string
|
||||
completed_at?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 用量记录 */
|
||||
export interface UsageRecord {
|
||||
day: string
|
||||
count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
}
|
||||
|
||||
/** 按模型用量 */
|
||||
export interface UsageByModel {
|
||||
model_id: string
|
||||
count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
}
|
||||
|
||||
/** 系统配置项 */
|
||||
export interface ConfigItem {
|
||||
id: string
|
||||
category: string
|
||||
key_path: string
|
||||
value_type: 'string' | 'number' | 'boolean'
|
||||
current_value?: string | number | boolean
|
||||
default_value?: string | number | boolean
|
||||
source: 'default' | 'env' | 'db'
|
||||
description?: string
|
||||
requires_restart: boolean
|
||||
}
|
||||
|
||||
/** 操作日志 */
|
||||
export interface OperationLog {
|
||||
id: string
|
||||
account_id: string
|
||||
action: string
|
||||
target_type: string
|
||||
target_id: string
|
||||
details?: string
|
||||
ip_address?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 仪表盘统计 */
|
||||
export interface DashboardStats {
|
||||
total_accounts: number
|
||||
active_accounts: number
|
||||
tasks_today: number
|
||||
active_providers: number
|
||||
active_models: number
|
||||
tokens_today_input: number
|
||||
tokens_today_output: number
|
||||
}
|
||||
|
||||
/** API 错误响应 */
|
||||
export interface ApiError {
|
||||
error: string
|
||||
message: string
|
||||
status?: number
|
||||
}
|
||||
34
admin/src/lib/utils.ts
Normal file
34
admin/src/lib/utils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = new Date(date)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
export function maskApiKey(key?: string): string {
|
||||
if (!key) return '-'
|
||||
if (key.length <= 8) return '****'
|
||||
return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
62
admin/tailwind.config.ts
Normal file
62
admin/tailwind.config.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: '#020617',
|
||||
foreground: '#F8FAFC',
|
||||
card: {
|
||||
DEFAULT: '#0F172A',
|
||||
foreground: '#F8FAFC',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: '#22C55E',
|
||||
foreground: '#020617',
|
||||
hover: '#16A34A',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: '#1E293B',
|
||||
foreground: '#94A3B8',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#334155',
|
||||
foreground: '#F8FAFC',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: '#EF4444',
|
||||
foreground: '#F8FAFC',
|
||||
},
|
||||
border: '#1E293B',
|
||||
input: '#1E293B',
|
||||
ring: '#22C55E',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
keyframes: {
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'slide-in': {
|
||||
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fade-in 0.2s ease-out',
|
||||
'slide-in': 'slide-in 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
21
admin/tsconfig.json
Normal file
21
admin/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
46
crates/zclaw-saas/Cargo.toml
Normal file
46
crates/zclaw-saas/Cargo.toml
Normal file
@@ -0,0 +1,46 @@
|
||||
[package]
|
||||
name = "zclaw-saas"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
description = "ZCLAW SaaS backend - account, API config, relay, migration"
|
||||
|
||||
[[bin]]
|
||||
name = "zclaw-saas"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
libsqlite3-sys = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
secrecy = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
url = "2"
|
||||
|
||||
axum = { workspace = true }
|
||||
axum-extra = { workspace = true }
|
||||
tower = { workspace = true }
|
||||
tower-http = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
totp-rs = { workspace = true }
|
||||
urlencoding = "2"
|
||||
data-encoding = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
275
crates/zclaw-saas/src/account/handlers.rs
Normal file
275
crates/zclaw-saas/src/account/handlers.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
//! 账号管理 HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::{log_operation, check_permission};
|
||||
use super::{types::*, service};
|
||||
|
||||
fn require_admin(ctx: &AuthContext) -> SaasResult<()> {
|
||||
check_permission(ctx, "account:admin")
|
||||
}
|
||||
|
||||
/// GET /api/v1/accounts (admin only)
|
||||
pub async fn list_accounts(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ListAccountsQuery>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<PaginatedResponse<serde_json::Value>>> {
|
||||
require_admin(&ctx)?;
|
||||
service::list_accounts(&state.db, &query).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/accounts/:id
|
||||
pub async fn get_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
// 只能查看自己,或 admin 查看任何人
|
||||
if id != ctx.account_id {
|
||||
require_admin(&ctx)?;
|
||||
}
|
||||
service::get_account(&state.db, &id).await.map(Json)
|
||||
}
|
||||
|
||||
/// PUT /api/v1/accounts/:id (admin or self for limited fields)
|
||||
pub async fn update_account(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<UpdateAccountRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
let is_self_update = id == ctx.account_id;
|
||||
|
||||
// 非管理员只能修改自己的资料
|
||||
if !is_self_update {
|
||||
require_admin(&ctx)?;
|
||||
}
|
||||
|
||||
// 安全限制: 非管理员修改自己时,剥离 role 字段防止自角色提升
|
||||
let safe_req = if is_self_update && !ctx.permissions.contains(&"admin:full".to_string()) {
|
||||
UpdateAccountRequest {
|
||||
role: None,
|
||||
..req
|
||||
}
|
||||
} else {
|
||||
req
|
||||
};
|
||||
|
||||
let result = service::update_account(&state.db, &id, &safe_req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "account.update", "account", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
/// PATCH /api/v1/accounts/:id/status (admin only)
|
||||
pub async fn update_status(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<UpdateStatusRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
require_admin(&ctx)?;
|
||||
service::update_account_status(&state.db, &id, &req.status).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "account.update_status", "account", &id,
|
||||
Some(serde_json::json!({"status": &req.status})), ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/tokens
|
||||
pub async fn list_tokens(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<TokenInfo>>> {
|
||||
service::list_api_tokens(&state.db, &ctx.account_id).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/tokens
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateTokenRequest>,
|
||||
) -> SaasResult<Json<TokenInfo>> {
|
||||
let token = service::create_api_token(&state.db, &ctx.account_id, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "token.create", "api_token", &token.id,
|
||||
Some(serde_json::json!({"name": &req.name})), ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(token))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/tokens/:id
|
||||
pub async fn revoke_token(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
service::revoke_api_token(&state.db, &id, &ctx.account_id).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "token.revoke", "api_token", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/logs/operations (admin only)
|
||||
pub async fn list_operation_logs(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
||||
require_admin(&ctx)?;
|
||||
let page: i64 = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1);
|
||||
let page_size: i64 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(50);
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
let rows: Vec<(i64, Option<String>, String, Option<String>, Option<String>, Option<String>, Option<String>, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, account_id, action, target_type, target_id, details, ip_address, created_at
|
||||
FROM operation_logs ORDER BY created_at DESC LIMIT ?1 OFFSET ?2"
|
||||
)
|
||||
.bind(page_size)
|
||||
.bind(offset)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows.into_iter().map(|(id, account_id, action, target_type, target_id, details, ip_address, created_at)| {
|
||||
serde_json::json!({
|
||||
"id": id, "account_id": account_id, "action": action,
|
||||
"target_type": target_type, "target_id": target_id,
|
||||
"details": details.and_then(|d| serde_json::from_str::<serde_json::Value>(&d).ok()),
|
||||
"ip_address": ip_address, "created_at": created_at,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
/// GET /api/v1/stats/dashboard — 仪表盘聚合统计 (需要 admin 权限)
|
||||
pub async fn dashboard_stats(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
require_admin(&ctx)?;
|
||||
|
||||
let total_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts")
|
||||
.fetch_one(&state.db).await?;
|
||||
let active_accounts: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM accounts WHERE status = 'active'")
|
||||
.fetch_one(&state.db).await?;
|
||||
let tasks_today: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM relay_tasks WHERE date(created_at) = date('now')"
|
||||
).fetch_one(&state.db).await?;
|
||||
let active_providers: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM providers WHERE enabled = 1")
|
||||
.fetch_one(&state.db).await?;
|
||||
let active_models: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM models WHERE enabled = 1")
|
||||
.fetch_one(&state.db).await?;
|
||||
let tokens_today_input: (i64,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(input_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
||||
).fetch_one(&state.db).await?;
|
||||
let tokens_today_output: (i64,) = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(output_tokens), 0) FROM usage_records WHERE date(created_at) = date('now')"
|
||||
).fetch_one(&state.db).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total_accounts": total_accounts.0,
|
||||
"active_accounts": active_accounts.0,
|
||||
"tasks_today": tasks_today.0,
|
||||
"active_providers": active_providers.0,
|
||||
"active_models": active_models.0,
|
||||
"tokens_today_input": tokens_today_input.0,
|
||||
"tokens_today_output": tokens_today_output.0,
|
||||
})))
|
||||
}
|
||||
|
||||
// ============ Devices ============
|
||||
|
||||
/// POST /api/v1/devices/register — 注册或更新设备
|
||||
pub async fn register_device(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
let device_id = req.get("device_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?;
|
||||
let device_name = req.get("device_name").and_then(|v| v.as_str()).unwrap_or("Unknown");
|
||||
let platform = req.get("platform").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let app_version = req.get("app_version").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let device_uuid = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// UPSERT: 已存在则更新 last_seen_at,不存在则插入
|
||||
sqlx::query(
|
||||
"INSERT INTO devices (id, account_id, device_id, device_name, platform, app_version, last_seen_at, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
|
||||
ON CONFLICT(account_id, device_id) DO UPDATE SET
|
||||
device_name = ?4, platform = ?5, app_version = ?6, last_seen_at = ?7"
|
||||
)
|
||||
.bind(&device_uuid)
|
||||
.bind(&ctx.account_id)
|
||||
.bind(device_id)
|
||||
.bind(device_name)
|
||||
.bind(platform)
|
||||
.bind(app_version)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "device.register", "device", device_id,
|
||||
Some(serde_json::json!({"device_name": device_name, "platform": platform})),
|
||||
ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "device_id": device_id})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/devices/heartbeat — 设备心跳
|
||||
pub async fn device_heartbeat(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
let device_id = req.get("device_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| SaasError::InvalidInput("缺少 device_id".into()))?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = sqlx::query(
|
||||
"UPDATE devices SET last_seen_at = ?1 WHERE account_id = ?2 AND device_id = ?3"
|
||||
)
|
||||
.bind(&now)
|
||||
.bind(&ctx.account_id)
|
||||
.bind(device_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound("设备未注册".into()));
|
||||
}
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/devices — 列出当前用户的设备
|
||||
pub async fn list_devices(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
||||
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, device_id, device_name, platform, app_version, last_seen_at, created_at
|
||||
FROM devices WHERE account_id = ?1 ORDER BY last_seen_at DESC"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows.into_iter().map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.0, "device_id": r.1,
|
||||
"device_name": r.2, "platform": r.3, "app_version": r.4,
|
||||
"last_seen_at": r.5, "created_at": r.6,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
Ok(Json(items))
|
||||
}
|
||||
23
crates/zclaw-saas/src/account/mod.rs
Normal file
23
crates/zclaw-saas/src/account/mod.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
//! 账号管理模块
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::{delete, get, patch, post, put};
|
||||
|
||||
pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/accounts", get(handlers::list_accounts))
|
||||
.route("/api/v1/accounts/{id}", get(handlers::get_account))
|
||||
.route("/api/v1/accounts/{id}", put(handlers::update_account))
|
||||
.route("/api/v1/accounts/{id}/status", patch(handlers::update_status))
|
||||
.route("/api/v1/tokens", get(handlers::list_tokens))
|
||||
.route("/api/v1/tokens", post(handlers::create_token))
|
||||
.route("/api/v1/tokens/{id}", delete(handlers::revoke_token))
|
||||
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
||||
.route("/api/v1/stats/dashboard", get(handlers::dashboard_stats))
|
||||
.route("/api/v1/devices", get(handlers::list_devices))
|
||||
.route("/api/v1/devices/register", post(handlers::register_device))
|
||||
.route("/api/v1/devices/heartbeat", post(handlers::device_heartbeat))
|
||||
}
|
||||
222
crates/zclaw-saas/src/account/service.rs
Normal file
222
crates/zclaw-saas/src/account/service.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! 账号管理业务逻辑
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::types::*;
|
||||
|
||||
pub async fn list_accounts(
|
||||
db: &SqlitePool,
|
||||
query: &ListAccountsQuery,
|
||||
) -> SaasResult<PaginatedResponse<serde_json::Value>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query.page_size.unwrap_or(20).min(100);
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
let mut where_clauses = Vec::new();
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(role) = &query.role {
|
||||
where_clauses.push("role = ?".to_string());
|
||||
params.push(role.clone());
|
||||
}
|
||||
if let Some(status) = &query.status {
|
||||
where_clauses.push("status = ?".to_string());
|
||||
params.push(status.clone());
|
||||
}
|
||||
if let Some(search) = &query.search {
|
||||
where_clauses.push("(username LIKE ? OR email LIKE ? OR display_name LIKE ?)".to_string());
|
||||
let pattern = format!("%{}%", search);
|
||||
params.push(pattern.clone());
|
||||
params.push(pattern.clone());
|
||||
params.push(pattern);
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
|
||||
let count_sql = format!("SELECT COUNT(*) as count FROM accounts {}", where_sql);
|
||||
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
|
||||
for p in ¶ms {
|
||||
count_query = count_query.bind(p);
|
||||
}
|
||||
let total: i64 = count_query.fetch_one(db).await?;
|
||||
|
||||
let data_sql = format!(
|
||||
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
|
||||
FROM accounts {} ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
where_sql
|
||||
);
|
||||
let mut data_query = sqlx::query_as::<_, (String, String, String, String, String, String, bool, Option<String>, String)>(&data_sql);
|
||||
for p in ¶ms {
|
||||
data_query = data_query.bind(p);
|
||||
}
|
||||
let rows = data_query.bind(page_size as i64).bind(offset as i64).fetch_all(db).await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows
|
||||
.into_iter()
|
||||
.map(|(id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at)| {
|
||||
serde_json::json!({
|
||||
"id": id, "username": username, "email": email, "display_name": display_name,
|
||||
"role": role, "status": status, "totp_enabled": totp_enabled,
|
||||
"last_login_at": last_login_at, "created_at": created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(PaginatedResponse { items, total, page, page_size })
|
||||
}
|
||||
|
||||
pub async fn get_account(db: &SqlitePool, account_id: &str) -> SaasResult<serde_json::Value> {
|
||||
let row: Option<(String, String, String, String, String, String, bool, Option<String>, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
|
||||
FROM accounts WHERE id = ?1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let (id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound(format!("账号 {} 不存在", account_id)))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": id, "username": username, "email": email, "display_name": display_name,
|
||||
"role": role, "status": status, "totp_enabled": totp_enabled,
|
||||
"last_login_at": last_login_at, "created_at": created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_account(
|
||||
db: &SqlitePool,
|
||||
account_id: &str,
|
||||
req: &UpdateAccountRequest,
|
||||
) -> SaasResult<serde_json::Value> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut updates = Vec::new();
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ref v) = req.display_name { updates.push("display_name = ?"); params.push(v.clone()); }
|
||||
if let Some(ref v) = req.email { updates.push("email = ?"); params.push(v.clone()); }
|
||||
if let Some(ref v) = req.role { updates.push("role = ?"); params.push(v.clone()); }
|
||||
if let Some(ref v) = req.avatar_url { updates.push("avatar_url = ?"); params.push(v.clone()); }
|
||||
|
||||
if updates.is_empty() {
|
||||
return get_account(db, account_id).await;
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
params.push(now.clone());
|
||||
params.push(account_id.to_string());
|
||||
|
||||
let sql = format!("UPDATE accounts SET {} WHERE id = ?", updates.join(", "));
|
||||
let mut query = sqlx::query(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
query.execute(db).await?;
|
||||
get_account(db, account_id).await
|
||||
}
|
||||
|
||||
pub async fn update_account_status(
|
||||
db: &SqlitePool,
|
||||
account_id: &str,
|
||||
status: &str,
|
||||
) -> SaasResult<()> {
|
||||
let valid = ["active", "disabled", "suspended"];
|
||||
if !valid.contains(&status) {
|
||||
return Err(SaasError::InvalidInput(format!("无效状态: {},有效值: {:?}", status, valid)));
|
||||
}
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = sqlx::query("UPDATE accounts SET status = ?1, updated_at = ?2 WHERE id = ?3")
|
||||
.bind(status).bind(&now).bind(account_id)
|
||||
.execute(db).await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound(format!("账号 {} 不存在", account_id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_api_token(
|
||||
db: &SqlitePool,
|
||||
account_id: &str,
|
||||
req: &CreateTokenRequest,
|
||||
) -> SaasResult<TokenInfo> {
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
let mut bytes = [0u8; 48];
|
||||
use rand::RngCore;
|
||||
rand::thread_rng().fill_bytes(&mut bytes);
|
||||
let raw_token = format!("zclaw_{}", hex::encode(bytes));
|
||||
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
||||
let token_prefix = raw_token[..8].to_string();
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let expires_at = req.expires_days.map(|d| {
|
||||
(chrono::Utc::now() + chrono::Duration::days(d)).to_rfc3339()
|
||||
});
|
||||
let permissions = serde_json::to_string(&req.permissions)?;
|
||||
let token_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO api_tokens (id, account_id, name, token_hash, token_prefix, permissions, created_at, expires_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
|
||||
)
|
||||
.bind(&token_id)
|
||||
.bind(account_id)
|
||||
.bind(&req.name)
|
||||
.bind(&token_hash)
|
||||
.bind(&token_prefix)
|
||||
.bind(&permissions)
|
||||
.bind(&now)
|
||||
.bind(&expires_at)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(TokenInfo {
|
||||
id: token_id,
|
||||
name: req.name.clone(),
|
||||
token_prefix,
|
||||
permissions: req.permissions.clone(),
|
||||
last_used_at: None,
|
||||
expires_at,
|
||||
created_at: now,
|
||||
token: Some(raw_token),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_api_tokens(
|
||||
db: &SqlitePool,
|
||||
account_id: &str,
|
||||
) -> SaasResult<Vec<TokenInfo>> {
|
||||
let rows: Vec<(String, String, String, String, Option<String>, Option<String>, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, token_prefix, permissions, last_used_at, expires_at, created_at
|
||||
FROM api_tokens WHERE account_id = ?1 AND revoked_at IS NULL ORDER BY created_at DESC"
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|(id, name, token_prefix, perms, last_used, expires, created)| {
|
||||
let permissions: Vec<String> = serde_json::from_str(&perms).unwrap_or_default();
|
||||
TokenInfo { id, name, token_prefix, permissions, last_used_at: last_used, expires_at: expires, created_at: created, token: None, }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn revoke_api_token(db: &SqlitePool, token_id: &str, account_id: &str) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = sqlx::query(
|
||||
"UPDATE api_tokens SET revoked_at = ?1 WHERE id = ?2 AND account_id = ?3 AND revoked_at IS NULL"
|
||||
)
|
||||
.bind(&now).bind(token_id).bind(account_id)
|
||||
.execute(db).await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound("Token 不存在或已撤销".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
53
crates/zclaw-saas/src/account/types.rs
Normal file
53
crates/zclaw-saas/src/account/types.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
//! 账号管理类型
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateAccountRequest {
|
||||
pub display_name: Option<String>,
|
||||
pub email: Option<String>,
|
||||
pub role: Option<String>,
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateStatusRequest {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListAccountsQuery {
|
||||
pub page: Option<u32>,
|
||||
pub page_size: Option<u32>,
|
||||
pub role: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub search: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct PaginatedResponse<T: Serialize> {
|
||||
pub items: Vec<T>,
|
||||
pub total: i64,
|
||||
pub page: u32,
|
||||
pub page_size: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateTokenRequest {
|
||||
pub name: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub expires_days: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TokenInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub token_prefix: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub last_used_at: Option<String>,
|
||||
pub expires_at: Option<String>,
|
||||
pub created_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
}
|
||||
278
crates/zclaw-saas/src/auth/handlers.rs
Normal file
278
crates/zclaw-saas/src/auth/handlers.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
//! 认证 HTTP 处理器
|
||||
|
||||
use axum::{extract::{State, ConnectInfo}, http::StatusCode, Json};
|
||||
use std::net::SocketAddr;
|
||||
use secrecy::ExposeSecret;
|
||||
use crate::state::AppState;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::{
|
||||
jwt::create_token,
|
||||
password::{hash_password, verify_password},
|
||||
types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, ChangePasswordRequest, AccountPublic},
|
||||
};
|
||||
|
||||
/// POST /api/v1/auth/register
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<AccountPublic>)> {
|
||||
if req.username.len() < 3 {
|
||||
return Err(SaasError::InvalidInput("用户名至少 3 个字符".into()));
|
||||
}
|
||||
if req.password.len() < 8 {
|
||||
return Err(SaasError::InvalidInput("密码至少 8 个字符".into()));
|
||||
}
|
||||
|
||||
let existing: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM accounts WHERE username = ?1 OR email = ?2"
|
||||
)
|
||||
.bind(&req.username)
|
||||
.bind(&req.email)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
if !existing.is_empty() {
|
||||
return Err(SaasError::AlreadyExists("用户名或邮箱已存在".into()));
|
||||
}
|
||||
|
||||
let password_hash = hash_password(&req.password)?;
|
||||
let account_id = uuid::Uuid::new_v4().to_string();
|
||||
let role = "user".to_string(); // 注册固定为普通用户,角色由管理员分配
|
||||
let display_name = req.display_name.unwrap_or_default();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO accounts (id, username, email, password_hash, display_name, role, status, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?7, ?7)"
|
||||
)
|
||||
.bind(&account_id)
|
||||
.bind(&req.username)
|
||||
.bind(&req.email)
|
||||
.bind(&password_hash)
|
||||
.bind(&display_name)
|
||||
.bind(&role)
|
||||
.bind(&now)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
let client_ip = addr.ip().to_string();
|
||||
log_operation(&state.db, &account_id, "account.create", "account", &account_id, None, Some(&client_ip)).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(AccountPublic {
|
||||
id: account_id,
|
||||
username: req.username,
|
||||
email: req.email,
|
||||
display_name,
|
||||
role,
|
||||
status: "active".into(),
|
||||
totp_enabled: false,
|
||||
created_at: now,
|
||||
})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/login
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> SaasResult<Json<LoginResponse>> {
|
||||
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
|
||||
FROM accounts WHERE username = ?1 OR email = ?1"
|
||||
)
|
||||
.bind(&req.username)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (id, username, email, display_name, role, status, totp_enabled, created_at) =
|
||||
row.ok_or_else(|| SaasError::AuthError("用户名或密码错误".into()))?;
|
||||
|
||||
if status != "active" {
|
||||
return Err(SaasError::Forbidden(format!("账号已{},请联系管理员", status)));
|
||||
}
|
||||
|
||||
let (password_hash,): (String,) = sqlx::query_as(
|
||||
"SELECT password_hash FROM accounts WHERE id = ?1"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if !verify_password(&req.password, &password_hash)? {
|
||||
return Err(SaasError::AuthError("用户名或密码错误".into()));
|
||||
}
|
||||
|
||||
// TOTP 验证: 如果用户已启用 2FA,必须提供有效 TOTP 码
|
||||
if totp_enabled {
|
||||
let code = req.totp_code.as_deref()
|
||||
.ok_or_else(|| SaasError::Totp("此账号已启用双因素认证,请提供 TOTP 码".into()))?;
|
||||
|
||||
let (totp_secret,): (Option<String>,) = sqlx::query_as(
|
||||
"SELECT totp_secret FROM accounts WHERE id = ?1"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let secret = totp_secret.ok_or_else(|| {
|
||||
SaasError::Internal("TOTP 已启用但密钥丢失,请联系管理员".into())
|
||||
})?;
|
||||
|
||||
if !super::totp::verify_totp_code(&secret, code) {
|
||||
return Err(SaasError::Totp("TOTP 码错误或已过期".into()));
|
||||
}
|
||||
}
|
||||
|
||||
let permissions = get_role_permissions(&state.db, &role).await?;
|
||||
let config = state.config.read().await;
|
||||
let token = create_token(
|
||||
&id, &role, permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.jwt_expiration_hours,
|
||||
)?;
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET last_login_at = ?1 WHERE id = ?2")
|
||||
.bind(&now).bind(&id)
|
||||
.execute(&state.db).await?;
|
||||
let client_ip = addr.ip().to_string();
|
||||
log_operation(&state.db, &id, "account.login", "account", &id, None, Some(&client_ip)).await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
token,
|
||||
account: AccountPublic {
|
||||
id, username, email, display_name, role, status, totp_enabled, created_at,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/refresh
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
let config = state.config.read().await;
|
||||
let token = create_token(
|
||||
&ctx.account_id, &ctx.role, ctx.permissions.clone(),
|
||||
state.jwt_secret.expose_secret(),
|
||||
config.auth.jwt_expiration_hours,
|
||||
)?;
|
||||
Ok(Json(serde_json::json!({ "token": token })))
|
||||
}
|
||||
|
||||
/// GET /api/v1/auth/me — 返回当前认证用户的公开信息
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
||||
) -> SaasResult<Json<AccountPublic>> {
|
||||
let row: Option<(String, String, String, String, String, String, bool, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
|
||||
FROM accounts WHERE id = ?1"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (id, username, email, display_name, role, status, totp_enabled, created_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound("账号不存在".into()))?;
|
||||
|
||||
Ok(Json(AccountPublic {
|
||||
id, username, email, display_name, role, status, totp_enabled, created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/auth/password — 修改密码
|
||||
pub async fn change_password(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
|
||||
Json(req): Json<ChangePasswordRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
if req.new_password.len() < 8 {
|
||||
return Err(SaasError::InvalidInput("新密码至少 8 个字符".into()));
|
||||
}
|
||||
|
||||
// 获取当前密码哈希
|
||||
let (password_hash,): (String,) = sqlx::query_as(
|
||||
"SELECT password_hash FROM accounts WHERE id = ?1"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
// 验证旧密码
|
||||
if !verify_password(&req.old_password, &password_hash)? {
|
||||
return Err(SaasError::AuthError("旧密码错误".into()));
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
let new_hash = hash_password(&req.new_password)?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET password_hash = ?1, updated_at = ?2 WHERE id = ?3")
|
||||
.bind(&new_hash)
|
||||
.bind(&now)
|
||||
.bind(&ctx.account_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "account.change_password", "account", &ctx.account_id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "message": "密码修改成功"})))
|
||||
}
|
||||
|
||||
pub(crate) async fn get_role_permissions(db: &sqlx::SqlitePool, role: &str) -> SaasResult<Vec<String>> {
|
||||
let row: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT permissions FROM roles WHERE id = ?1"
|
||||
)
|
||||
.bind(role)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let permissions_str = row
|
||||
.ok_or_else(|| SaasError::Internal(format!("角色 {} 不存在", role)))?
|
||||
.0;
|
||||
|
||||
let permissions: Vec<String> = serde_json::from_str(&permissions_str)?;
|
||||
Ok(permissions)
|
||||
}
|
||||
|
||||
/// 检查权限 (admin:full 自动通过所有检查)
|
||||
pub fn check_permission(ctx: &AuthContext, permission: &str) -> SaasResult<()> {
|
||||
if ctx.permissions.contains(&"admin:full".to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
if !ctx.permissions.contains(&permission.to_string()) {
|
||||
return Err(SaasError::Forbidden(format!("需要 {} 权限", permission)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 记录操作日志
|
||||
pub async fn log_operation(
|
||||
db: &sqlx::SqlitePool,
|
||||
account_id: &str,
|
||||
action: &str,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
details: Option<serde_json::Value>,
|
||||
ip_address: Option<&str>,
|
||||
) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO operation_logs (account_id, action, target_type, target_id, details, ip_address, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(action)
|
||||
.bind(target_type)
|
||||
.bind(target_id)
|
||||
.bind(details.map(|d| d.to_string()))
|
||||
.bind(ip_address)
|
||||
.bind(&now)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
91
crates/zclaw-saas/src/auth/jwt.rs
Normal file
91
crates/zclaw-saas/src/auth/jwt.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! JWT Token 创建与验证
|
||||
|
||||
use chrono::{Duration, Utc};
|
||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::SaasResult;
|
||||
|
||||
/// JWT Claims
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub iat: i64,
|
||||
pub exp: i64,
|
||||
}
|
||||
|
||||
impl Claims {
|
||||
pub fn new(account_id: &str, role: &str, permissions: Vec<String>, expiration_hours: i64) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
sub: account_id.to_string(),
|
||||
role: role.to_string(),
|
||||
permissions,
|
||||
iat: now.timestamp(),
|
||||
exp: (now + Duration::hours(expiration_hours)).timestamp(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建 JWT Token
|
||||
pub fn create_token(
|
||||
account_id: &str,
|
||||
role: &str,
|
||||
permissions: Vec<String>,
|
||||
secret: &str,
|
||||
expiration_hours: i64,
|
||||
) -> SaasResult<String> {
|
||||
let claims = Claims::new(account_id, role, permissions, expiration_hours);
|
||||
let token = encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&EncodingKey::from_secret(secret.as_bytes()),
|
||||
)?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// 验证 JWT Token
|
||||
pub fn verify_token(token: &str, secret: &str) -> SaasResult<Claims> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_SECRET: &str = "test-secret-key";
|
||||
|
||||
#[test]
|
||||
fn test_create_and_verify_token() {
|
||||
let token = create_token(
|
||||
"account-123", "admin",
|
||||
vec!["model:read".to_string()],
|
||||
TEST_SECRET, 24,
|
||||
).unwrap();
|
||||
|
||||
let claims = verify_token(&token, TEST_SECRET).unwrap();
|
||||
assert_eq!(claims.sub, "account-123");
|
||||
assert_eq!(claims.role, "admin");
|
||||
assert_eq!(claims.permissions, vec!["model:read"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token() {
|
||||
let result = verify_token("invalid.token.here", TEST_SECRET);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_secret() {
|
||||
let token = create_token("account-123", "admin", vec![], TEST_SECRET, 24).unwrap();
|
||||
let result = verify_token(&token, "wrong-secret");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
169
crates/zclaw-saas/src/auth/mod.rs
Normal file
169
crates/zclaw-saas/src/auth/mod.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! 认证模块
|
||||
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
pub mod types;
|
||||
pub mod handlers;
|
||||
pub mod totp;
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::header,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
extract::ConnectInfo,
|
||||
};
|
||||
use secrecy::ExposeSecret;
|
||||
use crate::error::SaasError;
|
||||
use crate::state::AppState;
|
||||
use types::AuthContext;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
/// 通过 API Token 验证身份
|
||||
///
|
||||
/// 流程: SHA-256 哈希 → 查 api_tokens 表 → 检查有效期 → 获取关联账号角色权限 → 更新 last_used_at
|
||||
async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option<String>) -> Result<AuthContext, SaasError> {
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
||||
|
||||
let row: Option<(String, Option<String>, String)> = sqlx::query_as(
|
||||
"SELECT account_id, expires_at, permissions FROM api_tokens
|
||||
WHERE token_hash = ?1 AND revoked_at IS NULL"
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (account_id, expires_at, permissions_json) = row
|
||||
.ok_or(SaasError::Unauthorized)?;
|
||||
|
||||
// 检查是否过期
|
||||
if let Some(ref exp) = expires_at {
|
||||
let now = chrono::Utc::now();
|
||||
if let Ok(exp_time) = chrono::DateTime::parse_from_rfc3339(exp) {
|
||||
if now >= exp_time.with_timezone(&chrono::Utc) {
|
||||
return Err(SaasError::Unauthorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询关联账号的角色
|
||||
let (role,): (String,) = sqlx::query_as(
|
||||
"SELECT role FROM accounts WHERE id = ?1 AND status = 'active'"
|
||||
)
|
||||
.bind(&account_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(SaasError::Unauthorized)?;
|
||||
|
||||
// 合并 token 权限与角色权限(去重)
|
||||
let role_permissions = handlers::get_role_permissions(&state.db, &role).await?;
|
||||
let token_permissions: Vec<String> = serde_json::from_str(&permissions_json).unwrap_or_default();
|
||||
let mut permissions = role_permissions;
|
||||
for p in token_permissions {
|
||||
if !permissions.contains(&p) {
|
||||
permissions.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// 异步更新 last_used_at(不阻塞请求)
|
||||
let db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let _ = sqlx::query("UPDATE api_tokens SET last_used_at = ?1 WHERE token_hash = ?2")
|
||||
.bind(&now).bind(&token_hash)
|
||||
.execute(&db).await;
|
||||
});
|
||||
|
||||
Ok(AuthContext {
|
||||
account_id,
|
||||
role,
|
||||
permissions,
|
||||
client_ip,
|
||||
})
|
||||
}
|
||||
|
||||
/// 从请求中提取客户端 IP
|
||||
fn extract_client_ip(req: &Request) -> Option<String> {
|
||||
// 优先从 ConnectInfo 获取
|
||||
if let Some(ConnectInfo(addr)) = req.extensions().get::<ConnectInfo<SocketAddr>>() {
|
||||
return Some(addr.ip().to_string());
|
||||
}
|
||||
// 回退到 X-Forwarded-For / X-Real-IP
|
||||
if let Some(forwarded) = req.headers()
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
return Some(forwarded.split(',').next()?.trim().to_string());
|
||||
}
|
||||
req.headers()
|
||||
.get("x-real-ip")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// 认证中间件: 从 JWT 或 API Token 提取身份
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AppState>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&req);
|
||||
let auth_header = req.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok());
|
||||
|
||||
let result = if let Some(auth) = auth_header {
|
||||
if let Some(token) = auth.strip_prefix("Bearer ") {
|
||||
if token.starts_with("zclaw_") {
|
||||
// API Token 路径
|
||||
verify_api_token(&state, token, client_ip.clone()).await
|
||||
} else {
|
||||
// JWT 路径
|
||||
jwt::verify_token(token, state.jwt_secret.expose_secret())
|
||||
.map(|claims| AuthContext {
|
||||
account_id: claims.sub,
|
||||
role: claims.role,
|
||||
permissions: claims.permissions,
|
||||
client_ip,
|
||||
})
|
||||
.map_err(|_| SaasError::Unauthorized)
|
||||
}
|
||||
} else {
|
||||
Err(SaasError::Unauthorized)
|
||||
}
|
||||
} else {
|
||||
Err(SaasError::Unauthorized)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(ctx) => {
|
||||
req.extensions_mut().insert(ctx);
|
||||
next.run(req).await
|
||||
}
|
||||
Err(e) => e.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 路由 (无需认证的端点)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
use axum::routing::post;
|
||||
|
||||
axum::Router::new()
|
||||
.route("/api/v1/auth/register", post(handlers::register))
|
||||
.route("/api/v1/auth/login", post(handlers::login))
|
||||
}
|
||||
|
||||
/// 需要认证的路由
|
||||
pub fn protected_routes() -> axum::Router<AppState> {
|
||||
use axum::routing::{get, post, put};
|
||||
|
||||
axum::Router::new()
|
||||
.route("/api/v1/auth/refresh", post(handlers::refresh))
|
||||
.route("/api/v1/auth/me", get(handlers::me))
|
||||
.route("/api/v1/auth/password", put(handlers::change_password))
|
||||
.route("/api/v1/auth/totp/setup", post(totp::setup_totp))
|
||||
.route("/api/v1/auth/totp/verify", post(totp::verify_totp))
|
||||
.route("/api/v1/auth/totp/disable", post(totp::disable_totp))
|
||||
}
|
||||
48
crates/zclaw-saas/src/auth/password.rs
Normal file
48
crates/zclaw-saas/src/auth/password.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! 密码哈希 (Argon2id)
|
||||
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
|
||||
/// 哈希密码
|
||||
pub fn hash_password(password: &str) -> SaasResult<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| SaasError::PasswordHash(e.to_string()))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// 验证密码
|
||||
pub fn verify_password(password: &str, hash: &str) -> SaasResult<bool> {
|
||||
let parsed_hash = PasswordHash::new(hash)
|
||||
.map_err(|e| SaasError::PasswordHash(e.to_string()))?;
|
||||
Ok(Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify() {
|
||||
let hash = hash_password("correct_password").unwrap();
|
||||
assert!(verify_password("correct_password", &hash).unwrap());
|
||||
assert!(!verify_password("wrong_password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_hashes_for_same_password() {
|
||||
let hash1 = hash_password("same_password").unwrap();
|
||||
let hash2 = hash_password("same_password").unwrap();
|
||||
assert_ne!(hash1, hash2);
|
||||
assert!(verify_password("same_password", &hash1).unwrap());
|
||||
assert!(verify_password("same_password", &hash2).unwrap());
|
||||
}
|
||||
}
|
||||
192
crates/zclaw-saas/src/auth/totp.rs
Normal file
192
crates/zclaw-saas/src/auth/totp.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
//! TOTP 双因素认证
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, State},
|
||||
Json,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::log_operation;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// TOTP 设置响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TotpSetupResponse {
|
||||
/// otpauth:// URI,用于扫码绑定
|
||||
pub otpauth_uri: String,
|
||||
/// Base32 编码的密钥(备用手动输入)
|
||||
pub secret: String,
|
||||
/// issuer 名称
|
||||
pub issuer: String,
|
||||
}
|
||||
|
||||
/// TOTP 验证请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TotpVerifyRequest {
|
||||
pub code: String,
|
||||
}
|
||||
|
||||
/// TOTP 禁用请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TotpDisableRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// 生成随机 Base32 密钥 (20 字节 = 32 字符 Base32)
|
||||
fn generate_random_secret() -> String {
|
||||
use rand::Rng;
|
||||
let mut bytes = [0u8; 20];
|
||||
rand::thread_rng().fill(&mut bytes);
|
||||
data_encoding::BASE32.encode(&bytes)
|
||||
}
|
||||
|
||||
/// Base32 解码
|
||||
fn base32_decode(data: &str) -> Option<Vec<u8>> {
|
||||
data_encoding::BASE32.decode(data.as_bytes()).ok()
|
||||
}
|
||||
|
||||
/// 生成 TOTP 密钥并返回 otpauth URI
|
||||
pub fn generate_totp_secret(issuer: &str, account_name: &str) -> TotpSetupResponse {
|
||||
let secret = generate_random_secret();
|
||||
let otpauth_uri = format!(
|
||||
"otpauth://totp/{}:{}?secret={}&issuer={}&algorithm=SHA1&digits=6&period=30",
|
||||
urlencoding::encode(issuer),
|
||||
urlencoding::encode(account_name),
|
||||
secret,
|
||||
urlencoding::encode(issuer),
|
||||
);
|
||||
|
||||
TotpSetupResponse {
|
||||
otpauth_uri,
|
||||
secret,
|
||||
issuer: issuer.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 验证 TOTP 6 位码
|
||||
pub fn verify_totp_code(secret: &str, code: &str) -> bool {
|
||||
let secret_bytes = match base32_decode(secret) {
|
||||
Some(b) => b,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let totp = match totp_rs::TOTP::new(
|
||||
totp_rs::Algorithm::SHA1,
|
||||
6, // digits
|
||||
1, // skew (允许 1 个周期偏差)
|
||||
30, // step (秒)
|
||||
secret_bytes,
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
totp.check_current(code).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/totp/setup
|
||||
/// 生成 TOTP 密钥并返回 otpauth URI
|
||||
/// 用户扫码后需要调用 /verify 验证一个码才能激活
|
||||
pub async fn setup_totp(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<TotpSetupResponse>> {
|
||||
// 如果已启用 TOTP,先清除旧密钥
|
||||
let (username,): (String,) = sqlx::query_as(
|
||||
"SELECT username FROM accounts WHERE id = ?1"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let config = state.config.read().await;
|
||||
let setup = generate_totp_secret(&config.auth.totp_issuer, &username);
|
||||
|
||||
// 存储密钥 (但不启用,需要 /verify 确认)
|
||||
sqlx::query("UPDATE accounts SET totp_secret = ?1 WHERE id = ?2")
|
||||
.bind(&setup.secret)
|
||||
.bind(&ctx.account_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "totp.setup", "account", &ctx.account_id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(setup))
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/totp/verify
|
||||
/// 验证 TOTP 码并启用 2FA
|
||||
pub async fn verify_totp(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<TotpVerifyRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
let code = req.code.trim();
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
|
||||
return Err(SaasError::InvalidInput("TOTP 码必须是 6 位数字".into()));
|
||||
}
|
||||
|
||||
// 获取存储的密钥
|
||||
let (totp_secret,): (Option<String>,) = sqlx::query_as(
|
||||
"SELECT totp_secret FROM accounts WHERE id = ?1"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
let secret = totp_secret.ok_or_else(|| {
|
||||
SaasError::InvalidInput("请先调用 /totp/setup 获取密钥".into())
|
||||
})?;
|
||||
|
||||
if !verify_totp_code(&secret, code) {
|
||||
return Err(SaasError::Totp("TOTP 码验证失败".into()));
|
||||
}
|
||||
|
||||
// 验证成功 → 启用 TOTP
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET totp_enabled = 1, updated_at = ?1 WHERE id = ?2")
|
||||
.bind(&now)
|
||||
.bind(&ctx.account_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "totp.verify", "account", &ctx.account_id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "totp_enabled": true, "message": "TOTP 已启用"})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/totp/disable
|
||||
/// 禁用 TOTP (需要密码确认)
|
||||
pub async fn disable_totp(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<TotpDisableRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
// 验证密码
|
||||
let (password_hash,): (String,) = sqlx::query_as(
|
||||
"SELECT password_hash FROM accounts WHERE id = ?1"
|
||||
)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if !crate::auth::password::verify_password(&req.password, &password_hash)? {
|
||||
return Err(SaasError::AuthError("密码错误".into()));
|
||||
}
|
||||
|
||||
// 清除 TOTP
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET totp_enabled = 0, totp_secret = NULL, updated_at = ?1 WHERE id = ?2")
|
||||
.bind(&now)
|
||||
.bind(&ctx.account_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "totp.disable", "account", &ctx.account_id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "totp_enabled": false, "message": "TOTP 已禁用"})))
|
||||
}
|
||||
56
crates/zclaw-saas/src/auth/types.rs
Normal file
56
crates/zclaw-saas/src/auth/types.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
//! 认证相关类型
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 登录请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub totp_code: Option<String>,
|
||||
}
|
||||
|
||||
/// 登录响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
pub account: AccountPublic,
|
||||
}
|
||||
|
||||
/// 注册请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RegisterRequest {
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
/// 修改密码请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangePasswordRequest {
|
||||
pub old_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
/// 公开账号信息 (无敏感数据)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AccountPublic {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub role: String,
|
||||
pub status: String,
|
||||
pub totp_enabled: bool,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 认证上下文 (注入到 request extensions)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthContext {
|
||||
pub account_id: String,
|
||||
pub role: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub client_ip: Option<String>,
|
||||
}
|
||||
184
crates/zclaw-saas/src/config.rs
Normal file
184
crates/zclaw-saas/src/config.rs
Normal file
@@ -0,0 +1,184 @@
|
||||
//! SaaS 服务器配置
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use secrecy::SecretString;
|
||||
|
||||
/// SaaS 服务器完整配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SaaSConfig {
|
||||
pub server: ServerConfig,
|
||||
pub database: DatabaseConfig,
|
||||
pub auth: AuthConfig,
|
||||
pub relay: RelayConfig,
|
||||
#[serde(default)]
|
||||
pub rate_limit: RateLimitConfig,
|
||||
}
|
||||
|
||||
/// 服务器配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
#[serde(default = "default_host")]
|
||||
pub host: String,
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
pub cors_origins: Vec<String>,
|
||||
}
|
||||
|
||||
/// 数据库配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DatabaseConfig {
|
||||
#[serde(default = "default_db_url")]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// 认证配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
#[serde(default = "default_jwt_hours")]
|
||||
pub jwt_expiration_hours: i64,
|
||||
#[serde(default = "default_totp_issuer")]
|
||||
pub totp_issuer: String,
|
||||
}
|
||||
|
||||
/// 中转服务配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayConfig {
|
||||
#[serde(default = "default_max_queue")]
|
||||
pub max_queue_size: usize,
|
||||
#[serde(default = "default_max_concurrent")]
|
||||
pub max_concurrent_per_provider: usize,
|
||||
#[serde(default = "default_batch_window")]
|
||||
pub batch_window_ms: u64,
|
||||
#[serde(default = "default_retry_delay")]
|
||||
pub retry_delay_ms: u64,
|
||||
#[serde(default = "default_max_attempts")]
|
||||
pub max_attempts: u32,
|
||||
}
|
||||
|
||||
fn default_host() -> String { "0.0.0.0".into() }
|
||||
fn default_port() -> u16 { 8080 }
|
||||
fn default_db_url() -> String { "sqlite:./saas-data.db".into() }
|
||||
fn default_jwt_hours() -> i64 { 24 }
|
||||
fn default_totp_issuer() -> String { "ZCLAW SaaS".into() }
|
||||
fn default_max_queue() -> usize { 1000 }
|
||||
fn default_max_concurrent() -> usize { 5 }
|
||||
fn default_batch_window() -> u64 { 50 }
|
||||
fn default_retry_delay() -> u64 { 1000 }
|
||||
fn default_max_attempts() -> u32 { 3 }
|
||||
|
||||
/// 速率限制配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RateLimitConfig {
|
||||
/// 每分钟最大请求数 (滑动窗口)
|
||||
#[serde(default = "default_rpm")]
|
||||
pub requests_per_minute: u32,
|
||||
/// 突发允许的额外请求数
|
||||
#[serde(default = "default_burst")]
|
||||
pub burst: u32,
|
||||
}
|
||||
|
||||
fn default_rpm() -> u32 { 60 }
|
||||
fn default_burst() -> u32 { 10 }
|
||||
|
||||
impl Default for RateLimitConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests_per_minute: default_rpm(),
|
||||
burst: default_burst(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SaaSConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server: ServerConfig::default(),
|
||||
database: DatabaseConfig::default(),
|
||||
auth: AuthConfig::default(),
|
||||
relay: RelayConfig::default(),
|
||||
rate_limit: RateLimitConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: default_host(),
|
||||
port: default_port(),
|
||||
cors_origins: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DatabaseConfig {
|
||||
fn default() -> Self {
|
||||
Self { url: default_db_url() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
jwt_expiration_hours: default_jwt_hours(),
|
||||
totp_issuer: default_totp_issuer(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RelayConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_queue_size: default_max_queue(),
|
||||
max_concurrent_per_provider: default_max_concurrent(),
|
||||
batch_window_ms: default_batch_window(),
|
||||
retry_delay_ms: default_retry_delay(),
|
||||
max_attempts: default_max_attempts(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SaaSConfig {
|
||||
/// 加载配置文件,优先级: 环境变量 > ZCLAW_SAAS_CONFIG > ./saas-config.toml
|
||||
pub fn load() -> anyhow::Result<Self> {
|
||||
let config_path = std::env::var("ZCLAW_SAAS_CONFIG")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from("saas-config.toml"));
|
||||
|
||||
let config = if config_path.exists() {
|
||||
let content = std::fs::read_to_string(&config_path)?;
|
||||
toml::from_str(&content)?
|
||||
} else {
|
||||
tracing::warn!("Config file {:?} not found, using defaults", config_path);
|
||||
SaaSConfig::default()
|
||||
};
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 获取 JWT 密钥 (从环境变量或生成临时值)
|
||||
/// 生产环境必须设置 ZCLAW_SAAS_JWT_SECRET
|
||||
pub fn jwt_secret(&self) -> anyhow::Result<SecretString> {
|
||||
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
match std::env::var("ZCLAW_SAAS_JWT_SECRET") {
|
||||
Ok(secret) => Ok(SecretString::from(secret)),
|
||||
Err(_) => {
|
||||
if is_dev {
|
||||
tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using development default (INSECURE)");
|
||||
Ok(SecretString::from("zclaw-dev-only-secret-do-not-use-in-prod".to_string()))
|
||||
} else {
|
||||
anyhow::bail!(
|
||||
"ZCLAW_SAAS_JWT_SECRET 环境变量未设置。\
|
||||
请设置一个强随机密钥 (至少 32 字符)。\
|
||||
开发环境可设置 ZCLAW_SAAS_DEV=true 使用默认值。"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
349
crates/zclaw-saas/src/db.rs
Normal file
349
crates/zclaw-saas/src/db.rs
Normal file
@@ -0,0 +1,349 @@
|
||||
//! 数据库初始化与 Schema
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use crate::error::SaasResult;
|
||||
|
||||
const SCHEMA_VERSION: i32 = 1;
|
||||
|
||||
const SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS saas_schema_version (
|
||||
version INTEGER PRIMARY KEY
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
totp_secret TEXT,
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
last_login_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_role ON accounts(role);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
token_prefix TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
last_used_at TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_account ON api_tokens(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS permission_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT,
|
||||
target_id TEXT,
|
||||
details TEXT,
|
||||
ip_address TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_op_logs_account ON operation_logs(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_op_logs_action ON operation_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_op_logs_time ON operation_logs(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT NOT NULL,
|
||||
api_key TEXT,
|
||||
base_url TEXT NOT NULL,
|
||||
api_protocol TEXT NOT NULL DEFAULT 'openai',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
rate_limit_rpm INTEGER,
|
||||
rate_limit_tpm INTEGER,
|
||||
config_json TEXT DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
alias TEXT NOT NULL,
|
||||
context_window INTEGER NOT NULL DEFAULT 8192,
|
||||
max_output_tokens INTEGER NOT NULL DEFAULT 4096,
|
||||
supports_streaming INTEGER NOT NULL DEFAULT 1,
|
||||
supports_vision INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
pricing_input REAL DEFAULT 0,
|
||||
pricing_output REAL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(provider_id, model_id),
|
||||
FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_models_provider ON models(provider_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
key_value TEXT NOT NULL,
|
||||
key_label TEXT,
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_used_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (provider_id) REFERENCES providers(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_api_keys_account ON account_api_keys(account_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
latency_ms INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_account ON usage_records(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_time ON usage_records(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS relay_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
request_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
attempt_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
request_body TEXT NOT NULL,
|
||||
response_body TEXT,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
queued_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_status ON relay_tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_account ON relay_tasks(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relay_provider ON relay_tasks(provider_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_items (
|
||||
id TEXT PRIMARY KEY,
|
||||
category TEXT NOT NULL,
|
||||
key_path TEXT NOT NULL,
|
||||
value_type TEXT NOT NULL,
|
||||
current_value TEXT,
|
||||
default_value TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
description TEXT,
|
||||
requires_restart INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(category, key_path)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_config_category ON config_items(category);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config_sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id TEXT NOT NULL,
|
||||
client_fingerprint TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
config_keys TEXT NOT NULL,
|
||||
client_values TEXT,
|
||||
saas_values TEXT,
|
||||
resolution TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_account ON config_sync_log(account_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT,
|
||||
platform TEXT,
|
||||
app_version TEXT,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_account ON devices(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_device_id ON devices(device_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_unique ON devices(account_id, device_id);
|
||||
"#;
|
||||
|
||||
const SEED_ROLES: &str = r#"
|
||||
INSERT OR IGNORE INTO roles (id, name, description, permissions, is_system, created_at, updated_at)
|
||||
VALUES
|
||||
('super_admin', '超级管理员', '拥有所有权限', '["admin:full","account:admin","provider:manage","model:manage","relay:admin","config:write"]', 1, datetime('now'), datetime('now')),
|
||||
('admin', '管理员', '管理账号和配置', '["account:read","account:admin","provider:manage","model:read","model:manage","relay:use","relay:admin","config:read","config:write"]', 1, datetime('now'), datetime('now')),
|
||||
('user', '普通用户', '基础使用权限', '["model:read","relay:use","config:read"]', 1, datetime('now'), datetime('now'));
|
||||
"#;
|
||||
|
||||
/// 初始化数据库
|
||||
pub async fn init_db(database_url: &str) -> SaasResult<SqlitePool> {
|
||||
if database_url.starts_with("sqlite:") {
|
||||
let path_part = database_url.strip_prefix("sqlite:").unwrap_or("");
|
||||
if path_part != ":memory:" {
|
||||
if let Some(parent) = std::path::Path::new(path_part).parent() {
|
||||
if !parent.as_os_str().is_empty() && !parent.exists() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let pool = SqlitePool::connect(database_url).await?;
|
||||
sqlx::query("PRAGMA journal_mode=WAL;")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(SCHEMA_SQL).execute(&pool).await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO saas_schema_version (version) VALUES (?1)")
|
||||
.bind(SCHEMA_VERSION)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(SEED_ROLES).execute(&pool).await?;
|
||||
seed_admin_account(&pool).await?;
|
||||
tracing::info!("Database initialized (schema v{})", SCHEMA_VERSION);
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// 创建内存数据库 (测试用)
|
||||
pub async fn init_memory_db() -> SaasResult<SqlitePool> {
|
||||
let pool = SqlitePool::connect("sqlite::memory:").await?;
|
||||
sqlx::query(SCHEMA_SQL).execute(&pool).await?;
|
||||
sqlx::query("INSERT OR IGNORE INTO saas_schema_version (version) VALUES (?1)")
|
||||
.bind(SCHEMA_VERSION)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(SEED_ROLES).execute(&pool).await?;
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
/// 如果 accounts 表为空且环境变量已设置,自动创建 super_admin 账号
|
||||
async fn seed_admin_account(pool: &SqlitePool) -> SaasResult<()> {
|
||||
let has_accounts: (bool,) = sqlx::query_as(
|
||||
"SELECT EXISTS(SELECT 1 FROM accounts LIMIT 1) as has"
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if has_accounts.0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let admin_username = std::env::var("ZCLAW_ADMIN_USERNAME")
|
||||
.unwrap_or_else(|_| "admin".to_string());
|
||||
let admin_password = match std::env::var("ZCLAW_ADMIN_PASSWORD") {
|
||||
Ok(pwd) => pwd,
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
"accounts 表为空但未设置 ZCLAW_ADMIN_PASSWORD 环境变量。\
|
||||
请通过 POST /api/v1/auth/register 注册首个用户,然后手动将其 role 改为 super_admin。\
|
||||
或设置 ZCLAW_ADMIN_USERNAME 和 ZCLAW_ADMIN_PASSWORD 环境变量后重启服务。"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
use crate::auth::password::hash_password;
|
||||
|
||||
let password_hash = hash_password(&admin_password)?;
|
||||
let account_id = uuid::Uuid::new_v4().to_string();
|
||||
let email = format!("{}@zclaw.local", admin_username);
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO accounts (id, username, email, password_hash, display_name, role, status, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 'super_admin', 'active', ?6, ?6)"
|
||||
)
|
||||
.bind(&account_id)
|
||||
.bind(&admin_username)
|
||||
.bind(&email)
|
||||
.bind(&password_hash)
|
||||
.bind(&admin_username)
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
tracing::info!(
|
||||
"自动创建 super_admin 账号: username={}, email={}", admin_username, email
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_init_memory_db() {
|
||||
let pool = init_memory_db().await.unwrap();
|
||||
let roles: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM roles WHERE is_system = 1"
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(roles.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_schema_tables_exist() {
|
||||
let pool = init_memory_db().await.unwrap();
|
||||
let tables = [
|
||||
"accounts", "api_tokens", "roles", "permission_templates",
|
||||
"operation_logs", "providers", "models", "account_api_keys",
|
||||
"usage_records", "relay_tasks", "config_items", "config_sync_log", "devices",
|
||||
];
|
||||
for table in tables {
|
||||
let count: (i64,) = sqlx::query_as(&format!(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='{}'", table
|
||||
))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(count.0, 1, "Table {} should exist", table);
|
||||
}
|
||||
}
|
||||
}
|
||||
129
crates/zclaw-saas/src/error.rs
Normal file
129
crates/zclaw-saas/src/error.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! SaaS 错误类型
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde_json::json;
|
||||
|
||||
/// SaaS 服务错误类型
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SaasError {
|
||||
#[error("未找到: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("权限不足: {0}")]
|
||||
Forbidden(String),
|
||||
|
||||
#[error("未认证")]
|
||||
Unauthorized,
|
||||
|
||||
#[error("无效输入: {0}")]
|
||||
InvalidInput(String),
|
||||
|
||||
#[error("认证失败: {0}")]
|
||||
AuthError(String),
|
||||
|
||||
#[error("用户已存在: {0}")]
|
||||
AlreadyExists(String),
|
||||
|
||||
#[error("序列化错误: {0}")]
|
||||
Serialization(#[from] serde_json::Error),
|
||||
|
||||
#[error("IO 错误: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
Database(#[from] sqlx::Error),
|
||||
|
||||
#[error("配置错误: {0}")]
|
||||
Config(#[from] toml::de::Error),
|
||||
|
||||
#[error("JWT 错误: {0}")]
|
||||
Jwt(#[from] jsonwebtoken::errors::Error),
|
||||
|
||||
#[error("密码哈希错误: {0}")]
|
||||
PasswordHash(String),
|
||||
|
||||
#[error("TOTP 错误: {0}")]
|
||||
Totp(String),
|
||||
|
||||
#[error("加密错误: {0}")]
|
||||
Encryption(String),
|
||||
|
||||
#[error("中转错误: {0}")]
|
||||
Relay(String),
|
||||
|
||||
#[error("速率限制: {0}")]
|
||||
RateLimited(String),
|
||||
|
||||
#[error("内部错误: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl SaasError {
|
||||
/// 获取 HTTP 状态码
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
match self {
|
||||
Self::NotFound(_) => StatusCode::NOT_FOUND,
|
||||
Self::Forbidden(_) => StatusCode::FORBIDDEN,
|
||||
Self::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
Self::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
Self::AlreadyExists(_) => StatusCode::CONFLICT,
|
||||
Self::RateLimited(_) => StatusCode::TOO_MANY_REQUESTS,
|
||||
Self::Database(_) | Self::Internal(_) | Self::Io(_) | Self::Serialization(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::AuthError(_) => StatusCode::UNAUTHORIZED,
|
||||
Self::Jwt(_) | Self::PasswordHash(_) | Self::Encryption(_) => {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
Self::Totp(_) => StatusCode::BAD_REQUEST,
|
||||
Self::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Relay(_) => StatusCode::BAD_GATEWAY,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取错误代码
|
||||
pub fn error_code(&self) -> &str {
|
||||
match self {
|
||||
Self::NotFound(_) => "NOT_FOUND",
|
||||
Self::Forbidden(_) => "FORBIDDEN",
|
||||
Self::Unauthorized => "UNAUTHORIZED",
|
||||
Self::InvalidInput(_) => "INVALID_INPUT",
|
||||
Self::AlreadyExists(_) => "ALREADY_EXISTS",
|
||||
Self::RateLimited(_) => "RATE_LIMITED",
|
||||
Self::Database(_) => "DATABASE_ERROR",
|
||||
Self::Io(_) => "IO_ERROR",
|
||||
Self::Serialization(_) => "SERIALIZATION_ERROR",
|
||||
Self::Internal(_) => "INTERNAL_ERROR",
|
||||
Self::AuthError(_) => "AUTH_ERROR",
|
||||
Self::Jwt(_) => "JWT_ERROR",
|
||||
Self::PasswordHash(_) => "PASSWORD_HASH_ERROR",
|
||||
Self::Totp(_) => "TOTP_ERROR",
|
||||
Self::Encryption(_) => "ENCRYPTION_ERROR",
|
||||
Self::Config(_) => "CONFIG_ERROR",
|
||||
Self::Relay(_) => "RELAY_ERROR",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 实现 Axum 响应
|
||||
impl IntoResponse for SaasError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = self.status_code();
|
||||
let (error_code, message) = match &self {
|
||||
// 500 错误不泄露内部细节给客户端
|
||||
Self::Database(_) | Self::Internal(_) | Self::Io(_)
|
||||
| Self::Jwt(_) | Self::Config(_) => {
|
||||
tracing::error!("内部错误 [{}]: {}", self.error_code(), self);
|
||||
(self.error_code().to_string(), "服务内部错误".to_string())
|
||||
}
|
||||
_ => (self.error_code().to_string(), self.to_string()),
|
||||
};
|
||||
let body = json!({
|
||||
"error": error_code,
|
||||
"message": message,
|
||||
});
|
||||
(status, axum::Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
/// Result 类型别名
|
||||
pub type SaasResult<T> = std::result::Result<T, SaasError>;
|
||||
15
crates/zclaw-saas/src/lib.rs
Normal file
15
crates/zclaw-saas/src/lib.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! ZCLAW SaaS Backend
|
||||
//!
|
||||
//! 独立的 SaaS 后端服务,提供账号权限管理、模型配置、请求中转和配置迁移。
|
||||
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod middleware;
|
||||
pub mod state;
|
||||
|
||||
pub mod auth;
|
||||
pub mod account;
|
||||
pub mod model_config;
|
||||
pub mod relay;
|
||||
pub mod migration;
|
||||
86
crates/zclaw-saas/src/main.rs
Normal file
86
crates/zclaw-saas/src/main.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! ZCLAW SaaS 服务入口
|
||||
|
||||
use tracing::info;
|
||||
use zclaw_saas::{config::SaaSConfig, db::init_db, state::AppState};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "zclaw_saas=debug,tower_http=debug".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config = SaaSConfig::load()?;
|
||||
info!("SaaS config loaded: {}:{}", config.server.host, config.server.port);
|
||||
|
||||
let db = init_db(&config.database.url).await?;
|
||||
info!("Database initialized");
|
||||
|
||||
let state = AppState::new(db, config.clone())?;
|
||||
let app = build_router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(format!("{}:{}", config.server.host, config.server.port))
|
||||
.await?;
|
||||
info!("SaaS server listening on {}:{}", config.server.host, config.server.port);
|
||||
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>()).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_router(state: AppState) -> axum::Router {
|
||||
use axum::middleware;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use axum::http::HeaderValue;
|
||||
let cors = {
|
||||
let config = state.config.blocking_read();
|
||||
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
if config.server.cors_origins.is_empty() {
|
||||
if is_dev {
|
||||
CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
} else {
|
||||
tracing::error!("生产环境必须配置 server.cors_origins,不能使用 allow_origin(Any)");
|
||||
panic!("生产环境必须配置 server.cors_origins 白名单。开发环境可设置 ZCLAW_SAAS_DEV=true 绕过。");
|
||||
}
|
||||
} else {
|
||||
let origins: Vec<HeaderValue> = config.server.cors_origins.iter()
|
||||
.filter_map(|o: &String| o.parse::<HeaderValue>().ok())
|
||||
.collect();
|
||||
CorsLayer::new()
|
||||
.allow_origin(origins)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
}
|
||||
};
|
||||
|
||||
let public_routes = zclaw_saas::auth::routes();
|
||||
|
||||
let protected_routes = zclaw_saas::auth::protected_routes()
|
||||
.merge(zclaw_saas::account::routes())
|
||||
.merge(zclaw_saas::model_config::routes())
|
||||
.merge(zclaw_saas::relay::routes())
|
||||
.merge(zclaw_saas::migration::routes())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
zclaw_saas::middleware::rate_limit_middleware,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
zclaw_saas::auth::auth_middleware,
|
||||
));
|
||||
|
||||
axum::Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
81
crates/zclaw-saas/src/middleware.rs
Normal file
81
crates/zclaw-saas/src/middleware.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! 通用中间件
|
||||
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 滑动窗口速率限制中间件
|
||||
///
|
||||
/// 按 account_id (从 AuthContext 提取) 做 per-minute 限流。
|
||||
/// 超限时返回 429 Too Many Requests + Retry-After header。
|
||||
pub async fn rate_limit_middleware(
|
||||
State(state): State<AppState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// 从 AuthContext 提取 account_id(由 auth_middleware 在此之前注入)
|
||||
let account_id = req
|
||||
.extensions()
|
||||
.get::<crate::auth::types::AuthContext>()
|
||||
.map(|ctx| ctx.account_id.clone());
|
||||
|
||||
let account_id = match account_id {
|
||||
Some(id) => id,
|
||||
None => return next.run(req).await,
|
||||
};
|
||||
|
||||
let config = state.config.read().await;
|
||||
let rpm = config.rate_limit.requests_per_minute as u64;
|
||||
let burst = config.rate_limit.burst as u64;
|
||||
let max_requests = rpm + burst;
|
||||
drop(config);
|
||||
|
||||
let now = Instant::now();
|
||||
let window_start = now - std::time::Duration::from_secs(60);
|
||||
|
||||
// 滑动窗口: 清理过期条目 + 计数
|
||||
let current_count = {
|
||||
let mut entries = state.rate_limit_entries.entry(account_id.clone()).or_default();
|
||||
entries.retain(|&ts| ts > window_start);
|
||||
let count = entries.len() as u64;
|
||||
if count < max_requests {
|
||||
entries.push(now);
|
||||
0 // 未超限
|
||||
} else {
|
||||
count
|
||||
}
|
||||
};
|
||||
|
||||
if current_count >= max_requests {
|
||||
// 计算最早条目的过期时间作为 Retry-After
|
||||
let retry_after = if let Some(mut entries) = state.rate_limit_entries.get_mut(&account_id) {
|
||||
entries.sort();
|
||||
let earliest = *entries.first().unwrap_or(&now);
|
||||
let elapsed = now.duration_since(earliest).as_secs();
|
||||
60u64.saturating_sub(elapsed)
|
||||
} else {
|
||||
60
|
||||
};
|
||||
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
[
|
||||
("Retry-After", retry_after.to_string()),
|
||||
("Content-Type", "application/json".to_string()),
|
||||
],
|
||||
axum::Json(serde_json::json!({
|
||||
"error": "RATE_LIMITED",
|
||||
"message": format!("请求过于频繁,请在 {} 秒后重试", retry_after),
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
107
crates/zclaw-saas/src/migration/handlers.rs
Normal file
107
crates/zclaw-saas/src/migration/handlers.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! 配置迁移 HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode, Json,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::SaasResult;
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::check_permission;
|
||||
use super::{types::*, service};
|
||||
|
||||
/// GET /api/v1/config/items?category=xxx&source=xxx
|
||||
pub async fn list_config_items(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<ConfigQuery>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<ConfigItemInfo>>> {
|
||||
service::list_config_items(&state.db, &query).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/config/items/:id
|
||||
pub async fn get_config_item(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<ConfigItemInfo>> {
|
||||
service::get_config_item(&state.db, &id).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/config/items (admin only)
|
||||
pub async fn create_config_item(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateConfigItemRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<ConfigItemInfo>)> {
|
||||
check_permission(&ctx, "config:write")?;
|
||||
let item = service::create_config_item(&state.db, &req).await?;
|
||||
Ok((StatusCode::CREATED, Json(item)))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/config/items/:id (admin only)
|
||||
pub async fn update_config_item(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<UpdateConfigItemRequest>,
|
||||
) -> SaasResult<Json<ConfigItemInfo>> {
|
||||
check_permission(&ctx, "config:write")?;
|
||||
service::update_config_item(&state.db, &id, &req).await.map(Json)
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/config/items/:id (admin only)
|
||||
pub async fn delete_config_item(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "config:write")?;
|
||||
service::delete_config_item(&state.db, &id).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// GET /api/v1/config/analysis
|
||||
pub async fn analyze_config(
|
||||
State(state): State<AppState>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<ConfigAnalysis>> {
|
||||
service::analyze_config(&state.db).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/config/seed (admin only)
|
||||
pub async fn seed_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "config:write")?;
|
||||
let count = service::seed_default_config_items(&state.db).await?;
|
||||
Ok(Json(serde_json::json!({"created": count})))
|
||||
}
|
||||
|
||||
/// POST /api/v1/config/sync
|
||||
pub async fn sync_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<SyncConfigRequest>,
|
||||
) -> SaasResult<Json<super::service::ConfigSyncResult>> {
|
||||
super::service::sync_config(&state.db, &ctx.account_id, &req).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/config/diff
|
||||
/// 计算客户端与 SaaS 端的配置差异 (不修改数据)
|
||||
pub async fn config_diff(
|
||||
State(state): State<AppState>,
|
||||
Extension(_ctx): Extension<AuthContext>,
|
||||
Json(req): Json<SyncConfigRequest>,
|
||||
) -> SaasResult<Json<ConfigDiffResponse>> {
|
||||
service::compute_config_diff(&state.db, &req).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/config/sync-logs
|
||||
pub async fn list_sync_logs(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<ConfigSyncLogInfo>>> {
|
||||
service::list_sync_logs(&state.db, &ctx.account_id).await.map(Json)
|
||||
}
|
||||
20
crates/zclaw-saas/src/migration/mod.rs
Normal file
20
crates/zclaw-saas/src/migration/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
//! 配置迁移模块
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 配置迁移路由 (需要认证)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/config/items", get(handlers::list_config_items).post(handlers::create_config_item))
|
||||
.route("/api/v1/config/items/{id}", get(handlers::get_config_item).put(handlers::update_config_item).delete(handlers::delete_config_item))
|
||||
.route("/api/v1/config/analysis", get(handlers::analyze_config))
|
||||
.route("/api/v1/config/seed", post(handlers::seed_config))
|
||||
.route("/api/v1/config/sync", post(handlers::sync_config))
|
||||
.route("/api/v1/config/diff", post(handlers::config_diff))
|
||||
.route("/api/v1/config/sync-logs", get(handlers::list_sync_logs))
|
||||
}
|
||||
360
crates/zclaw-saas/src/migration/service.rs
Normal file
360
crates/zclaw-saas/src/migration/service.rs
Normal file
@@ -0,0 +1,360 @@
|
||||
//! 配置迁移业务逻辑
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::types::*;
|
||||
use serde::Serialize;
|
||||
|
||||
// ============ Config Items ============
|
||||
|
||||
pub async fn list_config_items(
|
||||
db: &SqlitePool, query: &ConfigQuery,
|
||||
) -> SaasResult<Vec<ConfigItemInfo>> {
|
||||
let sql = match (&query.category, &query.source) {
|
||||
(Some(_), Some(_)) => {
|
||||
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
|
||||
FROM config_items WHERE category = ?1 AND source = ?2 ORDER BY category, key_path"
|
||||
}
|
||||
(Some(_), None) => {
|
||||
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
|
||||
FROM config_items WHERE category = ?1 ORDER BY key_path"
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
|
||||
FROM config_items WHERE source = ?1 ORDER BY category, key_path"
|
||||
}
|
||||
(None, None) => {
|
||||
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
|
||||
FROM config_items ORDER BY category, key_path"
|
||||
}
|
||||
};
|
||||
|
||||
let mut query_builder = sqlx::query_as::<_, (String, String, String, String, Option<String>, Option<String>, String, Option<String>, bool, String, String)>(sql);
|
||||
|
||||
if let Some(cat) = &query.category {
|
||||
query_builder = query_builder.bind(cat);
|
||||
}
|
||||
if let Some(src) = &query.source {
|
||||
query_builder = query_builder.bind(src);
|
||||
}
|
||||
|
||||
let rows = query_builder.fetch_all(db).await?;
|
||||
Ok(rows.into_iter().map(|(id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)| {
|
||||
ConfigItemInfo { id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn get_config_item(db: &SqlitePool, item_id: &str) -> SaasResult<ConfigItemInfo> {
|
||||
let row: Option<(String, String, String, String, Option<String>, Option<String>, String, Option<String>, bool, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at
|
||||
FROM config_items WHERE id = ?1"
|
||||
)
|
||||
.bind(item_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound(format!("配置项 {} 不存在", item_id)))?;
|
||||
|
||||
Ok(ConfigItemInfo { id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at })
|
||||
}
|
||||
|
||||
pub async fn create_config_item(
|
||||
db: &SqlitePool, req: &CreateConfigItemRequest,
|
||||
) -> SaasResult<ConfigItemInfo> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let source = req.source.as_deref().unwrap_or("local");
|
||||
let requires_restart = req.requires_restart.unwrap_or(false);
|
||||
|
||||
// 检查唯一性
|
||||
let existing: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM config_items WHERE category = ?1 AND key_path = ?2"
|
||||
)
|
||||
.bind(&req.category).bind(&req.key_path)
|
||||
.fetch_optional(db).await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(SaasError::AlreadyExists(format!(
|
||||
"配置项 {}:{} 已存在", req.category, req.key_path
|
||||
)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)"
|
||||
)
|
||||
.bind(&id).bind(&req.category).bind(&req.key_path).bind(&req.value_type)
|
||||
.bind(&req.current_value).bind(&req.default_value).bind(source)
|
||||
.bind(&req.description).bind(requires_restart).bind(&now)
|
||||
.execute(db).await?;
|
||||
|
||||
get_config_item(db, &id).await
|
||||
}
|
||||
|
||||
pub async fn update_config_item(
|
||||
db: &SqlitePool, item_id: &str, req: &UpdateConfigItemRequest,
|
||||
) -> SaasResult<ConfigItemInfo> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut updates = Vec::new();
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ref v) = req.current_value { updates.push("current_value = ?"); params.push(v.clone()); }
|
||||
if let Some(ref v) = req.source { updates.push("source = ?"); params.push(v.clone()); }
|
||||
if let Some(ref v) = req.description { updates.push("description = ?"); params.push(v.clone()); }
|
||||
|
||||
if updates.is_empty() {
|
||||
return get_config_item(db, item_id).await;
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
params.push(now);
|
||||
params.push(item_id.to_string());
|
||||
|
||||
let sql = format!("UPDATE config_items SET {} WHERE id = ?", updates.join(", "));
|
||||
let mut query = sqlx::query(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(p);
|
||||
}
|
||||
query.execute(db).await?;
|
||||
|
||||
get_config_item(db, item_id).await
|
||||
}
|
||||
|
||||
pub async fn delete_config_item(db: &SqlitePool, item_id: &str) -> SaasResult<()> {
|
||||
let result = sqlx::query("DELETE FROM config_items WHERE id = ?1")
|
||||
.bind(item_id).execute(db).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound(format!("配置项 {} 不存在", item_id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Config Analysis ============
|
||||
|
||||
pub async fn analyze_config(db: &SqlitePool) -> SaasResult<ConfigAnalysis> {
|
||||
let items = list_config_items(db, &ConfigQuery { category: None, source: None }).await?;
|
||||
|
||||
let mut categories: std::collections::HashMap<String, (i64, i64)> = std::collections::HashMap::new();
|
||||
for item in &items {
|
||||
let entry = categories.entry(item.category.clone()).or_insert((0, 0));
|
||||
entry.0 += 1;
|
||||
if item.source == "saas" {
|
||||
entry.1 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let category_summaries: Vec<CategorySummary> = categories.into_iter()
|
||||
.map(|(category, (count, saas_managed))| CategorySummary { category, count, saas_managed })
|
||||
.collect();
|
||||
|
||||
Ok(ConfigAnalysis {
|
||||
total_items: items.len() as i64,
|
||||
categories: category_summaries,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
/// 种子默认配置项
|
||||
pub async fn seed_default_config_items(db: &SqlitePool) -> SaasResult<usize> {
|
||||
let defaults = [
|
||||
("server", "server.host", "string", Some("127.0.0.1"), Some("127.0.0.1"), "服务器监听地址"),
|
||||
("server", "server.port", "integer", Some("4200"), Some("4200"), "服务器端口"),
|
||||
("server", "server.cors_origins", "array", None, None, "CORS 允许的源"),
|
||||
("agent", "agent.defaults.default_model", "string", Some("zhipu/glm-4-plus"), Some("zhipu/glm-4-plus"), "默认模型"),
|
||||
("agent", "agent.defaults.fallback_models", "array", None, None, "回退模型列表"),
|
||||
("agent", "agent.defaults.max_sessions", "integer", Some("10"), Some("10"), "最大并发会话数"),
|
||||
("agent", "agent.defaults.heartbeat_interval", "duration", Some("1h"), Some("1h"), "心跳间隔"),
|
||||
("agent", "agent.defaults.session_timeout", "duration", Some("24h"), Some("24h"), "会话超时"),
|
||||
("memory", "agent.defaults.memory.max_history_length", "integer", Some("100"), Some("100"), "最大历史长度"),
|
||||
("memory", "agent.defaults.memory.summarize_threshold", "integer", Some("50"), Some("50"), "摘要阈值"),
|
||||
("llm", "llm.default_provider", "string", Some("zhipu"), Some("zhipu"), "默认 LLM Provider"),
|
||||
("llm", "llm.temperature", "float", Some("0.7"), Some("0.7"), "默认温度"),
|
||||
("llm", "llm.max_tokens", "integer", Some("4096"), Some("4096"), "默认最大 token 数"),
|
||||
];
|
||||
|
||||
let mut created = 0;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
for (category, key_path, value_type, default_value, current_value, description) in defaults {
|
||||
let existing: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM config_items WHERE category = ?1 AND key_path = ?2"
|
||||
)
|
||||
.bind(category).bind(key_path)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
if existing.is_none() {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'local', ?7, 0, ?8, ?8)"
|
||||
)
|
||||
.bind(&id).bind(category).bind(key_path).bind(value_type)
|
||||
.bind(current_value).bind(default_value).bind(description).bind(&now)
|
||||
.execute(db)
|
||||
.await?;
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(created)
|
||||
}
|
||||
|
||||
// ============ Config Sync ============
|
||||
|
||||
/// 计算客户端与 SaaS 端的配置差异
|
||||
pub async fn compute_config_diff(
|
||||
db: &SqlitePool, req: &SyncConfigRequest,
|
||||
) -> SaasResult<ConfigDiffResponse> {
|
||||
let saas_items = list_config_items(db, &ConfigQuery { category: None, source: None }).await?;
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut conflicts = 0usize;
|
||||
|
||||
for key in &req.config_keys {
|
||||
let client_val = req.client_values.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 查找 SaaS 端的值
|
||||
let saas_item = saas_items.iter().find(|item| item.key_path == *key);
|
||||
let saas_val = saas_item.and_then(|item| item.current_value.clone());
|
||||
|
||||
let conflict = match (&client_val, &saas_val) {
|
||||
(Some(a), Some(b)) => a != b,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if conflict {
|
||||
conflicts += 1;
|
||||
}
|
||||
|
||||
items.push(ConfigDiffItem {
|
||||
key_path: key.clone(),
|
||||
client_value: client_val,
|
||||
saas_value: saas_val,
|
||||
conflict,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ConfigDiffResponse {
|
||||
total_keys: items.len(),
|
||||
conflicts,
|
||||
items,
|
||||
})
|
||||
}
|
||||
|
||||
/// 执行配置同步 (实际写入 config_items)
|
||||
pub async fn sync_config(
|
||||
db: &SqlitePool, account_id: &str, req: &SyncConfigRequest,
|
||||
) -> SaasResult<ConfigSyncResult> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let config_keys_str = serde_json::to_string(&req.config_keys)?;
|
||||
let client_values_str = Some(serde_json::to_string(&req.client_values)?);
|
||||
|
||||
// 获取 SaaS 端的配置值
|
||||
let saas_items = list_config_items(db, &ConfigQuery { category: None, source: None }).await?;
|
||||
let mut updated = 0i64;
|
||||
let created = 0i64;
|
||||
let mut skipped = 0i64;
|
||||
|
||||
for key in &req.config_keys {
|
||||
let client_val = req.client_values.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let saas_item = saas_items.iter().find(|item| item.key_path == *key);
|
||||
|
||||
match req.action.as_str() {
|
||||
"push" => {
|
||||
// 客户端推送 → 覆盖 SaaS 值
|
||||
if let Some(val) = &client_val {
|
||||
if let Some(item) = saas_item {
|
||||
// 更新已有配置项
|
||||
sqlx::query("UPDATE config_items SET current_value = ?1, source = 'local', updated_at = ?2 WHERE id = ?3")
|
||||
.bind(val).bind(&now).bind(&item.id)
|
||||
.execute(db).await?;
|
||||
updated += 1;
|
||||
} else {
|
||||
// 推送时如果 SaaS 不存在该 key,记录跳过
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
"merge" => {
|
||||
// 合并: 客户端有值且 SaaS 无值 → 创建; 都有值 → SaaS 优先保留
|
||||
if let Some(val) = &client_val {
|
||||
if let Some(item) = saas_item {
|
||||
if item.current_value.is_none() || item.current_value.as_deref() == Some("") {
|
||||
sqlx::query("UPDATE config_items SET current_value = ?1, source = 'local', updated_at = ?2 WHERE id = ?3")
|
||||
.bind(val).bind(&now).bind(&item.id)
|
||||
.execute(db).await?;
|
||||
updated += 1;
|
||||
} else {
|
||||
// 冲突: SaaS 有值 → 保留 SaaS 值
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
// 客户端有但 SaaS 完全没有的 key → 不自动创建 (需要管理员先创建)
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 默认: 记录日志但不修改 (向后兼容旧行为)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录同步日志
|
||||
let saas_values: serde_json::Value = saas_items.iter()
|
||||
.filter(|item| req.config_keys.contains(&item.key_path))
|
||||
.map(|item| {
|
||||
serde_json::json!({
|
||||
"value": item.current_value,
|
||||
"source": item.source,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let saas_values_str = Some(serde_json::to_string(&saas_values)?);
|
||||
let resolution = req.action.clone();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO config_sync_log (account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
|
||||
)
|
||||
.bind(account_id).bind(&req.client_fingerprint)
|
||||
.bind(&req.action).bind(&config_keys_str).bind(&client_values_str)
|
||||
.bind(&saas_values_str).bind(&resolution).bind(&now)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(ConfigSyncResult { updated, created, skipped })
|
||||
}
|
||||
|
||||
/// 同步结果
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConfigSyncResult {
|
||||
pub updated: i64,
|
||||
pub created: i64,
|
||||
pub skipped: i64,
|
||||
}
|
||||
|
||||
pub async fn list_sync_logs(
|
||||
db: &SqlitePool, account_id: &str,
|
||||
) -> SaasResult<Vec<ConfigSyncLogInfo>> {
|
||||
let rows: Vec<(i64, String, String, String, String, Option<String>, Option<String>, Option<String>, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at
|
||||
FROM config_sync_log WHERE account_id = ?1 ORDER BY created_at DESC LIMIT 50"
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|(id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at)| {
|
||||
ConfigSyncLogInfo { id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at }
|
||||
}).collect())
|
||||
}
|
||||
106
crates/zclaw-saas/src/migration/types.rs
Normal file
106
crates/zclaw-saas/src/migration/types.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
//! 配置迁移类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 配置项信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConfigItemInfo {
|
||||
pub id: String,
|
||||
pub category: String,
|
||||
pub key_path: String,
|
||||
pub value_type: String,
|
||||
pub current_value: Option<String>,
|
||||
pub default_value: Option<String>,
|
||||
pub source: String,
|
||||
pub description: Option<String>,
|
||||
pub requires_restart: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 创建配置项请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateConfigItemRequest {
|
||||
pub category: String,
|
||||
pub key_path: String,
|
||||
pub value_type: String,
|
||||
pub current_value: Option<String>,
|
||||
pub default_value: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub requires_restart: Option<bool>,
|
||||
}
|
||||
|
||||
/// 更新配置项请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateConfigItemRequest {
|
||||
pub current_value: Option<String>,
|
||||
pub source: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// 配置同步日志
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConfigSyncLogInfo {
|
||||
pub id: i64,
|
||||
pub account_id: String,
|
||||
pub client_fingerprint: String,
|
||||
pub action: String,
|
||||
pub config_keys: String,
|
||||
pub client_values: Option<String>,
|
||||
pub saas_values: Option<String>,
|
||||
pub resolution: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 配置分析结果
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConfigAnalysis {
|
||||
pub total_items: i64,
|
||||
pub categories: Vec<CategorySummary>,
|
||||
pub items: Vec<ConfigItemInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CategorySummary {
|
||||
pub category: String,
|
||||
pub count: i64,
|
||||
pub saas_managed: i64,
|
||||
}
|
||||
|
||||
/// 配置同步请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SyncConfigRequest {
|
||||
pub client_fingerprint: String,
|
||||
/// 同步方向: "push", "pull", "merge"
|
||||
#[serde(default = "default_sync_action")]
|
||||
pub action: String,
|
||||
pub config_keys: Vec<String>,
|
||||
pub client_values: serde_json::Value,
|
||||
}
|
||||
|
||||
fn default_sync_action() -> String { "push".to_string() }
|
||||
|
||||
/// 配置差异项
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConfigDiffItem {
|
||||
pub key_path: String,
|
||||
pub client_value: Option<String>,
|
||||
pub saas_value: Option<String>,
|
||||
pub conflict: bool,
|
||||
}
|
||||
|
||||
/// 配置差异响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConfigDiffResponse {
|
||||
pub items: Vec<ConfigDiffItem>,
|
||||
pub total_keys: usize,
|
||||
pub conflicts: usize,
|
||||
}
|
||||
|
||||
/// 配置查询参数
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ConfigQuery {
|
||||
pub category: Option<String>,
|
||||
pub source: Option<String>,
|
||||
}
|
||||
194
crates/zclaw-saas/src/model_config/handlers.rs
Normal file
194
crates/zclaw-saas/src/model_config/handlers.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
//! 模型配置 HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::StatusCode, Json,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::SaasResult;
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::{log_operation, check_permission};
|
||||
use super::{types::*, service};
|
||||
|
||||
// ============ Providers ============
|
||||
|
||||
/// GET /api/v1/providers
|
||||
pub async fn list_providers(
|
||||
State(state): State<AppState>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<ProviderInfo>>> {
|
||||
service::list_providers(&state.db).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/providers/:id
|
||||
pub async fn get_provider(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<ProviderInfo>> {
|
||||
service::get_provider(&state.db, &id).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/providers (admin only)
|
||||
pub async fn create_provider(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateProviderRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<ProviderInfo>)> {
|
||||
check_permission(&ctx, "provider:manage")?;
|
||||
let provider = service::create_provider(&state.db, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "provider.create", "provider", &provider.id,
|
||||
Some(serde_json::json!({"name": &req.name})), ctx.client_ip.as_deref()).await?;
|
||||
Ok((StatusCode::CREATED, Json(provider)))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/providers/:id (admin only)
|
||||
pub async fn update_provider(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<UpdateProviderRequest>,
|
||||
) -> SaasResult<Json<ProviderInfo>> {
|
||||
check_permission(&ctx, "provider:manage")?;
|
||||
let provider = service::update_provider(&state.db, &id, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "provider.update", "provider", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(provider))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/providers/:id (admin only)
|
||||
pub async fn delete_provider(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "provider:manage")?;
|
||||
service::delete_provider(&state.db, &id).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "provider.delete", "provider", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
// ============ Models ============
|
||||
|
||||
/// GET /api/v1/models?provider_id=xxx
|
||||
pub async fn list_models(
|
||||
State(state): State<AppState>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<ModelInfo>>> {
|
||||
let provider_id = params.get("provider_id").map(|s| s.as_str());
|
||||
service::list_models(&state.db, provider_id).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/models/:id
|
||||
pub async fn get_model(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<ModelInfo>> {
|
||||
service::get_model(&state.db, &id).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/models (admin only)
|
||||
pub async fn create_model(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateModelRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<ModelInfo>)> {
|
||||
check_permission(&ctx, "model:manage")?;
|
||||
let model = service::create_model(&state.db, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "model.create", "model", &model.id,
|
||||
Some(serde_json::json!({"model_id": &req.model_id, "provider_id": &req.provider_id})), ctx.client_ip.as_deref()).await?;
|
||||
Ok((StatusCode::CREATED, Json(model)))
|
||||
}
|
||||
|
||||
/// PUT /api/v1/models/:id (admin only)
|
||||
pub async fn update_model(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<UpdateModelRequest>,
|
||||
) -> SaasResult<Json<ModelInfo>> {
|
||||
check_permission(&ctx, "model:manage")?;
|
||||
let model = service::update_model(&state.db, &id, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "model.update", "model", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(model))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/models/:id (admin only)
|
||||
pub async fn delete_model(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "model:manage")?;
|
||||
service::delete_model(&state.db, &id).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "model.delete", "model", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
// ============ Account API Keys ============
|
||||
|
||||
/// GET /api/v1/keys?provider_id=xxx
|
||||
pub async fn list_api_keys(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||
) -> SaasResult<Json<Vec<AccountApiKeyInfo>>> {
|
||||
let provider_id = params.get("provider_id").map(|s| s.as_str());
|
||||
service::list_account_api_keys(&state.db, &ctx.account_id, provider_id).await.map(Json)
|
||||
}
|
||||
|
||||
/// POST /api/v1/keys
|
||||
pub async fn create_api_key(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateAccountApiKeyRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<AccountApiKeyInfo>)> {
|
||||
let key = service::create_account_api_key(&state.db, &ctx.account_id, &req).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "api_key.create", "api_key", &key.id,
|
||||
Some(serde_json::json!({"provider_id": &req.provider_id})), ctx.client_ip.as_deref()).await?;
|
||||
Ok((StatusCode::CREATED, Json(key)))
|
||||
}
|
||||
|
||||
/// POST /api/v1/keys/:id/rotate
|
||||
pub async fn rotate_api_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<RotateApiKeyRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
service::rotate_account_api_key(&state.db, &id, &ctx.account_id, &req.new_key_value).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "api_key.rotate", "api_key", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
/// DELETE /api/v1/keys/:id
|
||||
pub async fn revoke_api_key(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
service::revoke_account_api_key(&state.db, &id, &ctx.account_id).await?;
|
||||
log_operation(&state.db, &ctx.account_id, "api_key.revoke", "api_key", &id, None, ctx.client_ip.as_deref()).await?;
|
||||
Ok(Json(serde_json::json!({"ok": true})))
|
||||
}
|
||||
|
||||
// ============ Usage ============
|
||||
|
||||
/// GET /api/v1/usage?from=...&to=...&provider_id=...&model_id=...
|
||||
pub async fn get_usage(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Query(params): Query<UsageQuery>,
|
||||
) -> SaasResult<Json<UsageStats>> {
|
||||
service::get_usage_stats(&state.db, &ctx.account_id, ¶ms).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/providers/:id/models (便捷路由)
|
||||
pub async fn list_provider_models(
|
||||
State(state): State<AppState>,
|
||||
Path(provider_id): Path<String>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<ModelInfo>>> {
|
||||
service::list_models(&state.db, Some(&provider_id)).await.map(Json)
|
||||
}
|
||||
26
crates/zclaw-saas/src/model_config/mod.rs
Normal file
26
crates/zclaw-saas/src/model_config/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
//! 模型配置模块
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::{delete, get, post};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 模型配置路由 (需要认证)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
// Providers
|
||||
.route("/api/v1/providers", get(handlers::list_providers).post(handlers::create_provider))
|
||||
.route("/api/v1/providers/{id}", get(handlers::get_provider).put(handlers::update_provider).delete(handlers::delete_provider))
|
||||
.route("/api/v1/providers/{id}/models", get(handlers::list_provider_models))
|
||||
// Models
|
||||
.route("/api/v1/models", get(handlers::list_models).post(handlers::create_model))
|
||||
.route("/api/v1/models/{id}", get(handlers::get_model).put(handlers::update_model).delete(handlers::delete_model))
|
||||
// Account API Keys
|
||||
.route("/api/v1/keys", get(handlers::list_api_keys).post(handlers::create_api_key))
|
||||
.route("/api/v1/keys/{id}", delete(handlers::revoke_api_key))
|
||||
.route("/api/v1/keys/{id}/rotate", post(handlers::rotate_api_key))
|
||||
// Usage
|
||||
.route("/api/v1/usage", get(handlers::get_usage))
|
||||
}
|
||||
411
crates/zclaw-saas/src/model_config/service.rs
Normal file
411
crates/zclaw-saas/src/model_config/service.rs
Normal file
@@ -0,0 +1,411 @@
|
||||
//! 模型配置业务逻辑
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::types::*;
|
||||
|
||||
// ============ Providers ============
|
||||
|
||||
pub async fn list_providers(db: &SqlitePool) -> SaasResult<Vec<ProviderInfo>> {
|
||||
let rows: Vec<(String, String, String, String, String, bool, Option<i64>, Option<i64>, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at
|
||||
FROM providers ORDER BY name"
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|(id, name, display_name, base_url, api_protocol, enabled, rpm, tpm, created_at, updated_at)| {
|
||||
ProviderInfo { id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm: rpm, rate_limit_tpm: tpm, created_at, updated_at }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn get_provider(db: &SqlitePool, provider_id: &str) -> SaasResult<ProviderInfo> {
|
||||
let row: Option<(String, String, String, String, String, bool, Option<i64>, Option<i64>, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at
|
||||
FROM providers WHERE id = ?1"
|
||||
)
|
||||
.bind(provider_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let (id, name, display_name, base_url, api_protocol, enabled, rpm, tpm, created_at, updated_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound(format!("Provider {} 不存在", provider_id)))?;
|
||||
|
||||
Ok(ProviderInfo { id, name, display_name, base_url, api_protocol, enabled, rate_limit_rpm: rpm, rate_limit_tpm: tpm, created_at, updated_at })
|
||||
}
|
||||
|
||||
pub async fn create_provider(db: &SqlitePool, req: &CreateProviderRequest) -> SaasResult<ProviderInfo> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// 检查名称唯一性
|
||||
let existing: Option<(String,)> = sqlx::query_as("SELECT id FROM providers WHERE name = ?1")
|
||||
.bind(&req.name).fetch_optional(db).await?;
|
||||
if existing.is_some() {
|
||||
return Err(SaasError::AlreadyExists(format!("Provider '{}' 已存在", req.name)));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO providers (id, name, display_name, api_key, base_url, api_protocol, enabled, rate_limit_rpm, rate_limit_tpm, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?8, ?9, ?9)"
|
||||
)
|
||||
.bind(&id).bind(&req.name).bind(&req.display_name).bind(&req.api_key)
|
||||
.bind(&req.base_url).bind(&req.api_protocol).bind(&req.rate_limit_rpm).bind(&req.rate_limit_tpm).bind(&now)
|
||||
.execute(db).await?;
|
||||
|
||||
get_provider(db, &id).await
|
||||
}
|
||||
|
||||
pub async fn update_provider(
|
||||
db: &SqlitePool, provider_id: &str, req: &UpdateProviderRequest,
|
||||
) -> SaasResult<ProviderInfo> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut updates = Vec::new();
|
||||
let mut params: Vec<Box<dyn std::fmt::Display + Send + Sync>> = Vec::new();
|
||||
|
||||
if let Some(ref v) = req.display_name { updates.push("display_name = ?"); params.push(Box::new(v.clone())); }
|
||||
if let Some(ref v) = req.base_url { updates.push("base_url = ?"); params.push(Box::new(v.clone())); }
|
||||
if let Some(ref v) = req.api_protocol { updates.push("api_protocol = ?"); params.push(Box::new(v.clone())); }
|
||||
if let Some(ref v) = req.api_key { updates.push("api_key = ?"); params.push(Box::new(v.clone())); }
|
||||
if let Some(v) = req.enabled { updates.push("enabled = ?"); params.push(Box::new(v)); }
|
||||
if let Some(v) = req.rate_limit_rpm { updates.push("rate_limit_rpm = ?"); params.push(Box::new(v)); }
|
||||
if let Some(v) = req.rate_limit_tpm { updates.push("rate_limit_tpm = ?"); params.push(Box::new(v)); }
|
||||
|
||||
if updates.is_empty() {
|
||||
return get_provider(db, provider_id).await;
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
params.push(Box::new(now.clone()));
|
||||
params.push(Box::new(provider_id.to_string()));
|
||||
|
||||
let sql = format!("UPDATE providers SET {} WHERE id = ?", updates.join(", "));
|
||||
let mut query = sqlx::query(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(format!("{}", p));
|
||||
}
|
||||
query.execute(db).await?;
|
||||
|
||||
get_provider(db, provider_id).await
|
||||
}
|
||||
|
||||
pub async fn delete_provider(db: &SqlitePool, provider_id: &str) -> SaasResult<()> {
|
||||
let result = sqlx::query("DELETE FROM providers WHERE id = ?1")
|
||||
.bind(provider_id).execute(db).await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound(format!("Provider {} 不存在", provider_id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Models ============
|
||||
|
||||
pub async fn list_models(db: &SqlitePool, provider_id: Option<&str>) -> SaasResult<Vec<ModelInfo>> {
|
||||
let sql = if provider_id.is_some() {
|
||||
"SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at
|
||||
FROM models WHERE provider_id = ?1 ORDER BY alias"
|
||||
} else {
|
||||
"SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at
|
||||
FROM models ORDER BY provider_id, alias"
|
||||
};
|
||||
|
||||
let mut query = sqlx::query_as::<_, (String, String, String, String, i64, i64, bool, bool, bool, f64, f64, String, String)>(sql);
|
||||
if let Some(pid) = provider_id {
|
||||
query = query.bind(pid);
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(db).await?;
|
||||
Ok(rows.into_iter().map(|(id, provider_id, model_id, alias, ctx, max_out, streaming, vision, enabled, pi, po, created_at, updated_at)| {
|
||||
ModelInfo { id, provider_id, model_id, alias, context_window: ctx, max_output_tokens: max_out, supports_streaming: streaming, supports_vision: vision, enabled, pricing_input: pi, pricing_output: po, created_at, updated_at }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn create_model(db: &SqlitePool, req: &CreateModelRequest) -> SaasResult<ModelInfo> {
|
||||
// 验证 provider 存在
|
||||
let provider = get_provider(db, &req.provider_id).await?;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// 检查 model 唯一性
|
||||
let existing: Option<(String,)> = sqlx::query_as(
|
||||
"SELECT id FROM models WHERE provider_id = ?1 AND model_id = ?2"
|
||||
)
|
||||
.bind(&req.provider_id).bind(&req.model_id)
|
||||
.fetch_optional(db).await?;
|
||||
|
||||
if existing.is_some() {
|
||||
return Err(SaasError::AlreadyExists(format!(
|
||||
"模型 '{}' 已存在于 provider '{}'", req.model_id, provider.name
|
||||
)));
|
||||
}
|
||||
|
||||
let ctx = req.context_window.unwrap_or(8192);
|
||||
let max_out = req.max_output_tokens.unwrap_or(4096);
|
||||
let streaming = req.supports_streaming.unwrap_or(true);
|
||||
let vision = req.supports_vision.unwrap_or(false);
|
||||
let pi = req.pricing_input.unwrap_or(0.0);
|
||||
let po = req.pricing_output.unwrap_or(0.0);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO models (id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, ?9, ?10, ?11, ?11)"
|
||||
)
|
||||
.bind(&id).bind(&req.provider_id).bind(&req.model_id).bind(&req.alias)
|
||||
.bind(ctx).bind(max_out).bind(streaming).bind(vision).bind(pi).bind(po).bind(&now)
|
||||
.execute(db).await?;
|
||||
|
||||
get_model(db, &id).await
|
||||
}
|
||||
|
||||
pub async fn get_model(db: &SqlitePool, model_id: &str) -> SaasResult<ModelInfo> {
|
||||
let row: Option<(String, String, String, String, i64, i64, bool, bool, bool, f64, f64, String, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, provider_id, model_id, alias, context_window, max_output_tokens, supports_streaming, supports_vision, enabled, pricing_input, pricing_output, created_at, updated_at
|
||||
FROM models WHERE id = ?1"
|
||||
)
|
||||
.bind(model_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let (id, provider_id, model_id, alias, ctx, max_out, streaming, vision, enabled, pi, po, created_at, updated_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound(format!("模型 {} 不存在", model_id)))?;
|
||||
|
||||
Ok(ModelInfo { id, provider_id, model_id, alias, context_window: ctx, max_output_tokens: max_out, supports_streaming: streaming, supports_vision: vision, enabled, pricing_input: pi, pricing_output: po, created_at, updated_at })
|
||||
}
|
||||
|
||||
pub async fn update_model(
|
||||
db: &SqlitePool, model_id: &str, req: &UpdateModelRequest,
|
||||
) -> SaasResult<ModelInfo> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let mut updates = Vec::new();
|
||||
let mut params: Vec<Box<dyn std::fmt::Display + Send + Sync>> = Vec::new();
|
||||
|
||||
if let Some(ref v) = req.alias { updates.push("alias = ?"); params.push(Box::new(v.clone())); }
|
||||
if let Some(v) = req.context_window { updates.push("context_window = ?"); params.push(Box::new(v)); }
|
||||
if let Some(v) = req.max_output_tokens { updates.push("max_output_tokens = ?"); params.push(Box::new(v)); }
|
||||
if let Some(v) = req.supports_streaming { updates.push("supports_streaming = ?"); params.push(Box::new(v)); }
|
||||
if let Some(v) = req.supports_vision { updates.push("supports_vision = ?"); params.push(Box::new(v)); }
|
||||
if let Some(v) = req.enabled { updates.push("enabled = ?"); params.push(Box::new(v)); }
|
||||
if let Some(v) = req.pricing_input { updates.push("pricing_input = ?"); params.push(Box::new(v)); }
|
||||
if let Some(v) = req.pricing_output { updates.push("pricing_output = ?"); params.push(Box::new(v)); }
|
||||
|
||||
if updates.is_empty() {
|
||||
return get_model(db, model_id).await;
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
params.push(Box::new(now.clone()));
|
||||
params.push(Box::new(model_id.to_string()));
|
||||
|
||||
let sql = format!("UPDATE models SET {} WHERE id = ?", updates.join(", "));
|
||||
let mut query = sqlx::query(&sql);
|
||||
for p in ¶ms {
|
||||
query = query.bind(format!("{}", p));
|
||||
}
|
||||
query.execute(db).await?;
|
||||
|
||||
get_model(db, model_id).await
|
||||
}
|
||||
|
||||
pub async fn delete_model(db: &SqlitePool, model_id: &str) -> SaasResult<()> {
|
||||
let result = sqlx::query("DELETE FROM models WHERE id = ?1")
|
||||
.bind(model_id).execute(db).await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound(format!("模型 {} 不存在", model_id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Account API Keys ============
|
||||
|
||||
pub async fn list_account_api_keys(
|
||||
db: &SqlitePool, account_id: &str, provider_id: Option<&str>,
|
||||
) -> SaasResult<Vec<AccountApiKeyInfo>> {
|
||||
let sql = if provider_id.is_some() {
|
||||
"SELECT id, provider_id, key_label, permissions, enabled, last_used_at, created_at, key_value
|
||||
FROM account_api_keys WHERE account_id = ?1 AND provider_id = ?2 AND revoked_at IS NULL ORDER BY created_at DESC"
|
||||
} else {
|
||||
"SELECT id, provider_id, key_label, permissions, enabled, last_used_at, created_at, key_value
|
||||
FROM account_api_keys WHERE account_id = ?1 AND revoked_at IS NULL ORDER BY created_at DESC"
|
||||
};
|
||||
|
||||
let mut query = sqlx::query_as::<_, (String, String, Option<String>, String, bool, Option<String>, String, String)>(sql)
|
||||
.bind(account_id);
|
||||
if let Some(pid) = provider_id {
|
||||
query = query.bind(pid);
|
||||
}
|
||||
|
||||
let rows = query.fetch_all(db).await?;
|
||||
Ok(rows.into_iter().map(|(id, provider_id, key_label, perms, enabled, last_used, created_at, key_value)| {
|
||||
let permissions: Vec<String> = serde_json::from_str(&perms).unwrap_or_default();
|
||||
let masked = mask_api_key(&key_value);
|
||||
AccountApiKeyInfo { id, provider_id, key_label, permissions, enabled, last_used_at: last_used, created_at, masked_key: masked }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn create_account_api_key(
|
||||
db: &SqlitePool, account_id: &str, req: &CreateAccountApiKeyRequest,
|
||||
) -> SaasResult<AccountApiKeyInfo> {
|
||||
// 验证 provider 存在
|
||||
get_provider(db, &req.provider_id).await?;
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let permissions = serde_json::to_string(&req.permissions)?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO account_api_keys (id, account_id, provider_id, key_value, key_label, permissions, enabled, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, ?7, ?7)"
|
||||
)
|
||||
.bind(&id).bind(account_id).bind(&req.provider_id).bind(&req.key_value)
|
||||
.bind(&req.key_label).bind(&permissions).bind(&now)
|
||||
.execute(db).await?;
|
||||
|
||||
let masked = mask_api_key(&req.key_value);
|
||||
Ok(AccountApiKeyInfo {
|
||||
id, provider_id: req.provider_id.clone(), key_label: req.key_label.clone(),
|
||||
permissions: req.permissions.clone(), enabled: true, last_used_at: None,
|
||||
created_at: now, masked_key: masked,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn rotate_account_api_key(
|
||||
db: &SqlitePool, key_id: &str, account_id: &str, new_key_value: &str,
|
||||
) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = sqlx::query(
|
||||
"UPDATE account_api_keys SET key_value = ?1, updated_at = ?2 WHERE id = ?3 AND account_id = ?4 AND revoked_at IS NULL"
|
||||
)
|
||||
.bind(new_key_value).bind(&now).bind(key_id).bind(account_id)
|
||||
.execute(db).await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound("API Key 不存在或已撤销".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn revoke_account_api_key(
|
||||
db: &SqlitePool, key_id: &str, account_id: &str,
|
||||
) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let result = sqlx::query(
|
||||
"UPDATE account_api_keys SET revoked_at = ?1 WHERE id = ?2 AND account_id = ?3 AND revoked_at IS NULL"
|
||||
)
|
||||
.bind(&now).bind(key_id).bind(account_id)
|
||||
.execute(db).await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(SaasError::NotFound("API Key 不存在或已撤销".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Usage Statistics ============
|
||||
|
||||
pub async fn get_usage_stats(
|
||||
db: &SqlitePool, account_id: &str, query: &UsageQuery,
|
||||
) -> SaasResult<UsageStats> {
|
||||
let mut where_clauses = vec!["account_id = ?".to_string()];
|
||||
let mut params: Vec<String> = vec![account_id.to_string()];
|
||||
|
||||
if let Some(ref from) = query.from {
|
||||
where_clauses.push("created_at >= ?".to_string());
|
||||
params.push(from.clone());
|
||||
}
|
||||
if let Some(ref to) = query.to {
|
||||
where_clauses.push("created_at <= ?".to_string());
|
||||
params.push(to.clone());
|
||||
}
|
||||
if let Some(ref pid) = query.provider_id {
|
||||
where_clauses.push("provider_id = ?".to_string());
|
||||
params.push(pid.clone());
|
||||
}
|
||||
if let Some(ref mid) = query.model_id {
|
||||
where_clauses.push("model_id = ?".to_string());
|
||||
params.push(mid.clone());
|
||||
}
|
||||
|
||||
let where_sql = where_clauses.join(" AND ");
|
||||
|
||||
// 总量统计
|
||||
let total_sql = format!(
|
||||
"SELECT COUNT(*), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0)
|
||||
FROM usage_records WHERE {}", where_sql
|
||||
);
|
||||
let mut total_query = sqlx::query_as::<_, (i64, i64, i64)>(&total_sql);
|
||||
for p in ¶ms {
|
||||
total_query = total_query.bind(p);
|
||||
}
|
||||
let (total_requests, total_input, total_output) = total_query.fetch_one(db).await?;
|
||||
|
||||
// 按模型统计
|
||||
let by_model_sql = format!(
|
||||
"SELECT provider_id, model_id, COUNT(*), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0)
|
||||
FROM usage_records WHERE {} GROUP BY provider_id, model_id ORDER BY COUNT(*) DESC LIMIT 20",
|
||||
where_sql
|
||||
);
|
||||
let mut by_model_query = sqlx::query_as::<_, (String, String, i64, i64, i64)>(&by_model_sql);
|
||||
for p in ¶ms {
|
||||
by_model_query = by_model_query.bind(p);
|
||||
}
|
||||
let by_model_rows = by_model_query.fetch_all(db).await?;
|
||||
let by_model: Vec<ModelUsage> = by_model_rows.into_iter()
|
||||
.map(|(provider_id, model_id, count, input, output)| {
|
||||
ModelUsage { provider_id, model_id, request_count: count, input_tokens: input, output_tokens: output }
|
||||
}).collect();
|
||||
|
||||
// 按天统计 (最近 30 天)
|
||||
let from_30d = (chrono::Utc::now() - chrono::Duration::days(30)).to_rfc3339();
|
||||
let daily_sql = format!(
|
||||
"SELECT DATE(created_at) as day, COUNT(*), COALESCE(SUM(input_tokens), 0), COALESCE(SUM(output_tokens), 0)
|
||||
FROM usage_records WHERE account_id = ?1 AND created_at >= ?2
|
||||
GROUP BY DATE(created_at) ORDER BY day DESC LIMIT 30"
|
||||
);
|
||||
let daily_rows: Vec<(String, i64, i64, i64)> = sqlx::query_as(&daily_sql)
|
||||
.bind(account_id).bind(&from_30d)
|
||||
.fetch_all(db).await?;
|
||||
let by_day: Vec<DailyUsage> = daily_rows.into_iter()
|
||||
.map(|(date, count, input, output)| {
|
||||
DailyUsage { date, request_count: count, input_tokens: input, output_tokens: output }
|
||||
}).collect();
|
||||
|
||||
Ok(UsageStats {
|
||||
total_requests,
|
||||
total_input_tokens: total_input,
|
||||
total_output_tokens: total_output,
|
||||
by_model,
|
||||
by_day,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn record_usage(
|
||||
db: &SqlitePool, account_id: &str, provider_id: &str, model_id: &str,
|
||||
input_tokens: i64, output_tokens: i64, latency_ms: Option<i64>,
|
||||
status: &str, error_message: Option<&str>,
|
||||
) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query(
|
||||
"INSERT INTO usage_records (account_id, provider_id, model_id, input_tokens, output_tokens, latency_ms, status, error_message, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"
|
||||
)
|
||||
.bind(account_id).bind(provider_id).bind(model_id)
|
||||
.bind(input_tokens).bind(output_tokens).bind(latency_ms)
|
||||
.bind(status).bind(error_message).bind(&now)
|
||||
.execute(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
fn mask_api_key(key: &str) -> String {
|
||||
if key.len() <= 8 {
|
||||
return "*".repeat(key.len());
|
||||
}
|
||||
format!("{}...{}", &key[..4], &key[key.len()-4..])
|
||||
}
|
||||
172
crates/zclaw-saas/src/model_config/types.rs
Normal file
172
crates/zclaw-saas/src/model_config/types.rs
Normal file
@@ -0,0 +1,172 @@
|
||||
//! 模型配置类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// --- Provider ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub base_url: String,
|
||||
pub api_protocol: String,
|
||||
pub enabled: bool,
|
||||
pub rate_limit_rpm: Option<i64>,
|
||||
pub rate_limit_tpm: Option<i64>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateProviderRequest {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub base_url: String,
|
||||
#[serde(default = "default_protocol")]
|
||||
pub api_protocol: String,
|
||||
pub api_key: Option<String>,
|
||||
pub rate_limit_rpm: Option<i64>,
|
||||
pub rate_limit_tpm: Option<i64>,
|
||||
}
|
||||
|
||||
fn default_protocol() -> String { "openai".into() }
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateProviderRequest {
|
||||
pub display_name: Option<String>,
|
||||
pub base_url: Option<String>,
|
||||
pub api_protocol: Option<String>,
|
||||
pub api_key: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub rate_limit_rpm: Option<i64>,
|
||||
pub rate_limit_tpm: Option<i64>,
|
||||
}
|
||||
|
||||
// --- Model ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub provider_id: String,
|
||||
pub model_id: String,
|
||||
pub alias: String,
|
||||
pub context_window: i64,
|
||||
pub max_output_tokens: i64,
|
||||
pub supports_streaming: bool,
|
||||
pub supports_vision: bool,
|
||||
pub enabled: bool,
|
||||
pub pricing_input: f64,
|
||||
pub pricing_output: f64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateModelRequest {
|
||||
pub provider_id: String,
|
||||
pub model_id: String,
|
||||
pub alias: String,
|
||||
pub context_window: Option<i64>,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
pub supports_streaming: Option<bool>,
|
||||
pub supports_vision: Option<bool>,
|
||||
pub pricing_input: Option<f64>,
|
||||
pub pricing_output: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateModelRequest {
|
||||
pub alias: Option<String>,
|
||||
pub context_window: Option<i64>,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
pub supports_streaming: Option<bool>,
|
||||
pub supports_vision: Option<bool>,
|
||||
pub enabled: Option<bool>,
|
||||
pub pricing_input: Option<f64>,
|
||||
pub pricing_output: Option<f64>,
|
||||
}
|
||||
|
||||
// --- Account API Key ---
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountApiKeyInfo {
|
||||
pub id: String,
|
||||
pub provider_id: String,
|
||||
pub key_label: Option<String>,
|
||||
pub permissions: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub last_used_at: Option<String>,
|
||||
pub created_at: String,
|
||||
pub masked_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAccountApiKeyRequest {
|
||||
pub provider_id: String,
|
||||
pub key_value: String,
|
||||
pub key_label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub permissions: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RotateApiKeyRequest {
|
||||
pub new_key_value: String,
|
||||
}
|
||||
|
||||
// --- Usage ---
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UsageStats {
|
||||
pub total_requests: i64,
|
||||
pub total_input_tokens: i64,
|
||||
pub total_output_tokens: i64,
|
||||
pub by_model: Vec<ModelUsage>,
|
||||
pub by_day: Vec<DailyUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ModelUsage {
|
||||
pub provider_id: String,
|
||||
pub model_id: String,
|
||||
pub request_count: i64,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DailyUsage {
|
||||
pub date: String,
|
||||
pub request_count: i64,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UsageQuery {
|
||||
pub from: Option<String>,
|
||||
pub to: Option<String>,
|
||||
pub provider_id: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
}
|
||||
|
||||
// --- Seed Data ---
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SeedProvider {
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub base_url: String,
|
||||
pub models: Vec<SeedModel>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SeedModel {
|
||||
pub id: String,
|
||||
pub alias: String,
|
||||
pub context_window: Option<i64>,
|
||||
pub max_output_tokens: Option<i64>,
|
||||
pub supports_streaming: Option<bool>,
|
||||
pub supports_vision: Option<bool>,
|
||||
}
|
||||
249
crates/zclaw-saas/src/relay/handlers.rs
Normal file
249
crates/zclaw-saas/src/relay/handlers.rs
Normal file
@@ -0,0 +1,249 @@
|
||||
//! 中转服务 HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::{log_operation, check_permission};
|
||||
use crate::model_config::service as model_service;
|
||||
use super::{types::*, service};
|
||||
|
||||
/// POST /api/v1/relay/chat/completions
|
||||
/// OpenAI 兼容的聊天补全端点
|
||||
pub async fn chat_completions(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
_headers: HeaderMap,
|
||||
Json(req): Json<serde_json::Value>,
|
||||
) -> SaasResult<Response> {
|
||||
check_permission(&ctx, "relay:use")?;
|
||||
|
||||
let model_name = req.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| SaasError::InvalidInput("缺少 model 字段".into()))?;
|
||||
|
||||
let stream = req.get("stream")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// 查找 model 对应的 provider
|
||||
let models = model_service::list_models(&state.db, None).await?;
|
||||
let target_model = models.iter().find(|m| m.model_id == model_name && m.enabled)
|
||||
.ok_or_else(|| SaasError::NotFound(format!("模型 {} 不存在或未启用", model_name)))?;
|
||||
|
||||
// 获取 provider 信息
|
||||
let provider = model_service::get_provider(&state.db, &target_model.provider_id).await?;
|
||||
if !provider.enabled {
|
||||
return Err(SaasError::Forbidden(format!("Provider {} 已禁用", provider.name)));
|
||||
}
|
||||
|
||||
// 获取 provider 的 API key (从数据库直接查询)
|
||||
let provider_api_key: Option<String> = sqlx::query_scalar(
|
||||
"SELECT api_key FROM providers WHERE id = ?1"
|
||||
)
|
||||
.bind(&target_model.provider_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let request_body = serde_json::to_string(&req)?;
|
||||
|
||||
// 创建中转任务
|
||||
let config = state.config.read().await;
|
||||
let task = service::create_relay_task(
|
||||
&state.db, &ctx.account_id, &target_model.provider_id,
|
||||
&target_model.model_id, &request_body, 0,
|
||||
config.relay.max_attempts,
|
||||
).await?;
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "relay.request", "relay_task", &task.id,
|
||||
Some(serde_json::json!({"model": model_name, "stream": stream})), ctx.client_ip.as_deref()).await?;
|
||||
|
||||
// 执行中转 (带重试)
|
||||
let response = service::execute_relay(
|
||||
&state.db, &task.id, &provider.base_url,
|
||||
provider_api_key.as_deref(), &request_body, stream,
|
||||
config.relay.max_attempts,
|
||||
config.relay.retry_delay_ms,
|
||||
).await;
|
||||
|
||||
match response {
|
||||
Ok(service::RelayResponse::Json(body)) => {
|
||||
// 记录用量
|
||||
let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
|
||||
let input_tokens = parsed.get("usage")
|
||||
.and_then(|u| u.get("prompt_tokens"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
let output_tokens = parsed.get("usage")
|
||||
.and_then(|u| u.get("completion_tokens"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
model_service::record_usage(
|
||||
&state.db, &ctx.account_id, &target_model.provider_id,
|
||||
&target_model.model_id, input_tokens, output_tokens,
|
||||
None, "success", None,
|
||||
).await?;
|
||||
|
||||
Ok((StatusCode::OK, [(axum::http::header::CONTENT_TYPE, "application/json")], body).into_response())
|
||||
}
|
||||
Ok(service::RelayResponse::Sse(body)) => {
|
||||
model_service::record_usage(
|
||||
&state.db, &ctx.account_id, &target_model.provider_id,
|
||||
&target_model.model_id, 0, 0,
|
||||
None, "success", None,
|
||||
).await?;
|
||||
|
||||
// 流式响应: 直接转发 axum::body::Body
|
||||
let response = axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(axum::http::header::CONTENT_TYPE, "text/event-stream")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.header("Connection", "keep-alive")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
Ok(response)
|
||||
}
|
||||
Err(e) => {
|
||||
model_service::record_usage(
|
||||
&state.db, &ctx.account_id, &target_model.provider_id,
|
||||
&target_model.model_id, 0, 0,
|
||||
None, "failed", Some(&e.to_string()),
|
||||
).await?;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /api/v1/relay/tasks
|
||||
pub async fn list_tasks(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Query(query): Query<RelayTaskQuery>,
|
||||
) -> SaasResult<Json<Vec<RelayTaskInfo>>> {
|
||||
service::list_relay_tasks(&state.db, &ctx.account_id, &query).await.map(Json)
|
||||
}
|
||||
|
||||
/// GET /api/v1/relay/tasks/:id
|
||||
pub async fn get_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<RelayTaskInfo>> {
|
||||
let task = service::get_relay_task(&state.db, &id).await?;
|
||||
// 只允许查看自己的任务 (admin 可查看全部)
|
||||
if task.account_id != ctx.account_id {
|
||||
check_permission(&ctx, "relay:admin")?;
|
||||
}
|
||||
Ok(Json(task))
|
||||
}
|
||||
|
||||
/// GET /api/v1/relay/models
|
||||
/// 列出可用的中转模型 (enabled providers + enabled models)
|
||||
pub async fn list_available_models(
|
||||
State(state): State<AppState>,
|
||||
_ctx: Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<serde_json::Value>>> {
|
||||
let providers = model_service::list_providers(&state.db).await?;
|
||||
let enabled_provider_ids: std::collections::HashSet<String> =
|
||||
providers.iter().filter(|p| p.enabled).map(|p| p.id.clone()).collect();
|
||||
|
||||
let models = model_service::list_models(&state.db, None).await?;
|
||||
let available: Vec<serde_json::Value> = models.into_iter()
|
||||
.filter(|m| m.enabled && enabled_provider_ids.contains(&m.provider_id))
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"id": m.model_id,
|
||||
"provider_id": m.provider_id,
|
||||
"alias": m.alias,
|
||||
"context_window": m.context_window,
|
||||
"max_output_tokens": m.max_output_tokens,
|
||||
"supports_streaming": m.supports_streaming,
|
||||
"supports_vision": m.supports_vision,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(available))
|
||||
}
|
||||
|
||||
/// POST /api/v1/relay/tasks/:id/retry (admin only)
|
||||
/// 重试失败的中转任务
|
||||
pub async fn retry_task(
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<String>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
check_permission(&ctx, "relay:admin")?;
|
||||
|
||||
let task = service::get_relay_task(&state.db, &id).await?;
|
||||
if task.status != "failed" {
|
||||
return Err(SaasError::InvalidInput(format!(
|
||||
"只能重试失败的任务,当前状态: {}", task.status
|
||||
)));
|
||||
}
|
||||
|
||||
// 获取 provider 信息
|
||||
let provider = model_service::get_provider(&state.db, &task.provider_id).await?;
|
||||
let provider_api_key: Option<String> = sqlx::query_scalar(
|
||||
"SELECT api_key FROM providers WHERE id = ?1"
|
||||
)
|
||||
.bind(&task.provider_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
// 读取原始请求体
|
||||
let request_body: Option<String> = sqlx::query_scalar(
|
||||
"SELECT request_body FROM relay_tasks WHERE id = ?1"
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let body = request_body.ok_or_else(|| SaasError::Internal("任务请求体丢失".into()))?;
|
||||
|
||||
// 从 request body 解析 stream 标志
|
||||
let stream: bool = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("stream").and_then(|s| s.as_bool()))
|
||||
.unwrap_or(false);
|
||||
|
||||
let max_attempts = task.max_attempts as u32;
|
||||
let config = state.config.read().await;
|
||||
let base_delay_ms = config.relay.retry_delay_ms;
|
||||
|
||||
// 重置任务状态为 queued 以允许新的 processing
|
||||
sqlx::query(
|
||||
"UPDATE relay_tasks SET status = 'queued', error_message = NULL, started_at = NULL, completed_at = NULL WHERE id = ?1"
|
||||
)
|
||||
.bind(&id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
// 异步执行重试
|
||||
let db = state.db.clone();
|
||||
let task_id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
match service::execute_relay(
|
||||
&db, &task_id, &provider.base_url,
|
||||
provider_api_key.as_deref(), &body, stream,
|
||||
max_attempts, base_delay_ms,
|
||||
).await {
|
||||
Ok(_) => tracing::info!("Relay task {} 重试成功", task_id),
|
||||
Err(e) => tracing::warn!("Relay task {} 重试失败: {}", task_id, e),
|
||||
}
|
||||
});
|
||||
|
||||
log_operation(&state.db, &ctx.account_id, "relay.retry", "relay_task", &id,
|
||||
None, ctx.client_ip.as_deref()).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({"ok": true, "task_id": id})))
|
||||
}
|
||||
18
crates/zclaw-saas/src/relay/mod.rs
Normal file
18
crates/zclaw-saas/src/relay/mod.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
//! 中转服务模块
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 中转服务路由 (需要认证)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/v1/relay/chat/completions", post(handlers::chat_completions))
|
||||
.route("/api/v1/relay/tasks", get(handlers::list_tasks))
|
||||
.route("/api/v1/relay/tasks/{id}", get(handlers::get_task))
|
||||
.route("/api/v1/relay/tasks/{id}/retry", post(handlers::retry_task))
|
||||
.route("/api/v1/relay/models", get(handlers::list_available_models))
|
||||
}
|
||||
337
crates/zclaw-saas/src/relay/service.rs
Normal file
337
crates/zclaw-saas/src/relay/service.rs
Normal file
@@ -0,0 +1,337 @@
|
||||
//! 中转服务核心逻辑
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::types::*;
|
||||
use futures::StreamExt;
|
||||
|
||||
/// 判断 HTTP 状态码是否为可重试的瞬态错误 (5xx + 429)
|
||||
fn is_retryable_status(status: u16) -> bool {
|
||||
status == 429 || (500..600).contains(&status)
|
||||
}
|
||||
|
||||
/// 判断 reqwest 错误是否为可重试的网络错误
|
||||
fn is_retryable_error(e: &reqwest::Error) -> bool {
|
||||
e.is_timeout() || e.is_connect() || e.is_request()
|
||||
}
|
||||
|
||||
// ============ Relay Task Management ============
|
||||
|
||||
pub async fn create_relay_task(
|
||||
db: &SqlitePool,
|
||||
account_id: &str,
|
||||
provider_id: &str,
|
||||
model_id: &str,
|
||||
request_body: &str,
|
||||
priority: i64,
|
||||
max_attempts: u32,
|
||||
) -> SaasResult<RelayTaskInfo> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let request_hash = hash_request(request_body);
|
||||
let max_attempts = max_attempts.max(1).min(5);
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO relay_tasks (id, account_id, provider_id, model_id, request_hash, request_body, status, priority, attempt_count, max_attempts, queued_at, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'queued', ?7, 0, ?8, ?9, ?9)"
|
||||
)
|
||||
.bind(&id).bind(account_id).bind(provider_id).bind(model_id)
|
||||
.bind(&request_hash).bind(request_body).bind(priority).bind(max_attempts as i64).bind(&now)
|
||||
.execute(db).await?;
|
||||
|
||||
get_relay_task(db, &id).await
|
||||
}
|
||||
|
||||
pub async fn get_relay_task(db: &SqlitePool, task_id: &str) -> SaasResult<RelayTaskInfo> {
|
||||
let row: Option<(String, String, String, String, String, i64, i64, i64, i64, i64, Option<String>, String, Option<String>, Option<String>, String)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at
|
||||
FROM relay_tasks WHERE id = ?1"
|
||||
)
|
||||
.bind(task_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let (id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound(format!("中转任务 {} 不存在", task_id)))?;
|
||||
|
||||
Ok(RelayTaskInfo {
|
||||
id, account_id, provider_id, model_id, status, priority,
|
||||
attempt_count, max_attempts, input_tokens, output_tokens,
|
||||
error_message, queued_at, started_at, completed_at, created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_relay_tasks(
|
||||
db: &SqlitePool, account_id: &str, query: &RelayTaskQuery,
|
||||
) -> SaasResult<Vec<RelayTaskInfo>> {
|
||||
let page = query.page.unwrap_or(1).max(1);
|
||||
let page_size = query.page_size.unwrap_or(20).min(100);
|
||||
let offset = (page - 1) * page_size;
|
||||
|
||||
let sql = if query.status.is_some() {
|
||||
"SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at
|
||||
FROM relay_tasks WHERE account_id = ?1 AND status = ?2 ORDER BY created_at DESC LIMIT ?3 OFFSET ?4"
|
||||
} else {
|
||||
"SELECT id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at
|
||||
FROM relay_tasks WHERE account_id = ?1 ORDER BY created_at DESC LIMIT ?2 OFFSET ?3"
|
||||
};
|
||||
|
||||
let mut query_builder = sqlx::query_as::<_, (String, String, String, String, String, i64, i64, i64, i64, i64, Option<String>, String, Option<String>, Option<String>, String)>(sql)
|
||||
.bind(account_id);
|
||||
|
||||
if let Some(ref status) = query.status {
|
||||
query_builder = query_builder.bind(status);
|
||||
}
|
||||
|
||||
query_builder = query_builder.bind(page_size).bind(offset);
|
||||
|
||||
let rows = query_builder.fetch_all(db).await?;
|
||||
Ok(rows.into_iter().map(|(id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at)| {
|
||||
RelayTaskInfo { id, account_id, provider_id, model_id, status, priority, attempt_count, max_attempts, input_tokens, output_tokens, error_message, queued_at, started_at, completed_at, created_at }
|
||||
}).collect())
|
||||
}
|
||||
|
||||
pub async fn update_task_status(
|
||||
db: &SqlitePool, task_id: &str, status: &str,
|
||||
input_tokens: Option<i64>, output_tokens: Option<i64>,
|
||||
error_message: Option<&str>,
|
||||
) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let update_sql = match status {
|
||||
"processing" => "started_at = ?1, status = 'processing', attempt_count = attempt_count + 1",
|
||||
"completed" => "completed_at = ?1, status = 'completed', input_tokens = COALESCE(?2, input_tokens), output_tokens = COALESCE(?3, output_tokens)",
|
||||
"failed" => "completed_at = ?1, status = 'failed', error_message = ?2",
|
||||
_ => return Err(SaasError::InvalidInput(format!("无效任务状态: {}", status))),
|
||||
};
|
||||
|
||||
let sql = format!("UPDATE relay_tasks SET {} WHERE id = ?4", update_sql);
|
||||
|
||||
let mut query = sqlx::query(&sql).bind(&now);
|
||||
if status == "completed" {
|
||||
query = query.bind(input_tokens).bind(output_tokens);
|
||||
}
|
||||
if status == "failed" {
|
||||
query = query.bind(error_message);
|
||||
}
|
||||
query = query.bind(task_id);
|
||||
query.execute(db).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============ Relay Execution ============
|
||||
|
||||
pub async fn execute_relay(
|
||||
db: &SqlitePool,
|
||||
task_id: &str,
|
||||
provider_base_url: &str,
|
||||
provider_api_key: Option<&str>,
|
||||
request_body: &str,
|
||||
stream: bool,
|
||||
max_attempts: u32,
|
||||
base_delay_ms: u64,
|
||||
) -> SaasResult<RelayResponse> {
|
||||
validate_provider_url(provider_base_url)?;
|
||||
|
||||
let url = format!("{}/chat/completions", provider_base_url.trim_end_matches('/'));
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(if stream { 300 } else { 30 }))
|
||||
.build()
|
||||
.map_err(|e| SaasError::Internal(format!("HTTP 客户端构建失败: {}", e)))?;
|
||||
|
||||
let max_attempts = max_attempts.max(1).min(5);
|
||||
|
||||
for attempt in 0..max_attempts {
|
||||
let is_first = attempt == 0;
|
||||
if is_first {
|
||||
update_task_status(db, task_id, "processing", None, None, None).await?;
|
||||
}
|
||||
|
||||
let mut req_builder = client.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(request_body.to_string());
|
||||
|
||||
if let Some(key) = provider_api_key {
|
||||
req_builder = req_builder.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let result = req_builder.send().await;
|
||||
|
||||
match result {
|
||||
Ok(resp) if resp.status().is_success() => {
|
||||
// 成功
|
||||
if stream {
|
||||
let byte_stream = resp.bytes_stream()
|
||||
.map(|result| result.map_err(std::io::Error::other));
|
||||
let body = axum::body::Body::from_stream(byte_stream);
|
||||
update_task_status(db, task_id, "completed", None, None, None).await?;
|
||||
return Ok(RelayResponse::Sse(body));
|
||||
} else {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let (input_tokens, output_tokens) = extract_token_usage(&body);
|
||||
update_task_status(db, task_id, "completed",
|
||||
Some(input_tokens), Some(output_tokens), None).await?;
|
||||
return Ok(RelayResponse::Json(body));
|
||||
}
|
||||
}
|
||||
Ok(resp) => {
|
||||
let status = resp.status().as_u16();
|
||||
if !is_retryable_status(status) || attempt + 1 >= max_attempts {
|
||||
// 4xx 客户端错误或已达最大重试次数 → 立即失败
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let err_msg = format!("上游返回 HTTP {}: {}", status, &body[..body.len().min(500)]);
|
||||
update_task_status(db, task_id, "failed", None, None, Some(&err_msg)).await?;
|
||||
return Err(SaasError::Relay(err_msg));
|
||||
}
|
||||
// 可重试的服务端错误 → 继续循环
|
||||
tracing::warn!(
|
||||
"Relay task {} 可重试错误 HTTP {} (attempt {}/{})",
|
||||
task_id, status, attempt + 1, max_attempts
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
if !is_retryable_error(&e) || attempt + 1 >= max_attempts {
|
||||
let err_msg = format!("请求上游失败: {}", e);
|
||||
update_task_status(db, task_id, "failed", None, None, Some(&err_msg)).await?;
|
||||
return Err(SaasError::Relay(err_msg));
|
||||
}
|
||||
tracing::warn!(
|
||||
"Relay task {} 网络错误 (attempt {}/{}): {}",
|
||||
task_id, attempt + 1, max_attempts, e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 指数退避: base_delay * 2^attempt
|
||||
let delay_ms = base_delay_ms * (1 << attempt);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
|
||||
}
|
||||
|
||||
// 理论上不会到达 (循环内已处理),但满足编译器
|
||||
Err(SaasError::Relay("重试次数已耗尽".into()))
|
||||
}
|
||||
|
||||
/// 中转响应类型
|
||||
#[derive(Debug)]
|
||||
pub enum RelayResponse {
|
||||
Json(String),
|
||||
Sse(axum::body::Body),
|
||||
}
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
fn hash_request(body: &str) -> String {
|
||||
use sha2::{Sha256, Digest};
|
||||
hex::encode(Sha256::digest(body.as_bytes()))
|
||||
}
|
||||
|
||||
fn extract_token_usage(body: &str) -> (i64, i64) {
|
||||
let parsed: serde_json::Value = match serde_json::from_str(body) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (0, 0),
|
||||
};
|
||||
|
||||
let usage = parsed.get("usage");
|
||||
let input = usage
|
||||
.and_then(|u| u.get("prompt_tokens"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
let output = usage
|
||||
.and_then(|u| u.get("completion_tokens"))
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
(input, output)
|
||||
}
|
||||
|
||||
/// SSRF 防护: 验证 provider URL 不指向内网
|
||||
fn validate_provider_url(url: &str) -> SaasResult<()> {
|
||||
let parsed: url::Url = url.parse().map_err(|_| {
|
||||
SaasError::InvalidInput(format!("无效的 provider URL: {}", url))
|
||||
})?;
|
||||
|
||||
// 只允许 https
|
||||
match parsed.scheme() {
|
||||
"https" => {}
|
||||
"http" => {
|
||||
// 开发环境允许 http
|
||||
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
if !is_dev {
|
||||
return Err(SaasError::InvalidInput("生产环境禁止 http scheme,请使用 https".into()));
|
||||
}
|
||||
}
|
||||
_ => return Err(SaasError::InvalidInput(format!("不允许的 URL scheme: {}", parsed.scheme()))),
|
||||
}
|
||||
|
||||
// 禁止内网地址
|
||||
let host = match parsed.host_str() {
|
||||
Some(h) => h,
|
||||
None => return Err(SaasError::InvalidInput("provider URL 缺少 host".into())),
|
||||
};
|
||||
|
||||
// 精确匹配的阻止列表
|
||||
let blocked_exact = [
|
||||
"127.0.0.1", "0.0.0.0", "localhost", "::1", "::ffff:127.0.0.1",
|
||||
"0:0:0:0:0:ffff:7f00:1", "169.254.169.254", "metadata.google.internal",
|
||||
"10.0.0.1", "172.16.0.1", "192.168.0.1",
|
||||
];
|
||||
if blocked_exact.contains(&host) {
|
||||
return Err(SaasError::InvalidInput(format!("provider URL 指向禁止的内网地址: {}", host)));
|
||||
}
|
||||
|
||||
// 后缀匹配 (阻止子域名)
|
||||
let blocked_suffixes = ["localhost", "internal", "local", "localhost.localdomain"];
|
||||
for suffix in &blocked_suffixes {
|
||||
if host.ends_with(&format!(".{}", suffix)) {
|
||||
return Err(SaasError::InvalidInput(format!("provider URL 指向禁止的内网地址: {}", host)));
|
||||
}
|
||||
}
|
||||
|
||||
// 阻止 IPv4 私有网段 (通过解析 IP)
|
||||
if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
if is_private_ip(&ip) {
|
||||
return Err(SaasError::InvalidInput(format!("provider URL 指向私有 IP 地址: {}", host)));
|
||||
}
|
||||
}
|
||||
|
||||
// 阻止纯数字 host (可能是十进制 IP 表示法,如 2130706433 = 127.0.0.1)
|
||||
if host.parse::<u64>().is_ok() {
|
||||
return Err(SaasError::InvalidInput(format!("provider URL 使用了不允许的 IP 格式: {}", host)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检查 IP 是否属于私有/内网地址范围
|
||||
fn is_private_ip(ip: &std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let octets = v4.octets();
|
||||
// 10.0.0.0/8
|
||||
octets[0] == 10
|
||||
// 172.16.0.0/12
|
||||
|| (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31)
|
||||
// 192.168.0.0/16
|
||||
|| (octets[0] == 192 && octets[1] == 168)
|
||||
// 127.0.0.0/8 (loopback)
|
||||
|| octets[0] == 127
|
||||
// 169.254.0.0/16 (link-local)
|
||||
|| (octets[0] == 169 && octets[1] == 254)
|
||||
// 0.0.0.0/8
|
||||
|| octets[0] == 0
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
// ::1 (loopback)
|
||||
v6.is_loopback()
|
||||
// ::ffff:x.x.x.x (IPv6-mapped IPv4)
|
||||
|| v6.to_ipv4_mapped().map_or(false, |v4| is_private_ip(&std::net::IpAddr::V4(v4)))
|
||||
// fe80::/10 (link-local)
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
}
|
||||
}
|
||||
59
crates/zclaw-saas/src/relay/types.rs
Normal file
59
crates/zclaw-saas/src/relay/types.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! 中转服务类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 中转请求 (OpenAI 兼容格式)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayChatRequest {
|
||||
pub model: String,
|
||||
pub messages: Vec<ChatMessage>,
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub max_tokens: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: String,
|
||||
pub content: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 中转任务信息
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RelayTaskInfo {
|
||||
pub id: String,
|
||||
pub account_id: String,
|
||||
pub provider_id: String,
|
||||
pub model_id: String,
|
||||
pub status: String,
|
||||
pub priority: i64,
|
||||
pub attempt_count: i64,
|
||||
pub max_attempts: i64,
|
||||
pub input_tokens: i64,
|
||||
pub output_tokens: i64,
|
||||
pub error_message: Option<String>,
|
||||
pub queued_at: String,
|
||||
pub started_at: Option<String>,
|
||||
pub completed_at: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// 中转任务查询
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RelayTaskQuery {
|
||||
pub status: Option<String>,
|
||||
pub page: Option<i64>,
|
||||
pub page_size: Option<i64>,
|
||||
}
|
||||
|
||||
/// Provider 速率限制状态
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RateLimitState {
|
||||
pub rpm: i64,
|
||||
pub tpm: i64,
|
||||
pub concurrent: usize,
|
||||
pub max_concurrent: usize,
|
||||
}
|
||||
32
crates/zclaw-saas/src/state.rs
Normal file
32
crates/zclaw-saas/src/state.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
//! 应用状态
|
||||
|
||||
use sqlx::SqlitePool;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use tokio::sync::RwLock;
|
||||
use crate::config::SaaSConfig;
|
||||
|
||||
/// 全局应用状态,通过 Axum State 共享
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
/// 数据库连接池
|
||||
pub db: SqlitePool,
|
||||
/// 服务器配置 (可热更新)
|
||||
pub config: Arc<RwLock<SaaSConfig>>,
|
||||
/// JWT 密钥
|
||||
pub jwt_secret: secrecy::SecretString,
|
||||
/// 速率限制: account_id → 请求时间戳列表
|
||||
pub rate_limit_entries: Arc<dashmap::DashMap<String, Vec<Instant>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(db: SqlitePool, config: SaaSConfig) -> anyhow::Result<Self> {
|
||||
let jwt_secret = config.jwt_secret()?;
|
||||
Ok(Self {
|
||||
db,
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
jwt_secret,
|
||||
rate_limit_entries: Arc::new(dashmap::DashMap::new()),
|
||||
})
|
||||
}
|
||||
}
|
||||
1004
crates/zclaw-saas/tests/integration_test.rs
Normal file
1004
crates/zclaw-saas/tests/integration_test.rs
Normal file
File diff suppressed because it is too large
Load Diff
339
desktop/src/components/SaaS/ConfigMigrationWizard.tsx
Normal file
339
desktop/src/components/SaaS/ConfigMigrationWizard.tsx
Normal file
@@ -0,0 +1,339 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { saasClient, type SaaSConfigItem } from '../../lib/saas-client';
|
||||
import { ArrowLeft, ArrowRight, Upload, Check, Loader2, RefreshCw } from 'lucide-react';
|
||||
|
||||
interface LocalModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type SyncDirection = 'local-to-saas' | 'saas-to-local' | 'merge';
|
||||
|
||||
interface SyncConflict {
|
||||
key: string;
|
||||
localValue: string | null;
|
||||
saasValue: string | null;
|
||||
}
|
||||
|
||||
export function ConfigMigrationWizard({ onDone }: { onDone: () => void }) {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
const [direction, setDirection] = useState<SyncDirection>('local-to-saas');
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<'success' | 'partial' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Data
|
||||
const [localModels, setLocalModels] = useState<LocalModel[]>([]);
|
||||
const [saasConfigs, setSaasConfigs] = useState<SaaSConfigItem[]>([]);
|
||||
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
// Step 1: Load data
|
||||
useEffect(() => {
|
||||
if (step !== 1) return;
|
||||
|
||||
// Load local models from localStorage
|
||||
try {
|
||||
const raw = localStorage.getItem('zclaw-custom-models');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as LocalModel[];
|
||||
setLocalModels(Array.isArray(parsed) ? parsed : []);
|
||||
}
|
||||
} catch {
|
||||
setLocalModels([]);
|
||||
}
|
||||
|
||||
// Load SaaS config items
|
||||
saasClient.listConfig().then(setSaasConfigs).catch(() => setSaasConfigs([]));
|
||||
}, [step]);
|
||||
|
||||
const localCount = localModels.length;
|
||||
const saasCount = saasConfigs.length;
|
||||
|
||||
// Step 2: Compute conflicts based on direction
|
||||
useEffect(() => {
|
||||
if (step !== 2) return;
|
||||
|
||||
const found: SyncConflict[] = [];
|
||||
|
||||
if (direction === 'local-to-saas' || direction === 'merge') {
|
||||
// Check which local models already exist in SaaS
|
||||
for (const model of localModels) {
|
||||
const exists = saasConfigs.some((c) => c.key_path === `models.${model.id}`);
|
||||
if (exists) {
|
||||
found.push({
|
||||
key: model.id,
|
||||
localValue: JSON.stringify({ name: model.name, provider: model.provider }),
|
||||
saasValue: '已存在',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (direction === 'saas-to-local' || direction === 'merge') {
|
||||
// SaaS configs that have values not in local
|
||||
for (const config of saasConfigs) {
|
||||
if (!config.current_value) continue;
|
||||
const localRaw = localStorage.getItem('zclaw-custom-models');
|
||||
const localModels: LocalModel[] = localRaw ? JSON.parse(localRaw) : [];
|
||||
const isLocal = localModels.some((m) => m.id === config.key_path.replace('models.', ''));
|
||||
if (!isLocal && config.category === 'model') {
|
||||
found.push({
|
||||
key: config.key_path,
|
||||
localValue: null,
|
||||
saasValue: config.current_value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setConflicts(found);
|
||||
setSelectedKeys(new Set(found.map((c) => c.key)));
|
||||
}, [step, direction, localModels, saasConfigs]);
|
||||
|
||||
// Step 3: Execute sync
|
||||
async function executeSync() {
|
||||
setIsSyncing(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (direction === 'local-to-saas' && localModels.length > 0) {
|
||||
// Push local models as config items
|
||||
for (const model of localModels) {
|
||||
const exists = saasConfigs.some((c) => c.key_path === `models.${model.id}`);
|
||||
if (exists && !selectedKeys.has(model.id)) continue;
|
||||
|
||||
const body = {
|
||||
category: 'model',
|
||||
key_path: `models.${model.id}`,
|
||||
value_type: 'json',
|
||||
current_value: JSON.stringify({ name: model.name, provider: model.provider }),
|
||||
source: 'desktop',
|
||||
description: `从桌面端同步: ${model.name}`,
|
||||
};
|
||||
|
||||
if (exists) {
|
||||
await saasClient.request<unknown>('PUT', `/api/v1/config/items/${exists}`, body);
|
||||
} else {
|
||||
await saasClient.request<unknown>('POST', '/api/v1/config/items', body);
|
||||
}
|
||||
}
|
||||
} else if (direction === 'saas-to-local' && saasConfigs.length > 0) {
|
||||
// Pull SaaS models to local
|
||||
const syncedModels = localModels.filter((m) => !selectedKeys.has(m.id));
|
||||
const saasModels = saasConfigs
|
||||
.filter((c) => c.category === 'model' && c.current_value)
|
||||
.map((c) => {
|
||||
try {
|
||||
return JSON.parse(c.current_value!) as LocalModel;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((m): m is LocalModel => m !== null);
|
||||
|
||||
const merged = [...syncedModels, ...saasModels];
|
||||
localStorage.setItem('zclaw-custom-models', JSON.stringify(merged));
|
||||
} else if (direction === 'merge') {
|
||||
// Merge: local wins for conflicts
|
||||
const kept = localModels.filter((m) => !selectedKeys.has(m.id));
|
||||
const saasOnly = saasConfigs
|
||||
.filter((c) => c.category === 'model' && c.current_value)
|
||||
.map((c) => {
|
||||
try {
|
||||
return JSON.parse(c.current_value!) as LocalModel;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((m): m is LocalModel => m !== null)
|
||||
.filter((m) => !localModels.some((lm) => lm.id === m.id));
|
||||
|
||||
const merged = [...kept, ...saasOnly];
|
||||
localStorage.setItem('zclaw-custom-models', JSON.stringify(merged));
|
||||
}
|
||||
|
||||
setSyncResult(conflicts.length > 0 && conflicts.length === selectedKeys.size ? 'partial' : 'success');
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '同步失败');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset
|
||||
function reset() {
|
||||
setStep(1);
|
||||
setDirection('local-to-saas');
|
||||
setSyncResult(null);
|
||||
setError(null);
|
||||
setSelectedKeys(new Set());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">配置迁移向导</span>
|
||||
</div>
|
||||
{step > 1 && (
|
||||
<button onClick={() => setStep((step - 1) as 1 | 2)} className="text-xs text-gray-500 hover:text-gray-700 cursor-pointer">
|
||||
<ArrowLeft className="w-3.5 h-3.5 inline" /> 返回
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 1: Direction & Preview */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
选择迁移方向,检查本地和 SaaS 平台的配置差异。
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<DirectionOption
|
||||
label="本地 → SaaS"
|
||||
description={`将 ${localCount} 个本地模型推送到 SaaS 平台`}
|
||||
selected={direction === 'local-to-saas'}
|
||||
onClick={() => setDirection('local-to-saas')}
|
||||
/>
|
||||
<DirectionOption
|
||||
label="SaaS → 本地"
|
||||
description={`从 SaaS 平台拉取 ${saasCount} 项配置到本地`}
|
||||
selected={direction === 'saas-to-local'}
|
||||
onClick={() => setDirection('saas-to-local')}
|
||||
/>
|
||||
<DirectionOption
|
||||
label="双向合并"
|
||||
description="合并两边配置,冲突时保留本地版本"
|
||||
selected={direction === 'merge'}
|
||||
onClick={() => setDirection('merge')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setStep(2)}
|
||||
disabled={localCount === 0 && saasCount === 0}
|
||||
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
预览变更 <ArrowRight className="w-4 h-4 inline" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Resolve conflicts */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-4">
|
||||
{conflicts.length > 0 ? (
|
||||
<>
|
||||
<p className="text-sm text-amber-600">
|
||||
发现 {conflicts.length} 项冲突。勾选的项目将保留{direction === 'local-to-saas' ? '本地' : 'SaaS'}版本。
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{conflicts.map((c) => (
|
||||
<label key={c.key} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKeys.has(c.key)}
|
||||
onChange={(e) => {
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (e.target.checked) next.add(c.key);
|
||||
else next.delete(c.key);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-medium text-gray-800">{c.key}</span>
|
||||
<span className="text-xs text-gray-400 truncate">
|
||||
({direction === 'local-to-saas' ? '本地' : 'SaaS'}: {c.saasValue})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||
<Check className="w-4 h-4" />
|
||||
<span>无冲突,可直接同步</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => { setStep(3); executeSync(); }}
|
||||
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
{isSyncing ? (
|
||||
<><Loader2 className="w-4 h-4 inline animate-spin" /> 同步中...</>
|
||||
) : (
|
||||
<><ArrowRight className="w-4 h-4 inline" /> 执行同步</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Result */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-4">
|
||||
{syncResult === 'success' ? (
|
||||
<div className="flex items-center gap-2 text-sm text-emerald-600">
|
||||
<Check className="w-5 h-5" />
|
||||
<span>配置同步成功完成</span>
|
||||
</div>
|
||||
) : syncResult === 'partial' ? (
|
||||
<div className="flex items-center gap-2 text-amber-600">
|
||||
<Check className="w-5 h-5" />
|
||||
<span>部分同步完成({conflicts.length} 项跳过)</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-sm text-red-500">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex-1 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5 inline" /> 重新开始
|
||||
</button>
|
||||
<button
|
||||
onClick={onDone}
|
||||
className="flex-1 py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirectionOption({
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors cursor-pointer ${
|
||||
selected ? 'border-emerald-500 bg-emerald-50' : 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-800">{label}</div>
|
||||
<div className="text-xs text-gray-500">{description}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
190
desktop/src/components/SaaS/RelayTasksPanel.tsx
Normal file
190
desktop/src/components/SaaS/RelayTasksPanel.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { saasClient, type RelayTaskInfo } from '../../lib/saas-client';
|
||||
import { useSaaSStore } from '../../store/saasStore';
|
||||
import {
|
||||
RefreshCw, RotateCw, Loader2, AlertCircle,
|
||||
CheckCircle, XCircle, Clock, Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'completed', label: '成功' },
|
||||
{ key: 'failed', label: '失败' },
|
||||
{ key: 'processing', label: '处理中' },
|
||||
{ key: 'queued', label: '排队中' },
|
||||
] as const;
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const config: Record<string, { bg: string; text: string; icon: typeof CheckCircle }> = {
|
||||
completed: { bg: 'bg-emerald-100 text-emerald-700', text: '成功', icon: CheckCircle },
|
||||
failed: { bg: 'bg-red-100 text-red-700', text: '失败', icon: XCircle },
|
||||
processing: { bg: 'bg-amber-100 text-amber-700', text: '处理中', icon: Zap },
|
||||
queued: { bg: 'bg-gray-100 text-gray-500', text: '排队中', icon: Clock },
|
||||
};
|
||||
const c = config[status] ?? config.queued;
|
||||
const Icon = c.icon;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium ${c.bg}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{c.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(iso: string | null): string {
|
||||
if (!iso) return '-';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function RelayTasksPanel() {
|
||||
const account = useSaaSStore((s) => s.account);
|
||||
const isAdmin = account?.role === 'admin';
|
||||
|
||||
const [tasks, setTasks] = useState<RelayTaskInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [retryingId, setRetryingId] = useState<string | null>(null);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = statusFilter ? { status: statusFilter } : undefined;
|
||||
const data = await saasClient.listRelayTasks(query);
|
||||
setTasks(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
setTasks([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, [fetchTasks]);
|
||||
|
||||
const handleRetry = async (taskId: string) => {
|
||||
setRetryingId(taskId);
|
||||
try {
|
||||
await saasClient.retryRelayTask(taskId);
|
||||
await fetchTasks();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : '重试失败');
|
||||
} finally {
|
||||
setRetryingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">中转任务</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchTasks}
|
||||
disabled={isLoading}
|
||||
className="p-1.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg transition-colors cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status filter tabs */}
|
||||
<div className="flex gap-1 border-b border-gray-200">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(tab.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
statusFilter === tab.key
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && tasks.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 text-gray-400">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
||||
加载中...
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-gray-400">
|
||||
暂无中转任务
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg border border-gray-100 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{/* Status */}
|
||||
<StatusBadge status={task.status} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{task.model_id}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{task.input_tokens > 0 || task.output_tokens > 0
|
||||
? `(${task.input_tokens}in / ${task.output_tokens}out)`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{task.error_message && (
|
||||
<p className="text-xs text-red-500 truncate mt-0.5" title={task.error_message}>
|
||||
{task.error_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-xs text-gray-400 whitespace-nowrap">
|
||||
{formatTime(task.created_at)}
|
||||
</span>
|
||||
|
||||
{/* Retry button (admin only, failed tasks only) */}
|
||||
{isAdmin && task.status === 'failed' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(task.id)}
|
||||
disabled={retryingId === task.id}
|
||||
className="flex-shrink-0 p-1 text-gray-400 hover:text-emerald-600 hover:bg-emerald-50 rounded transition-colors cursor-pointer disabled:opacity-50"
|
||||
title="重试"
|
||||
>
|
||||
{retryingId === task.id ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
394
desktop/src/components/SaaS/SaaSLogin.tsx
Normal file
394
desktop/src/components/SaaS/SaaSLogin.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
import { useState } from 'react';
|
||||
import { LogIn, UserPlus, Globe, Eye, EyeOff, Loader2, AlertCircle, Mail, Shield, ShieldCheck, ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface SaaSLoginProps {
|
||||
onLogin: (saasUrl: string, username: string, password: string) => Promise<void>;
|
||||
onLoginWithTotp?: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
|
||||
onRegister?: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||
initialUrl?: string;
|
||||
isLoggingIn?: boolean;
|
||||
totpRequired?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export function SaaSLogin({ onLogin, onLoginWithTotp, onRegister, initialUrl, isLoggingIn, totpRequired, error }: SaaSLoginProps) {
|
||||
const [serverUrl, setServerUrl] = useState(initialUrl || '');
|
||||
const [username, setUsername] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isRegister, setIsRegister] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [showTotpStep, setShowTotpStep] = useState(false);
|
||||
|
||||
// Sync with parent prop
|
||||
if (totpRequired && !showTotpStep) {
|
||||
setShowTotpStep(true);
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLocalError(null);
|
||||
|
||||
if (!serverUrl.trim()) {
|
||||
setLocalError('请输入服务器地址');
|
||||
return;
|
||||
}
|
||||
if (!username.trim()) {
|
||||
setLocalError('请输入用户名');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setLocalError('请输入密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRegister) {
|
||||
if (!email.trim()) {
|
||||
setLocalError('请输入邮箱地址');
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
|
||||
setLocalError('邮箱格式不正确');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setLocalError('密码长度至少 6 个字符');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setLocalError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
if (onRegister) {
|
||||
try {
|
||||
await onRegister(
|
||||
serverUrl.trim(),
|
||||
username.trim(),
|
||||
email.trim(),
|
||||
password,
|
||||
displayName.trim() || undefined,
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setLocalError(message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await onLogin(serverUrl.trim(), username.trim(), password);
|
||||
// If TOTP required, login() won't throw but store sets totpRequired
|
||||
// The effect above will switch to TOTP step
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setLocalError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTotpSubmit = async () => {
|
||||
if (!onLoginWithTotp || totpCode.length !== 6) return;
|
||||
setLocalError(null);
|
||||
try {
|
||||
await onLoginWithTotp(serverUrl.trim(), username.trim(), password, totpCode);
|
||||
setTotpCode('');
|
||||
setShowTotpStep(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setLocalError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setShowTotpStep(false);
|
||||
setTotpCode('');
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
const displayError = error || localError;
|
||||
|
||||
const handleTabSwitch = (register: boolean) => {
|
||||
setIsRegister(register);
|
||||
setLocalError(null);
|
||||
setConfirmPassword('');
|
||||
setEmail('');
|
||||
setDisplayName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
|
||||
{/* TOTP Verification Step */}
|
||||
{showTotpStep ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield className="w-5 h-5 text-emerald-600" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">双因素认证</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
此账号已启用双因素认证,请输入 TOTP 验证码。
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label htmlFor="totp-code" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
TOTP 验证码
|
||||
</label>
|
||||
<input
|
||||
id="totp-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono tracking-widest text-center focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && totpCode.length === 6) handleTotpSubmit();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
disabled={isLoggingIn}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTotpSubmit}
|
||||
disabled={isLoggingIn || totpCode.length !== 6}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
)}
|
||||
验证
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
{isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mb-5">
|
||||
{isRegister
|
||||
? '创建账号以使用 ZCLAW 云端服务'
|
||||
: '连接到 ZCLAW SaaS 平台,解锁云端能力'}
|
||||
</p>
|
||||
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex mb-5 border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabSwitch(false)}
|
||||
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
!isRegister
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<LogIn className="w-3.5 h-3.5" />
|
||||
登录
|
||||
</span>
|
||||
</button>
|
||||
{onRegister && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTabSwitch(true)}
|
||||
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
|
||||
isRegister
|
||||
? 'border-emerald-500 text-emerald-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
注册
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Server URL */}
|
||||
<div>
|
||||
<label htmlFor="saas-url" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
服务器地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="saas-url"
|
||||
type="url"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
placeholder="https://saas.zclaw.com"
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Username */}
|
||||
<div>
|
||||
<label htmlFor="saas-username" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
id="saas-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="your-username"
|
||||
autoComplete="username"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email (Register only) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-email" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
邮箱
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
id="saas-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Display Name (Register only, optional) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-display-name" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
显示名称 <span className="text-gray-400 font-normal">(可选)</span>
|
||||
</label>
|
||||
<input
|
||||
id="saas-display-name"
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="ZCLAW User"
|
||||
autoComplete="name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="saas-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="saas-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={isRegister ? '至少 6 个字符' : 'Enter password'}
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
className="w-full px-3 pr-10 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 cursor-pointer"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password (Register only) */}
|
||||
{isRegister && (
|
||||
<div>
|
||||
<label htmlFor="saas-confirm-password" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
确认密码
|
||||
</label>
|
||||
<input
|
||||
id="saas-confirm-password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Re-enter password"
|
||||
autoComplete="new-password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoggingIn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoggingIn}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isLoggingIn ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{isRegister ? '注册中...' : '登录中...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isRegister ? (
|
||||
<><UserPlus className="w-4 h-4" />注册</>
|
||||
) : (
|
||||
<><LogIn className="w-4 h-4" />登录</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
desktop/src/components/SaaS/SaaSSettings.tsx
Normal file
334
desktop/src/components/SaaS/SaaSSettings.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useState } from 'react';
|
||||
import { useSaaSStore } from '../../store/saasStore';
|
||||
import { SaaSLogin } from './SaaSLogin';
|
||||
import { SaaSStatus } from './SaaSStatus';
|
||||
import { ConfigMigrationWizard } from './ConfigMigrationWizard';
|
||||
import { TOTPSettings } from './TOTPSettings';
|
||||
import { RelayTasksPanel } from './RelayTasksPanel';
|
||||
import { Cloud, Info, KeyRound } from 'lucide-react';
|
||||
import { saasClient } from '../../lib/saas-client';
|
||||
|
||||
export function SaaSSettings() {
|
||||
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
|
||||
const account = useSaaSStore((s) => s.account);
|
||||
const saasUrl = useSaaSStore((s) => s.saasUrl);
|
||||
const connectionMode = useSaaSStore((s) => s.connectionMode);
|
||||
const login = useSaaSStore((s) => s.login);
|
||||
const loginWithTotp = useSaaSStore((s) => s.loginWithTotp);
|
||||
const register = useSaaSStore((s) => s.register);
|
||||
const logout = useSaaSStore((s) => s.logout);
|
||||
const totpRequired = useSaaSStore((s) => s.totpRequired);
|
||||
|
||||
const [showLogin, setShowLogin] = useState(!isLoggedIn);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [isLoggingIn, setIsLoggingIn] = useState(false);
|
||||
|
||||
const handleLogin = async (url: string, username: string, password: string) => {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await login(url, username, password);
|
||||
if (useSaaSStore.getState().totpRequired) {
|
||||
return;
|
||||
}
|
||||
setShowLogin(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '登录失败';
|
||||
setLoginError(message);
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginWithTotp = async (url: string, username: string, password: string, totpCode: string) => {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await loginWithTotp(url, username, password, totpCode);
|
||||
setShowLogin(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'TOTP 验证失败';
|
||||
setLoginError(message);
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (
|
||||
url: string,
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
displayName?: string,
|
||||
) => {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
await register(url, username, email, password, displayName);
|
||||
// register auto-logs in, no need for separate login call
|
||||
setShowLogin(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '注册失败';
|
||||
setLoginError(message);
|
||||
} finally {
|
||||
setIsLoggingIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
setShowLogin(true);
|
||||
setLoginError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-9 h-9 rounded-lg bg-emerald-100 flex items-center justify-center">
|
||||
<Cloud className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">SaaS 账号</h1>
|
||||
<p className="text-sm text-gray-500">管理 ZCLAW 云端平台连接</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection mode info */}
|
||||
<div className="flex items-start gap-2 text-sm text-gray-500 bg-blue-50 rounded-lg border border-blue-100 p-3 mb-5">
|
||||
<Info className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" />
|
||||
<span>
|
||||
当前模式: <strong className="text-gray-700">{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'gateway' ? 'Gateway' : '本地 Tauri'}</strong>。
|
||||
{connectionMode !== 'saas' && '连接 SaaS 平台可解锁云端同步、团队协作等高级功能。'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Login form or status display */}
|
||||
{!showLogin ? (
|
||||
<SaaSStatus
|
||||
isLoggedIn={isLoggedIn}
|
||||
account={account}
|
||||
saasUrl={saasUrl}
|
||||
onLogout={handleLogout}
|
||||
onLogin={() => setShowLogin(true)}
|
||||
/>
|
||||
) : (
|
||||
<SaaSLogin
|
||||
onLogin={handleLogin}
|
||||
onLoginWithTotp={handleLoginWithTotp}
|
||||
onRegister={handleRegister}
|
||||
initialUrl={saasUrl}
|
||||
isLoggingIn={isLoggingIn}
|
||||
totpRequired={totpRequired}
|
||||
error={loginError}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Features list when logged in */}
|
||||
{isLoggedIn && !showLogin && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
云端功能
|
||||
</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
|
||||
<div className="space-y-3">
|
||||
<CloudFeatureRow
|
||||
name="云端同步"
|
||||
description="对话记录和配置自动同步到云端"
|
||||
status="active"
|
||||
/>
|
||||
<CloudFeatureRow
|
||||
name="团队协作"
|
||||
description="与团队成员共享 Agent 和技能"
|
||||
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
|
||||
/>
|
||||
<CloudFeatureRow
|
||||
name="高级分析"
|
||||
description="使用统计和用量分析仪表板"
|
||||
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password change section */}
|
||||
{isLoggedIn && !showLogin && <ChangePasswordSection />}
|
||||
|
||||
{/* TOTP 2FA */}
|
||||
{isLoggedIn && !showLogin && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
双因素认证
|
||||
</h2>
|
||||
<TOTPSettings />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relay tasks */}
|
||||
{isLoggedIn && !showLogin && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
中转任务
|
||||
</h2>
|
||||
<RelayTasksPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Config migration wizard */}
|
||||
{isLoggedIn && !showLogin && (
|
||||
<div className="mt-6">
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
|
||||
数据迁移
|
||||
</h2>
|
||||
<ConfigMigrationWizard onDone={() => {/* no-op: wizard self-contained */}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CloudFeatureRow({
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
}: {
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'active' | 'inactive';
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{name}</div>
|
||||
<div className="text-xs text-gray-500">{description}</div>
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
status === 'active'
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{status === 'active' ? '可用' : '需要订阅'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangePasswordSection() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError('新密码至少 8 个字符');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('两次输入的新密码不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await saasClient.changePassword(oldPassword, newPassword);
|
||||
setSuccess(true);
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '密码修改失败';
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div
|
||||
className="flex items-center justify-between cursor-pointer"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide">
|
||||
账号安全
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400">{isOpen ? '收起' : '展开'}</span>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm mt-3">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<KeyRound className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">修改密码</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
当前密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
新密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 mb-1">
|
||||
确认新密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
{success && (
|
||||
<p className="text-xs text-emerald-600">密码修改成功</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-2 text-sm font-medium text-white bg-emerald-600 rounded-lg hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isSubmitting ? '修改中...' : '修改密码'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
desktop/src/components/SaaS/SaaSStatus.tsx
Normal file
192
desktop/src/components/SaaS/SaaSStatus.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { SaaSAccountInfo, SaaSModelInfo } from '../../lib/saas-client';
|
||||
import { Cloud, CloudOff, LogOut, RefreshCw, Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useSaaSStore } from '../../store/saasStore';
|
||||
|
||||
interface SaaSStatusProps {
|
||||
isLoggedIn: boolean;
|
||||
account: SaaSAccountInfo | null;
|
||||
saasUrl: string;
|
||||
onLogout: () => void;
|
||||
onLogin: () => void;
|
||||
}
|
||||
|
||||
export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }: SaaSStatusProps) {
|
||||
const availableModels = useSaaSStore((s) => s.availableModels);
|
||||
const fetchAvailableModels = useSaaSStore((s) => s.fetchAvailableModels);
|
||||
const [serverReachable, setServerReachable] = useState<boolean>(true);
|
||||
|
||||
const [checkingHealth, setCheckingHealth] = useState(false);
|
||||
const [healthOk, setHealthOk] = useState<boolean | null>(null);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
fetchAvailableModels();
|
||||
}
|
||||
}, [isLoggedIn, fetchAvailableModels]);
|
||||
|
||||
// Poll server reachability every 30s
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
const check = () => {
|
||||
setServerReachable(saasClient.isServerReachable());
|
||||
};
|
||||
check();
|
||||
const timer = setInterval(check, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isLoggedIn]);
|
||||
|
||||
async function checkHealth() {
|
||||
setCheckingHealth(true);
|
||||
setHealthOk(null);
|
||||
try {
|
||||
const response = await fetch(`${saasUrl}/api/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
setHealthOk(response.ok);
|
||||
} catch {
|
||||
setHealthOk(false);
|
||||
} finally {
|
||||
setCheckingHealth(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoggedIn && account) {
|
||||
const displayName = account.display_name || account.username;
|
||||
const initial = displayName[0].toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Main status bar */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-emerald-500 flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
|
||||
{initial}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-gray-900 text-sm">{displayName}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{saasUrl}</div>
|
||||
<span className="inline-block mt-0.5 text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
|
||||
{account.role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{serverReachable ? (
|
||||
<div className="flex items-center gap-1.5 text-emerald-600 text-xs">
|
||||
<Cloud className="w-3.5 h-3.5" />
|
||||
<span>已连接</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-amber-500 text-xs">
|
||||
<CloudOff className="w-3.5 h-3.5" />
|
||||
<span>离线</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="px-2 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable details */}
|
||||
{showDetails && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
{/* Health Check */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-700">服务健康</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{healthOk === null && !checkingHealth && (
|
||||
<span className="text-xs text-gray-400">未检测</span>
|
||||
)}
|
||||
{checkingHealth && <Loader2 className="w-4 h-4 animate-spin text-gray-400" />}
|
||||
{healthOk === true && (
|
||||
<div className="flex items-center gap-1 text-green-600 text-sm">
|
||||
<CheckCircle className="w-4 h-4" />正常
|
||||
</div>
|
||||
)}
|
||||
{healthOk === false && (
|
||||
<div className="flex items-center gap-1 text-red-500 text-sm">
|
||||
<XCircle className="w-4 h-4" />不可达
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={checkHealth}
|
||||
disabled={checkingHealth}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${checkingHealth ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Models */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Cpu className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
可用模型 ({availableModels.length})
|
||||
</span>
|
||||
</div>
|
||||
{availableModels.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 pl-6">
|
||||
暂无可用模型,请确认管理员已在后台配置 Provider 和 Model
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1 pl-6">
|
||||
{availableModels.map((model) => (
|
||||
<ModelRow key={model.id} model={model} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CloudOff className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 text-sm">SaaS 平台</div>
|
||||
<div className="text-xs text-gray-500">未连接</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<Cloud className="w-3.5 h-3.5" />
|
||||
连接
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelRow({ model }: { model: SaaSModelInfo }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1.5 px-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-800">{model.alias || model.id}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
{model.supports_streaming && <span>流式</span>}
|
||||
{model.supports_vision && <span>视觉</span>}
|
||||
<span className="font-mono">{(model.context_window / 1000).toFixed(0)}k</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
285
desktop/src/components/SaaS/TOTPSettings.tsx
Normal file
285
desktop/src/components/SaaS/TOTPSettings.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useState } from 'react';
|
||||
import { useSaaSStore } from '../../store/saasStore';
|
||||
import { Shield, ShieldCheck, ShieldOff, Copy, Check, Loader2, AlertCircle, X } from 'lucide-react';
|
||||
|
||||
export function TOTPSettings() {
|
||||
const account = useSaaSStore((s) => s.account);
|
||||
const totpSetupData = useSaaSStore((s) => s.totpSetupData);
|
||||
const isLoading = useSaaSStore((s) => s.isLoading);
|
||||
const storeError = useSaaSStore((s) => s.error);
|
||||
const setupTotp = useSaaSStore((s) => s.setupTotp);
|
||||
const verifyTotp = useSaaSStore((s) => s.verifyTotp);
|
||||
const disableTotp = useSaaSStore((s) => s.disableTotp);
|
||||
const cancelTotpSetup = useSaaSStore((s) => s.cancelTotpSetup);
|
||||
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const [disablePassword, setDisablePassword] = useState('');
|
||||
const [showDisable, setShowDisable] = useState(false);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [copiedSecret, setCopiedSecret] = useState(false);
|
||||
|
||||
const displayError = storeError || localError;
|
||||
const isEnabled = account?.totp_enabled ?? false;
|
||||
const isSettingUp = !!totpSetupData;
|
||||
|
||||
const handleSetup = async () => {
|
||||
setLocalError(null);
|
||||
setSuccess(null);
|
||||
setVerifyCode('');
|
||||
try {
|
||||
await setupTotp();
|
||||
} catch {
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (verifyCode.length !== 6) return;
|
||||
setLocalError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await verifyTotp(verifyCode);
|
||||
setVerifyCode('');
|
||||
setSuccess('TOTP 已成功启用');
|
||||
} catch {
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async () => {
|
||||
if (!disablePassword) {
|
||||
setLocalError('请输入密码确认');
|
||||
return;
|
||||
}
|
||||
setLocalError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await disableTotp(disablePassword);
|
||||
setDisablePassword('');
|
||||
setShowDisable(false);
|
||||
setSuccess('TOTP 已成功禁用');
|
||||
} catch {
|
||||
// error already in store
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopySecret = async () => {
|
||||
if (!totpSetupData) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(totpSetupData.secret);
|
||||
setCopiedSecret(true);
|
||||
setTimeout(() => setCopiedSecret(false), 2000);
|
||||
} catch {
|
||||
// clipboard API not available
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelTotpSetup();
|
||||
setVerifyCode('');
|
||||
setLocalError(null);
|
||||
};
|
||||
|
||||
// Setup flow: QR code + verify code input
|
||||
if (isSettingUp) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-emerald-600" />
|
||||
<h3 className="text-sm font-semibold text-gray-900">设置双因素认证</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
className="text-gray-400 hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
使用 Google Authenticator / Authy 扫描下方二维码,然后输入验证码完成绑定。
|
||||
</p>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?data=${encodeURIComponent(totpSetupData.otpauth_uri)}&size=200x200`}
|
||||
alt="TOTP QR Code"
|
||||
className="w-48 h-48 border border-gray-200 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Manual secret */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-1">手动输入密钥:</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-2 py-1 bg-gray-50 rounded text-xs font-mono text-gray-700 break-all">
|
||||
{totpSetupData.secret}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopySecret}
|
||||
className="flex-shrink-0 p-1 text-gray-400 hover:text-emerald-600 cursor-pointer"
|
||||
title="复制密钥"
|
||||
>
|
||||
{copiedSecret ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verify code input */}
|
||||
<div>
|
||||
<label htmlFor="totp-verify-code" className="block text-sm font-medium text-gray-700 mb-1.5">
|
||||
验证码
|
||||
</label>
|
||||
<input
|
||||
id="totp-verify-code"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="输入 6 位验证码"
|
||||
autoComplete="one-time-code"
|
||||
autoFocus
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono tracking-widest text-center focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
|
||||
disabled={isLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && verifyCode.length === 6) handleVerify();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleVerify}
|
||||
disabled={isLoading || verifyCode.length !== 6}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <ShieldCheck className="w-4 h-4" />}
|
||||
确认启用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{isEnabled ? (
|
||||
<ShieldCheck className="w-5 h-5 text-emerald-600" />
|
||||
) : (
|
||||
<ShieldOff className="w-5 h-5 text-gray-400" />
|
||||
)}
|
||||
<h3 className="text-sm font-semibold text-gray-900">双因素认证</h3>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
||||
isEnabled ? 'bg-emerald-100 text-emerald-700' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{isEnabled ? '已启用' : '未启用'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
{isEnabled
|
||||
? '你的账号已启用双因素认证,登录时需要输入 TOTP 验证码。'
|
||||
: '启用双因素认证可以增强账号安全性。'}
|
||||
</p>
|
||||
|
||||
{displayError && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="flex items-start gap-2 text-sm text-emerald-600 bg-emerald-50 rounded-lg p-3">
|
||||
<Check className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEnabled && !showDisable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSetup}
|
||||
disabled={isLoading}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Shield className="w-4 h-4" />}
|
||||
启用 TOTP
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isEnabled && !showDisable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDisable(true)}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
禁用 TOTP
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showDisable && (
|
||||
<div className="space-y-3 p-3 bg-red-50 rounded-lg border border-red-200">
|
||||
<p className="text-sm text-red-700">禁用 TOTP 将降低账号安全性,请输入密码确认:</p>
|
||||
<input
|
||||
type="password"
|
||||
value={disablePassword}
|
||||
onChange={(e) => setDisablePassword(e.target.value)}
|
||||
placeholder="输入当前密码"
|
||||
autoComplete="current-password"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 focus:border-red-500 bg-white text-gray-900"
|
||||
disabled={isLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleDisable();
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowDisable(false); setDisablePassword(''); setLocalError(null); }}
|
||||
disabled={isLoading}
|
||||
className="flex-1 px-3 py-1.5 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisable}
|
||||
disabled={isLoading || !disablePassword}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-3 py-1.5 text-sm text-red-600 border border-red-300 rounded-lg hover:bg-red-100 transition-colors disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{isLoading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null}
|
||||
确认禁用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Heart,
|
||||
Key,
|
||||
Database,
|
||||
Cloud,
|
||||
} from 'lucide-react';
|
||||
import { silentErrorHandler } from '../../lib/error-utils';
|
||||
import { General } from './General';
|
||||
@@ -37,6 +38,7 @@ import { TaskList } from '../TaskList';
|
||||
import { HeartbeatConfig } from '../HeartbeatConfig';
|
||||
import { SecureStorage } from './SecureStorage';
|
||||
import { VikingPanel } from '../VikingPanel';
|
||||
import { SaaSSettings } from '../SaaS/SaaSSettings';
|
||||
|
||||
interface SettingsLayoutProps {
|
||||
onBack: () => void;
|
||||
@@ -54,6 +56,7 @@ type SettingsPage =
|
||||
| 'privacy'
|
||||
| 'security'
|
||||
| 'storage'
|
||||
| 'saas'
|
||||
| 'viking'
|
||||
| 'audit'
|
||||
| 'tasks'
|
||||
@@ -72,6 +75,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] =
|
||||
{ id: 'workspace', label: '工作区', icon: <FolderOpen className="w-4 h-4" /> },
|
||||
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
|
||||
{ id: 'storage', label: '安全存储', icon: <Key className="w-4 h-4" /> },
|
||||
{ id: 'saas', label: 'SaaS 平台', icon: <Cloud className="w-4 h-4" /> },
|
||||
{ id: 'viking', label: '语义记忆', icon: <Database className="w-4 h-4" /> },
|
||||
{ id: 'security', label: '安全状态', icon: <Shield className="w-4 h-4" /> },
|
||||
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" /> },
|
||||
@@ -97,6 +101,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
||||
case 'workspace': return <Workspace />;
|
||||
case 'privacy': return <Privacy />;
|
||||
case 'storage': return <SecureStorage />;
|
||||
case 'saas': return <SaaSSettings />;
|
||||
case 'security': return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { DEFAULT_MODEL_ID, DEFAULT_OPENAI_BASE_URL } from '../constants/models';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type LLMProvider = 'openai' | 'volcengine' | 'gateway' | 'mock';
|
||||
export type LLMProvider = 'openai' | 'volcengine' | 'gateway' | 'saas' | 'mock';
|
||||
|
||||
export interface LLMConfig {
|
||||
provider: LLMProvider;
|
||||
@@ -77,6 +77,12 @@ const DEFAULT_CONFIGS: Record<LLMProvider, LLMConfig> = {
|
||||
temperature: 0.7,
|
||||
timeout: 60000,
|
||||
},
|
||||
saas: {
|
||||
provider: 'saas',
|
||||
maxTokens: 4096,
|
||||
temperature: 0.7,
|
||||
timeout: 300000, // 5 min for streaming
|
||||
},
|
||||
mock: {
|
||||
provider: 'mock',
|
||||
maxTokens: 100,
|
||||
@@ -412,6 +418,85 @@ class GatewayLLMAdapter implements LLMServiceAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
// === SaaS Relay Adapter (via SaaS backend) ===
|
||||
|
||||
class SaasLLMAdapter implements LLMServiceAdapter {
|
||||
private config: LLMConfig;
|
||||
|
||||
constructor(config: LLMConfig) {
|
||||
this.config = { ...DEFAULT_CONFIGS.saas, ...config };
|
||||
}
|
||||
|
||||
async complete(messages: LLMMessage[], options?: Partial<LLMConfig>): Promise<LLMResponse> {
|
||||
const config = { ...this.config, ...options };
|
||||
const startTime = Date.now();
|
||||
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { useSaaSStore } = await import('../store/saasStore');
|
||||
const { saasUrl, authToken } = useSaaSStore.getState();
|
||||
|
||||
if (!saasUrl || !authToken) {
|
||||
throw new Error('[SaaS] 未登录 SaaS 平台,请先在设置中登录');
|
||||
}
|
||||
|
||||
// Dynamic import of SaaSClient singleton
|
||||
const { saasClient } = await import('./saas-client');
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
|
||||
const openaiBody = {
|
||||
model: config.model || 'default',
|
||||
messages,
|
||||
max_tokens: config.maxTokens || 4096,
|
||||
temperature: config.temperature ?? 0.7,
|
||||
stream: false,
|
||||
};
|
||||
|
||||
const response = await saasClient.chatCompletion(
|
||||
openaiBody,
|
||||
AbortSignal.timeout(config.timeout || 300000),
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({
|
||||
error: 'unknown',
|
||||
message: `SaaS relay 请求失败 (${response.status})`,
|
||||
}));
|
||||
throw new Error(
|
||||
`[SaaS] ${errorData.message || errorData.error || `请求失败: ${response.status}`}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const latencyMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
content: data.choices?.[0]?.message?.content || '',
|
||||
tokensUsed: {
|
||||
input: data.usage?.prompt_tokens || 0,
|
||||
output: data.usage?.completion_tokens || 0,
|
||||
},
|
||||
model: data.model,
|
||||
latencyMs,
|
||||
};
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
// Check synchronously via localStorage for availability check
|
||||
// Dynamic import would be async, so we use a simpler check
|
||||
try {
|
||||
const token = localStorage.getItem('zclaw-saas-token');
|
||||
return !!token;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getProvider(): LLMProvider {
|
||||
return 'saas';
|
||||
}
|
||||
}
|
||||
|
||||
// === Factory ===
|
||||
|
||||
let cachedAdapter: LLMServiceAdapter | null = null;
|
||||
@@ -427,6 +512,8 @@ export function createLLMAdapter(config?: Partial<LLMConfig>): LLMServiceAdapter
|
||||
return new VolcengineLLMAdapter(finalConfig);
|
||||
case 'gateway':
|
||||
return new GatewayLLMAdapter(finalConfig);
|
||||
case 'saas':
|
||||
return new SaasLLMAdapter(finalConfig);
|
||||
case 'mock':
|
||||
default:
|
||||
return new MockLLMAdapter(finalConfig);
|
||||
|
||||
562
desktop/src/lib/saas-client.ts
Normal file
562
desktop/src/lib/saas-client.ts
Normal file
@@ -0,0 +1,562 @@
|
||||
/**
|
||||
* ZCLAW SaaS Client
|
||||
*
|
||||
* Typed HTTP client for the ZCLAW SaaS backend API (v1).
|
||||
* Handles authentication, model listing, chat relay, and config management.
|
||||
*
|
||||
* API base path: /api/v1/...
|
||||
* Auth: Bearer token in Authorization header
|
||||
*/
|
||||
|
||||
// === Storage Keys ===
|
||||
|
||||
const SAASTOKEN_KEY = 'zclaw-saas-token';
|
||||
const SAASURL_KEY = 'zclaw-saas-url';
|
||||
const SAASACCOUNT_KEY = 'zclaw-saas-account';
|
||||
const SAASMODE_KEY = 'zclaw-connection-mode';
|
||||
|
||||
// === Types ===
|
||||
|
||||
/** Public account info returned by the SaaS backend */
|
||||
export interface SaaSAccountInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
totp_enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** A model available for relay through the SaaS backend */
|
||||
export interface SaaSModelInfo {
|
||||
id: string;
|
||||
provider_id: string;
|
||||
alias: string;
|
||||
context_window: number;
|
||||
max_output_tokens: number;
|
||||
supports_streaming: boolean;
|
||||
supports_vision: boolean;
|
||||
}
|
||||
|
||||
/** Config item from the SaaS backend */
|
||||
export interface SaaSConfigItem {
|
||||
id: string;
|
||||
category: string;
|
||||
key_path: string;
|
||||
value_type: string;
|
||||
current_value: string | null;
|
||||
default_value: string | null;
|
||||
source: string;
|
||||
description: string | null;
|
||||
requires_restart: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** SaaS API error shape */
|
||||
export interface SaaSErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Login response from POST /api/v1/auth/login */
|
||||
export interface SaaSLoginResponse {
|
||||
token: string;
|
||||
account: SaaSAccountInfo;
|
||||
}
|
||||
|
||||
/** Refresh response from POST /api/v1/auth/refresh */
|
||||
interface SaaSRefreshResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** TOTP setup response from POST /api/v1/auth/totp/setup */
|
||||
export interface TotpSetupResponse {
|
||||
otpauth_uri: string;
|
||||
secret: string;
|
||||
issuer: string;
|
||||
}
|
||||
|
||||
/** TOTP verify/disable response */
|
||||
export interface TotpResultResponse {
|
||||
ok: boolean;
|
||||
totp_enabled: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Device info stored on the SaaS backend */
|
||||
export interface DeviceInfo {
|
||||
id: string;
|
||||
device_id: string;
|
||||
device_name: string | null;
|
||||
platform: string | null;
|
||||
app_version: string | null;
|
||||
last_seen_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Relay task info from GET /api/v1/relay/tasks */
|
||||
export interface RelayTaskInfo {
|
||||
id: string;
|
||||
account_id: string;
|
||||
provider_id: string;
|
||||
model_id: string;
|
||||
status: string;
|
||||
priority: number;
|
||||
attempt_count: number;
|
||||
max_attempts: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
error_message: string | null;
|
||||
queued_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Config diff request for POST /api/v1/config/diff and /sync */
|
||||
export interface SyncConfigRequest {
|
||||
client_fingerprint: string;
|
||||
action: 'push' | 'merge';
|
||||
config_keys: string[];
|
||||
client_values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** A single config diff entry */
|
||||
export interface ConfigDiffItem {
|
||||
key_path: string;
|
||||
client_value: string | null;
|
||||
saas_value: string | null;
|
||||
conflict: boolean;
|
||||
}
|
||||
|
||||
/** Config diff response */
|
||||
export interface ConfigDiffResponse {
|
||||
items: ConfigDiffItem[];
|
||||
total_keys: number;
|
||||
conflicts: number;
|
||||
}
|
||||
|
||||
/** Config sync result */
|
||||
export interface ConfigSyncResult {
|
||||
updated: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
// === Error Class ===
|
||||
|
||||
export class SaaSApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SaaSApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// === Session Persistence ===
|
||||
|
||||
export interface SaaSSession {
|
||||
token: string;
|
||||
account: SaaSAccountInfo | null;
|
||||
saasUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a persisted SaaS session from localStorage.
|
||||
* Returns null if no valid session exists.
|
||||
*/
|
||||
export function loadSaaSSession(): SaaSSession | null {
|
||||
try {
|
||||
const token = localStorage.getItem(SAASTOKEN_KEY);
|
||||
const saasUrl = localStorage.getItem(SAASURL_KEY);
|
||||
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
|
||||
|
||||
if (!token || !saasUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const account: SaaSAccountInfo | null = accountRaw
|
||||
? (JSON.parse(accountRaw) as SaaSAccountInfo)
|
||||
: null;
|
||||
|
||||
return { token, account, saasUrl };
|
||||
} catch {
|
||||
// Corrupted data - clear all
|
||||
clearSaaSSession();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a SaaS session to localStorage.
|
||||
*/
|
||||
export function saveSaaSSession(session: SaaSSession): void {
|
||||
localStorage.setItem(SAASTOKEN_KEY, session.token);
|
||||
localStorage.setItem(SAASURL_KEY, session.saasUrl);
|
||||
if (session.account) {
|
||||
localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the persisted SaaS session from localStorage.
|
||||
*/
|
||||
export function clearSaaSSession(): void {
|
||||
localStorage.removeItem(SAASTOKEN_KEY);
|
||||
localStorage.removeItem(SAASURL_KEY);
|
||||
localStorage.removeItem(SAASACCOUNT_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the connection mode to localStorage.
|
||||
*/
|
||||
export function saveConnectionMode(mode: string): void {
|
||||
localStorage.setItem(SAASMODE_KEY, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the connection mode from localStorage.
|
||||
* Returns null if not set.
|
||||
*/
|
||||
export function loadConnectionMode(): string | null {
|
||||
return localStorage.getItem(SAASMODE_KEY);
|
||||
}
|
||||
|
||||
// === Client Implementation ===
|
||||
|
||||
export class SaaSClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Update the base URL (e.g. when user changes server address) */
|
||||
setBaseUrl(url: string): void {
|
||||
this.baseUrl = url.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Get the current base URL */
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/** Set or clear the auth token */
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
/** Check if the client has an auth token */
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.token;
|
||||
}
|
||||
|
||||
// --- Core HTTP ---
|
||||
|
||||
/** Track whether the server appears reachable */
|
||||
private _serverReachable: boolean = true;
|
||||
|
||||
/** Check if the SaaS server was last known to be reachable */
|
||||
isServerReachable(): boolean {
|
||||
return this._serverReachable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request with automatic retry on transient failures.
|
||||
* Retries up to 2 times with exponential backoff (1s, 2s).
|
||||
* Throws SaaSApiError on non-ok responses.
|
||||
*/
|
||||
public async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
timeoutMs = 15000,
|
||||
): Promise<T> {
|
||||
const maxRetries = 2;
|
||||
const baseDelay = 1000;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
this._serverReachable = true;
|
||||
|
||||
// Handle 401 specially - caller may want to trigger re-auth
|
||||
if (response.status === 401) {
|
||||
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = (await response.json().catch(() => null)) as SaaSErrorResponse | null;
|
||||
throw new SaaSApiError(
|
||||
response.status,
|
||||
errorBody?.error || 'UNKNOWN',
|
||||
errorBody?.message || `请求失败 (${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
} catch (err: unknown) {
|
||||
const isNetworkError = err instanceof TypeError
|
||||
&& (err.message.includes('Failed to fetch') || err.message.includes('NetworkError'));
|
||||
|
||||
if (isNetworkError && attempt < maxRetries) {
|
||||
this._serverReachable = false;
|
||||
const delay = baseDelay * Math.pow(2, attempt);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
this._serverReachable = false;
|
||||
if (err instanceof SaaSApiError) throw err;
|
||||
throw new SaaSApiError(0, 'NETWORK_ERROR', `网络错误: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Unreachable, but TypeScript needs it
|
||||
throw new SaaSApiError(0, 'UNKNOWN', '请求失败');
|
||||
}
|
||||
|
||||
// --- Health ---
|
||||
|
||||
/**
|
||||
* Quick connectivity check against the SaaS backend.
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.request<unknown>('GET', '/api/health', undefined, 5000);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth Endpoints ---
|
||||
|
||||
/**
|
||||
* Login with username and password.
|
||||
* Auto-sets the client token on success.
|
||||
*/
|
||||
async login(username: string, password: string, totpCode?: string): Promise<SaaSLoginResponse> {
|
||||
const body: Record<string, string> = { username, password };
|
||||
if (totpCode) body.totp_code = totpCode;
|
||||
const data = await this.request<SaaSLoginResponse>(
|
||||
'POST', '/api/v1/auth/login', body,
|
||||
);
|
||||
this.token = data.token;
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new account.
|
||||
* Auto-sets the client token on success.
|
||||
*/
|
||||
async register(data: {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
display_name?: string;
|
||||
}): Promise<SaaSLoginResponse> {
|
||||
const result = await this.request<SaaSLoginResponse>(
|
||||
'POST', '/api/v1/auth/register', data,
|
||||
);
|
||||
this.token = result.token;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current authenticated user's account info.
|
||||
*/
|
||||
async me(): Promise<SaaSAccountInfo> {
|
||||
return this.request<SaaSAccountInfo>('GET', '/api/v1/auth/me');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the current token.
|
||||
* Auto-updates the client token on success.
|
||||
*/
|
||||
async refreshToken(): Promise<string> {
|
||||
const data = await this.request<SaaSRefreshResponse>('POST', '/api/v1/auth/refresh');
|
||||
this.token = data.token;
|
||||
return data.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the current user's password.
|
||||
*/
|
||||
async changePassword(oldPassword: string, newPassword: string): Promise<void> {
|
||||
await this.request<unknown>('PUT', '/api/v1/auth/password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// --- TOTP Endpoints ---
|
||||
|
||||
/** Generate a TOTP secret and otpauth URI */
|
||||
async setupTotp(): Promise<TotpSetupResponse> {
|
||||
return this.request<TotpSetupResponse>('POST', '/api/v1/auth/totp/setup');
|
||||
}
|
||||
|
||||
/** Verify a TOTP code and enable 2FA */
|
||||
async verifyTotp(code: string): Promise<TotpResultResponse> {
|
||||
return this.request<TotpResultResponse>('POST', '/api/v1/auth/totp/verify', { code });
|
||||
}
|
||||
|
||||
/** Disable 2FA (requires password confirmation) */
|
||||
async disableTotp(password: string): Promise<TotpResultResponse> {
|
||||
return this.request<TotpResultResponse>('POST', '/api/v1/auth/totp/disable', { password });
|
||||
}
|
||||
|
||||
// --- Device Endpoints ---
|
||||
|
||||
/**
|
||||
* Register or update this device with the SaaS backend.
|
||||
* Uses UPSERT semantics — same (account, device_id) updates last_seen_at.
|
||||
*/
|
||||
async registerDevice(params: {
|
||||
device_id: string;
|
||||
device_name?: string;
|
||||
platform?: string;
|
||||
app_version?: string;
|
||||
}): Promise<void> {
|
||||
await this.request<unknown>('POST', '/api/v1/devices/register', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a heartbeat to indicate the device is still active.
|
||||
*/
|
||||
async deviceHeartbeat(deviceId: string): Promise<void> {
|
||||
await this.request<unknown>('POST', '/api/v1/devices/heartbeat', {
|
||||
device_id: deviceId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List devices registered for the current account.
|
||||
*/
|
||||
async listDevices(): Promise<DeviceInfo[]> {
|
||||
return this.request<DeviceInfo[]>('GET', '/api/v1/devices');
|
||||
}
|
||||
|
||||
// --- Model Endpoints ---
|
||||
|
||||
/**
|
||||
* List available models for relay.
|
||||
* Only returns enabled models from enabled providers.
|
||||
*/
|
||||
async listModels(): Promise<SaaSModelInfo[]> {
|
||||
return this.request<SaaSModelInfo[]>('GET', '/api/v1/relay/models');
|
||||
}
|
||||
|
||||
// --- Relay Task Management ---
|
||||
|
||||
/** List relay tasks for the current user */
|
||||
async listRelayTasks(query?: { status?: string; page?: number; page_size?: number }): Promise<RelayTaskInfo[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query?.status) params.set('status', query.status);
|
||||
if (query?.page) params.set('page', String(query.page));
|
||||
if (query?.page_size) params.set('page_size', String(query.page_size));
|
||||
const qs = params.toString();
|
||||
return this.request<RelayTaskInfo[]>('GET', `/api/v1/relay/tasks${qs ? '?' + qs : ''}`);
|
||||
}
|
||||
|
||||
/** Get a single relay task */
|
||||
async getRelayTask(taskId: string): Promise<RelayTaskInfo> {
|
||||
return this.request<RelayTaskInfo>('GET', `/api/v1/relay/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
/** Retry a failed relay task (admin only) */
|
||||
async retryRelayTask(taskId: string): Promise<{ ok: boolean; task_id: string }> {
|
||||
return this.request<{ ok: boolean; task_id: string }>('POST', `/api/v1/relay/tasks/${taskId}/retry`);
|
||||
}
|
||||
|
||||
// --- Chat Relay ---
|
||||
|
||||
/**
|
||||
* Send a chat completion request via the SaaS relay.
|
||||
* Returns the raw Response object to support both streaming and non-streaming.
|
||||
*
|
||||
* The caller is responsible for:
|
||||
* - Reading the response body (JSON or SSE stream)
|
||||
* - Handling errors from the response
|
||||
*/
|
||||
async chatCompletion(
|
||||
body: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
if (this.token) {
|
||||
headers['Authorization'] = `Bearer ${this.token}`;
|
||||
}
|
||||
|
||||
// Use caller's AbortSignal if provided, otherwise default 5min timeout
|
||||
const effectiveSignal = signal ?? AbortSignal.timeout(300_000);
|
||||
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/api/v1/relay/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: effectiveSignal,
|
||||
},
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// --- Config Endpoints ---
|
||||
|
||||
/**
|
||||
* List config items, optionally filtered by category.
|
||||
*/
|
||||
async listConfig(category?: string): Promise<SaaSConfigItem[]> {
|
||||
const qs = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
return this.request<SaaSConfigItem[]>('GET', `/api/v1/config/items${qs}`);
|
||||
}
|
||||
|
||||
/** Compute config diff between client and SaaS (read-only) */
|
||||
async computeConfigDiff(request: SyncConfigRequest): Promise<ConfigDiffResponse> {
|
||||
return this.request<ConfigDiffResponse>('POST', '/api/v1/config/diff', request);
|
||||
}
|
||||
|
||||
/** Sync config from client to SaaS (push) or merge */
|
||||
async syncConfig(request: SyncConfigRequest): Promise<ConfigSyncResult> {
|
||||
return this.request<ConfigSyncResult>('POST', '/api/v1/config/sync', request);
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
/**
|
||||
* Global SaaS client singleton.
|
||||
* Initialized with a default URL; the URL and token are updated on login.
|
||||
*/
|
||||
export const saasClient = new SaaSClient('https://saas.zclaw.com');
|
||||
@@ -213,6 +213,37 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
|
||||
try {
|
||||
set({ error: null });
|
||||
|
||||
// === SaaS Relay Mode ===
|
||||
// Check connection mode from localStorage (set by saasStore).
|
||||
// This takes priority over Tauri/Gateway when the user has selected SaaS mode.
|
||||
const savedMode = localStorage.getItem('zclaw-connection-mode');
|
||||
if (savedMode === 'saas') {
|
||||
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
|
||||
const session = loadSaaSSession();
|
||||
|
||||
if (!session || !session.token || !session.saasUrl) {
|
||||
throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台');
|
||||
}
|
||||
|
||||
log.debug('Using SaaS relay mode:', session.saasUrl);
|
||||
|
||||
// Configure the singleton client
|
||||
saasClient.setBaseUrl(session.saasUrl);
|
||||
saasClient.setToken(session.token);
|
||||
|
||||
// Health check via GET /api/v1/relay/models
|
||||
try {
|
||||
await saasClient.listModels();
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`SaaS 平台连接失败: ${errMsg}`);
|
||||
}
|
||||
|
||||
set({ connectionState: 'connected', gatewayVersion: 'saas-relay' });
|
||||
log.debug('Connected to SaaS relay');
|
||||
return;
|
||||
}
|
||||
|
||||
// === Internal Kernel Mode (Tauri) ===
|
||||
// Check at RUNTIME, not at module load time, to ensure __TAURI_INTERNALS__ is available
|
||||
const useInternalKernel = isTauriRuntime();
|
||||
|
||||
@@ -35,6 +35,10 @@ export type { SessionStore, SessionStateSlice, SessionActionsSlice, Session, Ses
|
||||
export { useMemoryGraphStore } from './memoryGraphStore';
|
||||
export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout } from './memoryGraphStore';
|
||||
|
||||
// === SaaS Store ===
|
||||
export { useSaaSStore } from './saasStore';
|
||||
export type { SaaSStore, SaaSStateSlice, SaaSActionsSlice, ConnectionMode } from './saasStore';
|
||||
|
||||
|
||||
// === Browser Hand Store ===
|
||||
export { useBrowserHandStore } from './browserHandStore';
|
||||
|
||||
466
desktop/src/store/saasStore.ts
Normal file
466
desktop/src/store/saasStore.ts
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* SaaS Store - SaaS Platform Connection State Management
|
||||
*
|
||||
* Manages SaaS login state, account info, connection mode,
|
||||
* and available models. Persists auth state to localStorage
|
||||
* via saas-client helpers.
|
||||
*
|
||||
* Connection modes:
|
||||
* - 'tauri': Local Kernel via Tauri (default)
|
||||
* - 'gateway': External Gateway via WebSocket
|
||||
* - 'saas': SaaS backend relay
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
saasClient,
|
||||
SaaSApiError,
|
||||
loadSaaSSession,
|
||||
saveSaaSSession,
|
||||
clearSaaSSession,
|
||||
saveConnectionMode,
|
||||
loadConnectionMode,
|
||||
type SaaSAccountInfo,
|
||||
type SaaSModelInfo,
|
||||
type SaaSLoginResponse,
|
||||
type TotpSetupResponse,
|
||||
} from '../lib/saas-client';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
const log = createLogger('SaaSStore');
|
||||
|
||||
// === Device ID ===
|
||||
|
||||
/** Generate or load a persistent device ID for this browser instance */
|
||||
function getOrCreateDeviceId(): string {
|
||||
const KEY = 'zclaw-device-id';
|
||||
const existing = localStorage.getItem(KEY);
|
||||
if (existing) return existing;
|
||||
const newId = crypto.randomUUID();
|
||||
localStorage.setItem(KEY, newId);
|
||||
return newId;
|
||||
}
|
||||
|
||||
const DEVICE_ID = getOrCreateDeviceId();
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
|
||||
|
||||
export interface SaaSStateSlice {
|
||||
isLoggedIn: boolean;
|
||||
account: SaaSAccountInfo | null;
|
||||
saasUrl: string;
|
||||
authToken: string | null;
|
||||
connectionMode: ConnectionMode;
|
||||
availableModels: SaaSModelInfo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
totpRequired: boolean;
|
||||
totpSetupData: TotpSetupResponse | null;
|
||||
}
|
||||
|
||||
export interface SaaSActionsSlice {
|
||||
login: (saasUrl: string, username: string, password: string) => Promise<void>;
|
||||
loginWithTotp: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
|
||||
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
setConnectionMode: (mode: ConnectionMode) => void;
|
||||
fetchAvailableModels: () => Promise<void>;
|
||||
registerCurrentDevice: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
restoreSession: () => void;
|
||||
setupTotp: () => Promise<TotpSetupResponse>;
|
||||
verifyTotp: (code: string) => Promise<void>;
|
||||
disableTotp: (password: string) => Promise<void>;
|
||||
cancelTotpSetup: () => void;
|
||||
}
|
||||
|
||||
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
|
||||
|
||||
// === Constants ===
|
||||
|
||||
const DEFAULT_SAAS_URL = 'https://saas.zclaw.com';
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/** Determine the initial connection mode from persisted state */
|
||||
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
|
||||
const persistedMode = loadConnectionMode();
|
||||
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
|
||||
return persistedMode;
|
||||
}
|
||||
return session ? 'saas' : 'tauri';
|
||||
}
|
||||
|
||||
// === Store Implementation ===
|
||||
|
||||
export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
// Restore session from localStorage on init
|
||||
const session = loadSaaSSession();
|
||||
const initialMode = resolveInitialMode(session);
|
||||
|
||||
// If session exists, configure the singleton client
|
||||
if (session) {
|
||||
saasClient.setBaseUrl(session.saasUrl);
|
||||
saasClient.setToken(session.token);
|
||||
}
|
||||
|
||||
return {
|
||||
// === Initial State ===
|
||||
isLoggedIn: session !== null,
|
||||
account: session?.account ?? null,
|
||||
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
|
||||
authToken: session?.token ?? null,
|
||||
connectionMode: initialMode,
|
||||
availableModels: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
totpSetupData: null,
|
||||
|
||||
// === Actions ===
|
||||
|
||||
login: async (saasUrl: string, username: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const trimmedUrl = saasUrl.trim();
|
||||
const trimmedUsername = username.trim();
|
||||
|
||||
if (!trimmedUrl) {
|
||||
throw new Error('请输入服务器地址');
|
||||
}
|
||||
if (!trimmedUsername) {
|
||||
throw new Error('请输入用户名');
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error('请输入密码');
|
||||
}
|
||||
|
||||
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
|
||||
|
||||
// Configure singleton client and attempt login
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
|
||||
|
||||
// Persist session
|
||||
const sessionData = {
|
||||
token: loginData.token,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: loginData.token,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Register device and start heartbeat in background
|
||||
get().registerCurrentDevice().catch((err: unknown) => {
|
||||
log.warn('Failed to register device:', err);
|
||||
});
|
||||
|
||||
// Fetch available models in background (non-blocking)
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models after login:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Check for TOTP required signal
|
||||
if (err instanceof SaaSApiError && err.code === 'TOTP_ERROR' && err.status === 400) {
|
||||
set({ isLoading: false, totpRequired: true, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = err instanceof SaaSApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
|
||||
const isNetworkError = message.includes('Failed to fetch')
|
||||
|| message.includes('NetworkError')
|
||||
|| message.includes('ECONNREFUSED')
|
||||
|| message.includes('timeout');
|
||||
|
||||
const userMessage = isNetworkError
|
||||
? `无法连接到 SaaS 服务器: ${get().saasUrl}`
|
||||
: message;
|
||||
|
||||
set({ isLoading: false, error: userMessage });
|
||||
throw new Error(userMessage);
|
||||
}
|
||||
},
|
||||
|
||||
loginWithTotp: async (saasUrl: string, username: string, password: string, totpCode: string) => {
|
||||
set({ isLoading: true, error: null, totpRequired: false });
|
||||
|
||||
try {
|
||||
const normalizedUrl = saasUrl.trim().replace(/\/+$/, '');
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const loginData = await saasClient.login(username.trim(), password, totpCode);
|
||||
|
||||
const sessionData = {
|
||||
token: loginData.token,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: loginData.token,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
});
|
||||
|
||||
get().registerCurrentDevice().catch((err: unknown) => {
|
||||
log.warn('Failed to register device:', err);
|
||||
});
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
register: async (saasUrl: string, username: string, email: string, password: string, displayName?: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const trimmedUrl = saasUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
throw new Error('请输入服务器地址');
|
||||
}
|
||||
if (!username.trim()) {
|
||||
throw new Error('请输入用户名');
|
||||
}
|
||||
if (!email.trim()) {
|
||||
throw new Error('请输入邮箱');
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error('请输入密码');
|
||||
}
|
||||
|
||||
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
|
||||
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const registerData: SaaSLoginResponse = await saasClient.register({
|
||||
username: username.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
display_name: displayName,
|
||||
});
|
||||
|
||||
const sessionData = {
|
||||
token: registerData.token,
|
||||
account: registerData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: registerData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: registerData.token,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
get().registerCurrentDevice().catch((err: unknown) => {
|
||||
log.warn('Failed to register device after register:', err);
|
||||
});
|
||||
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models after register:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
saasClient.setToken(null);
|
||||
clearSaaSSession();
|
||||
saveConnectionMode('tauri');
|
||||
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
account: null,
|
||||
authToken: null,
|
||||
connectionMode: 'tauri',
|
||||
availableModels: [],
|
||||
error: null,
|
||||
totpRequired: false,
|
||||
totpSetupData: null,
|
||||
});
|
||||
},
|
||||
|
||||
setConnectionMode: (mode: ConnectionMode) => {
|
||||
const { isLoggedIn } = get();
|
||||
|
||||
// Cannot switch to SaaS mode if not logged in
|
||||
if (mode === 'saas' && !isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveConnectionMode(mode);
|
||||
set({ connectionMode: mode });
|
||||
},
|
||||
|
||||
fetchAvailableModels: async () => {
|
||||
const { isLoggedIn, authToken, saasUrl } = get();
|
||||
|
||||
if (!isLoggedIn || !authToken) {
|
||||
set({ availableModels: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
const models = await saasClient.listModels();
|
||||
set({ availableModels: models });
|
||||
} catch (err: unknown) {
|
||||
log.warn('Failed to fetch available models:', err);
|
||||
// Do not set error state - model fetch failure is non-critical
|
||||
set({ availableModels: [] });
|
||||
}
|
||||
},
|
||||
|
||||
registerCurrentDevice: async () => {
|
||||
const { isLoggedIn, authToken, saasUrl } = get();
|
||||
|
||||
if (!isLoggedIn || !authToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
await saasClient.registerDevice({
|
||||
device_id: DEVICE_ID,
|
||||
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
|
||||
platform: navigator.platform,
|
||||
app_version: __APP_VERSION__ || 'unknown',
|
||||
});
|
||||
log.info('Device registered successfully');
|
||||
|
||||
// Start periodic heartbeat (every 5 minutes)
|
||||
if (typeof window !== 'undefined' && !get()._heartbeatTimer) {
|
||||
const timer = window.setInterval(() => {
|
||||
const state = get();
|
||||
if (state.isLoggedIn && state.authToken) {
|
||||
saasClient.deviceHeartbeat(DEVICE_ID).catch(() => {});
|
||||
} else {
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
set({ _heartbeatTimer: timer } as unknown as Partial<SaaSStore>);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log.warn('Failed to register device:', err);
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
restoreSession: () => {
|
||||
const restored = loadSaaSSession();
|
||||
if (restored) {
|
||||
saasClient.setBaseUrl(restored.saasUrl);
|
||||
saasClient.setToken(restored.token);
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: restored.account,
|
||||
saasUrl: restored.saasUrl,
|
||||
authToken: restored.token,
|
||||
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
|
||||
});
|
||||
get().fetchAvailableModels().catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
setupTotp: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const setupData = await saasClient.setupTotp();
|
||||
set({ totpSetupData: setupData, isLoading: false });
|
||||
return setupData;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
verifyTotp: async (code: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await saasClient.verifyTotp(code);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
}
|
||||
set({ totpSetupData: null, isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
disableTotp: async (password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await saasClient.disableTotp(password);
|
||||
const account = await saasClient.me();
|
||||
const { saasUrl, authToken } = get();
|
||||
if (authToken) {
|
||||
saveSaaSSession({ token: authToken, account, saasUrl });
|
||||
}
|
||||
set({ isLoading: false, account });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError ? err.message
|
||||
: err instanceof Error ? err.message : String(err);
|
||||
set({ isLoading: false, error: message });
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
|
||||
cancelTotpSetup: () => {
|
||||
set({ totpSetupData: null });
|
||||
},
|
||||
};
|
||||
});
|
||||
140
docs/features/08-saas-platform/00-saas-overview.md
Normal file
140
docs/features/08-saas-platform/00-saas-overview.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# ZCLAW SaaS 平台 — 总览
|
||||
|
||||
> 最后更新: 2026-03-27 | 实施状态: Phase 1-4 + P2 全部完成
|
||||
|
||||
## 架构概述
|
||||
|
||||
ZCLAW SaaS 平台为桌面端用户提供云端能力,包括模型中转、账号管理、配置同步和团队协作。
|
||||
|
||||
```text
|
||||
桌面端 (Tauri/React)
|
||||
│
|
||||
├── Mode A: Tauri Kernel (本地直连)
|
||||
├── Mode B: Gateway WebSocket
|
||||
└── Mode C: SaaS Cloud ──→ Rust/Axum 后端 ──→ 上游 LLM Provider
|
||||
│
|
||||
├── Admin Web (Next.js 管理后台)
|
||||
└── SQLite WAL (数据持久化)
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 | 说明 |
|
||||
|------|------|------|
|
||||
| 后端 | Rust + Axum + sqlx + SQLite WAL | JWT + API Token 双认证 |
|
||||
| Admin | Next.js 14 + shadcn/ui + Tailwind | 暗色 OLED 主题 |
|
||||
| 桌面端 | React 18 + Zustand + TypeScript | saas-client.ts HTTP 通信 |
|
||||
| 安全 | argon2 + TOTP 2FA + RBAC | 速率限制 + 操作审计 |
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 完成度 | 核心能力 |
|
||||
|------|--------|----------|
|
||||
| 认证 (Auth) | 100% | JWT + API Token + 密码修改 + /me + TOTP 2FA |
|
||||
| 账号 (Account) | 100% | CRUD + 角色管理 + 自角色限制 + 设备管理 |
|
||||
| 模型配置 (Model Config) | 95% | Provider/Model/Key CRUD + 用量记录 |
|
||||
| 中转 (Relay) | 95% | SSE 流式 + 任务记录 + 指数退避重试 + Admin 重试 |
|
||||
| 配置迁移 (Migration) | 90% | CRUD + 同步日志 + push/merge + diff |
|
||||
| Admin UI | 95% | 10 个 CRUD 页面 + Dashboard |
|
||||
| 桌面端集成 | 95% | 登录/注册/状态/密码/设备/离线/迁移向导 |
|
||||
|
||||
## API 端点一览
|
||||
|
||||
### 公开端点 (无需认证)
|
||||
- `POST /api/v1/auth/register` — 注册
|
||||
- `POST /api/v1/auth/login` — 登录
|
||||
- `GET /api/health` — 健康检查
|
||||
|
||||
### 认证端点
|
||||
- `GET /api/v1/auth/me` — 当前用户信息
|
||||
- `POST /api/v1/auth/refresh` — 刷新 Token
|
||||
- `PUT /api/v1/auth/password` — 修改密码
|
||||
|
||||
### TOTP 双因素认证 (P2)
|
||||
- `POST /api/v1/auth/totp/setup` — 生成 TOTP 密钥,返回 otpauth:// URI
|
||||
- `POST /api/v1/auth/totp/verify` — 验证 TOTP 码并启用 2FA
|
||||
- `POST /api/v1/auth/totp/disable` — 禁用 2FA (需密码确认)
|
||||
|
||||
### 账号管理
|
||||
- `GET /api/v1/accounts` — 列出账号 (admin)
|
||||
- `GET /api/v1/accounts/:id` — 获取账号
|
||||
- `PUT /api/v1/accounts/:id` — 更新账号
|
||||
- `PATCH /api/v1/accounts/:id/status` — 更新状态 (admin)
|
||||
- `GET /api/v1/stats/dashboard` — 仪表盘统计 (admin)
|
||||
|
||||
### API Token
|
||||
- `GET /api/v1/tokens` — 列出 Token
|
||||
- `POST /api/v1/tokens` — 创建 Token
|
||||
- `DELETE /api/v1/tokens/:id` — 撤销 Token
|
||||
|
||||
### 设备管理
|
||||
- `POST /api/v1/devices/register` — 注册/更新设备 (UPSERT)
|
||||
- `POST /api/v1/devices/heartbeat` — 设备心跳
|
||||
- `GET /api/v1/devices` — 列出设备
|
||||
|
||||
### 模型配置
|
||||
- `GET/POST /api/v1/providers` — Provider CRUD
|
||||
- `GET/POST/PUT/DELETE /api/v1/providers/:id` — 单个 Provider
|
||||
- `GET/POST /api/v1/models` — Model CRUD
|
||||
- `GET/POST/PUT/DELETE /api/v1/models/:id` — 单个 Model
|
||||
- `GET/POST/DELETE /api/v1/keys` — API Key CRUD
|
||||
|
||||
### 中转 (Relay)
|
||||
- `GET /api/v1/relay/models` — 可用中转模型
|
||||
- `POST /api/v1/relay/chat/completions` — 聊天中转 (SSE/JSON)
|
||||
- `GET /api/v1/relay/tasks` — 中转任务列表
|
||||
- `GET /api/v1/relay/tasks/:id` — 获取单个任务
|
||||
- `POST /api/v1/relay/tasks/:id/retry` — 重试失败任务 (admin)
|
||||
|
||||
### 配置
|
||||
- `GET /api/v1/config/items` — 列出配置项
|
||||
- `POST /api/v1/config/items` — 创建配置项
|
||||
- `GET /api/v1/config/items/:id` — 获取配置项
|
||||
- `PUT /api/v1/config/items/:id` — 更新配置项 (admin)
|
||||
- `DELETE /api/v1/config/items/:id` — 删除配置项 (admin)
|
||||
- `GET /api/v1/config/analysis` — 配置分析
|
||||
- `POST /api/v1/config/seed` — 种子配置 (admin)
|
||||
- `POST /api/v1/config/sync` — 配置同步 (push/merge)
|
||||
- `POST /api/v1/config/diff` — 配置差异对比 (只读)
|
||||
- `GET /api/v1/config/sync-logs` — 同步日志
|
||||
|
||||
### 审计
|
||||
- `GET /api/v1/logs/operations` — 操作日志 (admin)
|
||||
- `GET /api/v1/usage` — 用量统计
|
||||
|
||||
## 关键文件索引
|
||||
|
||||
### 后端 (crates/zclaw-saas/)
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/main.rs` | 服务启动 + ConnectInfo 注入 |
|
||||
| `src/db.rs` | 数据库初始化 + Schema + Admin 引导 |
|
||||
| `src/state.rs` | AppState (DB + Config) |
|
||||
| `src/config.rs` | 配置结构体 |
|
||||
| `src/error.rs` | SaasError 枚举 + IntoResponse |
|
||||
| `src/middleware.rs` | 速率限制中间件 |
|
||||
| `src/auth/mod.rs` | JWT + API Token 中间件 + 路由 |
|
||||
| `src/auth/handlers.rs` | 登录/注册/刷新/me/密码 (含 TOTP 登录验证) |
|
||||
| `src/auth/totp.rs` | TOTP 2FA (setup/verify/disable) |
|
||||
| `src/auth/types.rs` | AuthContext + Request/Response 类型 |
|
||||
| `src/account/handlers.rs` | 账号 CRUD + Dashboard + 设备 |
|
||||
| `src/model_config/handlers.rs` | Provider/Model/Key CRUD |
|
||||
| `src/relay/handlers.rs` + `service.rs` | SSE 中转 + 任务管理 + 指数退避重试 |
|
||||
| `src/migration/handlers.rs` + `service.rs` | 配置 CRUD + 同步 |
|
||||
|
||||
### Admin (admin/)
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/lib/api-client.ts` | 类型化 HTTP 客户端 |
|
||||
| `src/lib/auth.ts` | JWT 管理 |
|
||||
| `src/app/(dashboard)/` | 10 个 CRUD 页面 |
|
||||
|
||||
### 桌面端 (desktop/src/)
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `lib/saas-client.ts` | SaaS HTTP 客户端 (重试 + 离线检测) |
|
||||
| `store/saasStore.ts` | SaaS 状态 (登录/设备/心跳) |
|
||||
| `components/SaaS/SaaSLogin.tsx` | 登录/注册 UI |
|
||||
| `components/SaaS/SaaSStatus.tsx` | 连接状态 + 可用模型 |
|
||||
| `components/SaaS/SaaSSettings.tsx` | 设置页 (密码/迁移) |
|
||||
| `components/SaaS/ConfigMigrationWizard.tsx` | 3 步配置迁移向导 |
|
||||
17
saas-config.toml
Normal file
17
saas-config.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
|
||||
[database]
|
||||
url = "sqlite:./saas-data.db"
|
||||
|
||||
[auth]
|
||||
jwt_expiration_hours = 24
|
||||
totp_issuer = "ZCLAW SaaS"
|
||||
|
||||
[relay]
|
||||
max_queue_size = 1000
|
||||
max_concurrent_per_provider = 5
|
||||
batch_window_ms = 50
|
||||
retry_delay_ms = 1000
|
||||
max_attempts = 3
|
||||
Reference in New Issue
Block a user