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:
iven
2026-03-28 00:54:53 +08:00
84 changed files with 15558 additions and 5 deletions

349
Cargo.lock generated
View File

@@ -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"

View File

@@ -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
View File

@@ -0,0 +1,2 @@
.next/
node_modules/

5
admin/next-env.d.ts vendored Normal file
View 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
View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

37
admin/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

6
admin/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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>
)
}

View 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>
&quot;{revokeTarget?.name}&quot; 使访
</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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
&quot;{deleteTarget?.alias || deleteTarget?.model_id}&quot;
</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>
)
}

View 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>
)
}

View 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>
&quot;{deleteTarget?.display_name || deleteTarget?.name}&quot;
</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>
)
}

View 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>
)
}

View 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
View 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
View 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>
)
}

View 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>
)
}

View 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() }
}

View 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 }

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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 }

View 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,
}

View 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 }

View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}

View 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 }

View 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))
}

View 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))
}

View 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 &params {
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 &params {
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 &params {
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(())
}

View 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>,
}

View 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(())
}

View 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());
}
}

View 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))
}

View 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());
}
}

View 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 已禁用"})))
}

View 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>,
}

View 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
View 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);
}
}
}

View 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>;

View 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;

View 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)
}

View 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
}

View 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)
}

View 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))
}

View 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 &params {
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())
}

View 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>,
}

View 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, &params).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)
}

View 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))
}

View 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 &params {
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 &params {
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 &params {
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 &params {
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..])
}

View 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>,
}

View 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})))
}

View 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))
}

View 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
}
}
}

View 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,
}

View 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()),
})
}
}

File diff suppressed because it is too large Load Diff

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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);

View 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');

View File

@@ -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();

View File

@@ -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';

View 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 });
},
};
});

View 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
View 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