Compare commits
11 Commits
834aa12076
...
6821df5f44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6821df5f44 | ||
|
|
9d310e5a3c | ||
|
|
6529b67353 | ||
|
|
a0bbd4ba82 | ||
|
|
c2aff09811 | ||
|
|
e7b2d1c099 | ||
|
|
88aa4b1310 | ||
|
|
ecd7f2e928 | ||
|
|
544358764e | ||
|
|
ba2c6a6105 | ||
|
|
bc8c77e7fe |
61
Cargo.lock
generated
61
Cargo.lock
generated
@@ -137,6 +137,12 @@ dependencies = [
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arraydeque"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
@@ -2506,7 +2512,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -4189,7 +4195,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -4226,7 +4232,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
@@ -4677,6 +4683,17 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "saphyr-parser-bw"
|
||||
version = "0.0.610"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d643f5e972f17219245b82f038c22cd3c74320bb17c6e8f7e8537de268b1bc6"
|
||||
dependencies = [
|
||||
"arraydeque",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -4984,6 +5001,23 @@ dependencies = [
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml_bw"
|
||||
version = "2.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b4d05b26468431333c4d16963433fe0e04ef24e4a7b568c9da81e91d25c0dbb"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"num-traits",
|
||||
"regex",
|
||||
"saphyr-parser-bw",
|
||||
"serde",
|
||||
"unsafe-libyaml-norway",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -5133,6 +5167,16 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.5.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
@@ -6048,7 +6092,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"socket2 0.6.3",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -6531,6 +6575,12 @@ version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml-norway"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -8246,7 +8296,7 @@ dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"serde_yaml_bw",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
@@ -8328,6 +8378,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"socket2 0.5.10",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
|
||||
@@ -29,6 +29,7 @@ rust-version = "1.75"
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
tokio-util = "0.7"
|
||||
futures = "0.3"
|
||||
async-stream = "0.3"
|
||||
|
||||
@@ -102,7 +103,7 @@ tempfile = "3"
|
||||
|
||||
# SaaS dependencies
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
|
||||
tower = { version = "0.4", features = ["util"] }
|
||||
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "timeout"] }
|
||||
jsonwebtoken = "9"
|
||||
@@ -110,6 +111,9 @@ argon2 = "0.5"
|
||||
totp-rs = "5"
|
||||
hex = "0.4"
|
||||
|
||||
# TCP socket configuration
|
||||
socket2 = { version = "0.5", features = ["all"] }
|
||||
|
||||
# Internal crates
|
||||
zclaw-types = { path = "crates/zclaw-types" }
|
||||
zclaw-memory = { path = "crates/zclaw-memory" }
|
||||
|
||||
1
admin-v2/.env.development
Normal file
1
admin-v2/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
1
admin-v2/.env.production
Normal file
1
admin-v2/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
53
admin-v2/src/components/ErrorBoundary.tsx
Normal file
53
admin-v2/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Component, type ReactNode } from 'react'
|
||||
import { Result, Button } from 'antd'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('[ErrorBoundary] Unhandled error:', error, info.componentStack)
|
||||
}
|
||||
|
||||
private handleReload = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
private handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Result
|
||||
status="error"
|
||||
title="页面出现错误"
|
||||
subTitle={this.state.error?.message ?? '发生了未知错误,请刷新页面重试'}
|
||||
extra={[
|
||||
<Button key="retry" onClick={this.handleReset}>重试</Button>,
|
||||
<Button key="reload" type="primary" onClick={this.handleReload}>刷新页面</Button>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom'
|
||||
import { ConfigProvider, App as AntApp } from 'antd'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import { router } from './router'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -16,11 +17,13 @@ const queryClient = new QueryClient({
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AntApp>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AntApp>
|
||||
</ConfigProvider>,
|
||||
<ErrorBoundary>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<AntApp>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</ErrorBoundary>,
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function Accounts() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['accounts'],
|
||||
queryFn: () => accountService.list(),
|
||||
queryFn: ({ signal }) => accountService.list(signal),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function AgentTemplates() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['agent-templates'],
|
||||
queryFn: () => agentTemplateService.list(),
|
||||
queryFn: ({ signal }) => agentTemplateService.list(signal),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function ApiKeys() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['api-keys'],
|
||||
queryFn: () => apiKeyService.list(),
|
||||
queryFn: ({ signal }) => apiKeyService.list(signal),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function Config() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['config', category],
|
||||
queryFn: () => configService.list({ category }),
|
||||
queryFn: ({ signal }) => configService.list({ category }, signal),
|
||||
})
|
||||
|
||||
const updateMutation = useMutation({
|
||||
|
||||
@@ -42,12 +42,12 @@ const actionColors: Record<string, string> = {
|
||||
export default function Dashboard() {
|
||||
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
|
||||
queryKey: ['dashboard-stats'],
|
||||
queryFn: () => statsService.dashboard(),
|
||||
queryFn: ({ signal }) => statsService.dashboard(signal),
|
||||
})
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['recent-logs'],
|
||||
queryFn: () => logService.list({ page: 1, page_size: 10 }),
|
||||
queryFn: ({ signal }) => logService.list({ page: 1, page_size: 10 }, signal),
|
||||
})
|
||||
|
||||
if (statsError) {
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function Logs() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['logs', page, actionFilter],
|
||||
queryFn: () => logService.list({ page, page_size: 20, action: actionFilter }),
|
||||
queryFn: ({ signal }) => logService.list({ page, page_size: 20, action: actionFilter }, signal),
|
||||
})
|
||||
|
||||
const columns: ProColumns<OperationLog>[] = [
|
||||
|
||||
@@ -20,12 +20,12 @@ export default function Models() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['models'],
|
||||
queryFn: () => modelService.list(),
|
||||
queryFn: ({ signal }) => modelService.list(signal),
|
||||
})
|
||||
|
||||
const { data: providersData } = useQuery({
|
||||
queryKey: ['providers-for-select'],
|
||||
queryFn: () => providerService.list(),
|
||||
queryFn: ({ signal }) => providerService.list(signal),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
|
||||
@@ -26,18 +26,18 @@ export default function Prompts() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['prompts'],
|
||||
queryFn: () => promptService.list(),
|
||||
queryFn: ({ signal }) => promptService.list(signal),
|
||||
})
|
||||
|
||||
const { data: detailData } = useQuery({
|
||||
queryKey: ['prompt-detail', detailName],
|
||||
queryFn: () => promptService.get(detailName!),
|
||||
queryFn: ({ signal }) => promptService.get(detailName!, signal),
|
||||
enabled: !!detailName,
|
||||
})
|
||||
|
||||
const { data: versionsData } = useQuery({
|
||||
queryKey: ['prompt-versions', detailName],
|
||||
queryFn: () => promptService.listVersions(detailName!),
|
||||
queryFn: ({ signal }) => promptService.listVersions(detailName!, signal),
|
||||
enabled: !!detailName,
|
||||
})
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@ export default function Providers() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['providers'],
|
||||
queryFn: () => providerService.list(),
|
||||
queryFn: ({ signal }) => providerService.list(signal),
|
||||
})
|
||||
|
||||
const { data: keysData, isLoading: keysLoading } = useQuery({
|
||||
queryKey: ['provider-keys', keyModalProviderId],
|
||||
queryFn: () => providerService.listKeys(keyModalProviderId!),
|
||||
queryFn: ({ signal }) => providerService.listKeys(keyModalProviderId!, signal),
|
||||
enabled: !!keyModalProviderId,
|
||||
})
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Relay() {
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['relay-tasks', page, statusFilter],
|
||||
queryFn: () => relayService.list({ page, page_size: 20, status: statusFilter }),
|
||||
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
|
||||
})
|
||||
|
||||
const columns: ProColumns<RelayTask>[] = [
|
||||
|
||||
@@ -19,12 +19,12 @@ export default function Usage() {
|
||||
|
||||
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = useQuery({
|
||||
queryKey: ['usage-daily', days],
|
||||
queryFn: () => telemetryService.dailyStats({ days }),
|
||||
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
|
||||
})
|
||||
|
||||
const { data: modelData, isLoading: modelLoading } = useQuery({
|
||||
queryKey: ['usage-model', days],
|
||||
queryFn: () => telemetryService.modelStats({}),
|
||||
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
|
||||
})
|
||||
|
||||
if (dailyError) {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { AccountPublic, PaginatedResponse } from '@/types'
|
||||
|
||||
export const accountService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<AccountPublic>>('/accounts', { params }).then((r) => r.data),
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<AccountPublic>>('/accounts', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
request.get<AccountPublic>(`/accounts/${id}`).then((r) => r.data),
|
||||
get: (id: string, signal?: AbortSignal) =>
|
||||
request.get<AccountPublic>(`/accounts/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>) =>
|
||||
request.patch<AccountPublic>(`/accounts/${id}`, data).then((r) => r.data),
|
||||
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>, signal?: AbortSignal) =>
|
||||
request.patch<AccountPublic>(`/accounts/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
updateStatus: (id: string, data: { status: AccountPublic['status'] }) =>
|
||||
request.patch(`/accounts/${id}/status`, data).then((r) => r.data),
|
||||
updateStatus: (id: string, data: { status: AccountPublic['status'] }, signal?: AbortSignal) =>
|
||||
request.patch(`/accounts/${id}/status`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { AgentTemplate, PaginatedResponse } from '@/types'
|
||||
|
||||
export const agentTemplateService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', { params }).then((r) => r.data),
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
request.get<AgentTemplate>(`/agent-templates/${id}`).then((r) => r.data),
|
||||
get: (id: string, signal?: AbortSignal) =>
|
||||
request.get<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
create: (data: {
|
||||
name: string; description?: string; category?: string; source?: string
|
||||
model?: string; system_prompt?: string; tools?: string[]
|
||||
capabilities?: string[]; temperature?: number; max_tokens?: number
|
||||
visibility?: string
|
||||
}) =>
|
||||
request.post<AgentTemplate>('/agent-templates', data).then((r) => r.data),
|
||||
}, signal?: AbortSignal) =>
|
||||
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
update: (id: string, data: {
|
||||
description?: string; model?: string; system_prompt?: string
|
||||
tools?: string[]; capabilities?: string[]; temperature?: number
|
||||
max_tokens?: number; visibility?: string; status?: string
|
||||
}) =>
|
||||
request.post<AgentTemplate>(`/agent-templates/${id}`, data).then((r) => r.data),
|
||||
}, signal?: AbortSignal) =>
|
||||
request.post<AgentTemplate>(`/agent-templates/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
archive: (id: string) =>
|
||||
request.delete<AgentTemplate>(`/agent-templates/${id}`).then((r) => r.data),
|
||||
archive: (id: string, signal?: AbortSignal) =>
|
||||
request.delete<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
|
||||
|
||||
export const apiKeyService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<TokenInfo>>('/keys', { params }).then((r) => r.data),
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<TokenInfo>>('/keys', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
create: (data: CreateTokenRequest) =>
|
||||
request.post<TokenInfo>('/keys', data).then((r) => r.data),
|
||||
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
|
||||
request.post<TokenInfo>('/keys', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
revoke: (id: string) =>
|
||||
request.delete(`/keys/${id}`).then((r) => r.data),
|
||||
revoke: (id: string, signal?: AbortSignal) =>
|
||||
request.delete(`/keys/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { AccountPublic, LoginRequest, LoginResponse } from '@/types'
|
||||
|
||||
export const authService = {
|
||||
login: (data: LoginRequest) =>
|
||||
request.post<LoginResponse>('/auth/login', data).then((r) => r.data),
|
||||
login: (data: LoginRequest, signal?: AbortSignal) =>
|
||||
request.post<LoginResponse>('/auth/login', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
me: () =>
|
||||
request.get<AccountPublic>('/auth/me').then((r) => r.data),
|
||||
me: (signal?: AbortSignal) =>
|
||||
request.get<AccountPublic>('/auth/me', withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { ConfigItem, PaginatedResponse } from '@/types'
|
||||
|
||||
export const configService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<ConfigItem>>('/config/items', { params })
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<ConfigItem>>('/config/items', withSignal({ params }, signal))
|
||||
.then((r) => r.data.items),
|
||||
|
||||
update: (id: string, data: { value: string | number | boolean }) =>
|
||||
request.patch<ConfigItem>(`/config/items/${id}`, data).then((r) => r.data),
|
||||
update: (id: string, data: { value: string | number | boolean }, signal?: AbortSignal) =>
|
||||
request.patch<ConfigItem>(`/config/items/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { OperationLog, PaginatedResponse } from '@/types'
|
||||
|
||||
export const logService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<OperationLog>>('/logs/operations', { params }).then((r) => r.data),
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<OperationLog>>('/logs/operations', withSignal({ params }, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { Model, PaginatedResponse } from '@/types'
|
||||
|
||||
export const modelService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<Model>>('/models', { params }).then((r) => r.data),
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<Model>>('/models', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
create: (data: Partial<Omit<Model, 'id'>>) =>
|
||||
request.post<Model>('/models', data).then((r) => r.data),
|
||||
create: (data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
|
||||
request.post<Model>('/models', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
update: (id: string, data: Partial<Omit<Model, 'id'>>) =>
|
||||
request.patch<Model>(`/models/${id}`, data).then((r) => r.data),
|
||||
update: (id: string, data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
|
||||
request.patch<Model>(`/models/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
request.delete(`/models/${id}`).then((r) => r.data),
|
||||
delete: (id: string, signal?: AbortSignal) =>
|
||||
request.delete(`/models/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { PromptTemplate, PromptVersion, PaginatedResponse } from '@/types'
|
||||
|
||||
export const promptService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<PromptTemplate>>('/prompts', { params }).then((r) => r.data),
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<PromptTemplate>>('/prompts', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
get: (name: string) =>
|
||||
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`).then((r) => r.data),
|
||||
get: (name: string, signal?: AbortSignal) =>
|
||||
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
create: (data: {
|
||||
name: string; category: string; description?: string; source?: string
|
||||
system_prompt: string; user_prompt_template?: string
|
||||
variables?: unknown[]; min_app_version?: string
|
||||
}) =>
|
||||
request.post<PromptTemplate>('/prompts', data).then((r) => r.data),
|
||||
}, signal?: AbortSignal) =>
|
||||
request.post<PromptTemplate>('/prompts', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
update: (name: string, data: { description?: string; status?: string }) =>
|
||||
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data).then((r) => r.data),
|
||||
update: (name: string, data: { description?: string; status?: string }, signal?: AbortSignal) =>
|
||||
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
archive: (name: string) =>
|
||||
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`).then((r) => r.data),
|
||||
archive: (name: string, signal?: AbortSignal) =>
|
||||
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
listVersions: (name: string) =>
|
||||
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`).then((r) => r.data),
|
||||
listVersions: (name: string, signal?: AbortSignal) =>
|
||||
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
createVersion: (name: string, data: {
|
||||
system_prompt: string; user_prompt_template?: string
|
||||
variables?: unknown[]; changelog?: string; min_app_version?: string
|
||||
}) =>
|
||||
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data).then((r) => r.data),
|
||||
}, signal?: AbortSignal) =>
|
||||
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
rollback: (name: string, version: number) =>
|
||||
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`).then((r) => r.data),
|
||||
rollback: (name: string, version: number, signal?: AbortSignal) =>
|
||||
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`, undefined, withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { Provider, ProviderKey, PaginatedResponse } from '@/types'
|
||||
|
||||
export const providerService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<Provider>>('/providers', { params }).then((r) => r.data),
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<Provider>>('/providers', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
|
||||
request.post<Provider>('/providers', data).then((r) => r.data),
|
||||
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
|
||||
request.post<Provider>('/providers', data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
|
||||
request.patch<Provider>(`/providers/${id}`, data).then((r) => r.data),
|
||||
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
|
||||
request.patch<Provider>(`/providers/${id}`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
delete: (id: string) =>
|
||||
request.delete(`/providers/${id}`).then((r) => r.data),
|
||||
delete: (id: string, signal?: AbortSignal) =>
|
||||
request.delete(`/providers/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
listKeys: (providerId: string) =>
|
||||
request.get<ProviderKey[]>(`/providers/${providerId}/keys`).then((r) => r.data),
|
||||
listKeys: (providerId: string, signal?: AbortSignal) =>
|
||||
request.get<ProviderKey[]>(`/providers/${providerId}/keys`, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
addKey: (providerId: string, data: {
|
||||
key_label: string; key_value: string; priority?: number
|
||||
max_rpm?: number; max_tpm?: number; quota_reset_interval?: string
|
||||
}) =>
|
||||
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data).then((r) => r.data),
|
||||
}, signal?: AbortSignal) =>
|
||||
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
toggleKey: (providerId: string, keyId: string, active: boolean) =>
|
||||
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }).then((r) => r.data),
|
||||
toggleKey: (providerId: string, keyId: string, active: boolean, signal?: AbortSignal) =>
|
||||
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }, withSignal({}, signal)).then((r) => r.data),
|
||||
|
||||
deleteKey: (providerId: string, keyId: string) =>
|
||||
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`).then((r) => r.data),
|
||||
deleteKey: (providerId: string, keyId: string, signal?: AbortSignal) =>
|
||||
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`, withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { RelayTask, PaginatedResponse } from '@/types'
|
||||
|
||||
export const relayService = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
request.get<PaginatedResponse<RelayTask>>('/relay/tasks', { params }).then((r) => r.data),
|
||||
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<PaginatedResponse<RelayTask>>('/relay/tasks', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
request.get<RelayTask>(`/relay/tasks/${id}`).then((r) => r.data),
|
||||
get: (id: string, signal?: AbortSignal) =>
|
||||
request.get<RelayTask>(`/relay/tasks/${id}`, withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// ============================================================
|
||||
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
|
||||
// ============================================================
|
||||
//
|
||||
// 认证策略: 主路径使用 HttpOnly cookie(浏览器自动附加),
|
||||
// Authorization header 作为 fallback 保留用于 API 客户端。
|
||||
|
||||
import axios from 'axios'
|
||||
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { ApiError } from '@/types'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
@@ -25,9 +29,10 @@ const request = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
withCredentials: true, // 发送 HttpOnly cookies
|
||||
})
|
||||
|
||||
// ── 请求拦截器:自动附加 JWT ──────────────────────────────
|
||||
// ── 请求拦截器:附加 Authorization header fallback ──────────
|
||||
|
||||
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = useAuthStore.getState().token
|
||||
@@ -76,9 +81,15 @@ request.interceptors.response.use(
|
||||
try {
|
||||
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
|
||||
headers: { Authorization: `Bearer ${store.refreshToken}` },
|
||||
withCredentials: true, // 发送 refresh cookie
|
||||
})
|
||||
const newToken = res.data.token as string
|
||||
const newRefreshToken = res.data.refresh_token as string
|
||||
// 更新内存中的 token(实际认证通过 HttpOnly cookie,浏览器已自动更新)
|
||||
store.setToken(newToken)
|
||||
if (newRefreshToken) {
|
||||
store.setRefreshToken(newRefreshToken)
|
||||
}
|
||||
onTokenRefreshed(newToken)
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
return request(originalRequest)
|
||||
@@ -106,3 +117,11 @@ request.interceptors.response.use(
|
||||
)
|
||||
|
||||
export default request
|
||||
|
||||
/** 将 AbortSignal 注入 Axios config,用于 TanStack Query 的请求取消 */
|
||||
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
|
||||
if (signal) {
|
||||
return { ...config, signal }
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { DashboardStats } from '@/types'
|
||||
|
||||
export const statsService = {
|
||||
dashboard: () =>
|
||||
request.get<DashboardStats>('/stats/dashboard').then((r) => r.data),
|
||||
dashboard: (signal?: AbortSignal) =>
|
||||
request.get<DashboardStats>('/stats/dashboard', withSignal({}, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { ModelUsageStat, DailyUsageStat } from '@/types'
|
||||
|
||||
export const telemetryService = {
|
||||
modelStats: (params?: Record<string, unknown>) =>
|
||||
request.get<ModelUsageStat[]>('/telemetry/stats', { params }).then((r) => r.data),
|
||||
modelStats: (params?: Record<string, unknown>, signal?: AbortSignal) =>
|
||||
request.get<ModelUsageStat[]>('/telemetry/stats', withSignal({ params }, signal)).then((r) => r.data),
|
||||
|
||||
dailyStats: (params?: { days?: number }) =>
|
||||
request.get<DailyUsageStat[]>('/telemetry/daily', { params }).then((r) => r.data),
|
||||
dailyStats: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||
request.get<DailyUsageStat[]>('/telemetry/daily', withSignal({ params }, signal)).then((r) => r.data),
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import request from './request'
|
||||
import request, { withSignal } from './request'
|
||||
import type { UsageRecord, UsageByModel } from '@/types'
|
||||
|
||||
export const usageService = {
|
||||
daily: (params?: { days?: number }) =>
|
||||
request.get<{ by_day: UsageRecord[] }>('/usage', { params: { ...params, group_by: 'day' } })
|
||||
daily: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||
request.get<{ by_day: UsageRecord[] }>('/usage', withSignal({ params: { ...params, group_by: 'day' } }, signal))
|
||||
.then((r) => r.data.by_day || []),
|
||||
|
||||
byModel: (params?: { days?: number }) =>
|
||||
request.get<{ by_model: UsageByModel[] }>('/usage', { params: { ...params, group_by: 'model' } })
|
||||
byModel: (params?: { days?: number }, signal?: AbortSignal) =>
|
||||
request.get<{ by_model: UsageByModel[] }>('/usage', withSignal({ params: { ...params, group_by: 'model' } }, signal))
|
||||
.then((r) => r.data.by_model || []),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
// ============================================================
|
||||
// ZCLAW Admin V2 — Zustand 认证状态管理
|
||||
// ============================================================
|
||||
//
|
||||
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
|
||||
// account 信息(显示名/角色)仍存 localStorage 用于页面刷新后恢复 UI。
|
||||
// 内存中的 token/refreshToken 仅用于 Authorization header fallback(API 客户端兼容)。
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { AccountPublic } from '@/types'
|
||||
@@ -14,25 +18,22 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
],
|
||||
admin: [
|
||||
'account:read', 'account:admin', 'provider:manage', 'model:read',
|
||||
'model:manage', 'relay:use', 'relay:admin', 'config:read',
|
||||
'model:manage', 'relay:use', 'config:read',
|
||||
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
|
||||
],
|
||||
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
|
||||
}
|
||||
|
||||
const TOKEN_KEY = 'zclaw_admin_token'
|
||||
const REFRESH_KEY = 'zclaw_admin_refresh_token'
|
||||
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||
|
||||
function loadFromStorage(): { token: string | null; refreshToken: string | null; account: AccountPublic | null } {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const refreshToken = localStorage.getItem(REFRESH_KEY)
|
||||
/** 从 localStorage 恢复 account 信息(token 通过 HttpOnly cookie 管理) */
|
||||
function loadFromStorage(): { account: AccountPublic | null } {
|
||||
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||
let account: AccountPublic | null = null
|
||||
if (raw) {
|
||||
try { account = JSON.parse(raw) } catch { /* ignore */ }
|
||||
}
|
||||
return { token, refreshToken, account }
|
||||
return { account }
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
@@ -42,6 +43,7 @@ interface AuthState {
|
||||
permissions: string[]
|
||||
|
||||
setToken: (token: string) => void
|
||||
setRefreshToken: (refreshToken: string) => void
|
||||
login: (token: string, refreshToken: string, account: AccountPublic) => void
|
||||
logout: () => void
|
||||
hasPermission: (permission: string) => boolean
|
||||
@@ -49,23 +51,28 @@ interface AuthState {
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => {
|
||||
const stored = loadFromStorage()
|
||||
const perms = stored.account ? (ROLE_PERMISSIONS[stored.account.role] ?? []) : []
|
||||
const perms = stored.account?.role
|
||||
? (ROLE_PERMISSIONS[stored.account.role] ?? [])
|
||||
: []
|
||||
|
||||
return {
|
||||
token: stored.token,
|
||||
refreshToken: stored.refreshToken,
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
account: stored.account,
|
||||
permissions: perms,
|
||||
|
||||
setToken: (token: string) => {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
set({ token })
|
||||
},
|
||||
|
||||
setRefreshToken: (refreshToken: string) => {
|
||||
set({ refreshToken })
|
||||
},
|
||||
|
||||
login: (token: string, refreshToken: string, account: AccountPublic) => {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(REFRESH_KEY, refreshToken)
|
||||
// account 保留 localStorage(仅用于 UI 显示,非敏感)
|
||||
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||
// token 仅存内存(实际认证通过 HttpOnly cookie)
|
||||
set({
|
||||
token,
|
||||
refreshToken,
|
||||
@@ -75,10 +82,10 @@ export const useAuthStore = create<AuthState>((set, get) => {
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_KEY)
|
||||
localStorage.removeItem(ACCOUNT_KEY)
|
||||
set({ token: null, refreshToken: null, account: null, permissions: [] })
|
||||
// 调用后端 logout 清除 HttpOnly cookies(fire-and-forget)
|
||||
fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
},
|
||||
|
||||
hasPermission: (permission: string) => {
|
||||
|
||||
@@ -12,9 +12,26 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// SSE relay 端点需要长超时(流式响应可持续数分钟)
|
||||
'/api/v1/relay/chat/completions': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
timeout: 600_000,
|
||||
proxyTimeout: 600_000,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
timeout: 30_000,
|
||||
proxyTimeout: 30_000,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
proxyReq.setTimeout(30_000)
|
||||
})
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
proxyRes.setTimeout(30_000)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
2
admin/.gitignore
vendored
2
admin/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
5
admin/next-env.d.ts
vendored
5
admin/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
/// <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.
|
||||
@@ -1,13 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: 'http://localhost:8080/api/:path*',
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"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-separator": "^1.1.7",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.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",
|
||||
"swr": "^2.4.1",
|
||||
"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"
|
||||
}
|
||||
2200
admin/pnpm-lock.yaml
generated
2200
admin/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
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, getSwrErrorMessage } from '@/lib/utils'
|
||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
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 [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [mutationError, setMutationError] = useState('')
|
||||
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
const { data, error: swrError, isLoading, mutate } = useSWR(
|
||||
['accounts', page, debouncedSearch, roleFilter, statusFilter],
|
||||
() => {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (debouncedSearch.trim()) params.search = debouncedSearch.trim()
|
||||
if (roleFilter !== 'all') params.role = roleFilter
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
return api.accounts.list(params)
|
||||
},
|
||||
)
|
||||
|
||||
const accounts = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const error = getSwrErrorMessage(swrError) || mutationError
|
||||
|
||||
// 编辑 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 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)
|
||||
mutate()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setMutationError(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)
|
||||
mutate()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setMutationError(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 && <ErrorBanner message={error} onDismiss={() => { setMutationError('') }} />}
|
||||
|
||||
{/* 表格 */}
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={6} cols={7} />
|
||||
) : error ? null : accounts.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { AgentTemplate } from '@/lib/types'
|
||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function AgentTemplatesPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [error, setError] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
['agentTemplates.list', page],
|
||||
() => api.agentTemplates.list({ page, page_size: 50 }),
|
||||
)
|
||||
|
||||
const templates = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
|
||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
try {
|
||||
const tools = (fd.get('tools') as string || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
const capabilities = (fd.get('capabilities') as string || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
await api.agentTemplates.create({
|
||||
name: fd.get('name') as string,
|
||||
description: (fd.get('description') as string) || undefined,
|
||||
category: (fd.get('category') as string) || 'general',
|
||||
model: (fd.get('model') as string) || undefined,
|
||||
system_prompt: (fd.get('system_prompt') as string) || undefined,
|
||||
tools: tools.length > 0 ? tools : undefined,
|
||||
capabilities: capabilities.length > 0 ? capabilities : undefined,
|
||||
temperature: (fd.get('temperature') as string) ? parseFloat(fd.get('temperature') as string) : undefined,
|
||||
max_tokens: (fd.get('max_tokens') as string) ? parseInt(fd.get('max_tokens') as string, 10) : undefined,
|
||||
visibility: (fd.get('visibility') as string) || 'public',
|
||||
})
|
||||
setShowCreate(false)
|
||||
mutate()
|
||||
} catch {
|
||||
setError('创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string, name: string) => {
|
||||
if (!confirm(`确认归档模板 "${name}"?`)) return
|
||||
try {
|
||||
await api.agentTemplates.archive(id)
|
||||
mutate()
|
||||
} catch {
|
||||
setError('归档失败')
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'bg-emerald-500/20 text-emerald-400',
|
||||
archived: 'bg-zinc-500/20 text-zinc-400',
|
||||
}
|
||||
return <span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>{status}</span>
|
||||
}
|
||||
|
||||
const sourceBadge = (source: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
builtin: 'bg-blue-500/20 text-blue-400',
|
||||
custom: 'bg-purple-500/20 text-purple-400',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
|
||||
{source === 'builtin' ? '内置' : '自定义'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Agent 配置模板</h1>
|
||||
<p className="text-sm text-zinc-400 mt-1">管理 Agent 配置模板,支持团队共享和一键复用</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||||
>
|
||||
+ 新建模板
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
||||
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">名称</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">分类</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">来源</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">模型</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">工具数</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">可见性</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">状态</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">更新时间</th>
|
||||
<th className="text-right px-4 py-3 text-zinc-400 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={9}>
|
||||
<TableSkeleton rows={5} cols={9} hasToolbar={false} />
|
||||
</td>
|
||||
</tr>
|
||||
) : templates.length === 0 ? (
|
||||
<tr><td colSpan={9}><EmptyState message="暂无 Agent 模板" /></td></tr>
|
||||
) : (
|
||||
templates.map(t => (
|
||||
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3">
|
||||
<div>
|
||||
<span className="text-white font-medium">{t.name}</span>
|
||||
{t.description && (
|
||||
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">{t.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
|
||||
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
|
||||
<td className="px-4 py-3 text-zinc-300 font-mono text-xs">{t.model || '-'}</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{t.tools.length}</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{t.visibility}</td>
|
||||
<td className="px-4 py-3">{statusBadge(t.status)}</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||||
{new Date(t.updated_at).toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => setEditingId(editingId === t.id ? null : t.id)}
|
||||
className="text-zinc-400 hover:text-white mr-2"
|
||||
>
|
||||
详情
|
||||
</button>
|
||||
{t.source === 'custom' && (
|
||||
<button
|
||||
onClick={() => handleArchive(t.id, t.name)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
|
||||
共 {total} 个模板
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开详情 */}
|
||||
{editingId && (() => {
|
||||
const t = templates.find(t => t.id === editingId)
|
||||
if (!t) return null
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold text-white">{t.name} — 详情</h2>
|
||||
<button onClick={() => setEditingId(null)} className="text-zinc-400 hover:text-white text-sm">关闭</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-zinc-500">分类:</span>
|
||||
<span className="text-zinc-300">{t.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-zinc-500">模型:</span>
|
||||
<span className="text-zinc-300 font-mono">{t.model || '未指定'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-zinc-500">温度:</span>
|
||||
<span className="text-zinc-300">{t.temperature?.toFixed(2) || '默认'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-zinc-500">最大 Token:</span>
|
||||
<span className="text-zinc-300">{t.max_tokens || '未限制'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-zinc-500">工具:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{t.tools.length > 0 ? t.tools.map(tool => (
|
||||
<span key={tool} className="px-2 py-0.5 bg-zinc-800 rounded text-xs text-zinc-300">{tool}</span>
|
||||
)) : <span className="text-zinc-600">无</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-zinc-500">能力:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{t.capabilities.length > 0 ? t.capabilities.map(cap => (
|
||||
<span key={cap} className="px-2 py-0.5 bg-blue-500/10 rounded text-xs text-blue-400">{cap}</span>
|
||||
)) : <span className="text-zinc-600">无</span>}
|
||||
</div>
|
||||
</div>
|
||||
{t.system_prompt && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-zinc-500">系统提示词:</span>
|
||||
<pre className="text-xs text-zinc-400 bg-zinc-800/50 rounded p-2 mt-1 overflow-x-auto max-h-32">
|
||||
{t.system_prompt.substring(0, 500)}{t.system_prompt.length > 500 ? '...' : ''}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4 max-h-[80vh] overflow-y-auto">
|
||||
<h2 className="text-lg font-semibold text-white">新建 Agent 模板</h2>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">名称 *</label>
|
||||
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_agent" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">描述</label>
|
||||
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">分类</label>
|
||||
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
|
||||
<option value="general">通用</option>
|
||||
<option value="coding">编程</option>
|
||||
<option value="research">研究</option>
|
||||
<option value="creative">创意</option>
|
||||
<option value="assistant">助手</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">模型</label>
|
||||
<input name="model" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="如 glm-4-plus" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">系统提示词</label>
|
||||
<textarea name="system_prompt" rows={4} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" placeholder="Agent 系统提示词" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">工具(逗号分隔)</label>
|
||||
<input name="tools" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="browser, file_system, code_execute" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">能力(逗号分隔)</label>
|
||||
<input name="capabilities" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="streaming, vision, function_calling" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">温度</label>
|
||||
<input name="temperature" type="number" step="0.1" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="默认" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">最大 Token</label>
|
||||
<input name="max_tokens" type="number" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="不限" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">可见性</label>
|
||||
<select name="visibility" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
|
||||
<option value="public">公开</option>
|
||||
<option value="team">团队</option>
|
||||
<option value="private">私有</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm">取消</button>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
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 { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate, getSwrErrorMessage } from '@/lib/utils'
|
||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||||
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 [page, setPage] = useState(1)
|
||||
const [mutationError, setMutationError] = useState('')
|
||||
|
||||
const { data, error: swrError, isLoading, mutate } = useSWR(
|
||||
['tokens', page],
|
||||
() => api.tokens.list({ page, page_size: PAGE_SIZE }),
|
||||
)
|
||||
|
||||
const tokens = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const error = getSwrErrorMessage(swrError) || mutationError
|
||||
|
||||
// 创建 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 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'] })
|
||||
mutate()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setMutationError(err.body.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!revokeTarget) return
|
||||
setRevoking(true)
|
||||
try {
|
||||
await api.tokens.revoke(revokeTarget.id)
|
||||
setRevokeTarget(null)
|
||||
mutate()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setMutationError(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 && <ErrorBanner message={error} onDismiss={() => setMutationError('')} />}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={6} cols={7} />
|
||||
) : error ? null : tokens.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>前缀</TableHead>
|
||||
<TableHead>权限</TableHead>
|
||||
<TableHead>最后使用</TableHead>
|
||||
<TableHead>过期时间</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tokens.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.token_prefix}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{t.permissions.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-xs">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.last_used_at ? formatDate(t.last_used_at) : '未使用'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.expires_at ? formatDate(t.expires_at) : '永不过期'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(t.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => setRevokeTarget(t)} title="撤销">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建 Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建 API 密钥</DialogTitle>
|
||||
<DialogDescription>创建新的 API 密钥用于接口调用</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
placeholder="例如: 生产环境"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>过期天数 (留空则永不过期)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={createForm.expires_days}
|
||||
onChange={(e) => setCreateForm({ ...createForm, expires_days: e.target.value })}
|
||||
placeholder="365"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>权限 *</Label>
|
||||
<div className="flex flex-wrap gap-3 mt-1">
|
||||
{allPermissions.map((perm) => (
|
||||
<label
|
||||
key={perm.key}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.permissions.includes(perm.key)}
|
||||
onChange={() => togglePermission(perm.key)}
|
||||
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{perm.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !createForm.name.trim() || createForm.permissions.length === 0}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 创建成功 Dialog */}
|
||||
<Dialog open={!!createdToken} onOpenChange={() => setCreatedToken(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-400" />
|
||||
密钥已创建
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
请立即复制并安全保存此密钥,关闭后将无法再次查看完整密钥。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md bg-muted p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">完整密钥</p>
|
||||
<p className="font-mono text-sm break-all text-foreground">
|
||||
{createdToken?.token}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-3 text-sm text-yellow-400">
|
||||
此密钥仅显示一次。请确保已保存到安全的位置。
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={copyToken} variant="outline">
|
||||
{copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||
{copied ? '已复制' : '复制密钥'}
|
||||
</Button>
|
||||
<Button onClick={() => setCreatedToken(null)}>我已保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 撤销确认 */}
|
||||
<Dialog open={!!revokeTarget} onOpenChange={() => setRevokeTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认撤销</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要撤销密钥 "{revokeTarget?.name}" 吗?使用此密钥的应用将立即失去访问权限。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleRevoke} disabled={revoking}>
|
||||
{revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
撤销
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
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 { TableSkeleton } from '@/components/ui/skeleton'
|
||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||||
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 [error, setError] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
|
||||
// SWR for config list
|
||||
const { data: configs = [], isLoading, mutate } = useSWR(
|
||||
['config', activeTab],
|
||||
() => {
|
||||
const params: Record<string, unknown> = {}
|
||||
if (activeTab !== 'all') params.category = activeTab
|
||||
return api.config.list(params)
|
||||
}
|
||||
)
|
||||
|
||||
// 编辑 Dialog
|
||||
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function openEditDialog(config: ConfigItem) {
|
||||
setEditTarget(config)
|
||||
setEditValue(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)
|
||||
mutate()
|
||||
} 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 categoryLabels: Record<string, string> = {
|
||||
all: '全部',
|
||||
server: '服务器',
|
||||
agent: 'Agent',
|
||||
memory: '记忆',
|
||||
llm: 'LLM',
|
||||
security: '安全策略',
|
||||
}
|
||||
const categories = Object.keys(categoryLabels)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 分类 Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
{categories.map((cat) => (
|
||||
<TabsTrigger key={cat} value={cat}>
|
||||
{categoryLabels[cat] || cat}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={8} cols={8} hasToolbar={false} />
|
||||
) : error ? null : configs.length === 0 ? (
|
||||
<EmptyState message="暂无配置项" />
|
||||
) : (
|
||||
<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 != null && (
|
||||
<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 != null) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
'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,
|
||||
MessageSquare,
|
||||
Bot,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
Menu,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
||||
import { logout } from '@/lib/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */
|
||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
super_admin: ['admin:full', 'account:admin', 'provider:manage', 'model:manage', 'relay:admin', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish', 'prompt:admin'],
|
||||
admin: ['account:read', 'account:admin', 'provider:manage', 'model:read', 'model:manage', 'relay:use', 'relay:admin', 'config:read', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish'],
|
||||
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
|
||||
}
|
||||
|
||||
/** 根据 role 获取权限列表 */
|
||||
function getPermissionsForRole(role: string): string[] {
|
||||
return ROLE_PERMISSIONS[role] ?? []
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' },
|
||||
{ href: '/providers', label: '服务商', icon: Server, permission: 'provider:manage' },
|
||||
{ href: '/models', label: '模型管理', icon: Cpu, permission: 'model:read' },
|
||||
{ href: '/agent-templates', label: 'Agent 模板', icon: Bot, permission: 'model:read' },
|
||||
{ href: '/api-keys', label: 'API 密钥', icon: Key, permission: 'admin:full' },
|
||||
{ href: '/usage', label: '用量统计', icon: BarChart3, permission: 'admin:full' },
|
||||
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:use' },
|
||||
{ href: '/config', label: '系统配置', icon: Settings, permission: 'config:read' },
|
||||
{ href: '/prompts', label: '提示词管理', icon: MessageSquare, permission: 'prompt:read' },
|
||||
{ href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' },
|
||||
]
|
||||
|
||||
function Sidebar({
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { account } = useAuth()
|
||||
|
||||
const permissions = account ? getPermissionsForRole(account.role) : []
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
const filteredNavItems = navItems.filter((item) => {
|
||||
if (!item.permission) return true
|
||||
return permissions.includes(item.permission) || permissions.includes('admin:full')
|
||||
})
|
||||
|
||||
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">
|
||||
{filteredNavItems.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>
|
||||
)
|
||||
}
|
||||
@@ -1,414 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
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 { TableSkeleton } from '@/components/ui/skeleton'
|
||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||||
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 [page, setPage] = useState(1)
|
||||
const [providerFilter, setProviderFilter] = useState<string>('all')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// SWR for models list
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
['models', page, providerFilter],
|
||||
() => {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (providerFilter !== 'all') params.provider_id = providerFilter
|
||||
return api.models.list(params)
|
||||
}
|
||||
)
|
||||
const models = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
|
||||
// SWR for providers list (dropdown)
|
||||
const { data: providersData } = useSWR(
|
||||
['providers.all'],
|
||||
() => api.providers.list({ page: 1, page_size: 100 })
|
||||
)
|
||||
const providers = providersData?.items ?? []
|
||||
|
||||
// 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 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)
|
||||
mutate()
|
||||
} 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)
|
||||
mutate()
|
||||
} 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 && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={8} cols={9} hasToolbar={false} />
|
||||
) : error ? null : models.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模型 ID</TableHead>
|
||||
<TableHead>别名</TableHead>
|
||||
<TableHead>服务商</TableHead>
|
||||
<TableHead>上下文窗口</TableHead>
|
||||
<TableHead>最大输出</TableHead>
|
||||
<TableHead>流式</TableHead>
|
||||
<TableHead>视觉</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{models.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
|
||||
<TableCell>{m.alias || '-'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(m.context_window)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(m.max_output_tokens)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
|
||||
{m.supports_streaming ? '是' : '否'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
|
||||
{m.supports_vision ? '是' : '否'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.enabled ? 'success' : 'destructive'}>
|
||||
{m.enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑 Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="space-y-2">
|
||||
<Label>服务商 *</Label>
|
||||
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择服务商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.display_name || p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>模型 ID *</Label>
|
||||
<Input
|
||||
value={form.model_id}
|
||||
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
|
||||
placeholder="gpt-4o"
|
||||
disabled={!!editTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>别名</Label>
|
||||
<Input
|
||||
value={form.alias}
|
||||
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
||||
placeholder="GPT-4o"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>上下文窗口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.context_window}
|
||||
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>最大输出 Tokens</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.max_output_tokens}
|
||||
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Input 定价 ($/1M tokens)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.pricing_input}
|
||||
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Output 定价 ($/1M tokens)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.pricing_output}
|
||||
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
|
||||
<Label>流式</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
|
||||
<Label>视觉</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
||||
<Label>启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Users,
|
||||
Server,
|
||||
ArrowLeftRight,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import useSWR from 'swr'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { StatsSkeleton } from '@/components/ui/skeleton'
|
||||
import { ChartSkeleton } from '@/components/ui/skeleton'
|
||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||||
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 { data: stats, isLoading: statsLoading } = useSWR(
|
||||
['stats.dashboard'],
|
||||
() => api.stats.dashboard(),
|
||||
)
|
||||
|
||||
const { data: usageData = [], isLoading: usageLoading } = useSWR(
|
||||
['usage.daily.30'],
|
||||
() => api.usage.daily({ days: 30 }),
|
||||
)
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useSWR(
|
||||
['logs.recent'],
|
||||
() => api.logs.list({ page: 1, page_size: 5 }),
|
||||
)
|
||||
|
||||
const recentLogs: OperationLog[] = logsData?.items ?? []
|
||||
|
||||
const chartData = usageData.map((r: UsageRecord) => ({
|
||||
day: r.day.slice(5), // MM-DD
|
||||
请求量: r.count,
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片 */}
|
||||
{statsLoading ? (
|
||||
<StatsSkeleton count={4} />
|
||||
) : (
|
||||
<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">
|
||||
{/* 请求趋势 */}
|
||||
{usageLoading ? (
|
||||
<ChartSkeleton height={280} />
|
||||
) : (
|
||||
<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 用量 */}
|
||||
{usageLoading ? (
|
||||
<ChartSkeleton height={280} />
|
||||
) : (
|
||||
<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>
|
||||
{logsLoading ? (
|
||||
<TableSkeleton rows={5} cols={5} hasToolbar={false} />
|
||||
) : 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>
|
||||
)
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { api } from '@/lib/api-client'
|
||||
import type { PromptTemplate, PromptVersion } from '@/lib/types'
|
||||
import { EmptyState } from '@/components/ui/state'
|
||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function PromptsPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null)
|
||||
const [versions, setVersions] = useState<PromptVersion[]>([])
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [showNewVersion, setShowNewVersion] = useState(false)
|
||||
const [filter, setFilter] = useState<{ source?: string; status?: string }>({})
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
['prompts.list', page, filter.source, filter.status],
|
||||
() => api.prompts.list({ page, page_size: 50, ...filter }),
|
||||
)
|
||||
|
||||
const templates = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
|
||||
const fetchVersions = async (name: string) => {
|
||||
try {
|
||||
const res = await api.prompts.listVersions(name)
|
||||
setVersions(res)
|
||||
setSelectedName(name)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch versions:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
try {
|
||||
await api.prompts.create({
|
||||
name: fd.get('name') as string,
|
||||
category: fd.get('category') as string,
|
||||
description: (fd.get('description') as string) || undefined,
|
||||
source: 'custom',
|
||||
system_prompt: fd.get('system_prompt') as string,
|
||||
})
|
||||
setShowCreate(false)
|
||||
mutate()
|
||||
} catch (err) {
|
||||
console.error('Failed to create prompt:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewVersion = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault()
|
||||
if (!selectedName) return
|
||||
const fd = new FormData(e.currentTarget)
|
||||
try {
|
||||
await api.prompts.createVersion(selectedName, {
|
||||
system_prompt: fd.get('system_prompt') as string,
|
||||
changelog: (fd.get('changelog') as string) || undefined,
|
||||
})
|
||||
setShowNewVersion(false)
|
||||
fetchVersions(selectedName)
|
||||
} catch (err) {
|
||||
console.error('Failed to create version:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRollback = async (name: string, version: number) => {
|
||||
if (!confirm(`确认回退到版本 ${version}?`)) return
|
||||
try {
|
||||
await api.prompts.rollback(name, version)
|
||||
fetchVersions(name)
|
||||
mutate()
|
||||
} catch (err) {
|
||||
console.error('Failed to rollback:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (name: string) => {
|
||||
if (!confirm(`确认归档 ${name}?`)) return
|
||||
try {
|
||||
await api.prompts.archive(name)
|
||||
mutate()
|
||||
} catch (err) {
|
||||
console.error('Failed to archive:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
active: 'bg-emerald-500/20 text-emerald-400',
|
||||
deprecated: 'bg-amber-500/20 text-amber-400',
|
||||
archived: 'bg-zinc-500/20 text-zinc-400',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const sourceBadge = (source: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
builtin: 'bg-blue-500/20 text-blue-400',
|
||||
custom: 'bg-purple-500/20 text-purple-400',
|
||||
}
|
||||
return (
|
||||
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
|
||||
{source === 'builtin' ? '内置' : '自定义'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">提示词管理</h1>
|
||||
<p className="text-sm text-zinc-400 mt-1">管理内置和自定义提示词模板,支持版本控制和 OTA 分发</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||||
>
|
||||
+ 新建模板
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'builtin', 'custom'] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFilter(s === 'all' ? {} : { source: s })}
|
||||
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
|
||||
(filter.source || 'all') === s
|
||||
? 'bg-zinc-700 text-white'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{s === 'all' ? '全部' : s === 'builtin' ? '内置' : '自定义'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template List */}
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">名称</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">分类</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">来源</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">版本</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">状态</th>
|
||||
<th className="text-left px-4 py-3 text-zinc-400 font-medium">更新时间</th>
|
||||
<th className="text-right px-4 py-3 text-zinc-400 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<TableSkeleton rows={5} cols={7} hasToolbar={false} />
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr><td colSpan={7} className="px-4 py-8 text-center text-red-400">加载失败</td></tr>
|
||||
) : templates.length === 0 ? (
|
||||
<tr><td colSpan={7}><EmptyState message="暂无提示词模板" /></td></tr>
|
||||
) : (
|
||||
templates.map(t => (
|
||||
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => fetchVersions(t.name)}
|
||||
className="text-blue-400 hover:text-blue-300 font-mono"
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
|
||||
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
|
||||
<td className="px-4 py-3 text-zinc-300">v{t.current_version}</td>
|
||||
<td className="px-4 py-3">{statusBadge(t.status)}</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs">
|
||||
{new Date(t.updated_at).toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => fetchVersions(t.name)}
|
||||
className="text-zinc-400 hover:text-white mr-2"
|
||||
>
|
||||
历史
|
||||
</button>
|
||||
{t.source === 'custom' && (
|
||||
<button
|
||||
onClick={() => handleArchive(t.name)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
归档
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
|
||||
共 {total} 个模板
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Version History Panel */}
|
||||
{selectedName && (
|
||||
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{selectedName} — 版本历史
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowNewVersion(true)}
|
||||
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-xs"
|
||||
>
|
||||
发布新版本
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectedName(null); setVersions([]) }}
|
||||
className="px-3 py-1.5 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-xs"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{versions.map(v => (
|
||||
<div key={v.id} className="bg-zinc-800/50 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-mono text-zinc-300">v{v.version}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-zinc-500">
|
||||
{new Date(v.created_at).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
{v.changelog && (
|
||||
<span className="text-xs text-zinc-400">— {v.changelog}</span>
|
||||
)}
|
||||
{v.min_app_version && (
|
||||
<span className="text-xs text-amber-400">最低版本: {v.min_app_version}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<pre className="text-xs text-zinc-400 bg-zinc-900 rounded p-2 overflow-x-auto max-h-32">
|
||||
{v.system_prompt.substring(0, 300)}{v.system_prompt.length > 300 ? '...' : ''}
|
||||
</pre>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(v.system_prompt)
|
||||
}}
|
||||
className="text-xs text-zinc-500 hover:text-white"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRollback(selectedName, v.version)}
|
||||
className="text-xs text-amber-500 hover:text-amber-400"
|
||||
>
|
||||
回退到此版本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{versions.length === 0 && (
|
||||
<EmptyState message="暂无版本历史" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">新建提示词模板</h2>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">名称</label>
|
||||
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_prompt" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">分类</label>
|
||||
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
|
||||
<option value="custom_system">系统提示词</option>
|
||||
<option value="custom_extraction">提取提示词</option>
|
||||
<option value="custom_compaction">压缩提示词</option>
|
||||
<option value="custom_other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">描述</label>
|
||||
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">系统提示词</label>
|
||||
<textarea name="system_prompt" required rows={6} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm">取消</button>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Version Modal */}
|
||||
{showNewVersion && selectedName && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form onSubmit={handleNewVersion} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4">
|
||||
<h2 className="text-lg font-semibold text-white">发布 {selectedName} 新版本</h2>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">系统提示词</label>
|
||||
<textarea name="system_prompt" required rows={6} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-zinc-400 mb-1">变更说明</label>
|
||||
<input name="changelog" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="描述本次变更" />
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => setShowNewVersion(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm">取消</button>
|
||||
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm">发布</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,605 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Trash2,
|
||||
KeyRound,
|
||||
Power,
|
||||
PowerOff,
|
||||
} 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 { TableSkeleton } from '@/components/ui/skeleton'
|
||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate, maskApiKey } from '@/lib/utils'
|
||||
|
||||
function formatTokens(tokens: number): string {
|
||||
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
|
||||
return String(tokens)
|
||||
}
|
||||
import type { Provider, ProviderKey } 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 [page, setPage] = useState(1)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// SWR for providers list
|
||||
const { data, isLoading, mutate } = useSWR(
|
||||
['providers', page],
|
||||
() => api.providers.list({ page, page_size: PAGE_SIZE })
|
||||
)
|
||||
const providers = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
|
||||
// 创建/编辑 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)
|
||||
|
||||
// Key Pool 管理
|
||||
const [keyPoolProvider, setKeyPoolProvider] = useState<Provider | null>(null)
|
||||
const [showAddKey, setShowAddKey] = useState(false)
|
||||
const [addKeyForm, setAddKeyForm] = useState({
|
||||
key_label: '',
|
||||
key_value: '',
|
||||
priority: 0,
|
||||
max_rpm: '',
|
||||
max_tpm: '',
|
||||
quota_reset_interval: '',
|
||||
})
|
||||
const [addingKey, setAddingKey] = useState(false)
|
||||
|
||||
// SWR for key pool — only fetches when dialog is open
|
||||
const { data: providerKeys = [], isLoading: keysLoading, mutate: mutateKeys } = useSWR(
|
||||
keyPoolProvider ? ['provider.keys', keyPoolProvider.id] : null,
|
||||
() => api.providers.listKeys(keyPoolProvider!.id)
|
||||
)
|
||||
|
||||
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)
|
||||
mutate()
|
||||
} 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)
|
||||
mutate()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key Pool 管理 ─────────────────────────────────────
|
||||
|
||||
function openKeyPool(provider: Provider) {
|
||||
setKeyPoolProvider(provider)
|
||||
setShowAddKey(false)
|
||||
}
|
||||
|
||||
async function handleAddKey() {
|
||||
if (!keyPoolProvider || !addKeyForm.key_label.trim() || !addKeyForm.key_value.trim()) return
|
||||
setAddingKey(true)
|
||||
try {
|
||||
await api.providers.addKey(keyPoolProvider.id, {
|
||||
key_label: addKeyForm.key_label.trim(),
|
||||
key_value: addKeyForm.key_value.trim(),
|
||||
priority: addKeyForm.priority,
|
||||
max_rpm: addKeyForm.max_rpm ? parseInt(addKeyForm.max_rpm, 10) : undefined,
|
||||
max_tpm: addKeyForm.max_tpm ? parseInt(addKeyForm.max_tpm, 10) : undefined,
|
||||
quota_reset_interval: addKeyForm.quota_reset_interval.trim() || undefined,
|
||||
})
|
||||
setAddKeyForm({ key_label: '', key_value: '', priority: 0, max_rpm: '', max_tpm: '', quota_reset_interval: '' })
|
||||
setShowAddKey(false)
|
||||
mutateKeys()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setAddingKey(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleKey(keyId: string, active: boolean) {
|
||||
if (!keyPoolProvider) return
|
||||
try {
|
||||
await api.providers.toggleKey(keyPoolProvider.id, keyId, active)
|
||||
mutateKeys()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteKey(keyId: string) {
|
||||
if (!keyPoolProvider || !confirm('确认删除此 Key?')) return
|
||||
try {
|
||||
await api.providers.deleteKey(keyPoolProvider.id, keyId)
|
||||
mutateKeys()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
}
|
||||
}
|
||||
|
||||
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 && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={6} cols={9} hasToolbar={false} />
|
||||
) : error ? null : providers.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<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={() => openKeyPool(p)} title="Key Pool">
|
||||
<KeyRound className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(p)} title="删除">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑 Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? '编辑服务商' : '新建服务商'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editTarget ? '修改服务商配置' : '添加新的 AI 服务商'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="例如: openai"
|
||||
disabled={!!editTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>显示名</Label>
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
placeholder="例如: OpenAI"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Base URL *</Label>
|
||||
<Input
|
||||
value={form.base_url}
|
||||
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API 协议</Label>
|
||||
<Select value={form.api_protocol} onValueChange={(v) => setForm({ ...form, api_protocol: v as 'openai' | 'anthropic' })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
placeholder={editTarget ? '留空则不修改' : 'sk-...'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
|
||||
/>
|
||||
<Label>启用</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>RPM 限制</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.rate_limit_rpm}
|
||||
onChange={(e) => setForm({ ...form, rate_limit_rpm: e.target.value })}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>TPM 限制</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.rate_limit_tpm}
|
||||
onChange={(e) => setForm({ ...form, rate_limit_tpm: e.target.value })}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form.name.trim() || !form.base_url.trim()}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 Dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除服务商 "{deleteTarget?.display_name || deleteTarget?.name}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Key Pool 管理 Dialog */}
|
||||
<Dialog open={!!keyPoolProvider} onOpenChange={() => setKeyPoolProvider(null)}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Key Pool 管理 — {keyPoolProvider?.display_name || keyPoolProvider?.name}</DialogTitle>
|
||||
<DialogDescription>
|
||||
管理此服务商的多个 API Key,实现智能轮转绕过限额。优先级数字越小越优先。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[50vh] overflow-y-auto scrollbar-thin">
|
||||
{keysLoading ? (
|
||||
<TableSkeleton rows={4} cols={8} hasToolbar={false} />
|
||||
) : providerKeys.length === 0 && !showAddKey ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
<p>尚未配置 Key Pool</p>
|
||||
<p className="mt-1 text-xs">将使用服务商主 API Key 作为回退</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>RPM</TableHead>
|
||||
<TableHead>TPM</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>请求/Token</TableHead>
|
||||
<TableHead>最后 429</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{providerKeys.map((k) => {
|
||||
const isCooling = k.cooldown_until && new Date(k.cooldown_until) > new Date()
|
||||
return (
|
||||
<TableRow key={k.id} className={isCooling ? 'opacity-60' : ''}>
|
||||
<TableCell className="font-medium">{k.key_label}</TableCell>
|
||||
<TableCell>{k.priority}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{k.max_rpm ?? '-'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{k.max_tpm ?? '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={k.is_active ? 'success' : 'secondary'}>
|
||||
{isCooling ? '冷却中' : k.is_active ? '活跃' : '禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{k.total_requests} / {formatTokens(k.total_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{k.last_429_at ? formatDate(k.last_429_at) : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleToggleKey(k.id, !k.is_active)}
|
||||
title={k.is_active ? '禁用' : '启用'}
|
||||
>
|
||||
{k.is_active ? <PowerOff className="h-3.5 w-3.5 text-amber-500" /> : <Power className="h-3.5 w-3.5 text-green-500" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteKey(k.id)}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!showAddKey ? (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setKeyPoolProvider(null)}>关闭</Button>
|
||||
<Button onClick={() => setShowAddKey(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加 Key
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : (
|
||||
<div className="space-y-3 border-t pt-4">
|
||||
<p className="text-sm font-medium">添加新 Key</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">标签 *</Label>
|
||||
<Input
|
||||
value={addKeyForm.key_label}
|
||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, key_label: e.target.value })}
|
||||
placeholder="如 zhipu-coding-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">优先级</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={addKeyForm.priority}
|
||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, priority: parseInt(e.target.value, 10) || 0 })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">API Key *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={addKeyForm.key_value}
|
||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, key_value: e.target.value })}
|
||||
placeholder="输入 API Key"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">RPM 限额</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={addKeyForm.max_rpm}
|
||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, max_rpm: e.target.value })}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">TPM 限额</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={addKeyForm.max_tpm}
|
||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, max_tpm: e.target.value })}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-xs">限额重置周期</Label>
|
||||
<Input
|
||||
value={addKeyForm.quota_reset_interval}
|
||||
onChange={(e) => setAddKeyForm({ ...addKeyForm, quota_reset_interval: e.target.value })}
|
||||
placeholder="如 5h, 1d(可选)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setShowAddKey(false); setAddKeyForm({ key_label: '', key_value: '', priority: 0, max_rpm: '', max_tpm: '', quota_reset_interval: '' }) }}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAddKey} disabled={addingKey || !addKeyForm.key_label.trim() || !addKeyForm.key_value.trim()}>
|
||||
{addingKey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
添加
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
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, getSwrErrorMessage } from '@/lib/utils'
|
||||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||||
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 [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const { data, error: swrError, isLoading } = useSWR(
|
||||
['relay', page, statusFilter],
|
||||
() => {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
return api.relay.list(params)
|
||||
},
|
||||
)
|
||||
|
||||
const tasks = data?.items ?? []
|
||||
const total = data?.total ?? 0
|
||||
const error = getSwrErrorMessage(swrError)
|
||||
|
||||
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 && <ErrorBanner message={error} onDismiss={() => {}} />}
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton rows={6} cols={10} />
|
||||
) : error ? null : tasks.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { Zap, Monitor, Smartphone } 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 { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||||
import { TableSkeleton, ChartSkeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
import type { UsageRecord, UsageByModel, ModelUsageStat, DailyUsageStat } from '@/lib/types'
|
||||
|
||||
export default function UsagePage() {
|
||||
const [days, setDays] = useState(7)
|
||||
const [activeTab, setActiveTab] = useState('relay')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 4 parallel SWR calls — each loads independently
|
||||
const { data: dailyData = [], isLoading: dailyLoading } = useSWR(
|
||||
['usage.daily', days],
|
||||
() => api.usage.daily({ days })
|
||||
)
|
||||
const { data: modelData = [], isLoading: modelLoading } = useSWR(
|
||||
['usage.byModel', days],
|
||||
() => api.usage.byModel({ days })
|
||||
)
|
||||
const { data: telemetryModels = [] } = useSWR(
|
||||
['telemetry.modelStats'],
|
||||
() => api.telemetry.modelStats()
|
||||
)
|
||||
const { data: telemetryDaily = [] } = useSWR(
|
||||
['telemetry.dailyStats', days],
|
||||
() => api.telemetry.dailyStats({ days })
|
||||
)
|
||||
|
||||
const relayLoading = dailyLoading || modelLoading
|
||||
const telemetryLoading = !telemetryModels.length && !telemetryDaily.length && (dailyLoading || modelLoading)
|
||||
|
||||
// === Relay 用量图表数据 ===
|
||||
|
||||
const relayLineData = dailyData.map((r) => ({
|
||||
day: r.day.slice(5),
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
const relayBarData = modelData.map((r) => ({
|
||||
model: r.model_id,
|
||||
请求量: r.count,
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
const relayTotalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
|
||||
const relayTotalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
|
||||
const relayTotalRequests = dailyData.reduce((s, r) => s + r.count, 0)
|
||||
|
||||
// === 遥测图表数据 ===
|
||||
|
||||
const telemetryLineData = telemetryDaily.map((r) => ({
|
||||
day: r.day.slice(5),
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
设备数: r.unique_devices,
|
||||
}))
|
||||
|
||||
const telemetryTotalInput = telemetryDaily.reduce((s, r) => s + r.input_tokens, 0)
|
||||
const telemetryTotalOutput = telemetryDaily.reduce((s, r) => s + r.output_tokens, 0)
|
||||
const telemetryTotalRequests = telemetryDaily.reduce((s, r) => s + r.request_count, 0)
|
||||
|
||||
// === 合计 ===
|
||||
|
||||
const totalInput = relayTotalInput + telemetryTotalInput
|
||||
const totalOutput = relayTotalOutput + telemetryTotalOutput
|
||||
const totalRequests = relayTotalRequests + telemetryTotalRequests
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
||||
{/* 时间范围 */}
|
||||
<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>
|
||||
|
||||
{/* 汇总统计 — render immediately, use 0 while loading */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<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>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Monitor className="h-4 w-4 text-green-400" />
|
||||
<p className="text-sm text-muted-foreground">中转请求</p>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-green-400">
|
||||
{formatNumber(relayTotalRequests)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="h-4 w-4 text-purple-400" />
|
||||
<p className="text-sm text-muted-foreground">桌面端调用</p>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold text-purple-400">
|
||||
{formatNumber(telemetryTotalRequests)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="relay">
|
||||
<Monitor className="h-4 w-4 mr-1" />
|
||||
中转用量
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="telemetry">
|
||||
<Smartphone className="h-4 w-4 mr-1" />
|
||||
桌面端遥测
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Relay 用量 Tab */}
|
||||
<TabsContent value="relay" className="space-y-6">
|
||||
{relayLoading ? (
|
||||
<>
|
||||
<ChartSkeleton height={320} />
|
||||
<ChartSkeleton height={280} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
中转 Token 用量趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{relayLineData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={relayLineData}>
|
||||
<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>
|
||||
) : (
|
||||
<EmptyState message="暂无中转数据" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">中转按模型分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{relayBarData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={Math.max(200, relayBarData.length * 40)}>
|
||||
<BarChart data={relayBarData} 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>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 遥测 Tab */}
|
||||
<TabsContent value="telemetry" className="space-y-6">
|
||||
{telemetryLoading ? (
|
||||
<>
|
||||
<ChartSkeleton height={320} />
|
||||
<TableSkeleton rows={5} cols={6} hasToolbar={false} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Smartphone className="h-4 w-4 text-purple-400" />
|
||||
桌面端 Token 用量趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{telemetryLineData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={telemetryLineData}>
|
||||
<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>
|
||||
) : (
|
||||
<EmptyState message="暂无桌面端遥测数据(需要桌面端上报)" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">桌面端按模型统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{telemetryModels.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead className="text-right">请求数</TableHead>
|
||||
<TableHead className="text-right">Input Tokens</TableHead>
|
||||
<TableHead className="text-right">Output Tokens</TableHead>
|
||||
<TableHead className="text-right">平均延迟</TableHead>
|
||||
<TableHead className="text-right">成功率</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{telemetryModels.map((stat) => (
|
||||
<TableRow key={stat.model_id}>
|
||||
<TableCell className="font-mono text-sm">{stat.model_id}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(stat.request_count)}</TableCell>
|
||||
<TableCell className="text-right text-blue-400">{formatNumber(stat.input_tokens)}</TableCell>
|
||||
<TableCell className="text-right text-orange-400">{formatNumber(stat.output_tokens)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{stat.avg_latency_ms !== null ? `${Math.round(stat.avg_latency_ms)}ms` : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={stat.success_rate >= 0.95 ? 'default' : 'destructive'}>
|
||||
{(stat.success_rate * 100).toFixed(1)}%
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<EmptyState />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#0f172a"/>
|
||||
<text x="16" y="22" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#60a5fa" text-anchor="middle">Z</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 282 B |
@@ -1,30 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { SWRProvider } from '@/lib/swr-provider'
|
||||
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">
|
||||
<SWRProvider>
|
||||
{children}
|
||||
</SWRProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Lock, User, Loader2, Eye, EyeOff, ShieldCheck } 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 [totpCode, setTotpCode] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [needTotp, setNeedTotp] = 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,
|
||||
totp_code: totpCode.trim() || undefined,
|
||||
})
|
||||
login(res.token, res.account)
|
||||
// 用 window.location.href 替代 router.replace 避免 Next.js RSC flight
|
||||
// 导致 client component 树重建和 SWR abort 循环
|
||||
window.location.href = '/'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
const msg = err.body.message || ''
|
||||
// 后端返回 "需要 TOTP" 时显示 TOTP 输入框
|
||||
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || err.status === 403) {
|
||||
setNeedTotp(true)
|
||||
setError(msg || '请输入两步验证码')
|
||||
} else {
|
||||
setError(msg || '登录失败,请检查用户名和密码')
|
||||
}
|
||||
} 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>
|
||||
|
||||
{/* TOTP 验证码 */}
|
||||
{needTotp && (
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="totp"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
两步验证码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
id="totp"
|
||||
type="text"
|
||||
placeholder="请输入 6 位验证码"
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
maxLength={6}
|
||||
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 tracking-widest"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
请使用身份验证器 App(如 Google Authenticator)扫描二维码后生成的验证码
|
||||
</p>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated, clearAuth } from '@/lib/auth'
|
||||
import { api, ApiRequestError } from '@/lib/api-client'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthGuard — 纯 useEffect redirect,始终渲染 children
|
||||
*
|
||||
* 不做任何 loading/authorized 状态切换,避免组件卸载。
|
||||
* useEffect 在客户端 hydration 后执行,检查认证状态。
|
||||
*/
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
// 后台验证 token
|
||||
api.auth.me().catch((err) => {
|
||||
if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403)) {
|
||||
clearAuth()
|
||||
router.replace('/login')
|
||||
}
|
||||
})
|
||||
}, [router])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
// 简化版 — 直接读 localStorage
|
||||
const account = typeof window !== 'undefined'
|
||||
? JSON.parse(localStorage.getItem('zclaw_admin_account') || 'null')
|
||||
: null
|
||||
return { account, loading: false, isAuthenticated: !!localStorage.getItem('zclaw_admin_token') }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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 }
|
||||
@@ -1,56 +0,0 @@
|
||||
'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 }
|
||||
@@ -1,75 +0,0 @@
|
||||
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 }
|
||||
@@ -1,118 +0,0 @@
|
||||
'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,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
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 }
|
||||
@@ -1,23 +0,0 @@
|
||||
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 }
|
||||
@@ -1,100 +0,0 @@
|
||||
'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,
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'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 }
|
||||
@@ -1,115 +0,0 @@
|
||||
// ============================================================
|
||||
// Skeleton 组件 — 替代全屏 spinner 的骨架屏
|
||||
// ============================================================
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function SkeletonBase({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'animate-pulse rounded-md bg-muted',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** 表格骨架屏 */
|
||||
export function TableSkeleton({
|
||||
rows = 5,
|
||||
cols = 5,
|
||||
hasToolbar = true,
|
||||
}: {
|
||||
rows?: number
|
||||
cols?: number
|
||||
hasToolbar?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{hasToolbar && (
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonBase className="h-9 w-[200px]" />
|
||||
<SkeletonBase className="h-9 w-[120px]" />
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-muted/30 px-4 py-3">
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<SkeletonBase
|
||||
key={i}
|
||||
className={cn(
|
||||
'h-4',
|
||||
i === 0 ? 'w-[120px]' : i === cols - 1 ? 'w-[80px]' : 'w-[100px]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Rows */}
|
||||
{Array.from({ length: rows }).map((_, rowIdx) => (
|
||||
<div
|
||||
key={rowIdx}
|
||||
className={cn(
|
||||
'px-4 py-3',
|
||||
rowIdx < rows - 1 && 'border-b border-border',
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-4">
|
||||
{Array.from({ length: cols }).map((_, colIdx) => (
|
||||
<SkeletonBase
|
||||
key={colIdx}
|
||||
className={cn(
|
||||
'h-4',
|
||||
colIdx === 0 ? 'w-[120px]' : colIdx === cols - 1 ? 'w-[80px]' : 'w-[100px]',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonBase className="h-4 w-[140px]" />
|
||||
<div className="flex gap-2">
|
||||
<SkeletonBase className="h-8 w-[80px]" />
|
||||
<SkeletonBase className="h-8 w-[80px]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 统计卡片骨架屏 */
|
||||
export function StatsSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${count}`}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className="rounded-lg border border-border p-6">
|
||||
<SkeletonBase className="h-4 w-[80px]" />
|
||||
<SkeletonBase className="mt-2 h-8 w-[100px]" />
|
||||
<SkeletonBase className="mt-1 h-3 w-[120px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 图表骨架屏 */
|
||||
export function ChartSkeleton({ height }: { height?: number }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border">
|
||||
<div className="border-b border-border px-6 py-4">
|
||||
<SkeletonBase className="h-5 w-[140px]" />
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<SkeletonBase className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SkeletonBase as Skeleton }
|
||||
@@ -1,63 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { AlertCircle, Inbox } from 'lucide-react'
|
||||
|
||||
/** 统一的错误提示横幅 */
|
||||
export function ErrorBanner({
|
||||
message,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string
|
||||
onDismiss?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1">{message}</span>
|
||||
{onDismiss && (
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="underline cursor-pointer shrink-0"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 统一的空状态占位 */
|
||||
export function EmptyState({
|
||||
message = '暂无数据',
|
||||
}: {
|
||||
message?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Inbox className="h-8 w-8" />
|
||||
<span className="text-sm">{message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 统一的加载失败提示 + 重试 */
|
||||
export function ErrorRetry({
|
||||
message = '请求失败,请重试',
|
||||
onRetry,
|
||||
}: {
|
||||
message?: string
|
||||
onRetry: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<span className="text-sm">{message}</span>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
'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 }
|
||||
@@ -1,119 +0,0 @@
|
||||
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,
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'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 }
|
||||
@@ -1,31 +0,0 @@
|
||||
'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 }
|
||||
@@ -1,16 +0,0 @@
|
||||
// ============================================================
|
||||
// useDebounce — 防抖 hook
|
||||
// ============================================================
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useDebounce<T>(value: T, delay = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedValue(value), delay)
|
||||
return () => clearTimeout(handler)
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
// ============================================================
|
||||
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
||||
// ============================================================
|
||||
|
||||
import { getToken, login as saveToken, logout, getAccount } from './auth'
|
||||
import type {
|
||||
AccountPublic,
|
||||
AgentTemplate,
|
||||
ApiError,
|
||||
ConfigItem,
|
||||
CreateTokenRequest,
|
||||
DashboardStats,
|
||||
DailyUsageStat,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
Model,
|
||||
ModelUsageStat,
|
||||
OperationLog,
|
||||
PaginatedResponse,
|
||||
PromptTemplate,
|
||||
PromptVersion,
|
||||
Provider,
|
||||
ProviderKey,
|
||||
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 || '/api/v1'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/** 判断是否为可重试的网络错误(不含 AbortError) */
|
||||
function isRetryableNetworkError(err: unknown): boolean {
|
||||
// AbortError 不重试:可能是组件卸载或路由切换导致的外部取消
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return false
|
||||
if (err instanceof TypeError) {
|
||||
const msg = (err as TypeError).message
|
||||
return msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ECONNREFUSED')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** 尝试刷新 Token,成功返回新 token,失败返回 null */
|
||||
async function tryRefreshToken(): Promise<string | null> {
|
||||
try {
|
||||
const token = getToken()
|
||||
if (!token) return null
|
||||
|
||||
const res = await fetch(`${BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) return null
|
||||
|
||||
const data = await res.json()
|
||||
const newToken = data.token as string
|
||||
const account = getAccount()
|
||||
if (account && newToken) {
|
||||
saveToken(newToken, account)
|
||||
}
|
||||
return newToken
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
_isRetry = false,
|
||||
externalSignal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
// Merge external signal (e.g. from SWR) with a timeout signal
|
||||
const signals: AbortSignal[] = [AbortSignal.timeout(DEFAULT_TIMEOUT_MS)]
|
||||
if (externalSignal) signals.push(externalSignal)
|
||||
const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals)
|
||||
|
||||
try {
|
||||
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,
|
||||
signal,
|
||||
})
|
||||
|
||||
// 401: 尝试刷新 Token 后重试
|
||||
if (res.status === 401 && !_isRetry) {
|
||||
const newToken = await tryRefreshToken()
|
||||
if (newToken) {
|
||||
return request<T>(method, path, body, true)
|
||||
}
|
||||
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>
|
||||
} catch (err) {
|
||||
// API 错误和外部取消的 AbortError 直接抛出,不重试
|
||||
if (err instanceof ApiRequestError) throw err
|
||||
if (err instanceof DOMException && err.name === 'AbortError') throw err
|
||||
|
||||
lastError = err
|
||||
|
||||
// 仅对可重试的网络错误重试
|
||||
if (attempt < MAX_RETRIES && isRetryableNetworkError(err)) {
|
||||
await sleep(1000 * Math.pow(2, attempt))
|
||||
continue
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
// ── API 客户端 ────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
// ── 认证 ──────────────────────────────────────────────
|
||||
auth: {
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('POST', '/auth/login', data)
|
||||
},
|
||||
|
||||
async register(data: {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
display_name?: string
|
||||
}): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('POST', '/auth/register', data)
|
||||
},
|
||||
|
||||
async me(): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('GET', '/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', `/accounts${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('GET', `/accounts/${id}`)
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
||||
): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('PATCH', `/accounts/${id}`, data)
|
||||
},
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
data: { status: AccountPublic['status'] },
|
||||
): Promise<void> {
|
||||
return request<void>('PATCH', `/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', `/providers${qs}`)
|
||||
},
|
||||
|
||||
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
||||
return request<Provider>('POST', '/providers', data)
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
||||
): Promise<Provider> {
|
||||
return request<Provider>('PATCH', `/providers/${id}`, data)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/providers/${id}`)
|
||||
},
|
||||
|
||||
// Key Pool 管理
|
||||
async listKeys(providerId: string): Promise<ProviderKey[]> {
|
||||
return request<ProviderKey[]>('GET', `/providers/${providerId}/keys`)
|
||||
},
|
||||
|
||||
async addKey(providerId: string, data: {
|
||||
key_label: string
|
||||
key_value: string
|
||||
priority?: number
|
||||
max_rpm?: number
|
||||
max_tpm?: number
|
||||
quota_reset_interval?: string
|
||||
}): Promise<{ ok: boolean; key_id: string }> {
|
||||
return request<{ ok: boolean; key_id: string }>('POST', `/providers/${providerId}/keys`, data)
|
||||
},
|
||||
|
||||
async toggleKey(providerId: string, keyId: string, active: boolean): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>('PUT', `/providers/${providerId}/keys/${keyId}/toggle`, { active })
|
||||
},
|
||||
|
||||
async deleteKey(providerId: string, keyId: string): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>('DELETE', `/providers/${providerId}/keys/${keyId}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 模型管理 ──────────────────────────────────────────
|
||||
models: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
provider_id?: string
|
||||
}): Promise<PaginatedResponse<Model>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
|
||||
},
|
||||
|
||||
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||
return request<Model>('POST', '/models', data)
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||
return request<Model>('PATCH', `/models/${id}`, data)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/models/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── API 密钥 ──────────────────────────────────────────
|
||||
tokens: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<TokenInfo>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<TokenInfo>>('GET', `/keys${qs}`)
|
||||
},
|
||||
|
||||
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||
return request<TokenInfo>('POST', '/keys', data)
|
||||
},
|
||||
|
||||
async revoke(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/keys/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 用量统计 ──────────────────────────────────────────
|
||||
usage: {
|
||||
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
|
||||
const qs = buildQueryString({ ...params, group_by: 'day' })
|
||||
const result = await request<{ by_day: UsageRecord[] }>('GET', `/usage${qs}`)
|
||||
return result.by_day || []
|
||||
},
|
||||
|
||||
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
|
||||
const qs = buildQueryString({ ...params, group_by: 'model' })
|
||||
const result = await request<{ by_model: UsageByModel[] }>('GET', `/usage${qs}`)
|
||||
return result.by_model || []
|
||||
},
|
||||
},
|
||||
|
||||
// ── 中转任务 ──────────────────────────────────────────
|
||||
relay: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<RelayTask>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<RelayTask> {
|
||||
return request<RelayTask>('GET', `/relay/tasks/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 系统配置 ──────────────────────────────────────────
|
||||
config: {
|
||||
async list(params?: {
|
||||
category?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<ConfigItem[]> {
|
||||
const qs = buildQueryString(params)
|
||||
const result = await request<PaginatedResponse<ConfigItem>>('GET', `/config/items${qs}`)
|
||||
return result.items
|
||||
},
|
||||
|
||||
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
|
||||
return request<ConfigItem>('PATCH', `/config/items/${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', `/logs/operations${qs}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 仪表盘 ────────────────────────────────────────────
|
||||
stats: {
|
||||
async dashboard(): Promise<DashboardStats> {
|
||||
return request<DashboardStats>('GET', '/stats/dashboard')
|
||||
},
|
||||
},
|
||||
|
||||
// ── 提示词管理 ────────────────────────────────────────
|
||||
prompts: {
|
||||
async list(params?: {
|
||||
category?: string
|
||||
source?: string
|
||||
status?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<PromptTemplate>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<PromptTemplate>>('GET', `/prompts${qs}`)
|
||||
},
|
||||
|
||||
async get(name: string): Promise<PromptTemplate> {
|
||||
return request<PromptTemplate>('GET', `/prompts/${encodeURIComponent(name)}`)
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
name: string
|
||||
category: string
|
||||
description?: string
|
||||
source?: string
|
||||
system_prompt: string
|
||||
user_prompt_template?: string
|
||||
variables?: unknown[]
|
||||
min_app_version?: string
|
||||
}): Promise<PromptTemplate> {
|
||||
return request<PromptTemplate>('POST', '/prompts', data)
|
||||
},
|
||||
|
||||
async update(name: string, data: {
|
||||
description?: string
|
||||
status?: string
|
||||
}): Promise<PromptTemplate> {
|
||||
return request<PromptTemplate>('PUT', `/prompts/${encodeURIComponent(name)}`, data)
|
||||
},
|
||||
|
||||
async archive(name: string): Promise<PromptTemplate> {
|
||||
return request<PromptTemplate>('DELETE', `/prompts/${encodeURIComponent(name)}`)
|
||||
},
|
||||
|
||||
async listVersions(name: string): Promise<PromptVersion[]> {
|
||||
return request<PromptVersion[]>('GET', `/prompts/${encodeURIComponent(name)}/versions`)
|
||||
},
|
||||
|
||||
async createVersion(name: string, data: {
|
||||
system_prompt: string
|
||||
user_prompt_template?: string
|
||||
variables?: unknown[]
|
||||
changelog?: string
|
||||
min_app_version?: string
|
||||
}): Promise<PromptVersion> {
|
||||
return request<PromptVersion>('POST', `/prompts/${encodeURIComponent(name)}/versions`, data)
|
||||
},
|
||||
|
||||
async rollback(name: string, version: number): Promise<PromptTemplate> {
|
||||
return request<PromptTemplate>('POST', `/prompts/${encodeURIComponent(name)}/rollback/${version}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Agent 配置模板 ──────────────────────────────────
|
||||
agentTemplates: {
|
||||
async list(params?: {
|
||||
category?: string
|
||||
source?: string
|
||||
visibility?: string
|
||||
status?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<AgentTemplate>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<AgentTemplate>>('GET', `/agent-templates${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<AgentTemplate> {
|
||||
return request<AgentTemplate>('GET', `/agent-templates/${id}`)
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
name: string
|
||||
description?: string
|
||||
category?: string
|
||||
source?: string
|
||||
model?: string
|
||||
system_prompt?: string
|
||||
tools?: string[]
|
||||
capabilities?: string[]
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
visibility?: string
|
||||
}): Promise<AgentTemplate> {
|
||||
return request<AgentTemplate>('POST', '/agent-templates', data)
|
||||
},
|
||||
|
||||
async update(id: string, data: {
|
||||
description?: string
|
||||
model?: string
|
||||
system_prompt?: string
|
||||
tools?: string[]
|
||||
capabilities?: string[]
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
visibility?: string
|
||||
status?: string
|
||||
}): Promise<AgentTemplate> {
|
||||
return request<AgentTemplate>('POST', `/agent-templates/${id}`, data)
|
||||
},
|
||||
|
||||
async archive(id: string): Promise<AgentTemplate> {
|
||||
return request<AgentTemplate>('DELETE', `/agent-templates/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 遥测统计 ──────────────────────────────────────────
|
||||
telemetry: {
|
||||
/** 按模型聚合用量统计 */
|
||||
async modelStats(params?: {
|
||||
from?: string
|
||||
to?: string
|
||||
model_id?: string
|
||||
connection_mode?: string
|
||||
}): Promise<ModelUsageStat[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<ModelUsageStat[]>('GET', `/telemetry/stats${qs}`)
|
||||
},
|
||||
|
||||
/** 按天聚合用量统计 */
|
||||
async dailyStats(params?: {
|
||||
days?: number
|
||||
}): Promise<DailyUsageStat[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<DailyUsageStat[]>('GET', `/telemetry/daily${qs}`)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────
|
||||
|
||||
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}`
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// ============================================================
|
||||
// API Error 类 — 与 swr-fetcher 共享
|
||||
// ============================================================
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: { error?: string; message?: string },
|
||||
) {
|
||||
super(body.message || `Request failed with status ${status}`)
|
||||
this.name = 'ApiRequestError'
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
// ============================================================
|
||||
// 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)
|
||||
}
|
||||
|
||||
/** 清除认证状态(用于 Token 验证失败时) */
|
||||
export function clearAuth(): 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()
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// ============================================================
|
||||
// SWR fetcher — 将 SWR key 映射到 api-client 调用
|
||||
// ============================================================
|
||||
|
||||
import { api } from './api-client'
|
||||
import { ApiRequestError } from './api-client'
|
||||
|
||||
type ApiMethod = typeof api
|
||||
|
||||
/** SWR fetcher: key 可以是字符串或 [method-path, params] 元组 */
|
||||
type SwrKey =
|
||||
| string
|
||||
| [string, ...unknown[]]
|
||||
|
||||
/** SWR fetcher 支持 AbortSignal 传递 */
|
||||
type SwrFetcherArgs = { signal?: AbortSignal } | null
|
||||
|
||||
async function resolveApiCall(key: SwrKey, args: SwrFetcherArgs): Promise<unknown> {
|
||||
if (typeof key === 'string') {
|
||||
// 简单字符串 key,直接 fetch
|
||||
return fetchGeneric(key, args?.signal)
|
||||
}
|
||||
|
||||
const [path, ...rest] = key
|
||||
return callByPath(path, rest, args?.signal)
|
||||
}
|
||||
|
||||
async function fetchGeneric(path: string, signal?: AbortSignal): Promise<unknown> {
|
||||
const res = await fetch(path, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: 'unknown', message: `请求失败 (${res.status})` }))
|
||||
throw new ApiRequestError(res.status, body)
|
||||
}
|
||||
if (res.status === 204) return null
|
||||
return res.json()
|
||||
}
|
||||
|
||||
/** 根据 path 调用对应的 api 方法 */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function callByPath(path: string, callArgs: unknown[], signal?: AbortSignal): Promise<unknown> {
|
||||
const parts = path.split('.')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let target: any = api
|
||||
for (const part of parts) {
|
||||
target = target[part]
|
||||
if (!target) throw new Error(`API method not found: ${path}`)
|
||||
}
|
||||
// Append signal as last argument if the target is the request function
|
||||
// For api.xxx() calls that ultimately use request(), we pass signal through
|
||||
// The simplest approach: pass signal as part of an options bag
|
||||
return target(...callArgs, signal ? { signal } : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* SWR fetcher — 接受 SWR 自动传入的 AbortSignal
|
||||
*
|
||||
* 用法: useSWR(key, swrFetcher)
|
||||
* SWR 会自动在组件卸载或 key 变化时 abort 请求
|
||||
*/
|
||||
export function swrFetcher<T = unknown>(key: SwrKey, args: SwrFetcherArgs): Promise<T> {
|
||||
return resolveApiCall(key, args) as Promise<T>
|
||||
}
|
||||
|
||||
/** 创建 SWR key helper — 类型安全 */
|
||||
export function createKey<TMethod extends string>(
|
||||
method: TMethod,
|
||||
...args: unknown[]
|
||||
): [TMethod, ...unknown[]] {
|
||||
return [method, ...args]
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { SWRConfig } from 'swr'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 判断是否为请求被中断(页面导航等场景) */
|
||||
function isAbortError(err: unknown): boolean {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return true
|
||||
if (err instanceof Error && err.message?.includes('aborted')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export function SWRProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
// 关闭所有自动 revalidation — 只在手动 mutate 或 key 变化时刷新
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
|
||||
// 60s 去重窗口:Dashboard 数据变化不频繁,避免短时间内重复请求
|
||||
dedupingInterval: 60_000,
|
||||
|
||||
// 保留旧数据直到新数据返回,避免 loading 闪烁
|
||||
keepPreviousData: true,
|
||||
|
||||
// 最多重试 1 次,间隔 3s
|
||||
errorRetryCount: 1,
|
||||
errorRetryInterval: 3000,
|
||||
|
||||
shouldRetryOnError: (err: unknown) => {
|
||||
if (isAbortError(err)) return false
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
const status = (err as { status: number }).status
|
||||
return status !== 401 && status !== 403 && status !== 404
|
||||
}
|
||||
return true
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
if (isAbortError(err)) return
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SWRConfig>
|
||||
)
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
// ============================================================
|
||||
// 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
|
||||
last_login_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 登录请求 */
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
totp_code?: string
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
refresh_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: string
|
||||
enabled: boolean
|
||||
rate_limit_rpm: number | null
|
||||
rate_limit_tpm: number | null
|
||||
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: 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
|
||||
}
|
||||
|
||||
/** 用量记录 */
|
||||
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
|
||||
current_value: string | null
|
||||
default_value: string | null
|
||||
source: string
|
||||
description: string | null
|
||||
requires_restart: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 操作日志 */
|
||||
export interface OperationLog {
|
||||
id: number
|
||||
account_id: string | null
|
||||
action: string
|
||||
target_type: string | null
|
||||
target_id: string | null
|
||||
details: Record<string, unknown> | null
|
||||
ip_address: string | null
|
||||
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
|
||||
}
|
||||
|
||||
// ── 提示词模板 ────────────────────────────────────────────
|
||||
|
||||
/** 提示词模板 */
|
||||
export interface PromptTemplate {
|
||||
id: string
|
||||
name: string
|
||||
category: string
|
||||
description?: string
|
||||
source: 'builtin' | 'custom'
|
||||
current_version: number
|
||||
status: 'active' | 'deprecated' | 'archived'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 提示词版本 */
|
||||
export interface PromptVersion {
|
||||
id: string
|
||||
template_id: string
|
||||
version: number
|
||||
system_prompt: string
|
||||
user_prompt_template?: string
|
||||
variables: PromptVariable[]
|
||||
changelog?: string
|
||||
min_app_version?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/** 提示词变量定义 */
|
||||
export interface PromptVariable {
|
||||
name: string
|
||||
type: 'string' | 'number' | 'select' | 'boolean'
|
||||
default_value?: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
/** OTA 更新检查请求 */
|
||||
export interface PromptCheckRequest {
|
||||
device_id: string
|
||||
versions: Record<string, number>
|
||||
}
|
||||
|
||||
/** OTA 更新响应 */
|
||||
export interface PromptCheckResponse {
|
||||
updates: PromptUpdatePayload[]
|
||||
server_time: string
|
||||
}
|
||||
|
||||
/** 单个更新载荷 */
|
||||
export interface PromptUpdatePayload {
|
||||
name: string
|
||||
version: number
|
||||
system_prompt: string
|
||||
user_prompt_template?: string
|
||||
variables: PromptVariable[]
|
||||
source: string
|
||||
min_app_version?: string
|
||||
changelog?: string
|
||||
}
|
||||
|
||||
// ── Agent 配置模板 ────────────────────────────────────────
|
||||
|
||||
/** Agent 模板 */
|
||||
export interface AgentTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
category: string
|
||||
source: 'builtin' | 'custom'
|
||||
model?: string
|
||||
system_prompt?: string
|
||||
tools: string[]
|
||||
capabilities: string[]
|
||||
temperature?: number
|
||||
max_tokens?: number
|
||||
visibility: 'public' | 'team' | 'private'
|
||||
status: 'active' | 'archived'
|
||||
current_version: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ── Provider Key Pool ─────────────────────────────────────
|
||||
|
||||
/** Provider Key */
|
||||
export interface ProviderKey {
|
||||
id: string
|
||||
provider_id: string
|
||||
key_label: string
|
||||
priority: number
|
||||
max_rpm?: number
|
||||
max_tpm?: number
|
||||
quota_reset_interval?: string
|
||||
is_active: boolean
|
||||
last_429_at?: string
|
||||
cooldown_until?: string
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ── 遥测统计 ────────────────────────────────────────────
|
||||
|
||||
/** 按模型聚合的用量统计 */
|
||||
export interface ModelUsageStat {
|
||||
model_id: string
|
||||
request_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
avg_latency_ms: number | null
|
||||
success_rate: number
|
||||
}
|
||||
|
||||
/** 按天的用量统计 */
|
||||
export interface DailyUsageStat {
|
||||
day: string
|
||||
request_count: number
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
unique_devices: number
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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))
|
||||
}
|
||||
|
||||
/** 从 SWR error 中提取用户可见消息,过滤 abort 错误 */
|
||||
export function getSwrErrorMessage(err: unknown): string | undefined {
|
||||
if (!err) return undefined
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return undefined
|
||||
if (err instanceof Error) {
|
||||
if (err.name === 'AbortError' || err.message?.includes('aborted')) return undefined
|
||||
return err.message
|
||||
}
|
||||
return String(err)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -5,6 +5,8 @@ use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, mpsc, Mutex};
|
||||
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result, HandRun, HandRunId, HandRunStatus, HandRunFilter, TriggerSource};
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_types::Capability;
|
||||
#[cfg(feature = "multi-agent")]
|
||||
use zclaw_protocols::{A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
@@ -114,6 +116,39 @@ impl SkillExecutor for KernelSkillExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inbox wrapper for A2A message receivers that supports re-queuing
|
||||
/// non-matching messages instead of dropping them.
|
||||
#[cfg(feature = "multi-agent")]
|
||||
struct AgentInbox {
|
||||
rx: tokio::sync::mpsc::Receiver<A2aEnvelope>,
|
||||
pending: std::collections::VecDeque<A2aEnvelope>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "multi-agent")]
|
||||
impl AgentInbox {
|
||||
fn new(rx: tokio::sync::mpsc::Receiver<A2aEnvelope>) -> Self {
|
||||
Self { rx, pending: std::collections::VecDeque::new() }
|
||||
}
|
||||
|
||||
fn try_recv(&mut self) -> std::result::Result<A2aEnvelope, tokio::sync::mpsc::error::TryRecvError> {
|
||||
if let Some(msg) = self.pending.pop_front() {
|
||||
return Ok(msg);
|
||||
}
|
||||
self.rx.try_recv()
|
||||
}
|
||||
|
||||
async fn recv(&mut self) -> Option<A2aEnvelope> {
|
||||
if let Some(msg) = self.pending.pop_front() {
|
||||
return Some(msg);
|
||||
}
|
||||
self.rx.recv().await
|
||||
}
|
||||
|
||||
fn requeue(&mut self, envelope: A2aEnvelope) {
|
||||
self.pending.push_back(envelope);
|
||||
}
|
||||
}
|
||||
|
||||
/// The ZCLAW Kernel
|
||||
pub struct Kernel {
|
||||
config: KernelConfig,
|
||||
@@ -137,9 +172,9 @@ pub struct Kernel {
|
||||
/// A2A router for inter-agent messaging (gated by multi-agent feature)
|
||||
#[cfg(feature = "multi-agent")]
|
||||
a2a_router: Arc<A2aRouter>,
|
||||
/// Per-agent A2A inbox receivers
|
||||
/// Per-agent A2A inbox receivers (supports re-queuing non-matching messages)
|
||||
#[cfg(feature = "multi-agent")]
|
||||
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<mpsc::Receiver<A2aEnvelope>>>>>,
|
||||
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<AgentInbox>>>>,
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
@@ -435,21 +470,22 @@ impl Kernel {
|
||||
// Register in memory
|
||||
self.memory.save_agent(&config).await?;
|
||||
|
||||
// Register in registry
|
||||
self.registry.register(config);
|
||||
|
||||
// Register with A2A router for multi-agent messaging
|
||||
// Register with A2A router for multi-agent messaging (before config is moved)
|
||||
#[cfg(feature = "multi-agent")]
|
||||
{
|
||||
let profile = Self::agent_config_to_a2a_profile(&config_clone);
|
||||
let profile = Self::agent_config_to_a2a_profile(&config);
|
||||
let rx = self.a2a_router.register_agent(profile).await;
|
||||
self.a2a_inboxes.insert(id, Arc::new(Mutex::new(rx)));
|
||||
self.a2a_inboxes.insert(id, Arc::new(Mutex::new(AgentInbox::new(rx))));
|
||||
}
|
||||
|
||||
// Register in registry (consumes config)
|
||||
let name = config.name.clone();
|
||||
self.registry.register(config);
|
||||
|
||||
// Emit event
|
||||
self.events.publish(Event::AgentSpawned {
|
||||
agent_id: id,
|
||||
name: self.registry.get(&id).map(|a| a.name.clone()).unwrap_or_default(),
|
||||
name,
|
||||
});
|
||||
|
||||
Ok(id)
|
||||
@@ -1332,8 +1368,8 @@ impl Kernel {
|
||||
format!("No A2A inbox for agent: {}", agent_id)
|
||||
))?;
|
||||
|
||||
let mut rx = inbox.lock().await;
|
||||
match rx.try_recv() {
|
||||
let mut inbox = inbox.lock().await;
|
||||
match inbox.try_recv() {
|
||||
Ok(envelope) => {
|
||||
self.events.publish(Event::A2aMessageReceived {
|
||||
from: envelope.from,
|
||||
@@ -1392,23 +1428,24 @@ impl Kernel {
|
||||
// Wait for response with timeout
|
||||
let timeout = tokio::time::Duration::from_millis(timeout_ms);
|
||||
let result = tokio::time::timeout(timeout, async {
|
||||
let inbox = self.a2a_inboxes.get(from)
|
||||
let inbox_entry = self.a2a_inboxes.get(from)
|
||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(
|
||||
format!("No A2A inbox for agent: {}", from)
|
||||
))?;
|
||||
let mut rx = inbox.lock().await;
|
||||
let mut inbox = inbox_entry.lock().await;
|
||||
|
||||
// Poll for matching response
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
match inbox.recv().await {
|
||||
Some(msg) => {
|
||||
// Check if this is a response to our task
|
||||
if msg.message_type == A2aMessageType::Response
|
||||
&& msg.reply_to.as_deref() == Some(&envelope_id) {
|
||||
return Ok::<_, zclaw_types::ZclawError>(msg.payload);
|
||||
}
|
||||
// Not our response — put it back by logging it (would need a re-queue mechanism for production)
|
||||
tracing::warn!("Received non-matching A2A response, discarding: {}", msg.id);
|
||||
// Not our response — requeue it for later processing
|
||||
tracing::debug!("Re-queuing non-matching A2A message: {}", msg.id);
|
||||
inbox.requeue(msg);
|
||||
}
|
||||
None => {
|
||||
return Err(zclaw_types::ZclawError::Internal(
|
||||
|
||||
@@ -12,7 +12,7 @@ tokio = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
serde_yaml = { package = "serde_yaml_bw", version = "2" }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -405,7 +405,15 @@ impl A2aRouter {
|
||||
if let Some(members) = groups.get(group_id) {
|
||||
for agent_id in members {
|
||||
if let Some(tx) = queues.get(agent_id) {
|
||||
let _ = tx.send(envelope.clone()).await;
|
||||
match tx.try_send(envelope.clone()) {
|
||||
Ok(()) => {},
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
||||
tracing::warn!("A2A delivery to agent {} dropped: channel full", agent_id);
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
|
||||
tracing::warn!("A2A delivery to agent {} dropped: channel closed", agent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -414,7 +422,15 @@ impl A2aRouter {
|
||||
// Broadcast to all registered agents
|
||||
for (agent_id, tx) in queues.iter() {
|
||||
if agent_id != &envelope.from {
|
||||
let _ = tx.send(envelope.clone()).await;
|
||||
match tx.try_send(envelope.clone()) {
|
||||
Ok(()) => {},
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
|
||||
tracing::warn!("A2A delivery to agent {} dropped: channel full", agent_id);
|
||||
}
|
||||
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
|
||||
tracing::warn!("A2A delivery to agent {} dropped: channel closed", agent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -444,6 +460,35 @@ impl A2aRouter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Add agent to a group (creates group if not exists)
|
||||
pub async fn add_to_group(&self, group_id: &str, agent_id: AgentId) {
|
||||
let mut groups = self.groups.write().await;
|
||||
let members = groups.entry(group_id.to_string()).or_insert_with(Vec::new);
|
||||
if !members.contains(&agent_id) {
|
||||
members.push(agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove agent from a group
|
||||
pub async fn remove_from_group(&self, group_id: &str, agent_id: &AgentId) {
|
||||
let mut groups = self.groups.write().await;
|
||||
if let Some(members) = groups.get_mut(group_id) {
|
||||
members.retain(|id| id != agent_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// List all groups
|
||||
pub async fn list_groups(&self) -> Vec<String> {
|
||||
let groups = self.groups.read().await;
|
||||
groups.keys().cloned().collect()
|
||||
}
|
||||
|
||||
/// Get members of a group
|
||||
pub async fn get_group_members(&self, group_id: &str) -> Vec<AgentId> {
|
||||
let groups = self.groups.read().await;
|
||||
groups.get(group_id).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get all registered agent profiles
|
||||
pub async fn list_profiles(&self) -> Vec<A2aAgentProfile> {
|
||||
let profiles = self.profiles.read().await;
|
||||
|
||||
@@ -31,6 +31,7 @@ sha2 = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
socket2 = { workspace = true }
|
||||
url = "2"
|
||||
|
||||
axum = { workspace = true }
|
||||
@@ -42,9 +43,9 @@ argon2 = { workspace = true }
|
||||
totp-rs = { workspace = true }
|
||||
urlencoding = "2"
|
||||
data-encoding = "2"
|
||||
regex = "1"
|
||||
aes-gcm = "0.10"
|
||||
bytes = "1"
|
||||
regex = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 用户定义的定时任务表
|
||||
-- 前端 SchedulerPanel 通过此表持久化定时任务配置
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
schedule TEXT NOT NULL, -- cron 表达式 / interval / ISO timestamp
|
||||
schedule_type TEXT NOT NULL DEFAULT 'cron' CHECK (schedule_type IN ('cron', 'interval', 'once')),
|
||||
target_type TEXT NOT NULL CHECK (target_type IN ('agent', 'hand', 'workflow')),
|
||||
target_id TEXT NOT NULL,
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
input_payload JSONB, -- 执行时的输入参数
|
||||
last_run_at TIMESTAMPTZ,
|
||||
next_run_at TIMESTAMPTZ,
|
||||
run_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_account ON scheduled_tasks(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_enabled_next ON scheduled_tasks(enabled, next_run_at) WHERE enabled = TRUE;
|
||||
@@ -1,6 +1,8 @@
|
||||
//! 认证 HTTP 处理器
|
||||
|
||||
use axum::{extract::{State, ConnectInfo}, http::StatusCode, Json};
|
||||
use axum::{extract::{State, ConnectInfo}, Json};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||
use std::net::SocketAddr;
|
||||
use secrecy::ExposeSecret;
|
||||
use crate::state::AppState;
|
||||
@@ -12,13 +14,49 @@ use super::{
|
||||
types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, ChangePasswordRequest, AccountPublic, RefreshRequest},
|
||||
};
|
||||
|
||||
/// Cookie 配置常量
|
||||
const ACCESS_TOKEN_COOKIE: &str = "zclaw_access_token";
|
||||
const REFRESH_TOKEN_COOKIE: &str = "zclaw_refresh_token";
|
||||
|
||||
/// 构建 auth cookies 并附加到 CookieJar
|
||||
fn set_auth_cookies(jar: CookieJar, token: &str, refresh_token: &str) -> CookieJar {
|
||||
let access_max_age = std::time::Duration::from_secs(2 * 3600); // 2h
|
||||
let refresh_max_age = std::time::Duration::from_secs(7 * 86400); // 7d
|
||||
|
||||
// cookie crate 需要 time::Duration,从 std 转换
|
||||
let access = Cookie::build((ACCESS_TOKEN_COOKIE, token.to_string()))
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/api")
|
||||
.max_age(access_max_age.try_into().unwrap_or_else(|_| std::time::Duration::from_secs(3600).try_into().unwrap()))
|
||||
.build();
|
||||
|
||||
let refresh = Cookie::build((REFRESH_TOKEN_COOKIE, refresh_token.to_string()))
|
||||
.http_only(true)
|
||||
.secure(true)
|
||||
.same_site(SameSite::Strict)
|
||||
.path("/api/v1/auth")
|
||||
.max_age(refresh_max_age.try_into().unwrap_or_else(|_| std::time::Duration::from_secs(86400).try_into().unwrap()))
|
||||
.build();
|
||||
|
||||
jar.add(access).add(refresh)
|
||||
}
|
||||
|
||||
/// 清除 auth cookies
|
||||
fn clear_auth_cookies(jar: CookieJar) -> CookieJar {
|
||||
jar.remove(Cookie::build(ACCESS_TOKEN_COOKIE).path("/api"))
|
||||
.remove(Cookie::build(REFRESH_TOKEN_COOKIE).path("/api/v1/auth"))
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/register
|
||||
/// 注册成功后自动签发 JWT,返回与 login 一致的 LoginResponse
|
||||
pub async fn register(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
jar: CookieJar,
|
||||
Json(req): Json<RegisterRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<LoginResponse>)> {
|
||||
) -> SaasResult<(CookieJar, Json<LoginResponse>)> {
|
||||
if req.username.len() < 3 {
|
||||
return Err(SaasError::InvalidInput("用户名至少 3 个字符".into()));
|
||||
}
|
||||
@@ -100,9 +138,9 @@ pub async fn register(
|
||||
state.jwt_secret.expose_secret(), 168,
|
||||
).await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(LoginResponse {
|
||||
let resp = LoginResponse {
|
||||
token,
|
||||
refresh_token,
|
||||
refresh_token: refresh_token.clone(),
|
||||
account: AccountPublic {
|
||||
id: account_id,
|
||||
username: req.username,
|
||||
@@ -113,15 +151,18 @@ pub async fn register(
|
||||
totp_enabled: false,
|
||||
created_at: now,
|
||||
},
|
||||
})))
|
||||
};
|
||||
let jar = set_auth_cookies(jar, &resp.token, &refresh_token);
|
||||
Ok((jar, Json(resp)))
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/login
|
||||
pub async fn login(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
jar: CookieJar,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> SaasResult<Json<LoginResponse>> {
|
||||
) -> SaasResult<(CookieJar, Json<LoginResponse>)> {
|
||||
// 一次查询获取用户信息 + password_hash + totp_secret(合并原来的 3 次查询)
|
||||
let row: Option<AccountLoginRow> =
|
||||
sqlx::query_as(
|
||||
@@ -189,14 +230,16 @@ pub async fn login(
|
||||
state.jwt_secret.expose_secret(), 168,
|
||||
).await?;
|
||||
|
||||
Ok(Json(LoginResponse {
|
||||
let resp = LoginResponse {
|
||||
token,
|
||||
refresh_token,
|
||||
refresh_token: refresh_token.clone(),
|
||||
account: AccountPublic {
|
||||
id: r.id, username: r.username, email: r.email, display_name: r.display_name,
|
||||
role: r.role, status: r.status, totp_enabled: r.totp_enabled, created_at: r.created_at,
|
||||
},
|
||||
}))
|
||||
};
|
||||
let jar = set_auth_cookies(jar, &resp.token, &refresh_token);
|
||||
Ok((jar, Json(resp)))
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/refresh
|
||||
@@ -204,8 +247,9 @@ pub async fn login(
|
||||
/// refresh_token 一次性使用,使用后立即失效
|
||||
pub async fn refresh(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Json(req): Json<RefreshRequest>,
|
||||
) -> SaasResult<Json<serde_json::Value>> {
|
||||
) -> SaasResult<(CookieJar, Json<serde_json::Value>)> {
|
||||
// 1. 验证 refresh token 签名 (跳过过期检查,但有 7 天窗口限制)
|
||||
let claims = verify_token_skip_expiry(&req.refresh_token, state.jwt_secret.expose_secret())?;
|
||||
|
||||
@@ -282,10 +326,11 @@ pub async fn refresh(
|
||||
// 9. 清理过期/已使用的 refresh tokens 已迁移到 Scheduler 定期执行
|
||||
// 不再在每次 refresh 时阻塞请求
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
let jar = set_auth_cookies(jar, &new_access, &new_refresh);
|
||||
Ok((jar, Json(serde_json::json!({
|
||||
"token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
})))
|
||||
}))))
|
||||
}
|
||||
|
||||
/// GET /api/v1/auth/me — 返回当前认证用户的公开信息
|
||||
@@ -456,3 +501,10 @@ fn sha256_hex(input: &str) -> String {
|
||||
use sha2::{Sha256, Digest};
|
||||
hex::encode(Sha256::digest(input.as_bytes()))
|
||||
}
|
||||
|
||||
/// POST /api/v1/auth/logout — 清除 auth cookies
|
||||
pub async fn logout(
|
||||
jar: CookieJar,
|
||||
) -> (CookieJar, axum::http::StatusCode) {
|
||||
(clear_auth_cookies(jar), axum::http::StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -103,9 +103,10 @@ fn extract_client_ip(req: &Request) -> Option<String> {
|
||||
.map(|s| s.to_string())
|
||||
}
|
||||
|
||||
/// 认证中间件: 从 JWT 或 API Token 提取身份
|
||||
/// 认证中间件: 从 JWT Cookie / Authorization Header / API Token 提取身份
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AppState>,
|
||||
jar: axum_extra::extract::cookie::CookieJar,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
@@ -114,25 +115,30 @@ pub async fn auth_middleware(
|
||||
.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 路径
|
||||
let verify_result = jwt::verify_token(token, state.jwt_secret.expose_secret());
|
||||
verify_result
|
||||
.map(|claims| AuthContext {
|
||||
account_id: claims.sub,
|
||||
role: claims.role,
|
||||
permissions: claims.permissions,
|
||||
client_ip,
|
||||
})
|
||||
.map_err(|_| SaasError::Unauthorized)
|
||||
}
|
||||
// 尝试从 Authorization header 提取 token
|
||||
let header_token = auth_header.and_then(|auth| auth.strip_prefix("Bearer "));
|
||||
|
||||
// 尝试从 HttpOnly cookie 提取 token (仅当 header 不存在时)
|
||||
let cookie_token = jar.get("zclaw_access_token").map(|c| c.value().to_string());
|
||||
|
||||
let token = header_token
|
||||
.or(cookie_token.as_deref());
|
||||
|
||||
let result = if let Some(token) = token {
|
||||
if token.starts_with("zclaw_") {
|
||||
// API Token 路径
|
||||
verify_api_token(&state, token, client_ip.clone()).await
|
||||
} else {
|
||||
Err(SaasError::Unauthorized)
|
||||
// JWT 路径
|
||||
let verify_result = jwt::verify_token(token, state.jwt_secret.expose_secret());
|
||||
verify_result
|
||||
.map(|claims| AuthContext {
|
||||
account_id: claims.sub,
|
||||
role: claims.role,
|
||||
permissions: claims.permissions,
|
||||
client_ip,
|
||||
})
|
||||
.map_err(|_| SaasError::Unauthorized)
|
||||
}
|
||||
} else {
|
||||
Err(SaasError::Unauthorized)
|
||||
@@ -155,6 +161,7 @@ pub fn routes() -> axum::Router<AppState> {
|
||||
.route("/api/v1/auth/register", post(handlers::register))
|
||||
.route("/api/v1/auth/login", post(handlers::login))
|
||||
.route("/api/v1/auth/refresh", post(handlers::refresh))
|
||||
.route("/api/v1/auth/logout", post(handlers::logout))
|
||||
}
|
||||
|
||||
/// 需要认证的路由
|
||||
|
||||
@@ -148,6 +148,34 @@ pub async fn verify_totp(
|
||||
return Err(SaasError::InvalidInput("TOTP 码必须是 6 位数字".into()));
|
||||
}
|
||||
|
||||
// TOTP 暴力破解保护: 10 分钟内最多 5 次失败
|
||||
const MAX_TOTP_FAILURES: u32 = 5;
|
||||
const TOTP_LOCKOUT_SECS: u64 = 600;
|
||||
let now = std::time::Instant::now();
|
||||
let lockout_duration = std::time::Duration::from_secs(TOTP_LOCKOUT_SECS);
|
||||
|
||||
let is_locked = {
|
||||
if let Some(entry) = state.totp_fail_counts.get(&ctx.account_id) {
|
||||
let (count, first_fail) = entry.value();
|
||||
if *count >= MAX_TOTP_FAILURES && now.duration_since(*first_fail) < lockout_duration {
|
||||
true
|
||||
} else {
|
||||
// 窗口过期,重置
|
||||
drop(entry);
|
||||
state.totp_fail_counts.remove(&ctx.account_id);
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if is_locked {
|
||||
return Err(SaasError::RateLimited(
|
||||
format!("TOTP 验证失败次数过多,请 {} 秒后重试", TOTP_LOCKOUT_SECS)
|
||||
));
|
||||
}
|
||||
|
||||
// 获取存储的密钥
|
||||
let (totp_secret,): (Option<String>,) = sqlx::query_as(
|
||||
"SELECT totp_secret FROM accounts WHERE id = $1"
|
||||
@@ -172,9 +200,24 @@ pub async fn verify_totp(
|
||||
};
|
||||
|
||||
if !verify_totp_code(&secret, code) {
|
||||
// 记录失败次数
|
||||
let new_count = {
|
||||
let mut entry = state.totp_fail_counts
|
||||
.entry(ctx.account_id.clone())
|
||||
.or_insert((0, now));
|
||||
entry.value_mut().0 += 1;
|
||||
entry.value().0
|
||||
};
|
||||
tracing::warn!(
|
||||
"TOTP verify failed for account {} ({}/{} attempts)",
|
||||
ctx.account_id, new_count, MAX_TOTP_FAILURES
|
||||
);
|
||||
return Err(SaasError::Totp("TOTP 码验证失败".into()));
|
||||
}
|
||||
|
||||
// 验证成功 → 清除失败计数
|
||||
state.totp_fail_counts.remove(&ctx.account_id);
|
||||
|
||||
// 验证成功 → 启用 TOTP,同时确保密钥已加密
|
||||
let final_secret = if encrypted_secret.starts_with(crypto::ENCRYPTED_PREFIX) {
|
||||
encrypted_secret
|
||||
@@ -183,10 +226,10 @@ pub async fn verify_totp(
|
||||
encrypt_totp_secret(&secret, &enc_key)?
|
||||
};
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let now_ts = chrono::Utc::now().to_rfc3339();
|
||||
sqlx::query("UPDATE accounts SET totp_enabled = true, totp_secret = $1, updated_at = $2 WHERE id = $3")
|
||||
.bind(&final_secret)
|
||||
.bind(&now)
|
||||
.bind(&now_ts)
|
||||
.bind(&ctx.account_id)
|
||||
.execute(&state.db)
|
||||
.await?;
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
use secrecy::SecretString;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use secrecy::ExposeSecret;
|
||||
#[cfg(not(debug_assertions))]
|
||||
use sha2::Digest;
|
||||
|
||||
/// SaaS 服务器完整配置
|
||||
@@ -226,21 +229,20 @@ impl SaaSConfig {
|
||||
/// 获取 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 {
|
||||
// 开发 fallback 密钥仅在 debug 构建中可用,不会进入 release
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
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 {
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
anyhow::bail!(
|
||||
"ZCLAW_SAAS_JWT_SECRET 环境变量未设置。\
|
||||
请设置一个强随机密钥 (至少 32 字符)。\
|
||||
开发环境可设置 ZCLAW_SAAS_DEV=true 使用默认值。"
|
||||
请设置一个强随机密钥 (至少 32 字符)。"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -256,10 +258,6 @@ impl SaaSConfig {
|
||||
/// 从 ZCLAW_TOTP_ENCRYPTION_KEY 环境变量加载 (hex 编码的 64 字符)
|
||||
/// 开发环境使用默认值 (不安全)
|
||||
pub fn totp_encryption_key(&self) -> anyhow::Result<[u8; 32]> {
|
||||
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
match std::env::var("ZCLAW_TOTP_ENCRYPTION_KEY") {
|
||||
Ok(hex_key) => {
|
||||
if hex_key.len() != 64 {
|
||||
@@ -273,13 +271,16 @@ impl SaaSConfig {
|
||||
Ok(key)
|
||||
}
|
||||
Err(_) => {
|
||||
if is_dev {
|
||||
// 开发环境: 仅在 debug 构建中使用固定密钥
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
tracing::warn!("ZCLAW_TOTP_ENCRYPTION_KEY not set, using development default (INSECURE)");
|
||||
// 开发环境使用固定密钥
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(b"zclaw-dev-totp-encrypt-key-32b!x");
|
||||
Ok(key)
|
||||
} else {
|
||||
}
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
// 生产环境: 使用 JWT 密钥的 SHA-256 哈希作为加密密钥
|
||||
tracing::warn!("ZCLAW_TOTP_ENCRYPTION_KEY not set, deriving from JWT secret");
|
||||
let jwt = self.jwt_secret()?;
|
||||
|
||||
@@ -4,7 +4,7 @@ use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
use crate::error::SaasResult;
|
||||
|
||||
const SCHEMA_VERSION: i32 = 6;
|
||||
const SCHEMA_VERSION: i32 = 7;
|
||||
|
||||
/// 初始化数据库
|
||||
pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {
|
||||
@@ -90,7 +90,7 @@ async fn run_migration_files(pool: &PgPool, dir: &std::path::Path) -> SaasResult
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
tracing::info!("Running migration: {}", filename);
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
for stmt in content.split(';') {
|
||||
for stmt in split_sql_statements(&content) {
|
||||
let trimmed = stmt.trim();
|
||||
if !trimmed.is_empty() && !trimmed.starts_with("--") {
|
||||
sqlx::query(trimmed).execute(pool).await?;
|
||||
@@ -100,6 +100,150 @@ async fn run_migration_files(pool: &PgPool, dir: &std::path::Path) -> SaasResult
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按语句分割 SQL 文件内容,正确处理:
|
||||
/// - 单引号字符串 `'...'`
|
||||
/// - 双引号标识符 `"..."`
|
||||
/// - 美元符号引用字符串 `$$...$$` 和 `$tag$...$tag$`
|
||||
/// - `--` 单行注释
|
||||
/// - `/* ... */` 块注释
|
||||
/// - `E'...'` 转义字符串
|
||||
fn split_sql_statements(sql: &str) -> Vec<String> {
|
||||
let mut statements = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut chars = sql.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\'' => {
|
||||
// 单引号字符串
|
||||
current.push(ch);
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('\'') => {
|
||||
current.push('\'');
|
||||
// 检查是否为转义引号 ''
|
||||
if chars.peek() == Some(&'\'') {
|
||||
current.push(chars.next().unwrap());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(c) => current.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
// 双引号标识符
|
||||
current.push(ch);
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('"') => {
|
||||
current.push('"');
|
||||
break;
|
||||
}
|
||||
Some(c) => current.push(c),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
'-' if chars.peek() == Some(&'-') => {
|
||||
// 单行注释: 跳过直到行尾
|
||||
chars.next(); // consume second '-'
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c == '\n' {
|
||||
chars.next();
|
||||
current.push(c);
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
'/' if chars.peek() == Some(&'*') => {
|
||||
// 块注释: 跳过直到 */
|
||||
chars.next(); // consume '*'
|
||||
current.push_str("/*");
|
||||
let mut prev = ' ';
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('/') if prev == '*' => {
|
||||
current.push('/');
|
||||
break;
|
||||
}
|
||||
Some(c) => {
|
||||
current.push(c);
|
||||
prev = c;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
'$' => {
|
||||
// 美元符号引用: $$ 或 $tag$ ... $tag$
|
||||
current.push(ch);
|
||||
// 读取 tag (字母数字和下划线)
|
||||
let mut tag = String::new();
|
||||
while let Some(&c) = chars.peek() {
|
||||
if c == '$' || c.is_alphanumeric() || c == '_' {
|
||||
if c == '$' {
|
||||
chars.next();
|
||||
current.push(c);
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
tag.push(c);
|
||||
current.push(c);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 如果 tag 为空,就是 $$ 格式
|
||||
let end_marker = if tag.is_empty() {
|
||||
"$$".to_string()
|
||||
} else {
|
||||
format!("${}$", tag)
|
||||
};
|
||||
// 读取直到遇到 end_marker
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some(c) => {
|
||||
current.push(c);
|
||||
buf.push(c);
|
||||
if buf.len() > end_marker.len() {
|
||||
buf.remove(0);
|
||||
}
|
||||
if buf == end_marker {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
';' => {
|
||||
// 语句结束
|
||||
let trimmed = current.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
statements.push(trimmed);
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
_ => {
|
||||
current.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最后一条语句 (可能不以分号结尾)
|
||||
let trimmed = current.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
statements.push(trimmed);
|
||||
}
|
||||
|
||||
statements
|
||||
}
|
||||
|
||||
/// Seed 角色数据
|
||||
async fn seed_roles(pool: &PgPool) -> SaasResult<()> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
@@ -52,6 +52,10 @@ pub enum SaasError {
|
||||
#[error("中转错误: {0}")]
|
||||
Relay(String),
|
||||
|
||||
#[error("通用错误: {0}")]
|
||||
General(#[from] anyhow::Error),
|
||||
|
||||
|
||||
#[error("速率限制: {0}")]
|
||||
RateLimited(String),
|
||||
|
||||
@@ -77,6 +81,7 @@ impl SaasError {
|
||||
Self::Totp(_) => StatusCode::BAD_REQUEST,
|
||||
Self::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Self::Relay(_) => StatusCode::BAD_GATEWAY,
|
||||
Self::General(_) => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +105,7 @@ impl SaasError {
|
||||
Self::Encryption(_) => "ENCRYPTION_ERROR",
|
||||
Self::Config(_) => "CONFIG_ERROR",
|
||||
Self::Relay(_) => "RELAY_ERROR",
|
||||
Self::General(_) => "GENERAL_ERROR",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,4 +22,5 @@ pub mod migration;
|
||||
pub mod role;
|
||||
pub mod prompt;
|
||||
pub mod agent_template;
|
||||
pub mod scheduled_task;
|
||||
pub mod telemetry;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! ZCLAW SaaS 服务入口
|
||||
|
||||
use axum::extract::State;
|
||||
use socket2::{Domain, Protocol, Socket, TcpKeepalive, Type};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tower_http::timeout::TimeoutLayer;
|
||||
use tracing::info;
|
||||
use zclaw_saas::{config::SaaSConfig, db::init_db, state::AppState};
|
||||
@@ -36,7 +36,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
dispatcher.register(UpdateLastUsedWorker);
|
||||
info!("Worker dispatcher initialized (5 workers registered)");
|
||||
|
||||
let state = AppState::new(db.clone(), config.clone(), dispatcher)?;
|
||||
// 优雅停机令牌 — 取消后所有 SSE 流和长连接立即终止
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let state = AppState::new(db.clone(), config.clone(), dispatcher, shutdown_token.clone())?;
|
||||
|
||||
// 启动声明式 Scheduler(从 TOML 配置读取定时任务)
|
||||
let scheduler_config = &config.scheduler;
|
||||
@@ -46,6 +48,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
// 启动内置 DB 清理任务(设备清理等不通过 Worker 的任务)
|
||||
zclaw_saas::scheduler::start_db_cleanup_tasks(db.clone());
|
||||
|
||||
// 启动用户定时任务调度循环(30s 轮询 scheduled_tasks 表)
|
||||
zclaw_saas::scheduler::start_user_task_scheduler(db.clone());
|
||||
|
||||
// 启动内存中的 rate limit 条目清理
|
||||
let rate_limit_state = state.clone();
|
||||
tokio::spawn(async move {
|
||||
@@ -58,34 +63,58 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
let app = build_router(state).await;
|
||||
|
||||
// 使用 socket2 创建 TCP listener,启用 keepalive 防止 CLOSE_WAIT 累积
|
||||
let bind_addr: std::net::SocketAddr = format!("{}:{}", config.server.host, config.server.port).parse()?;
|
||||
let domain = if bind_addr.is_ipv6() { Domain::IPV6 } else { Domain::IPV4 };
|
||||
let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
|
||||
socket.set_reuse_address(true)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
|
||||
let keepalive = TcpKeepalive::new()
|
||||
.with_time(std::time::Duration::from_secs(60))
|
||||
.with_interval(std::time::Duration::from_secs(10));
|
||||
#[cfg(target_os = "linux")]
|
||||
let keepalive = keepalive.with_retries(3);
|
||||
socket.set_tcp_keepalive(&keepalive)?;
|
||||
info!("TCP keepalive enabled: 60s idle, 10s interval");
|
||||
|
||||
socket.bind(&bind_addr.into())?;
|
||||
socket.listen(128)?;
|
||||
let std_listener: std::net::TcpListener = socket.into();
|
||||
let listener = tokio::net::TcpListener::from_std(std_listener)?;
|
||||
// 配置 TCP keepalive + 短 SO_LINGER,防止 CLOSE_WAIT 累积
|
||||
let listener = create_listener(&config.server.host, config.server.port)?;
|
||||
info!("SaaS server listening on {}:{}", config.server.host, config.server.port);
|
||||
|
||||
// 优雅停机: Ctrl+C → 取消 CancellationToken → SSE 流终止 → 连接排空
|
||||
let token = shutdown_token.clone();
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>())
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.with_graceful_shutdown(async move {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
info!("Received shutdown signal, cancelling SSE streams and draining connections...");
|
||||
token.cancel();
|
||||
})
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_handler(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
|
||||
/// 创建带 TCP keepalive 和短 SO_LINGER 的 TcpListener,防止 CLOSE_WAIT 累积
|
||||
fn create_listener(host: &str, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
|
||||
let addr = format!("{}:{}", host, port);
|
||||
let socket = socket2::Socket::new(
|
||||
socket2::Domain::for_address(addr.parse::<std::net::SocketAddr>()?),
|
||||
socket2::Type::STREAM,
|
||||
Some(socket2::Protocol::TCP),
|
||||
)?;
|
||||
|
||||
// SO_REUSEADDR: 允许快速重启时复用 TIME_WAIT 端口
|
||||
socket.set_reuse_address(true)?;
|
||||
|
||||
// TCP keepalive: 60s 空闲后每 10s 探测,连续 3 次无响应则关闭
|
||||
// 防止已断开但对端未发 FIN 的连接永远留在 CLOSE_WAIT
|
||||
let keepalive = socket2::SockRef::from(&socket);
|
||||
keepalive.set_tcp_keepalive(
|
||||
&socket2::TcpKeepalive::new()
|
||||
.with_time(std::time::Duration::from_secs(60))
|
||||
.with_interval(std::time::Duration::from_secs(10)),
|
||||
)?;
|
||||
|
||||
// 短 SO_LINGER (1s): 关闭时最多等 1 秒即 RST,避免大量 TIME_WAIT
|
||||
socket.set_linger(Some(std::time::Duration::from_secs(1)))?;
|
||||
|
||||
socket.bind(&addr.parse::<std::net::SocketAddr>()?.into())?;
|
||||
socket.listen(1024)?;
|
||||
socket.set_nonblocking(true)?;
|
||||
|
||||
Ok(tokio::net::TcpListener::from_std(socket.into())?)
|
||||
}
|
||||
|
||||
async fn health_handler(
|
||||
State(state): State<AppState>,
|
||||
) -> (axum::http::StatusCode, axum::Json<serde_json::Value> ) {
|
||||
// health 必须独立快速返回,用 3s 超时避免连接池满时阻塞
|
||||
let db_healthy = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(3),
|
||||
@@ -95,15 +124,41 @@ async fn health_handler(State(state): State<AppState>) -> axum::Json<serde_json:
|
||||
.map(|r| r.is_ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
let status = if db_healthy { "healthy" } else { "degraded" };
|
||||
let _code = if db_healthy { 200 } else { 503 };
|
||||
// 连接池容量检查: 使用率 >= 80% 返回 503 (degraded)
|
||||
let pool = &state.db;
|
||||
let total = pool.options().get_max_connections() as usize;
|
||||
if total > 0 {
|
||||
let idle = pool.num_idle() as usize;
|
||||
let used = total - idle;
|
||||
let ratio = used * 100 / total;
|
||||
if ratio >= 80 {
|
||||
return (
|
||||
axum::http::StatusCode::SERVICE_UNAVAILABLE,
|
||||
axum::Json(serde_json::json!({
|
||||
"status": "degraded",
|
||||
"database": true,
|
||||
"database_pool": {
|
||||
"usage_pct": ratio,
|
||||
"used": used,
|
||||
"total": total,
|
||||
},
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
axum::Json(serde_json::json!({
|
||||
let status = if db_healthy { "healthy" } else { "degraded" };
|
||||
let code = if db_healthy {
|
||||
axum::http::StatusCode::OK } else { axum::http::StatusCode::SERVICE_UNAVAILABLE };
|
||||
|
||||
(code, axum::Json(serde_json::json!({
|
||||
"status": status,
|
||||
"database": db_healthy,
|
||||
"timestamp": chrono::Utc::now().to_rfc3339(),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
async fn build_router(state: AppState) -> axum::Router {
|
||||
@@ -123,6 +178,7 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any)
|
||||
.allow_credentials(true)
|
||||
} else {
|
||||
tracing::error!("生产环境必须配置 server.cors_origins,不能使用 allow_origin(Any)");
|
||||
panic!("生产环境必须配置 server.cors_origins 白名单。开发环境可设置 ZCLAW_SAAS_DEV=true 绕过。");
|
||||
@@ -144,13 +200,19 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
.allow_headers([
|
||||
axum::http::header::AUTHORIZATION,
|
||||
axum::http::header::CONTENT_TYPE,
|
||||
axum::http::header::COOKIE,
|
||||
axum::http::HeaderName::from_static("x-request-id"),
|
||||
])
|
||||
.allow_credentials(true)
|
||||
}
|
||||
};
|
||||
|
||||
let public_routes = zclaw_saas::auth::routes()
|
||||
.route("/api/health", axum::routing::get(health_handler));
|
||||
.route("/api/health", axum::routing::get(health_handler))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
zclaw_saas::middleware::public_rate_limit_middleware,
|
||||
));
|
||||
|
||||
let protected_routes = zclaw_saas::auth::protected_routes()
|
||||
.merge(zclaw_saas::account::routes())
|
||||
@@ -160,6 +222,7 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
.merge(zclaw_saas::role::routes())
|
||||
.merge(zclaw_saas::prompt::routes())
|
||||
.merge(zclaw_saas::agent_template::routes())
|
||||
.merge(zclaw_saas::scheduled_task::routes())
|
||||
.merge(zclaw_saas::telemetry::routes())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
@@ -178,19 +241,16 @@ async fn build_router(state: AppState) -> axum::Router {
|
||||
zclaw_saas::auth::auth_middleware,
|
||||
));
|
||||
|
||||
axum::Router::new()
|
||||
// 非流式路由应用全局 15s 超时(relay SSE 端点需要更长超时)
|
||||
let non_streaming_routes = axum::Router::new()
|
||||
.merge(public_routes)
|
||||
.merge(protected_routes)
|
||||
.layer(TimeoutLayer::new(std::time::Duration::from_secs(15)))
|
||||
.layer(TimeoutLayer::new(std::time::Duration::from_secs(15)));
|
||||
|
||||
axum::Router::new()
|
||||
.merge(non_streaming_routes)
|
||||
.merge(zclaw_saas::relay::routes())
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// 监听 Ctrl+C 信号,触发 graceful shutdown
|
||||
async fn shutdown_signal() {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("Failed to install Ctrl+C handler");
|
||||
info!("Received shutdown signal, draining connections...");
|
||||
}
|
||||
|
||||
@@ -49,6 +49,10 @@ pub async fn api_version_middleware(
|
||||
|
||||
/// 速率限制中间件
|
||||
/// 基于账号的请求频率限制
|
||||
///
|
||||
/// ⚠️ CRITICAL: DashMap 的 RefMut 持有 parking_lot 写锁。
|
||||
/// 必须在独立作用域块内完成所有 DashMap 操作,确保锁在 .await 之前释放。
|
||||
/// 否则并发请求争抢同一 shard 锁会阻塞 tokio worker thread,导致运行时死锁。
|
||||
pub async fn rate_limit_middleware(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
@@ -59,25 +63,77 @@ pub async fn rate_limit_middleware(
|
||||
.map(|ctx| ctx.account_id.clone())
|
||||
.unwrap_or_else(|| "anonymous".to_string());
|
||||
|
||||
// 无锁读取 rate limit 配置(避免每个请求获取 RwLock)
|
||||
let rate_limit = state.rate_limit_rpm() as usize;
|
||||
|
||||
let key = format!("rate_limit:{}", account_id);
|
||||
|
||||
let now = Instant::now();
|
||||
let window_start = now - std::time::Duration::from_secs(60);
|
||||
|
||||
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
|
||||
entries.retain(|&time| time > window_start);
|
||||
|
||||
if entries.len() >= rate_limit {
|
||||
|
||||
// DashMap 操作限定在作用域块内,确保 RefMut(持有 parking_lot 锁)在 await 前释放
|
||||
let blocked = {
|
||||
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
|
||||
entries.retain(|&time| time > window_start);
|
||||
|
||||
if entries.len() >= rate_limit {
|
||||
true
|
||||
} else {
|
||||
entries.push(now);
|
||||
false
|
||||
}
|
||||
}; // ← RefMut 在此处 drop,释放 parking_lot shard 锁
|
||||
|
||||
if blocked {
|
||||
return SaasError::RateLimited(format!(
|
||||
"请求频率超限,每分钟最多 {} 次请求",
|
||||
rate_limit
|
||||
)).into_response();
|
||||
}
|
||||
|
||||
entries.push(now);
|
||||
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// 公共端点速率限制中间件 (基于客户端 IP,更严格)
|
||||
/// 用于登录/注册/刷新等无认证端点,防止暴力破解
|
||||
const PUBLIC_RATE_LIMIT_RPM: usize = 20;
|
||||
|
||||
pub async fn public_rate_limit_middleware(
|
||||
State(state): State<AppState>,
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response<Body> {
|
||||
// 从连接信息或 header 提取客户端 IP
|
||||
let client_ip = req.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|ci| ci.0.ip().to_string())
|
||||
.unwrap_or_else(|| {
|
||||
req.headers()
|
||||
.get("x-real-ip")
|
||||
.or_else(|| req.headers().get("x-forwarded-for"))
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.split(',').next().unwrap_or("unknown").trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
});
|
||||
|
||||
let key = format!("public_rate_limit:{}", client_ip);
|
||||
let now = Instant::now();
|
||||
let window_start = now - std::time::Duration::from_secs(60);
|
||||
|
||||
let blocked = {
|
||||
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
|
||||
entries.retain(|&time| time > window_start);
|
||||
|
||||
if entries.len() >= PUBLIC_RATE_LIMIT_RPM {
|
||||
true
|
||||
} else {
|
||||
entries.push(now);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if blocked {
|
||||
return SaasError::RateLimited(
|
||||
"请求频率超限,请稍后再试".into()
|
||||
).into_response();
|
||||
}
|
||||
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
@@ -399,8 +399,12 @@ pub async fn add_provider_key(
|
||||
return Err(SaasError::InvalidInput("key_value 不能包含空白字符".into()));
|
||||
}
|
||||
|
||||
// Encrypt the API key before storing in database
|
||||
let enc_key = state.config.read().await.totp_encryption_key()?;
|
||||
let encrypted_value = crate::crypto::encrypt_value(&req.key_value, &enc_key)?;
|
||||
|
||||
let key_id = super::key_pool::add_provider_key(
|
||||
&state.db, &provider_id, &req.key_label, &req.key_value,
|
||||
&state.db, &provider_id, &req.key_label, &encrypted_value,
|
||||
req.priority, req.max_rpm, req.max_tpm,
|
||||
req.quota_reset_interval.as_deref(),
|
||||
).await?;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use sqlx::PgPool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::models::{ProviderKeySelectRow, ProviderKeyRow};
|
||||
use crate::models::ProviderKeyRow;
|
||||
use crate::crypto;
|
||||
|
||||
/// 解密 key_value (如果已加密),否则原样返回
|
||||
@@ -36,19 +36,63 @@ pub struct KeySelection {
|
||||
}
|
||||
|
||||
/// 从 provider 的 Key Pool 中选择最佳可用 Key
|
||||
///
|
||||
/// 优化: 单次 JOIN 查询获取 Key + 当前分钟使用量,避免 N+1 查询
|
||||
pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) -> SaasResult<KeySelection> {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let current_minute = chrono::Utc::now().format("%Y-%m-%dT%H:%M").to_string();
|
||||
|
||||
// 获取所有活跃 Key
|
||||
let rows: Vec<ProviderKeySelectRow> =
|
||||
// 单次查询: 活跃 Key + 当前分钟的 RPM/TPM 使用量 (LEFT JOIN)
|
||||
let rows: Vec<(String, String, i32, Option<i64>, Option<i64>, Option<String>, Option<i64>, Option<i64>)> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, key_value, priority, max_rpm, max_tpm, quota_reset_interval
|
||||
FROM provider_keys
|
||||
WHERE provider_id = $1 AND is_active = TRUE AND (cooldown_until IS NULL OR cooldown_until <= $2)
|
||||
ORDER BY priority ASC"
|
||||
).bind(provider_id).bind(&now).fetch_all(db).await?;
|
||||
"SELECT pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm, pk.quota_reset_interval,
|
||||
uw.request_count, uw.token_count
|
||||
FROM provider_keys pk
|
||||
LEFT JOIN key_usage_window uw ON pk.id = uw.key_id AND uw.window_minute = $1
|
||||
WHERE pk.provider_id = $2 AND pk.is_active = TRUE
|
||||
AND (pk.cooldown_until IS NULL OR pk.cooldown_until <= $3)
|
||||
ORDER BY pk.priority ASC"
|
||||
).bind(¤t_minute).bind(provider_id).bind(&now).fetch_all(db).await?;
|
||||
|
||||
for (id, key_value, priority, max_rpm, max_tpm, quota_reset_interval, req_count, token_count) in &rows {
|
||||
// RPM 检查
|
||||
if let Some(rpm_limit) = max_rpm {
|
||||
if *rpm_limit > 0 {
|
||||
let count = req_count.unwrap_or(0);
|
||||
if count >= *rpm_limit {
|
||||
tracing::debug!("Key {} hit RPM limit ({}/{})", id, count, rpm_limit);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TPM 检查
|
||||
if let Some(tpm_limit) = max_tpm {
|
||||
if *tpm_limit > 0 {
|
||||
let tokens = token_count.unwrap_or(0);
|
||||
if tokens >= *tpm_limit {
|
||||
tracing::debug!("Key {} hit TPM limit ({}/{})", id, tokens, tpm_limit);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 此 Key 可用 — 解密 key_value
|
||||
let decrypted_kv = decrypt_key_value(key_value, enc_key)?;
|
||||
return Ok(KeySelection {
|
||||
key: PoolKey {
|
||||
id: id.clone(),
|
||||
key_value: decrypted_kv,
|
||||
priority: *priority,
|
||||
max_rpm: *max_rpm,
|
||||
max_tpm: *max_tpm,
|
||||
quota_reset_interval: quota_reset_interval.clone(),
|
||||
},
|
||||
key_id: id.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// 所有 Key 都超限或无 Key
|
||||
if rows.is_empty() {
|
||||
// 检查是否有冷却中的 Key,返回预计等待时间
|
||||
let cooldown_row: Option<(String,)> = sqlx::query_as(
|
||||
@@ -59,88 +103,14 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
|
||||
).bind(provider_id).bind(&now).fetch_optional(db).await?;
|
||||
|
||||
if let Some((earliest,)) = cooldown_row {
|
||||
// 尝试解析时间差
|
||||
let wait_secs = parse_cooldown_remaining(&earliest, &now);
|
||||
return Err(SaasError::RateLimited(
|
||||
format!("所有 Key 均在冷却中,预计 {} 秒后可用", wait_secs)
|
||||
));
|
||||
}
|
||||
|
||||
// 检查 provider 级别的单 Key
|
||||
let provider_key: Option<String> = sqlx::query_scalar(
|
||||
"SELECT api_key FROM providers WHERE id = $1"
|
||||
).bind(provider_id).fetch_optional(db).await?.flatten();
|
||||
|
||||
if let Some(key) = provider_key {
|
||||
let decrypted = decrypt_key_value(&key, enc_key)?;
|
||||
return Ok(KeySelection {
|
||||
key: PoolKey {
|
||||
id: "provider-fallback".to_string(),
|
||||
key_value: decrypted,
|
||||
priority: 0,
|
||||
max_rpm: None,
|
||||
max_tpm: None,
|
||||
quota_reset_interval: None,
|
||||
},
|
||||
key_id: "provider-fallback".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
return Err(SaasError::NotFound(format!("Provider {} 没有可用的 API Key", provider_id)));
|
||||
}
|
||||
|
||||
// 检查滑动窗口使用量
|
||||
for row in rows {
|
||||
// 检查 RPM 限额
|
||||
if let Some(rpm_limit) = row.max_rpm {
|
||||
if rpm_limit > 0 {
|
||||
let window: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(request_count), 0) FROM key_usage_window
|
||||
WHERE key_id = $1 AND window_minute = $2"
|
||||
).bind(&row.id).bind(¤t_minute).fetch_optional(db).await?;
|
||||
|
||||
if let Some((count,)) = window {
|
||||
if count >= rpm_limit {
|
||||
tracing::debug!("Key {} hit RPM limit ({}/{})", row.id, count, rpm_limit);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 TPM 限额
|
||||
if let Some(tpm_limit) = row.max_tpm {
|
||||
if tpm_limit > 0 {
|
||||
let window: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT COALESCE(SUM(token_count), 0) FROM key_usage_window
|
||||
WHERE key_id = $1 AND window_minute = $2"
|
||||
).bind(&row.id).bind(¤t_minute).fetch_optional(db).await?;
|
||||
|
||||
if let Some((tokens,)) = window {
|
||||
if tokens >= tpm_limit {
|
||||
tracing::debug!("Key {} hit TPM limit ({}/{})", row.id, tokens, tpm_limit);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 此 Key 可用 — 解密 key_value
|
||||
let decrypted_kv = decrypt_key_value(&row.key_value, enc_key)?;
|
||||
return Ok(KeySelection {
|
||||
key: PoolKey {
|
||||
id: row.id.clone(),
|
||||
key_value: decrypted_kv,
|
||||
priority: row.priority,
|
||||
max_rpm: row.max_rpm,
|
||||
max_tpm: row.max_tpm,
|
||||
quota_reset_interval: row.quota_reset_interval,
|
||||
},
|
||||
key_id: row.id,
|
||||
});
|
||||
}
|
||||
|
||||
// 所有 Key 都超限,回退到 provider 单 Key
|
||||
// 回退到 provider 单 Key
|
||||
let provider_key: Option<String> = sqlx::query_scalar(
|
||||
"SELECT api_key FROM providers WHERE id = $1"
|
||||
).bind(provider_id).fetch_optional(db).await?.flatten();
|
||||
@@ -160,9 +130,13 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
|
||||
});
|
||||
}
|
||||
|
||||
Err(SaasError::RateLimited(
|
||||
format!("Provider {} 所有 Key 均已达限额", provider_id)
|
||||
))
|
||||
if rows.is_empty() {
|
||||
Err(SaasError::NotFound(format!("Provider {} 没有可用的 API Key", provider_id)))
|
||||
} else {
|
||||
Err(SaasError::RateLimited(
|
||||
format!("Provider {} 所有 Key 均已达限额", provider_id)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录 Key 使用量(滑动窗口)
|
||||
|
||||
@@ -298,7 +298,20 @@ pub async fn execute_relay(
|
||||
let body = axum::body::Body::from_stream(body_stream);
|
||||
|
||||
// SSE 流结束后异步记录 usage + Key 使用量
|
||||
// 使用全局 Arc<Semaphore> 限制并发 spawned tasks,防止高并发时耗尽连接池
|
||||
static SSE_SPAWN_SEMAPHORE: std::sync::OnceLock<Arc<tokio::sync::Semaphore>> = std::sync::OnceLock::new();
|
||||
let semaphore = SSE_SPAWN_SEMAPHORE.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(16)));
|
||||
let permit = match semaphore.clone().try_acquire_owned() {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
// 信号量满时跳过 usage 记录,流本身不受影响
|
||||
tracing::warn!("SSE usage spawn at capacity, skipping usage record for task {}", task_id);
|
||||
return Ok(RelayResponse::Sse(body));
|
||||
}
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit; // 持有 permit 直到任务完成
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let capture = usage_capture.lock().await;
|
||||
let (input, output) = (
|
||||
@@ -464,11 +477,11 @@ async fn validate_provider_url(url: &str) -> SaasResult<()> {
|
||||
// 去除 IPv6 方括号
|
||||
let host = host.trim_start_matches('[').trim_end_matches(']');
|
||||
|
||||
// 精确匹配的阻止列表
|
||||
// 精确匹配的阻止列表: 仅包含主机名和特殊域名
|
||||
// 私有 IP 范围 (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, ::1 等)
|
||||
// 由 is_private_ip() 统一判断,无需在此重复列出
|
||||
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",
|
||||
"localhost", "metadata.google.internal",
|
||||
];
|
||||
if blocked_exact.contains(&host) {
|
||||
return Err(SaasError::InvalidInput(format!("provider URL 指向禁止的内网地址: {}", host)));
|
||||
|
||||
79
crates/zclaw-saas/src/scheduled_task/handlers.rs
Normal file
79
crates/zclaw-saas/src/scheduled_task/handlers.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! 定时任务 HTTP 处理器
|
||||
|
||||
use axum::{
|
||||
extract::{State, Path, Extension},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::error::SaasResult;
|
||||
use crate::auth::types::AuthContext;
|
||||
use super::{types::*, service};
|
||||
|
||||
/// POST /api/scheduler/tasks — 创建定时任务
|
||||
pub async fn create_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Json(req): Json<CreateScheduledTaskRequest>,
|
||||
) -> SaasResult<(StatusCode, Json<ScheduledTaskResponse>)> {
|
||||
// 验证
|
||||
if req.name.is_empty() {
|
||||
return Err(crate::error::SaasError::InvalidInput("任务名称不能为空".into()));
|
||||
}
|
||||
if req.schedule.is_empty() {
|
||||
return Err(crate::error::SaasError::InvalidInput("调度表达式不能为空".into()));
|
||||
}
|
||||
if !["cron", "interval", "once"].contains(&req.schedule_type.as_str()) {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
format!("无效的 schedule_type: {},可选: cron, interval, once", req.schedule_type)
|
||||
));
|
||||
}
|
||||
if !["agent", "hand", "workflow"].contains(&req.target.target_type.as_str()) {
|
||||
return Err(crate::error::SaasError::InvalidInput(
|
||||
format!("无效的 target_type: {},可选: agent, hand, workflow", req.target.target_type)
|
||||
));
|
||||
}
|
||||
|
||||
let resp = service::create_task(&state.db, &ctx.account_id, &req).await?;
|
||||
Ok((StatusCode::CREATED, Json(resp)))
|
||||
}
|
||||
|
||||
/// GET /api/scheduler/tasks — 列出定时任务
|
||||
pub async fn list_tasks(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
) -> SaasResult<Json<Vec<ScheduledTaskResponse>>> {
|
||||
let tasks = service::list_tasks(&state.db, &ctx.account_id).await?;
|
||||
Ok(Json(tasks))
|
||||
}
|
||||
|
||||
/// GET /api/scheduler/tasks/:id — 获取单个定时任务
|
||||
pub async fn get_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
) -> SaasResult<Json<ScheduledTaskResponse>> {
|
||||
let task = service::get_task(&state.db, &ctx.account_id, &id).await?;
|
||||
Ok(Json(task))
|
||||
}
|
||||
|
||||
/// PATCH /api/scheduler/tasks/:id — 更新定时任务
|
||||
pub async fn update_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
Json(req): Json<UpdateScheduledTaskRequest>,
|
||||
) -> SaasResult<Json<ScheduledTaskResponse>> {
|
||||
let task = service::update_task(&state.db, &ctx.account_id, &id, &req).await?;
|
||||
Ok(Json(task))
|
||||
}
|
||||
|
||||
/// DELETE /api/scheduler/tasks/:id — 删除定时任务
|
||||
pub async fn delete_task(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(id): Path<String>,
|
||||
) -> SaasResult<StatusCode> {
|
||||
service::delete_task(&state.db, &ctx.account_id, &id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
15
crates/zclaw-saas/src/scheduled_task/mod.rs
Normal file
15
crates/zclaw-saas/src/scheduled_task/mod.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
//! 用户定时任务管理模块
|
||||
|
||||
pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
|
||||
use axum::routing::{get, post, patch, delete};
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 定时任务路由 (需要认证)
|
||||
pub fn routes() -> axum::Router<AppState> {
|
||||
axum::Router::new()
|
||||
.route("/api/scheduler/tasks", get(handlers::list_tasks).post(handlers::create_task))
|
||||
.route("/api/scheduler/tasks/:id", get(handlers::get_task).patch(handlers::update_task).delete(handlers::delete_task))
|
||||
}
|
||||
195
crates/zclaw-saas/src/scheduled_task/service.rs
Normal file
195
crates/zclaw-saas/src/scheduled_task/service.rs
Normal file
@@ -0,0 +1,195 @@
|
||||
//! 定时任务数据库服务层
|
||||
|
||||
use sqlx::{PgPool, FromRow};
|
||||
use crate::error::SaasResult;
|
||||
use super::types::*;
|
||||
|
||||
/// 数据库行结构
|
||||
#[derive(Debug, FromRow)]
|
||||
struct ScheduledTaskRow {
|
||||
id: String,
|
||||
account_id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
schedule: String,
|
||||
schedule_type: String,
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
enabled: bool,
|
||||
last_run_at: Option<String>,
|
||||
next_run_at: Option<String>,
|
||||
run_count: i32,
|
||||
last_error: Option<String>,
|
||||
input_payload: Option<serde_json::Value>,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
impl ScheduledTaskRow {
|
||||
fn to_response(&self) -> ScheduledTaskResponse {
|
||||
ScheduledTaskResponse {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
schedule: self.schedule.clone(),
|
||||
schedule_type: self.schedule_type.clone(),
|
||||
target: TaskTarget {
|
||||
target_type: self.target_type.clone(),
|
||||
id: self.target_id.clone(),
|
||||
},
|
||||
enabled: self.enabled,
|
||||
description: self.description.clone(),
|
||||
last_run: self.last_run_at.clone(),
|
||||
next_run: self.next_run_at.clone(),
|
||||
run_count: self.run_count,
|
||||
last_error: self.last_error.clone(),
|
||||
created_at: self.created_at.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建定时任务
|
||||
pub async fn create_task(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
req: &CreateScheduledTaskRequest,
|
||||
) -> SaasResult<ScheduledTaskResponse> {
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let input_json = req.input.as_ref().map(|v| v.to_string());
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO scheduled_tasks (id, account_id, name, description, schedule, schedule_type, target_type, target_id, enabled, input_payload, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)"
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(account_id)
|
||||
.bind(&req.name)
|
||||
.bind(&req.description)
|
||||
.bind(&req.schedule)
|
||||
.bind(&req.schedule_type)
|
||||
.bind(&req.target.target_type)
|
||||
.bind(&req.target.id)
|
||||
.bind(req.enabled.unwrap_or(true))
|
||||
.bind(&input_json)
|
||||
.bind(&now)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
Ok(ScheduledTaskResponse {
|
||||
id,
|
||||
name: req.name.clone(),
|
||||
schedule: req.schedule.clone(),
|
||||
schedule_type: req.schedule_type.clone(),
|
||||
target: req.target.clone(),
|
||||
enabled: req.enabled.unwrap_or(true),
|
||||
description: req.description.clone(),
|
||||
last_run: None,
|
||||
next_run: None,
|
||||
run_count: 0,
|
||||
last_error: None,
|
||||
created_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// 列出用户的定时任务
|
||||
pub async fn list_tasks(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
) -> SaasResult<Vec<ScheduledTaskResponse>> {
|
||||
let rows: Vec<ScheduledTaskRow> = sqlx::query_as(
|
||||
"SELECT id, account_id, name, description, schedule, schedule_type,
|
||||
target_type, target_id, enabled, last_run_at, next_run_at,
|
||||
run_count, last_error, input_payload, created_at
|
||||
FROM scheduled_tasks WHERE account_id = $1 ORDER BY created_at DESC"
|
||||
)
|
||||
.bind(account_id)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
Ok(rows.iter().map(|r| r.to_response()).collect())
|
||||
}
|
||||
|
||||
/// 获取单个定时任务
|
||||
pub async fn get_task(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
task_id: &str,
|
||||
) -> SaasResult<ScheduledTaskResponse> {
|
||||
let row: Option<ScheduledTaskRow> = sqlx::query_as(
|
||||
"SELECT id, account_id, name, description, schedule, schedule_type,
|
||||
target_type, target_id, enabled, last_run_at, next_run_at,
|
||||
run_count, last_error, input_payload, created_at
|
||||
FROM scheduled_tasks WHERE id = $1 AND account_id = $2"
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(account_id)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
Ok(row
|
||||
.ok_or_else(|| crate::error::SaasError::NotFound("定时任务不存在".into()))?
|
||||
.to_response())
|
||||
}
|
||||
|
||||
/// 更新定时任务
|
||||
pub async fn update_task(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
task_id: &str,
|
||||
req: &UpdateScheduledTaskRequest,
|
||||
) -> SaasResult<ScheduledTaskResponse> {
|
||||
let existing = get_task(db, account_id, task_id).await?;
|
||||
|
||||
let name = req.name.as_deref().unwrap_or(&existing.name);
|
||||
let schedule = req.schedule.as_deref().unwrap_or(&existing.schedule);
|
||||
let schedule_type = req.schedule_type.as_deref().unwrap_or(&existing.schedule_type);
|
||||
let enabled = req.enabled.unwrap_or(existing.enabled);
|
||||
let description = req.description.as_deref().or(existing.description.as_deref());
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
let (target_type, target_id) = if let Some(ref target) = req.target {
|
||||
(target.target_type.as_str(), target.id.as_str())
|
||||
} else {
|
||||
(existing.target.target_type.as_str(), existing.target.id.as_str())
|
||||
};
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE scheduled_tasks SET name = $1, schedule = $2, schedule_type = $3,
|
||||
target_type = $4, target_id = $5, enabled = $6, description = $7,
|
||||
updated_at = $8
|
||||
WHERE id = $9 AND account_id = $10"
|
||||
)
|
||||
.bind(name)
|
||||
.bind(schedule)
|
||||
.bind(schedule_type)
|
||||
.bind(target_type)
|
||||
.bind(target_id)
|
||||
.bind(enabled)
|
||||
.bind(description)
|
||||
.bind(&now)
|
||||
.bind(task_id)
|
||||
.bind(account_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
get_task(db, account_id, task_id).await
|
||||
}
|
||||
|
||||
/// 删除定时任务
|
||||
pub async fn delete_task(
|
||||
db: &PgPool,
|
||||
account_id: &str,
|
||||
task_id: &str,
|
||||
) -> SaasResult<()> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM scheduled_tasks WHERE id = $1 AND account_id = $2"
|
||||
)
|
||||
.bind(task_id)
|
||||
.bind(account_id)
|
||||
.execute(db)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::error::SaasError::NotFound("定时任务不存在".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
63
crates/zclaw-saas/src/scheduled_task/types.rs
Normal file
63
crates/zclaw-saas/src/scheduled_task/types.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! 定时任务类型定义
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 创建定时任务请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateScheduledTaskRequest {
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
/// "cron" | "interval" | "once"
|
||||
#[serde(default = "default_schedule_type")]
|
||||
pub schedule_type: String,
|
||||
pub target: TaskTarget,
|
||||
pub description: Option<String>,
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: Option<bool>,
|
||||
pub input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_schedule_type() -> String {
|
||||
"cron".to_string()
|
||||
}
|
||||
|
||||
fn default_enabled() -> Option<bool> {
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// 任务目标
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct TaskTarget {
|
||||
#[serde(rename = "type")]
|
||||
pub target_type: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// 更新定时任务请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateScheduledTaskRequest {
|
||||
pub name: Option<String>,
|
||||
pub schedule: Option<String>,
|
||||
pub schedule_type: Option<String>,
|
||||
pub target: Option<TaskTarget>,
|
||||
pub description: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
pub input: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 定时任务响应
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ScheduledTaskResponse {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub schedule: String,
|
||||
pub schedule_type: String,
|
||||
pub target: TaskTarget,
|
||||
pub enabled: bool,
|
||||
pub description: Option<String>,
|
||||
pub last_run: Option<String>,
|
||||
pub next_run: Option<String>,
|
||||
pub run_count: i32,
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user