From b5fc4356739b8c21427af658cacae925fd32039b Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:19:31 +0800 Subject: [PATCH 01/28] feat(types): add AccountGroup types, tags/groups fields, and UsageStats extensions - Add AccountGroup, AccountGroupsResponse, CreateAccountGroupRequest, UpdateAccountGroupRequest - Add tags/group_ids to AccountRow, allowed_group_ids to APIKeyRow - Extend UpdateAccountSchedulerRequest with proxy_url, tags, group_ids - Add cache_hit_rate to AccountUsageDetail - Add optional cache/latency fields to UsageStats (avg_first_token_ms, today_cache_rate, etc.) --- frontend/src/types.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a2ab80d6..63340d0f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -43,6 +43,8 @@ export interface AccountRow { base_concurrency_effective?: number dynamic_concurrency_limit?: number allowed_api_key_ids?: number[] + tags?: string[] + group_ids?: number[] scheduler_breakdown?: { unauthorized_penalty: number rate_limit_penalty: number @@ -139,6 +141,38 @@ export interface UpdateAccountSchedulerRequest { score_bias_override: number | null base_concurrency_override: number | null allowed_api_key_ids?: number[] | null + proxy_url?: string | null + tags?: string[] | null + group_ids?: number[] | null +} + +export interface AccountGroup { + id: number + name: string + description: string + color: string + sort_order: number + member_count: number + created_at: ISODateString + updated_at: ISODateString +} + +export interface AccountGroupsResponse { + groups: AccountGroup[] +} + +export interface CreateAccountGroupRequest { + name: string + description?: string + color?: string + sort_order?: number +} + +export interface UpdateAccountGroupRequest { + name?: string + description?: string + color?: string + sort_order?: number } export interface AccountModelStat { @@ -154,6 +188,7 @@ export interface AccountUsageDetail { output_tokens: number reasoning_tokens: number cached_tokens: number + cache_hit_rate: number models: AccountModelStat[] } @@ -436,18 +471,24 @@ export interface UsageStats { total_tokens: number total_prompt_tokens: number total_completion_tokens: number + total_input_tokens?: number total_cached_tokens: number + total_cache_rate?: number total_account_billed: number total_user_billed: number avg_account_billed_per_request: number avg_user_billed_per_request: number today_requests: number today_tokens: number + today_input_tokens?: number + today_cached_tokens?: number + today_cache_rate?: number today_account_billed: number today_user_billed: number rpm: number tpm: number avg_duration_ms: number + avg_first_token_ms?: number error_rate: number feature_stats: UsageFeatureStats model_stats: UsageModelStat[] @@ -593,6 +634,7 @@ export interface APIKeyRow { quota_used: number expires_at?: ISODateString | null status?: 'active' | 'expired' | 'quota_exhausted' + allowed_group_ids?: number[] created_at: ISODateString } From 537e107a9a1ea97708650b497695e8a4a19f7584 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:20:10 +0800 Subject: [PATCH 02/28] feat(apikeys): add inline rename with pencil button Add editingId/editingName state, handleRenameKey handler calling api.updateAPIKey(id, { name }), and inline Input+Check/X UI for renaming API keys directly in the table row. --- frontend/src/pages/APIKeys.tsx | 80 ++++++++++++++++++++++++++++------ 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/frontend/src/pages/APIKeys.tsx b/frontend/src/pages/APIKeys.tsx index 67557b5d..6d39346f 100644 --- a/frontend/src/pages/APIKeys.tsx +++ b/frontend/src/pages/APIKeys.tsx @@ -1,4 +1,4 @@ -import type { ChangeEvent, FormEvent, ReactNode } from 'react' +import type { ChangeEvent, FormEvent, KeyboardEvent, ReactNode } from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { api } from '../api' @@ -26,6 +26,7 @@ import { TableRow, } from '@/components/ui/table' import { + Check, Copy, CalendarClock, CircleDollarSign, @@ -34,9 +35,11 @@ import { Fingerprint, KeyRound, LockKeyhole, + Pencil, Plus, ShieldCheck, Trash2, + X, } from 'lucide-react' type ExpireMode = 'never' | '7' | '30' | '90' | 'custom' @@ -65,6 +68,9 @@ export default function APIKeys() { const [visibleKeys, setVisibleKeys] = useState>(new Set()) const [creating, setCreating] = useState(false) const [deletingIds, setDeletingIds] = useState>(new Set()) + const [editingId, setEditingId] = useState(null) + const [editingName, setEditingName] = useState('') + const [saving, setSaving] = useState(false) const { toast, showToast } = useToast() const { confirm, confirmDialog } = useConfirmDialog() @@ -207,6 +213,27 @@ export default function APIKeys() { }) } + const handleRenameKey = async (id: number) => { + const trimmed = editingName.trim() + if (!trimmed) return + setSaving(true) + try { + await api.updateAPIKey(id, { name: trimmed }) + showToast(t('apiKeys.keyRenamed')) // TODO: Track B adds i18n key apiKeys.keyRenamed + setEditingId(null) + void reload() + } catch (error) { + showToast(`${t('apiKeys.renameFailed')}: ${getErrorMessage(error)}`, 'error') // TODO: Track B adds i18n key apiKeys.renameFailed + } finally { + setSaving(false) + } + } + + const startEditing = (keyRow: APIKeyRow) => { + setEditingId(keyRow.id) + setEditingName(keyRow.name) + } + return ( -
- {keyRow.name} - {isNew ? ( - - {t('apiKeys.newBadge')} - - ) : null} - {status !== 'active' ? ( - - {t(`apiKeys.status.${status}`)} - - ) : null} -
+ {editingId === keyRow.id ? ( +
+ ) => setEditingName(e.target.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === 'Enter') void handleRenameKey(keyRow.id) + if (e.key === 'Escape') setEditingId(null) + }} + autoFocus + disabled={saving} + /> + + +
+ ) : ( +
+ {keyRow.name} + {isNew ? ( + + {t('apiKeys.newBadge')} + + ) : null} + {status !== 'active' ? ( + + {t(`apiKeys.status.${status}`)} + + ) : null} + +
+ )}
From 65dcfc5039825e39b786fcd445b8fb58623c91f8 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:20:45 +0800 Subject: [PATCH 03/28] feat(api): add account group CRUD, updateAPIKey, and cache:no-store - Add listAccountGroups, createAccountGroup, updateAccountGroup, deleteAccountGroup - Add updateAPIKey for name and allowed_group_ids - Add no-store cache to admin request function --- frontend/src/api.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 18501e38..7a969baf 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -42,6 +42,10 @@ import type { UsageLogsResponse, UsageLogsPagedResponse, UsageStats, + AccountGroup, + AccountGroupsResponse, + CreateAccountGroupRequest, + UpdateAccountGroupRequest, } from './types' const BASE = '/api/admin' @@ -100,6 +104,7 @@ async function request(path: string, options: RequestInit = {}): Promise { const res = await fetch(BASE + path, { ...options, + cache: options.cache ?? 'no-store', headers, }) @@ -201,6 +206,13 @@ export const api = { request(`/accounts/${id}/refresh`, { method: 'POST' }), updateAccountScheduler: (id: number, data: UpdateAccountSchedulerRequest) => request(`/accounts/${id}/scheduler`, { method: 'PATCH', body: JSON.stringify(data) }), + listAccountGroups: () => request('/account-groups'), + createAccountGroup: (data: CreateAccountGroupRequest) => + request<{ id: number; message: string }>('/account-groups', { method: 'POST', body: JSON.stringify(data) }), + updateAccountGroup: (id: number, data: UpdateAccountGroupRequest) => + request(`/account-groups/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteAccountGroup: (id: number, force = false) => + request(`/account-groups/${id}${force ? '?force=true' : ''}`, { method: 'DELETE' }), toggleAccountEnabled: (id: number, enabled: boolean) => request(`/accounts/${id}/enable`, { method: 'POST', body: JSON.stringify({ enabled }) }), toggleAccountLock: (id: number, locked: boolean) => @@ -311,6 +323,8 @@ export const api = { }), deleteAPIKey: (id: number) => request(`/keys/${id}`, { method: 'DELETE' }), + updateAPIKey: (id: number, data: { name?: string; allowed_group_ids?: number[] }) => + request(`/keys/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), getImagePromptTemplates: (params: { q?: string; tag?: string } = {}) => { const sp = new URLSearchParams() if (params.q) sp.set('q', params.q) From 8fcaf27150fa8bd8ea046febbd7f86c07d467891 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:21:29 +0800 Subject: [PATCH 04/28] feat(css): update font stack and add code panel typography - Prefer Cascadia Mono/Code in --font-mono stack - Add code-panel, code-panel-header, code-panel-pre, code-inline classes - Add syntax token colors and shiki wrapper styles - Add card/button transition animations --- frontend/src/index.css | 72 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 2 deletions(-) diff --git a/frontend/src/index.css b/frontend/src/index.css index de62f72a..5dda1861 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,8 +4,8 @@ @theme { --font-sans: 'Inter', 'Noto Sans SC', 'PingFang SC', 'Microsoft YaHei', ui-sans-serif, system-ui, sans-serif; - --font-mono: 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; - --font-geist-mono: 'Geist Mono', 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; + --font-mono: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; + --font-geist-mono: 'Cascadia Mono', 'Cascadia Code', Consolas, 'JetBrains Mono', 'Fira Code', 'SFMono-Regular', ui-monospace, monospace; --radius-sm: 0.25rem; --radius-md: 0.375rem; @@ -120,6 +120,74 @@ .data-table-shell [data-slot="table-row"]:hover { background: color-mix(in oklab, var(--color-muted) 58%, transparent); } + + [data-slot="card"] { + @apply transition-[border-color,box-shadow,transform,background-color] duration-200; + } + + button, + a { + @apply transition-[color,background-color,border-color,box-shadow,transform,opacity] duration-200; + } + + button:not(:disabled):active { + transform: scale(0.98); + } + + code, + pre { + font-family: var(--font-mono); + font-variant-ligatures: none; + } + + .code-panel { + @apply overflow-hidden rounded-lg border border-border bg-[hsl(222_12%_20%)] shadow-sm; + } + + .code-panel-header { + @apply flex items-center justify-between border-b border-border/80 bg-[hsl(222_11%_23%)] px-4 py-1.5; + } + + .code-panel-label { + @apply rounded-md bg-background/85 px-2.5 py-1 text-xs font-semibold text-foreground; + font-family: var(--font-sans); + } + + .code-panel-copy { + @apply inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-all duration-200 hover:bg-white/8 hover:text-foreground hover:shadow-sm active:scale-95; + } + + .code-panel-pre { + @apply overflow-x-auto p-4 text-sm leading-relaxed text-[hsl(170_18%_94%)]; + font-family: var(--font-mono); + font-variant-ligatures: none; + } + + .code-inline { + @apply rounded-md border border-border bg-muted/55 px-2 py-0.5 text-[13px] font-semibold text-foreground; + font-family: var(--font-mono); + font-variant-ligatures: none; + } + + .code-token-keyword { color: #c586c0; } + .code-token-string { color: #ce9178; } + .code-token-number { color: #b5cea8; } + .code-token-property { color: #9cdcfe; } + .code-token-command { color: #dcdcaa; } + + .shiki-wrapper pre { + margin: 0; + padding: 0; + background: transparent !important; + overflow-x: auto; + } + + .shiki-wrapper code { + font-family: var(--font-mono); + font-variant-ligatures: none; + font-size: inherit; + line-height: inherit; + } } /* 主题切换 — View Transition 圆形扩散动画 */ From 17e98e01003ee5d0a3b7bfd62116579abdcf9c4b Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:21:54 +0800 Subject: [PATCH 05/28] feat(PageHeader): add actionMeta prop for secondary header content Supports a text/meta line above the action buttons area, useful for showing context like timestamp or status info next to the header. --- frontend/src/components/PageHeader.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/PageHeader.tsx b/frontend/src/components/PageHeader.tsx index 4d602b83..24b101c3 100644 --- a/frontend/src/components/PageHeader.tsx +++ b/frontend/src/components/PageHeader.tsx @@ -9,6 +9,7 @@ interface PageHeaderProps { onRefresh?: () => void refreshLabel?: string actions?: ReactNode + actionMeta?: ReactNode } export default function PageHeader({ @@ -17,9 +18,10 @@ export default function PageHeader({ onRefresh, refreshLabel, actions, + actionMeta, }: PageHeaderProps) { const { t } = useTranslation() - const hasActions = Boolean(onRefresh) || Boolean(actions) + const hasActions = Boolean(onRefresh) || Boolean(actions) || Boolean(actionMeta) const resolvedRefreshLabel = refreshLabel ?? t('common.refresh') return ( @@ -35,14 +37,21 @@ export default function PageHeader({ ) : null}
{hasActions ? ( -
- {onRefresh ? ( - +
+ {actionMeta ? ( +
+ {actionMeta} +
) : null} - {actions} +
+ {actions} + {onRefresh ? ( + + ) : null} +
) : null}
From e713199f25a639a6f08a60c2fd5a3ae5d27610f2 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:22:32 +0800 Subject: [PATCH 06/28] feat(ChipInput): add reusable multi-select chip input component Supports free-text tag entry (Enter/comma to add), select-from-list mode with options prop, chips with X to remove, and overflow "+N" badge. --- frontend/src/components/ChipInput.tsx | 172 ++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 frontend/src/components/ChipInput.tsx diff --git a/frontend/src/components/ChipInput.tsx b/frontend/src/components/ChipInput.tsx new file mode 100644 index 00000000..61155747 --- /dev/null +++ b/frontend/src/components/ChipInput.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ChangeEvent } from 'react' +import { X, ChevronDown } from 'lucide-react' + +export interface ChipInputProps { + value: string[] + onChange: (next: string[]) => void + /** Pre-defined options for select-from-list mode */ + options?: string[] + placeholder?: string + disabled?: boolean + maxVisible?: number + className?: string +} + +/** + * Reusable multi-select chip input supporting: + * - Free-text tag entry (type + Enter/comma to add) + * - Select-from-list mode (with options prop) + * - Chips with X to remove + * - Max N visible chips + "+N" overflow badge + */ +export default function ChipInput({ + value, + onChange, + options, + placeholder = '', + disabled = false, + maxVisible = 3, + className = '', +}: ChipInputProps) { + const [draft, setDraft] = useState('') + const [showDropdown, setShowDropdown] = useState(false) + const inputRef = useRef(null) + const containerRef = useRef(null) + + const hasOptions = Array.isArray(options) && options.length > 0 + + const availableOptions = useMemo(() => { + if (!hasOptions) return [] + const selected = new Set(value.map(v => v.toLowerCase())) + return options!.filter(opt => !selected.has(opt.toLowerCase())) + }, [hasOptions, options, value]) + + const addChip = useCallback((tag: string) => { + const trimmed = tag.trim() + if (!trimmed) return + const lower = trimmed.toLowerCase() + if (value.some(v => v.toLowerCase() === lower)) return + onChange([...value, trimmed]) + setDraft('') + setShowDropdown(false) + }, [value, onChange]) + + const removeChip = useCallback((index: number) => { + const next = [...value] + next.splice(index, 1) + onChange(next) + }, [value, onChange]) + + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if (disabled) return + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + if (draft.trim()) { + addChip(draft) + } + } else if (e.key === 'Backspace' && !draft && value.length > 0) { + removeChip(value.length - 1) + } + }, [disabled, draft, addChip, removeChip, value.length]) + + const handleChange = useCallback((e: ChangeEvent) => { + const v = e.target.value + if (v.includes(',')) { + const parts = v.split(',') + for (let i = 0; i < parts.length - 1; i++) { + if (parts[i].trim()) addChip(parts[i]) + } + setDraft(parts[parts.length - 1]) + } else { + setDraft(v) + } + if (hasOptions) setShowDropdown(true) + }, [addChip, hasOptions]) + + // Close dropdown on outside click + useEffect(() => { + if (!showDropdown) return + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowDropdown(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [showDropdown]) + + const visibleChips = value.slice(0, maxVisible) + const overflowCount = value.length - maxVisible + + return ( +
+
inputRef.current?.focus()} + > + {visibleChips.map((chip, i) => ( + + {chip} + {!disabled && ( + + )} + + ))} + {overflowCount > 0 && ( + + +{overflowCount} + + )} + { if (hasOptions) setShowDropdown(true) }} + placeholder={value.length === 0 ? placeholder : ''} + disabled={disabled} + className="flex-1 min-w-[80px] bg-transparent outline-none text-sm placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {hasOptions && ( + + )} +
+ + {/* Dropdown for select-from-list mode */} + {hasOptions && showDropdown && availableOptions.length > 0 && ( +
+ {availableOptions.map((opt) => ( + + ))} +
+ )} +
+ ) +} From 567592bde589676c8fe496b413ab0dd6ed37387d Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:22:41 +0800 Subject: [PATCH 07/28] feat(proxies): add edit dialog + concurrent test with progress - Edit dialog with URL/label inputs and api.updateProxy call - Concurrent proxy testing (TEST_ALL_CONCURRENCY=4) with progress display showing done/total/failed counts - Error reporting via toast for all batch operations - Pencil edit button in each proxy table row --- frontend/src/pages/Proxies.tsx | 181 ++++++++++++++++++++++++++++++--- 1 file changed, 165 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/Proxies.tsx b/frontend/src/pages/Proxies.tsx index 90790383..625cea6d 100644 --- a/frontend/src/pages/Proxies.tsx +++ b/frontend/src/pages/Proxies.tsx @@ -1,10 +1,23 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' -import { Globe, Plus, Trash2, Play, MapPin, Loader2, Zap, ChevronLeft, ChevronRight, Eye, EyeOff } from 'lucide-react' +import { Globe, Plus, Trash2, Play, MapPin, Loader2, Zap, ChevronLeft, ChevronRight, Eye, EyeOff, AlertTriangle, Pencil } from 'lucide-react' import { Card, CardContent } from '@/components/ui/card' import { api, type ProxyRow, type ProxyTestResult } from '../api' +import ToastNotice from '../components/ToastNotice' +import { useToast } from '../hooks/useToast' +import { getErrorMessage } from '../utils/error' const PAGE_SIZE = 10 +const TEST_ALL_CONCURRENCY = 4 + +function validateProxyInput(url: string): boolean { + try { + const parsed = new URL(url) + return Boolean(parsed.hostname) && ['http:', 'https:', 'socks5:', 'socks5h:'].includes(parsed.protocol) + } catch { + return false + } +} function latencyColor(ms: number): string { if (ms <= 0) return 'text-muted-foreground' @@ -33,6 +46,7 @@ function maskUrl(url: string): string { export default function Proxies() { const { t, i18n } = useTranslation() + const { toast, showToast } = useToast() const [proxies, setProxies] = useState([]) const [loading, setLoading] = useState(true) const [poolEnabled, setPoolEnabled] = useState(false) @@ -43,8 +57,15 @@ export default function Proxies() { const [selected, setSelected] = useState>(new Set()) const [testingIds, setTestingIds] = useState>(new Set()) const [testAllLoading, setTestAllLoading] = useState(false) + const [testAllDone, setTestAllDone] = useState(0) + const [testAllFailed, setTestAllFailed] = useState(0) const [page, setPage] = useState(1) const [revealedIds, setRevealedIds] = useState>(new Set()) + const [editingProxy, setEditingProxy] = useState(null) + const [editUrl, setEditUrl] = useState('') + const [editLabel, setEditLabel] = useState('') + const [editSaving, setEditSaving] = useState(false) + const [editError, setEditError] = useState('') const ipApiLang = i18n.language?.startsWith('zh') ? 'zh-CN' : 'en' @@ -53,9 +74,11 @@ export default function Proxies() { const [proxyRes, settingsRes] = await Promise.all([api.listProxies(), api.getSettings()]) setProxies(proxyRes.proxies) setPoolEnabled(settingsRes.proxy_pool_enabled) - } catch { /* ignore */ } + } catch (error) { + showToast(t('proxies.loadFailed', { error: getErrorMessage(error) }), 'error') // TODO: Track B adds i18n key + } setLoading(false) - }, []) + }, [showToast, t]) useEffect(() => { reload() }, [reload]) @@ -86,7 +109,9 @@ export default function Proxies() { setAddLabel('') setShowAdd(false) await reload() - } catch { /* ignore */ } + } catch (error) { + showToast(t('proxies.addFailed', { error: getErrorMessage(error) }), 'error') // TODO: Track B adds i18n key + } setAddLoading(false) } @@ -94,7 +119,9 @@ export default function Proxies() { try { await api.deleteProxy(id) await reload() - } catch { /* ignore */ } + } catch (error) { + showToast(t('proxies.deleteFailed', { error: getErrorMessage(error) }), 'error') // TODO: Track B adds i18n key + } } const handleBatchDelete = async () => { @@ -103,7 +130,37 @@ export default function Proxies() { await api.batchDeleteProxies([...selected]) setSelected(new Set()) await reload() - } catch { /* ignore */ } + } catch (error) { + showToast(t('proxies.batchDeleteFailed', { error: getErrorMessage(error) }), 'error') // TODO: Track B adds i18n key + } + } + + const startEdit = (p: ProxyRow) => { + setEditingProxy(p) + setEditUrl(p.url) + setEditLabel(p.label || '') + setEditError('') + } + + const handleEditSave = async () => { + if (!editingProxy) return + const trimmedUrl = editUrl.trim() + if (!trimmedUrl || !validateProxyInput(trimmedUrl)) { + setEditError(t('proxies.invalidProxyUrl')) // TODO: Track B adds i18n key + return + } + setEditSaving(true) + setEditError('') + try { + await api.updateProxy(editingProxy.id, { url: trimmedUrl, label: editLabel.trim() || undefined }) + setEditingProxy(null) + await reload() + showToast(t('proxies.proxyUpdated')) // TODO: Track B adds i18n key + } catch (error) { + setEditError(getErrorMessage(error)) + } finally { + setEditSaving(false) + } } const handleToggle = async (p: ProxyRow) => { @@ -124,7 +181,9 @@ export default function Proxies() { : px )) } - } catch { /* ignore */ } + } catch (error) { + showToast(t('proxies.testFailed', { error: getErrorMessage(error) }), 'error') // TODO: Track B adds i18n key + } setTestingIds(prev => { const next = new Set(prev) next.delete(p.id) @@ -134,7 +193,13 @@ export default function Proxies() { const handleTestAll = async () => { setTestAllLoading(true) - for (const p of proxies) { + setTestAllDone(0) + setTestAllFailed(0) + let failedCount = 0 + let firstError = '' + let nextIndex = 0 + const queue = [...proxies] + const testOne = async (p: ProxyRow) => { setTestingIds(prev => new Set(prev).add(p.id)) try { const result = await api.testProxy(p.url, p.id, ipApiLang) @@ -145,12 +210,33 @@ export default function Proxies() { : px )) } - } catch { /* ignore */ } - setTestingIds(prev => { - const next = new Set(prev) - next.delete(p.id) - return next - }) + } catch (error) { + failedCount += 1 + setTestAllFailed(failedCount) + if (!firstError) firstError = getErrorMessage(error) + } finally { + setTestAllDone(prev => prev + 1) + setTestingIds(prev => { + const next = new Set(prev) + next.delete(p.id) + return next + }) + } + } + + const worker = async () => { + for (;;) { + const current = nextIndex + nextIndex += 1 + const proxy = queue[current] + if (!proxy) return + await testOne(proxy) + } + } + + await Promise.all(Array.from({ length: Math.min(TEST_ALL_CONCURRENCY, queue.length) }, worker)) + if (failedCount > 0) { + showToast(t('proxies.testAllFailed', { count: failedCount, error: firstError }), 'error') // TODO: Track B adds i18n key } setTestAllLoading(false) } @@ -224,7 +310,9 @@ export default function Proxies() { className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-muted/50 disabled:opacity-50" > {testAllLoading ? : } - {testAllLoading ? t('proxies.testingAll') : t('proxies.testAll')} + {testAllLoading + ? t('proxies.testingAllProgress', { done: testAllDone, total: proxies.length, failed: testAllFailed }) // TODO: Track B adds i18n key + : t('proxies.testAll')} )} @@ -411,6 +499,13 @@ export default function Proxies() {
+ + +
+ + + + )} + + ) } From 6daf41c3be0a852c2cb912c83d7ffdeca570eae8 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:23:42 +0800 Subject: [PATCH 08/28] feat(Dashboard): replace inline stats with UsageStatsSummary component - Create UsageStatsSummary with Flow/Token/Cache/Health metric groups - Health uses first-token latency as primary metric - Token billing shows "Today: $X / Total: $X" format - Remove noisy "0% availability" subtext from available StatCard - Cache labels: today cache hit rate, today cached tokens, total cache hit rate --- frontend/src/components/UsageStatsSummary.tsx | 136 ++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 38 +---- 2 files changed, 139 insertions(+), 35 deletions(-) create mode 100644 frontend/src/components/UsageStatsSummary.tsx diff --git a/frontend/src/components/UsageStatsSummary.tsx b/frontend/src/components/UsageStatsSummary.tsx new file mode 100644 index 00000000..4ac6b18d --- /dev/null +++ b/frontend/src/components/UsageStatsSummary.tsx @@ -0,0 +1,136 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { BarChart3, Clock, Gauge, Zap } from 'lucide-react' +import type { UsageStats } from '../types' +import { Card, CardContent } from '@/components/ui/card' + +interface UsageStatsSummaryProps { + stats: UsageStats + className?: string +} + +export default function UsageStatsSummary({ stats, className = '' }: UsageStatsSummaryProps) { + const { t, i18n } = useTranslation() + const locale = i18n.language + + return ( + + +

{t('dashboard.usageStats')}

+
+ } + iconBg="bg-blue-500/10 text-blue-500" + title={t('dashboard.trafficGroup')} + primaryLabel={t('dashboard.todayRequests')} + primaryValue={formatInteger(stats.today_requests, locale)} + > + + + + + } + iconBg="bg-purple-500/10 text-purple-500" + title={t('dashboard.tokenGroup')} + primaryLabel={t('dashboard.todayTokens')} + primaryValue={formatInteger(stats.today_tokens, locale)} + > + + + + + } + iconBg="bg-teal-500/10 text-teal-500" + title={t('dashboard.cacheGroup')} + primaryLabel={t('dashboard.todayCacheHitRate')} + primaryValue={formatPercent(stats.today_cache_rate ?? 0)} + > + + + + + } + iconBg="bg-cyan-500/10 text-cyan-500" + title={t('dashboard.healthGroup')} + primaryLabel={t('dashboard.avgFirstTokenLatency')} + primaryValue={formatLatency(stats.avg_first_token_ms)} + > + + 1 ? 'danger' : 'default'} /> + +
+
+
+ ) +} + +function MetricGroup({ + icon, + iconBg, + title, + primaryLabel, + primaryValue, + children, +}: { + icon: ReactNode + iconBg: string + title: string + primaryLabel: string + primaryValue: string + children: ReactNode +}) { + return ( +
+
+ +
+
{title}
+
{primaryLabel}
+
+
+
+ {primaryValue} +
+
+ {children} +
+
+ ) +} + +function MetricLine({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) { + return ( +
+ {label} + + {value} + +
+ ) +} + +function formatInteger(value: number, locale: string): string { + return Math.round(value).toLocaleString(locale) +} + +function formatPercent(value: number): string { + return `${value.toFixed(value >= 10 ? 1 : 2)}%` +} + +function formatLatency(value?: number): string { + const ms = value ?? 0 + if (ms <= 0) return '-' + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s` + return `${Math.round(ms)}ms` +} + +function formatMoney(value: number): string { + if (value >= 100) return `$${value.toLocaleString(undefined, { maximumFractionDigits: 1 })}` + if (value >= 1) return `$${value.toFixed(2)}` + return `$${value.toFixed(4)}` +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b62135d9..bde18720 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -6,10 +6,11 @@ import { getTimeRangeISO, getBucketConfig, type TimeRangeKey } from '../lib/time import PageHeader from '../components/PageHeader' import StateShell from '../components/StateShell' import StatCard from '../components/StatCard' +import UsageStatsSummary from '../components/UsageStatsSummary' import type { StatsResponse, UsageStats, ChartAggregation } from '../types' import { useDataLoader } from '../hooks/useDataLoader' import { Card, CardContent } from '@/components/ui/card' -import { Users, CheckCircle, XCircle, Activity, Zap, Clock, AlertTriangle, BarChart3, Database } from 'lucide-react' +import { Users, CheckCircle, XCircle, Activity } from 'lucide-react' const DashboardUsageCharts = lazy(() => import('../components/DashboardUsageCharts')) @@ -145,7 +146,6 @@ export default function Dashboard() { iconClass="green" label={t('dashboard.available')} value={available} - sub={t('dashboard.availableRate', { rate: total ? Math.round((available / total) * 100) : 0 })} /> @@ -154,25 +154,7 @@ export default function Dashboard() { {/* Usage stats */} {usageStats && (
- - -

{t('dashboard.usageStats')}

-
- } iconBg="bg-blue-500/10 text-blue-500" label={t('dashboard.totalRequests')} value={usageStats.total_requests.toLocaleString()} /> - } iconBg="bg-purple-500/10 text-purple-500" label={t('dashboard.totalTokens')} value={usageStats.total_tokens.toLocaleString()} /> - } iconBg="bg-emerald-500/10 text-emerald-500" label={t('dashboard.todayTokens')} value={usageStats.today_tokens.toLocaleString()} /> - } iconBg="bg-indigo-500/10 text-indigo-500" label={t('dashboard.cachedTokens')} value={usageStats.total_cached_tokens.toLocaleString()} /> - } iconBg="bg-amber-500/10 text-amber-500" label={t('dashboard.rpmTpm')} value={`${usageStats.rpm} / ${usageStats.tpm.toLocaleString()}`} /> - } - iconBg="bg-cyan-500/10 text-cyan-500" - label={t('dashboard.avgLatency')} - value={usageStats.avg_duration_ms > 1000 ? `${(usageStats.avg_duration_ms / 1000).toFixed(1)}s` : `${Math.round(usageStats.avg_duration_ms)}ms`} - /> - } iconBg="bg-red-500/10 text-red-500" label={t('dashboard.todayErrorRate')} value={`${usageStats.error_rate.toFixed(1)}%`} /> -
-
-
+ }> ) } - -function StatItem({ icon, iconBg, label, value }: { icon: ReactNode; iconBg: string; label: string; value: string }) { - return ( -
-
- {icon} -
-
-
{label}
-
{value}
-
-
- ) -} From 82b2fa350638395592a59d959e816dd7f4f27e68 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:24:33 +0800 Subject: [PATCH 09/28] feat(api): add updateAPIKey and extend updateProxy with url param Minimal additions to api.ts required by frontend-pages features: - updateAPIKey(id, { name?, allowed_group_ids? }) for API key rename - updateProxy now accepts url parameter for proxy edit dialog --- frontend/src/api.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 18501e38..080beb1b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -311,6 +311,8 @@ export const api = { }), deleteAPIKey: (id: number) => request(`/keys/${id}`, { method: 'DELETE' }), + updateAPIKey: (id: number, data: { name?: string; allowed_group_ids?: number[] }) => + request(`/keys/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), getImagePromptTemplates: (params: { q?: string; tag?: string } = {}) => { const sp = new URLSearchParams() if (params.q) sp.set('q', params.q) @@ -424,7 +426,7 @@ export const api = { request<{ message: string; inserted: number; total: number }>('/proxies', { method: 'POST', body: JSON.stringify(data) }), deleteProxy: (id: number) => request(`/proxies/${id}`, { method: 'DELETE' }), - updateProxy: (id: number, data: { label?: string; enabled?: boolean }) => + updateProxy: (id: number, data: { url?: string; label?: string; enabled?: boolean }) => request(`/proxies/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), batchDeleteProxies: (ids: number[]) => request<{ message: string; deleted: number }>('/proxies/batch-delete', { method: 'POST', body: JSON.stringify({ ids }) }), From 83f9c6ed8e9c2ff953ada1ddcfc39d9031b51566 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:35:24 +0800 Subject: [PATCH 10/28] feat(usage): add configurable request log columns --- frontend/src/pages/Usage.tsx | 201 ++++++++++++++++++++++++++++------- 1 file changed, 162 insertions(+), 39 deletions(-) diff --git a/frontend/src/pages/Usage.tsx b/frontend/src/pages/Usage.tsx index 8046e56e..be1270ee 100644 --- a/frontend/src/pages/Usage.tsx +++ b/frontend/src/pages/Usage.tsx @@ -25,7 +25,7 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { Activity, Box, Clock, Zap, AlertTriangle, Search, Brain, DatabaseZap, X, Image as ImageIcon, Info, CircleDollarSign, BarChart3, KeyRound, Route } from 'lucide-react' +import { Activity, Box, Clock, Zap, AlertTriangle, Search, Brain, DatabaseZap, X, Image as ImageIcon, Info, CircleDollarSign, BarChart3, KeyRound, Route, SlidersHorizontal } from 'lucide-react' import { Input } from '@/components/ui/input' import { Select } from '@/components/ui/select' @@ -650,11 +650,119 @@ function StatusCodeBadge({ log }: { log: UsageLog }) { const usageTableHeadClass = 'text-[12px] font-semibold' const usageTableTextClass = 'text-[14px]' -const usageTableMonoClass = 'font-geist-mono text-[13px] tabular-nums' +const usageTableMonoClass = 'font-mono text-[13px] tabular-nums' const usageTableBadgeClass = 'text-[13px]' const modelPieColors = ['#2563eb', '#059669', '#f59e0b', '#dc2626', '#7c3aed', '#0891b2', '#db2777'] const modelPieShellClass = 'flex min-h-[196px] flex-col border-l border-border pl-4 max-lg:min-h-0 max-lg:border-l-0 max-lg:border-t max-lg:pl-0 max-lg:pt-3' +type UsageTableColumn = 'status' | 'model' | 'account' | 'apiKey' | 'endpoint' | 'type' | 'token' | 'cost' | 'cached' | 'firstToken' | 'duration' | 'time' + +const USAGE_COLUMN_DEFINITIONS: Array<{ key: UsageTableColumn; labelKey: string }> = [ + { key: 'status', labelKey: 'usage.tableStatus' }, + { key: 'model', labelKey: 'usage.tableModel' }, + { key: 'account', labelKey: 'usage.tableAccount' }, + { key: 'apiKey', labelKey: 'usage.tableApiKey' }, + { key: 'endpoint', labelKey: 'usage.tableEndpoint' }, + { key: 'type', labelKey: 'usage.tableType' }, + { key: 'token', labelKey: 'usage.tableToken' }, + { key: 'cost', labelKey: 'usage.tableCost' }, + { key: 'cached', labelKey: 'usage.tableCached' }, + { key: 'firstToken', labelKey: 'usage.tableFirstToken' }, + { key: 'duration', labelKey: 'usage.tableDuration' }, + { key: 'time', labelKey: 'usage.tableTime' }, +] + +const USAGE_VISIBLE_COLUMNS_KEY = 'codex2api:usage:visible-columns' +const DEFAULT_USAGE_VISIBLE_COLUMNS: Record = { + status: true, + model: true, + account: true, + apiKey: true, + endpoint: true, + type: true, + token: true, + cost: true, + cached: true, + firstToken: true, + duration: true, + time: true, +} + +function getInitialUsageVisibleColumns(): Record { + try { + const stored = localStorage.getItem(USAGE_VISIBLE_COLUMNS_KEY) + if (stored) { + const parsed = JSON.parse(stored) + if (parsed && typeof parsed === 'object') { + const defaults: Record = { ...DEFAULT_USAGE_VISIBLE_COLUMNS } + for (const key of Object.keys(defaults) as UsageTableColumn[]) { + if (key in parsed) defaults[key] = Boolean(parsed[key]) + } + return defaults + } + } + } catch { /* ignore */ } + return { ...DEFAULT_USAGE_VISIBLE_COLUMNS } +} + +function persistUsageVisibleColumns(columns: Record) { + try { localStorage.setItem(USAGE_VISIBLE_COLUMNS_KEY, JSON.stringify(columns)) } catch { /* ignore */ } +} + +function ColumnSettingsDropdown({ + open, + columns, + onOpenChange, + onToggle, +}: { + open: boolean + columns: Record + onOpenChange: (open: boolean) => void + onToggle: (key: UsageTableColumn) => void +}) { + const { t } = useTranslation() + + return ( +
+ + {open ? ( +
+
+ {t('accounts.columnSettings', { defaultValue: 'Columns' })} +
+ {USAGE_COLUMN_DEFINITIONS.map((column) => ( + + ))} +
+ ) : null} +
+ ) +} + export default function Usage() { const { t } = useTranslation() const { toast, showToast } = useToast() @@ -679,6 +787,8 @@ export default function Usage() { const showFastFilter = true const pageSizeOptions = [10, 20, 50, 100] const searchTimer = useRef>(null) + const [visibleColumns, setVisibleColumns] = useState>(getInitialUsageVisibleColumns) + const [columnSettingsOpen, setColumnSettingsOpen] = useState(false) // 搜索防抖:输入停止 400ms 后触发查询 const handleSearchChange = useCallback((value: string) => { @@ -773,6 +883,10 @@ export default function Usage() { return () => window.clearInterval(timer) }, [reloadSilently]) + useEffect(() => { + persistUsageVisibleColumns(visibleColumns) + }, [visibleColumns]) + const { stats } = data const totalPages = Math.max(1, Math.ceil(logsTotal / pageSize)) const currentPage = Math.min(page, totalPages) @@ -1094,6 +1208,15 @@ export default function Usage() { {t('usage.clearFilters')} )} + +
+ setVisibleColumns((current) => ({ ...current, [key]: !current[key] }))} + /> +
- {t('usage.tableStatus')} - {t('usage.tableModel')} - {t('usage.tableAccount')} - {t('usage.tableApiKey')} - {t('usage.tableEndpoint')} - {t('usage.tableType')} - {t('usage.tableToken')} - {t('usage.tableCost')} - {t('usage.tableCached')} - {t('usage.tableFirstToken')} - {t('usage.tableDuration')} - {t('usage.tableTime')} + {visibleColumns.status && {t('usage.tableStatus')}} + {visibleColumns.model && {t('usage.tableModel')}} + {visibleColumns.account && {t('usage.tableAccount')}} + {visibleColumns.apiKey && {t('usage.tableApiKey')}} + {visibleColumns.endpoint && {t('usage.tableEndpoint')}} + {visibleColumns.type && {t('usage.tableType')}} + {visibleColumns.token && {t('usage.tableToken')}} + {visibleColumns.cost && {t('usage.tableCost')}} + {visibleColumns.cached && {t('usage.tableCached')}} + {visibleColumns.firstToken && {t('usage.tableFirstToken')}} + {visibleColumns.duration && {t('usage.tableDuration')}} + {visibleColumns.time && {t('usage.tableTime')}} {logs.map((log: UsageLog) => { return ( - + {visibleColumns.status && - - + } + {visibleColumns.model &&
{log.model || '-'} @@ -1165,16 +1288,16 @@ export default function Usage() { )}
-
- + } + {visibleColumns.account && {formatCompactEmail(log.account_email)} - - - + } + {visibleColumns.apiKey && + {formatUsageAPIKeyLabel(log.api_key_name, log.api_key_masked) || t('usage.unknownApiKey')} - - + } + {visibleColumns.endpoint &&
{log.inbound_endpoint || log.endpoint || '-'} @@ -1183,8 +1306,8 @@ export default function Usage() { → {log.upstream_endpoint} )}
-
- + } + {visibleColumns.type && {log.stream ? 'stream' : 'sync'} - - + } + {visibleColumns.token && {log.status_code < 400 && (log.input_tokens > 0 || log.output_tokens > 0) ? (
↓{formatTokens(log.input_tokens)} @@ -1213,11 +1336,11 @@ export default function Usage() { ) : ( - )} - - + } + {visibleColumns.cost && - - + } + {visibleColumns.cached && {log.cached_tokens > 0 ? ( @@ -1226,22 +1349,22 @@ export default function Usage() { ) : ( - )} - - + } + {visibleColumns.firstToken && {log.first_token_ms > 0 ? ( 5000 ? 'text-red-500' : log.first_token_ms > 2000 ? 'text-amber-500' : 'text-emerald-500'}`}> {log.first_token_ms > 1000 ? `${(log.first_token_ms / 1000).toFixed(1)}s` : `${log.first_token_ms}ms`} ) : -} - - + } + {visibleColumns.duration && 30000 ? 'text-red-500' : log.duration_ms > 10000 ? 'text-amber-500' : 'text-muted-foreground'}`}> {log.duration_ms > 1000 ? `${(log.duration_ms / 1000).toFixed(1)}s` : `${log.duration_ms}ms`} - - + } + {visibleColumns.time && {formatBeijingTime(log.created_at)} - + } ) })} From ae4cc095c678e0e6265aa731f912c6e9829cc35c Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:37:31 +0800 Subject: [PATCH 11/28] feat(accounts): add tags groups and column controls --- frontend/src/locales/en.json | 55 +++++- frontend/src/locales/zh.json | 55 +++++- frontend/src/pages/Accounts.tsx | 335 +++++++++++++++++++++++++++----- 3 files changed, 389 insertions(+), 56 deletions(-) diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 4dc3a9de..1ace3f64 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -107,6 +107,17 @@ "rpmTpm": "RPM / TPM", "avgLatency": "Avg Latency", "todayErrorRate": "Error Rate (Today)", + "trafficGroup": "Traffic", + "tokenGroup": "Token", + "healthGroup": "Health", + "cacheGroup": "Cache", + "billing": "Billing", + "totalCostShort": "Total", + "todayCacheHitRate": "Today's Cache Hit Rate", + "todayCachedTokens": "Today's Cached Tokens", + "totalCacheHitRate": "Total Cache Hit Rate", + "avgFirstTokenLatency": "First Token Latency", + "avgCompletionLatency": "Completion Latency", "usageCharts": "Usage Trends", "usageChartsDesc": "Auto-aggregated from {{count}} logs in the selected time range.", "liveBadge": "Live", @@ -380,6 +391,37 @@ "allowedAPIKeysPlaceholder": "Choose which tokens may use this account", "allowedAPIKeysNoOptions": "No API keys yet", "allowedAPIKeysNoOptionsHint": "No API keys have been created, so this account currently allows all callers.", + "tagsLabel": "Tags", + "tagsHint": "Add searchable free-text labels to this account.", + "tagsPlaceholder": "Type a tag and press Enter", + "tagsFilter": "All tags", + "groupsLabel": "Account Groups", + "groupsHint": "Assign groups for API key routing limits.", + "groupsPlaceholder": "Select groups", + "groupsFilter": "All groups", + "groupsNone": "No account groups", + "groupManage": "Manage Groups", + "groupManageTitle": "Account Group Management", + "groupCreate": "Create Group", + "groupCreateTitle": "Create Account Group", + "groupName": "Group Name", + "groupNamePlaceholder": "e.g. Pro Pool", + "groupDescription": "Description", + "groupDescriptionPlaceholder": "Optional description", + "groupColor": "Color", + "groupColorPlaceholder": "#2563eb", + "groupMembers": "Members", + "groupEmpty": "No groups yet", + "groupEmptyDesc": "Create groups, then assign them in account editing.", + "groupCreated": "Group created", + "groupUpdated": "Group updated", + "groupDeleted": "Group deleted", + "groupDeleteTitle": "Delete Account Group", + "groupDeleteDesc": "Accounts will be removed from this group.", + "groupDeleteWithMembers": "This group still has accounts. Force delete it?", + "groupDeleteEmpty": "Delete this empty group?", + "groupDeleteForce": "Force delete", + "columnSettings": "Columns", "schedulerPreviewTitle": "Current Scheduler Preview", "schedulerPreviewRawScore": "Raw Score", "schedulerPreviewDispatchScore": "Dispatch Score", @@ -1114,7 +1156,18 @@ "quota_exhausted": "Quota exhausted" }, "showKey": "Show key", - "hideKey": "Hide key" + "hideKey": "Hide key", + "allowedGroups": "Allowed Groups", + "allowedGroupsHint": "Leaving this empty allows the key to use all account groups.", + "allowedGroupsLabel": "Allowed Account Groups", + "allowedGroupsPlaceholder": "Choose allowed groups", + "allowedGroupsAll": "All groups", + "allowedGroupsNone": "No group limit", + "allowedGroupsSaved": "Allowed groups saved", + "allowedGroupsSaveFailed": "Failed to save allowed groups", + "renameKey": "Rename key", + "keyRenamed": "Key renamed", + "renameFailed": "Rename failed" }, "nav2": { "docs": "Docs", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index bc7149ee..9ef94f84 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -107,6 +107,17 @@ "rpmTpm": "RPM / TPM", "avgLatency": "平均延迟", "todayErrorRate": "今日错误率", + "trafficGroup": "流量", + "tokenGroup": "Token", + "healthGroup": "健康", + "cacheGroup": "缓存", + "billing": "计费", + "totalCostShort": "总计", + "todayCacheHitRate": "今日缓存命中率", + "todayCachedTokens": "今日缓存 Token", + "totalCacheHitRate": "总缓存命中率", + "avgFirstTokenLatency": "首字延迟", + "avgCompletionLatency": "完成延迟", "usageCharts": "使用趋势", "usageChartsDesc": "基于选定时间范围内 {{count}} 条请求日志自动聚合。", "liveBadge": "实时中", @@ -380,6 +391,37 @@ "allowedAPIKeysPlaceholder": "选择允许调用的 Token", "allowedAPIKeysNoOptions": "暂无 API Key", "allowedAPIKeysNoOptionsHint": "当前未创建 API Key,因此该账号默认允许全部调用。", + "tagsLabel": "标签", + "tagsHint": "为账号添加可搜索的自由文本标签。", + "tagsPlaceholder": "输入标签后按 Enter", + "tagsFilter": "全部标签", + "groupsLabel": "账号分组", + "groupsHint": "限制账号所属分组,用于 API Key 调度范围。", + "groupsPlaceholder": "选择分组", + "groupsFilter": "全部分组", + "groupsNone": "暂无账号分组", + "groupManage": "管理分组", + "groupManageTitle": "账号分组管理", + "groupCreate": "创建分组", + "groupCreateTitle": "创建账号分组", + "groupName": "分组名称", + "groupNamePlaceholder": "例如 Pro 池", + "groupDescription": "分组说明", + "groupDescriptionPlaceholder": "可选说明", + "groupColor": "颜色", + "groupColorPlaceholder": "#2563eb", + "groupMembers": "成员", + "groupEmpty": "暂无分组", + "groupEmptyDesc": "创建分组后可在账号编辑中分配。", + "groupCreated": "分组已创建", + "groupUpdated": "分组已更新", + "groupDeleted": "分组已删除", + "groupDeleteTitle": "删除账号分组", + "groupDeleteDesc": "删除后账号会从该分组移除。", + "groupDeleteWithMembers": "该分组仍有账号,确认强制删除?", + "groupDeleteEmpty": "确认删除该空分组?", + "groupDeleteForce": "强制删除", + "columnSettings": "列设置", "schedulerPreviewTitle": "当前调度预览", "schedulerPreviewRawScore": "原始分", "schedulerPreviewDispatchScore": "当前调度分", @@ -1114,7 +1156,18 @@ "quota_exhausted": "额度耗尽" }, "showKey": "显示密钥", - "hideKey": "隐藏密钥" + "hideKey": "隐藏密钥", + "allowedGroups": "允许分组", + "allowedGroupsHint": "不选择表示该密钥可使用全部账号分组。", + "allowedGroupsLabel": "允许账号分组", + "allowedGroupsPlaceholder": "选择允许调用的分组", + "allowedGroupsAll": "全部分组", + "allowedGroupsNone": "不限制分组", + "allowedGroupsSaved": "允许分组已保存", + "allowedGroupsSaveFailed": "允许分组保存失败", + "renameKey": "重命名密钥", + "keyRenamed": "密钥已重命名", + "renameFailed": "重命名失败" }, "nav2": { "docs": "使用文档", diff --git a/frontend/src/pages/Accounts.tsx b/frontend/src/pages/Accounts.tsx index 7a632d7a..33345f95 100644 --- a/frontend/src/pages/Accounts.tsx +++ b/frontend/src/pages/Accounts.tsx @@ -10,7 +10,7 @@ import ToastNotice from '../components/ToastNotice' import { useDataLoader } from '../hooks/useDataLoader' import { useConfirmDialog } from '../hooks/useConfirmDialog' import { useToast } from '../hooks/useToast' -import type { AccountRow, AddAccountRequest, AddATAccountRequest, AddOpenAIResponsesAccountRequest, UpdateOpenAIResponsesAccountRequest, APIKeyRow, OpsOverviewResponse } from '../types' +import type { AccountRow, AddAccountRequest, AddATAccountRequest, AddOpenAIResponsesAccountRequest, UpdateOpenAIResponsesAccountRequest, APIKeyRow, OpsOverviewResponse, AccountGroup } from '../types' import { getErrorMessage } from '../utils/error' import { formatCompactEmail } from '../lib/utils' import { formatRelativeTime, formatBeijingTime } from '../utils/time' @@ -26,15 +26,39 @@ import { TableHeader, TableRow, } from '@/components/ui/table' -import { Plus, RefreshCw, Trash2, Zap, FlaskConical, Ban, Timer, AlertTriangle, Upload, Download, ArrowDownToLine, KeyRound, ExternalLink, FileText, FileJson, BarChart3, Search, Fingerprint, FolderOpen, Lock, Unlock, RotateCcw, Pencil, Check, ChevronDown, Copy, Power, PowerOff, Hourglass, X } from 'lucide-react' +import { Plus, RefreshCw, Trash2, Zap, FlaskConical, Ban, Timer, AlertTriangle, Upload, Download, ArrowDownToLine, KeyRound, ExternalLink, FileText, FileJson, BarChart3, Search, Fingerprint, FolderOpen, Lock, Unlock, RotateCcw, Pencil, Check, ChevronDown, Copy, Power, PowerOff, Hourglass, X, SlidersHorizontal } from 'lucide-react' import { useTranslation } from 'react-i18next' import AccountUsageModal from '../components/AccountUsageModal' import AccountQuotaDistributionChart from '../components/AccountQuotaDistributionChart' import AccountRateLimitRecoveryChart from '../components/AccountRateLimitRecoveryChart' +import ChipInput from '../components/ChipInput' const ACCOUNT_BATCH_CONCURRENCY = 6 const ACCOUNT_REFRESH_BATCH_CONCURRENCY = 4 const ACCOUNT_ANALYSIS_VISIBILITY_KEY = 'codex2api:accounts:analysis-visible' +const ACCOUNT_VISIBLE_COLUMNS_KEY = 'codex2api:accounts:visible-columns' +const ACCOUNT_TABLE_COLUMNS = ['sequence', 'email', 'plan', 'status', 'requests', 'usage', 'importTime', 'updatedAt', 'actions'] as const +type AccountTableColumn = typeof ACCOUNT_TABLE_COLUMNS[number] + +function getInitialAccountVisibleColumns(): Record { + const fallback = Object.fromEntries(ACCOUNT_TABLE_COLUMNS.map((column) => [column, true])) as Record + try { + const raw = window.localStorage.getItem(ACCOUNT_VISIBLE_COLUMNS_KEY) + if (!raw) return fallback + const parsed = JSON.parse(raw) as Partial> + return Object.fromEntries(ACCOUNT_TABLE_COLUMNS.map((column) => [column, parsed[column] !== false])) as Record + } catch { + return fallback + } +} + +function persistAccountVisibleColumns(columns: Record) { + try { + window.localStorage.setItem(ACCOUNT_VISIBLE_COLUMNS_KEY, JSON.stringify(columns)) + } catch { + // Keep the in-memory preference working when localStorage is unavailable. + } +} function getInitialAnalysisVisibility(): boolean { try { @@ -146,6 +170,7 @@ export default function Accounts() { const [concurrencyMode, setConcurrencyMode] = useState<'default' | 'custom'>('default') const [concurrencyInput, setConcurrencyInput] = useState('') const [allowedAPIKeySelection, setAllowedAPIKeySelection] = useState([]) + const [editProxyUrl, setEditProxyUrl] = useState('') const [editOpenAIForm, setEditOpenAIForm] = useState({ name: '', base_url: 'https://api.openai.com', @@ -187,6 +212,12 @@ export default function Accounts() { const [oauthName, setOauthName] = useState('') const [oauthGenerating, setOauthGenerating] = useState(false) const [oauthCompleting, setOauthCompleting] = useState(false) + const [editTags, setEditTags] = useState([]) + const [editGroupIds, setEditGroupIds] = useState([]) + const [tagFilter, setTagFilter] = useState('') + const [groupFilter, setGroupFilter] = useState(null) + const [allGroups, setAllGroups] = useState([]) + const [visibleColumns, setVisibleColumns] = useState>(getInitialAccountVisibleColumns) const fileInputRef = useRef(null) const jsonInputRef = useRef(null) const atFileInputRef = useRef(null) @@ -196,11 +227,13 @@ export default function Accounts() { const { confirm, confirmDialog } = useConfirmDialog() const loadAccounts = useCallback(async () => { - const [accountsResponse, apiKeysResponse, opsOverview] = await Promise.all([ + const [accountsResponse, apiKeysResponse, opsOverview, groupsResponse] = await Promise.all([ api.getAccounts(), api.getAPIKeys(), api.getOpsOverview().catch((): OpsOverviewResponse | null => null), + api.listAccountGroups().catch(() => ({ groups: [] })), ]) + setAllGroups(groupsResponse.groups ?? []) return { accounts: accountsResponse.accounts ?? [], apiKeys: apiKeysResponse.keys ?? [], @@ -225,6 +258,10 @@ export default function Accounts() { persistAnalysisVisibility(showAnalysisCharts) }, [showAnalysisCharts]) + useEffect(() => { + persistAccountVisibleColumns(visibleColumns) + }, [visibleColumns]) + useEffect(() => { const needsUsageReload = (account: AccountRow) => { if (account.status !== 'active' && account.status !== 'ready') { @@ -302,6 +339,16 @@ export default function Accounts() { riskyAccounts, } = accountSummary + const allTags = useMemo(() => { + const tags = new Set() + for (const account of accounts) { + for (const tag of account.tags ?? []) { + tags.add(tag) + } + } + return Array.from(tags).sort() + }, [accounts]) + const filteredAccounts = useMemo(() => { const query = searchQuery.toLowerCase() return accounts.filter((account) => { @@ -334,9 +381,11 @@ export default function Accounts() { const name = (account.name || '').toLowerCase() if (!email.includes(query) && !name.includes(query)) return false } + if (tagFilter && !(account.tags ?? []).includes(tagFilter)) return false + if (groupFilter !== null && !(account.group_ids ?? []).includes(groupFilter)) return false return true }) - }, [accounts, planFilter, searchQuery, statusFilter]) + }, [accounts, groupFilter, planFilter, searchQuery, statusFilter, tagFilter]) const sortedAccounts = useMemo(() => { if (!sortKey) return filteredAccounts @@ -1182,6 +1231,9 @@ export default function Accounts() { setConcurrencyMode(account.base_concurrency_override === null || account.base_concurrency_override === undefined ? 'default' : 'custom') setConcurrencyInput(account.base_concurrency_override === null || account.base_concurrency_override === undefined ? '' : String(account.base_concurrency_override)) setAllowedAPIKeySelection(filterExistingAPIKeyIDs(account.allowed_api_key_ids ?? [], apiKeys)) + setEditProxyUrl(account.proxy_url ?? '') + setEditTags(account.tags ?? []) + setEditGroupIds(account.group_ids ?? []) setEditOpenAIForm({ name: account.name ?? '', base_url: account.base_url || 'https://api.openai.com', @@ -1201,6 +1253,9 @@ export default function Accounts() { setConcurrencyMode('default') setConcurrencyInput('') setAllowedAPIKeySelection([]) + setEditProxyUrl('') + setEditTags([]) + setEditGroupIds([]) setEditOpenAIForm({ name: '', base_url: 'https://api.openai.com', api_key: '', models: [], proxy_url: '' }) setEditOpenAIModelDraft('') } @@ -1249,6 +1304,9 @@ export default function Accounts() { score_bias_override: scoreMode === 'custom' ? parsedScoreBias : null, base_concurrency_override: concurrencyMode === 'custom' ? parsedBaseConcurrency : null, allowed_api_key_ids: allowedAPIKeySelection, + proxy_url: editProxyUrl.trim() || null, + tags: editTags, + group_ids: editGroupIds, } await api.updateAccountScheduler(editingAccount.id, payload) showToast(t('accounts.schedulerSaveSuccess')) @@ -1463,29 +1521,31 @@ export default function Accounts() {
) : null} -
- {t('accounts.filter')} - {([['all', t('accounts.filterAll')], ['normal', t('accounts.filterNormal')], ['rate_limited', t('accounts.filterRateLimited')], ['banned', t('accounts.filterBanned')], ['error', t('accounts.filterError')], ['disabled', t('accounts.filterDisabled')], ['locked', t('accounts.filterLocked')]] as const).map(([key, label]) => ( - - ))} -
+
+
+ {t('accounts.filter')} + {([['all', t('accounts.filterAll')], ['normal', t('accounts.filterNormal')], ['rate_limited', t('accounts.filterRateLimited')], ['banned', t('accounts.filterBanned')], ['error', t('accounts.filterError')], ['disabled', t('accounts.filterDisabled')], ['locked', t('accounts.filterLocked')]] as const).map(([key, label]) => ( + + ))} +
-
- {t('accounts.schedulerView')} - - - - +
+ {t('accounts.schedulerView')} + + + + +
@@ -1517,6 +1577,42 @@ export default function Accounts() { ))}
+ { setGroupFilter(value === 'all' ? null : Number(value)); setPage(1) }} + options={[ + { value: 'all', label: t('accounts.groupsFilter') }, + ...allGroups.map((group) => ({ value: String(group.id), label: group.name })), + ]} + /> + setVisibleColumns((current) => ({ ...current, [column]: !current[column] }))} + labels={{ + sequence: t('accounts.sequence'), + email: t('accounts.email'), + plan: t('accounts.plan'), + status: t('accounts.status'), + requests: t('accounts.requests'), + usage: t('accounts.usage'), + importTime: t('accounts.importTime'), + updatedAt: t('accounts.updatedAt'), + actions: t('accounts.actions'), + }} + title={t('accounts.columnSettings')} + />
{selected.size > 0 && ( @@ -1576,30 +1672,30 @@ export default function Accounts() { onChange={toggleSelectAll} /> - {t('accounts.sequence')} - {t('accounts.email')} - {t('accounts.plan')} - {t('accounts.status')} - {t('accounts.sequence')}} + {visibleColumns.email && {t('accounts.email')}} + {visibleColumns.plan && {t('accounts.plan')}} + {visibleColumns.status && {t('accounts.status')}} + {visibleColumns.requests && { if (sortKey === 'requests') { setSortDir(d => d === 'asc' ? 'desc' : 'asc') } else { setSortKey('requests'); setSortDir('desc') }; setPage(1) }} > {t('accounts.requests')} {sortKey === 'requests' ? (sortDir === 'desc' ? '↓' : '↑') : ''} - - } + {visibleColumns.usage && { if (sortKey === 'usage') { setSortDir(d => d === 'asc' ? 'desc' : 'asc') } else { setSortKey('usage'); setSortDir('desc') }; setPage(1) }} > {t('accounts.usage')} {sortKey === 'usage' ? (sortDir === 'desc' ? '↓' : '↑') : ''} - - } + {visibleColumns.importTime && { if (sortKey === 'importTime') { setSortDir(d => d === 'asc' ? 'desc' : 'asc') } else { setSortKey('importTime'); setSortDir('desc') }; setPage(1) }} > {t('accounts.importTime')} {sortKey === 'importTime' ? (sortDir === 'desc' ? '↓' : '↑') : ''} - - {t('accounts.updatedAt')} - {t('accounts.actions')} + } + {visibleColumns.updatedAt && {t('accounts.updatedAt')}} + {visibleColumns.actions && {t('accounts.actions')}}
@@ -1615,10 +1711,10 @@ export default function Accounts() { onChange={() => toggleSelect(account.id)} />
- + {visibleColumns.sequence && {(currentPage - 1) * pageSize + index + 1} - - + } + {visibleColumns.email && {account.openai_responses_api ? formatAccountName(account) : formatCompactEmail(account.email)} {account.at_only && ( @@ -1640,11 +1736,13 @@ export default function Accounts() { {t('accounts.lock')} )} - - + + allGroups.find((group) => group.id === id)?.name).filter(Boolean) as string[]} tone="blue" /> + } + {visibleColumns.plan && - - + } + {visibleColumns.status &&
@@ -1669,8 +1767,8 @@ export default function Accounts() { })}
-
- + } + {visibleColumns.requests &&
{account.success_requests ?? 0} @@ -1683,13 +1781,13 @@ export default function Accounts() {
)}
-
- + } + {visibleColumns.usage && - - {formatBeijingTime(account.created_at)} - {formatRelativeTime(account.updated_at)} - + } + {visibleColumns.importTime && {formatBeijingTime(account.created_at)}} + {visibleColumns.updatedAt && {formatRelativeTime(account.updated_at)}} + {visibleColumns.actions &&
-
+
} ) })} @@ -2583,6 +2681,57 @@ export default function Accounts() { +
+
{t('accounts.proxyUrl')}
+
+ ) => setEditProxyUrl(event.target.value)} + /> +
+
+ +
+
+
{t('accounts.tagsLabel')}
+
{t('accounts.tagsHint')}
+ +
+ +
+
{t('accounts.groupsLabel')}
+
{t('accounts.groupsHint')}
+
+ {allGroups.length === 0 ? ( + {t('accounts.groupsNone')} + ) : allGroups.map((group) => { + const active = editGroupIds.includes(group.id) + return ( + + ) + })} +
+
+
+
{t('accounts.schedulerPreviewTitle')}
@@ -3272,6 +3421,84 @@ function SchedulerChip({ ) } +function ChipList({ items, tone }: { items: string[]; tone: 'purple' | 'blue' }) { + if (items.length === 0) return null + const visible = items.slice(0, 3) + const hidden = items.length - visible.length + const toneClass = tone === 'purple' + ? 'bg-purple-500/10 text-purple-700 ring-purple-500/20 dark:text-purple-300' + : 'bg-blue-500/10 text-blue-700 ring-blue-500/20 dark:text-blue-300' + + return ( +
+ {visible.map((item) => ( + + {item} + + ))} + {hidden > 0 && ( + + +{hidden} + + )} +
+ ) +} + +function ColumnSettingsMenu({ + columns, + onToggle, + labels, + title, +}: { + columns: Record + onToggle: (column: AccountTableColumn) => void + labels: Record + title: string +}) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (event: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(event.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [open]) + + return ( +
+ + {open ? ( +
+ {ACCOUNT_TABLE_COLUMNS.map((column) => ( + + ))} +
+ ) : null} +
+ ) +} + function formatHealthTier(healthTier?: string, t?: any) { if (!t) return 'Unknown' switch (healthTier) { From 474e4ea7f573c8aa599af82d503d855df2a3a428 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:42:19 +0800 Subject: [PATCH 12/28] Implement backend account groups and scheduler scope --- admin/account_groups.go | 250 +++++++++++++++++++++++++ admin/handler.go | 227 ++++++++++++++++++++++- admin/responses.go | 38 ++-- auth/fast_scheduler.go | 23 ++- auth/store.go | 128 ++++++++++++- database/account_groups.go | 370 +++++++++++++++++++++++++++++++++++++ database/helpers.go | 50 +++++ database/image_studio.go | 5 +- database/postgres.go | 185 ++++++++++++++++--- database/sqlite.go | 19 ++ 10 files changed, 1240 insertions(+), 55 deletions(-) create mode 100644 admin/account_groups.go create mode 100644 database/account_groups.go diff --git a/admin/account_groups.go b/admin/account_groups.go new file mode 100644 index 00000000..cbf50f02 --- /dev/null +++ b/admin/account_groups.go @@ -0,0 +1,250 @@ +package admin + +import ( + "context" + "database/sql" + "errors" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/codex2api/database" + "github.com/gin-gonic/gin" +) + +const ( + maxAccountGroups = 64 + maxAccountGroupNameRuneSize = 80 +) + +type accountGroupResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + SortOrder int64 `json:"sort_order"` + MemberCount int64 `json:"member_count"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func toAccountGroupResponse(g database.AccountGroup) accountGroupResponse { + return accountGroupResponse{ + ID: g.ID, + Name: g.Name, + Description: g.Description, + Color: g.Color, + SortOrder: g.SortOrder, + MemberCount: g.MemberCount, + CreatedAt: g.CreatedAt.Format(time.RFC3339), + UpdatedAt: g.UpdatedAt.Format(time.RFC3339), + } +} + +func (h *Handler) ListAccountGroups(c *gin.Context) { + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + groups, err := h.db.ListAccountGroups(ctx) + if err != nil { + writeInternalError(c, err) + return + } + out := make([]accountGroupResponse, 0, len(groups)) + for _, group := range groups { + out = append(out, toAccountGroupResponse(group)) + } + c.JSON(http.StatusOK, gin.H{"groups": out}) +} + +type createAccountGroupReq struct { + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` + SortOrder *int64 `json:"sort_order"` +} + +func (h *Handler) CreateAccountGroup(c *gin.Context) { + var req createAccountGroupReq + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + name, err := sanitizeAccountGroupName(req.Name) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + description := strings.TrimSpace(req.Description) + if utf8.RuneCountInString(description) > 240 { + writeError(c, http.StatusBadRequest, "描述长度不能超过 240 字符") + return + } + color := strings.TrimSpace(req.Color) + if utf8.RuneCountInString(color) > 20 { + writeError(c, http.StatusBadRequest, "颜色长度不能超过 20 字符") + return + } + sortOrder := int64(0) + if req.SortOrder != nil { + sortOrder = *req.SortOrder + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + groups, err := h.db.ListAccountGroups(ctx) + if err != nil { + writeInternalError(c, err) + return + } + if len(groups) >= maxAccountGroups { + writeError(c, http.StatusBadRequest, "分组数量已达上限") + return + } + id, err := h.db.CreateAccountGroup(ctx, name, description, color, sortOrder) + if err != nil { + if errors.Is(err, database.ErrDuplicateAccountGroupName) { + writeError(c, http.StatusConflict, err.Error()) + return + } + writeInternalError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"id": id, "message": "分组已创建"}) +} + +type updateAccountGroupReq struct { + Name *string `json:"name"` + Description *string `json:"description"` + Color *string `json:"color"` + SortOrder *int64 `json:"sort_order"` +} + +func (h *Handler) UpdateAccountGroup(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + writeError(c, http.StatusBadRequest, "无效的分组 ID") + return + } + var req updateAccountGroupReq + if err := c.ShouldBindJSON(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + if req.Name != nil { + name, err := sanitizeAccountGroupName(*req.Name) + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + req.Name = &name + } + if req.Description != nil { + desc := strings.TrimSpace(*req.Description) + if utf8.RuneCountInString(desc) > 240 { + writeError(c, http.StatusBadRequest, "描述长度不能超过 240 字符") + return + } + req.Description = &desc + } + if req.Color != nil { + color := strings.TrimSpace(*req.Color) + if utf8.RuneCountInString(color) > 20 { + writeError(c, http.StatusBadRequest, "颜色长度不能超过 20 字符") + return + } + req.Color = &color + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + if err := h.db.UpdateAccountGroup(ctx, id, req.Name, req.Description, req.Color, req.SortOrder); err != nil { + if err == sql.ErrNoRows { + writeError(c, http.StatusNotFound, "分组不存在") + return + } + if errors.Is(err, database.ErrDuplicateAccountGroupName) { + writeError(c, http.StatusConflict, err.Error()) + return + } + writeInternalError(c, err) + return + } + writeMessage(c, http.StatusOK, "分组已更新") +} + +func (h *Handler) DeleteAccountGroup(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + writeError(c, http.StatusBadRequest, "无效的分组 ID") + return + } + force := strings.EqualFold(c.Query("force"), "true") + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + if err := h.db.DeleteAccountGroup(ctx, id, force); err != nil { + if err == sql.ErrNoRows { + writeError(c, http.StatusNotFound, "分组不存在") + return + } + if errors.Is(err, database.ErrAccountGroupNotEmpty) { + writeError(c, http.StatusConflict, err.Error()) + return + } + writeInternalError(c, err) + return + } + if h.store != nil { + for _, acc := range h.store.Accounts() { + acc.Mu().RLock() + groups := removeInt64(acc.GroupIDs, id) + acc.Mu().RUnlock() + h.store.ApplyAccountGroups(acc.DBID, groups) + } + } + writeMessage(c, http.StatusOK, "分组已删除") +} + +func sanitizeAccountGroupName(raw string) (string, error) { + name := strings.TrimSpace(raw) + if name == "" { + return "", errors.New("分组名称不能为空") + } + if utf8.RuneCountInString(name) > maxAccountGroupNameRuneSize { + return "", errors.New("分组名称长度超过 80 字符") + } + for _, r := range name { + if r < 0x20 || r == 0x7f { + return "", errors.New("分组名称包含非法控制字符") + } + } + return name, nil +} + +func removeInt64(slice []int64, target int64) []int64 { + out := make([]int64, 0, len(slice)) + for _, v := range slice { + if v != target { + out = append(out, v) + } + } + return out +} + +func dedupeInt64(ids []int64) []int64 { + if len(ids) == 0 { + return nil + } + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} diff --git a/admin/handler.go b/admin/handler.go index e92d6f7a..b6fbe9c9 100644 --- a/admin/handler.go +++ b/admin/handler.go @@ -223,7 +223,12 @@ func (h *Handler) RegisterRoutes(r *gin.Engine) { api.DELETE("/usage/logs", h.ClearUsageLogs) api.GET("/keys", h.ListAPIKeys) api.POST("/keys", h.CreateAPIKey) + api.PATCH("/keys/:id", h.UpdateAPIKey) api.DELETE("/keys/:id", h.DeleteAPIKey) + api.GET("/account-groups", h.ListAccountGroups) + api.POST("/account-groups", h.CreateAccountGroup) + api.PATCH("/account-groups/:id", h.UpdateAccountGroup) + api.DELETE("/account-groups/:id", h.DeleteAccountGroup) api.GET("/health", h.GetHealth) api.GET("/system/update", h.GetSelfUpdateStatus) api.POST("/system/update", h.StartSelfUpdate) @@ -422,6 +427,8 @@ type accountResponse struct { Enabled bool `json:"enabled"` Locked bool `json:"locked"` AllowedAPIKeyIDs []int64 `json:"allowed_api_key_ids"` + Tags []string `json:"tags"` + GroupIDs []int64 `json:"group_ids"` // 图片配额信息 ImageQuotaRemaining *int `json:"image_quota_remaining,omitempty"` ImageQuotaTotal *int `json:"image_quota_total,omitempty"` @@ -509,6 +516,7 @@ func (h *Handler) ListAccounts(c *gin.Context) { Enabled: row.Enabled, Locked: row.Locked, AllowedAPIKeyIDs: row.GetCredentialInt64Slice("allowed_api_key_ids"), + Tags: append([]string(nil), row.Tags...), ScoreBiasOverride: nullableInt64Pointer(row.ScoreBiasOverride), ScoreBiasEffective: effectiveScoreBias(planType, row.ScoreBiasOverride), BaseConcurrencyOverride: nullableInt64Pointer(row.BaseConcurrencyOverride), @@ -517,6 +525,9 @@ func (h *Handler) ListAccounts(c *gin.Context) { UpdatedAt: row.UpdatedAt.Format(time.RFC3339), } if acc, ok := accountMap[row.ID]; ok { + acc.Mu().RLock() + resp.GroupIDs = append([]int64(nil), acc.GroupIDs...) + acc.Mu().RUnlock() resp.ActiveRequests = acc.GetActiveRequests() resp.TotalRequests = acc.GetTotalRequests() debug := acc.GetSchedulerDebugSnapshot(int64(h.store.GetMaxConcurrency())) @@ -628,6 +639,9 @@ type updateAccountSchedulerReq struct { ScoreBiasOverride json.RawMessage `json:"score_bias_override"` BaseConcurrencyOverride json.RawMessage `json:"base_concurrency_override"` AllowedAPIKeyIDs json.RawMessage `json:"allowed_api_key_ids"` + Tags json.RawMessage `json:"tags"` + GroupIDs json.RawMessage `json:"group_ids"` + ProxyURL *string `json:"proxy_url"` } // UpdateAccountScheduler 更新账号调度配置。 @@ -661,6 +675,16 @@ func (h *Handler) UpdateAccountScheduler(c *gin.Context) { writeError(c, http.StatusBadRequest, err.Error()) return } + tags, err := parseOptionalStringSliceField(req.Tags, "tags") + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + groupIDs, err := parseOptionalIntegerSliceField(req.GroupIDs, "group_ids") + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() @@ -680,6 +704,21 @@ func (h *Handler) UpdateAccountScheduler(c *gin.Context) { return } } + if groupIDs.Set { + missingGroupIDs, err := h.db.VerifyAccountGroupIDs(ctx, groupIDs.Values) + if err != nil { + writeError(c, http.StatusInternalServerError, "校验账号分组失败: "+err.Error()) + return + } + if len(missingGroupIDs) > 0 { + values := make([]string, 0, len(missingGroupIDs)) + for _, value := range missingGroupIDs { + values = append(values, strconv.FormatInt(value, 10)) + } + writeError(c, http.StatusBadRequest, "group_ids 包含不存在的分组 ID: "+strings.Join(values, ", ")) + return + } + } if err := h.db.UpdateAccountSchedulerConfig(ctx, id, scoreBiasOverride, baseConcurrencyOverride, allowedAPIKeyIDs); err != nil { if err == sql.ErrNoRows { @@ -695,10 +734,76 @@ func (h *Handler) UpdateAccountScheduler(c *gin.Context) { h.store.ApplyAccountAllowedAPIKeys(id, allowedAPIKeyIDs.Values) } } + if tags.Set { + if err := h.db.UpdateAccountTags(ctx, id, tags.Values); err != nil { + writeError(c, http.StatusInternalServerError, "更新账号标签失败: "+err.Error()) + return + } + if h.store != nil { + h.store.ApplyAccountTags(id, tags.Values) + } + } + if groupIDs.Set { + if err := h.db.SetAccountGroups(ctx, id, groupIDs.Values); err != nil { + writeError(c, http.StatusInternalServerError, "更新账号分组失败: "+err.Error()) + return + } + if h.store != nil { + h.store.ApplyAccountGroups(id, groupIDs.Values) + } + } + if req.ProxyURL != nil { + if err := h.db.UpdateAccountProxyURL(ctx, id, *req.ProxyURL); err != nil { + writeError(c, http.StatusInternalServerError, "更新账号代理失败: "+err.Error()) + return + } + if h.store != nil { + h.store.ApplyAccountProxyURL(id, *req.ProxyURL) + } + } writeMessage(c, http.StatusOK, "账号调度配置已更新") } +type optionalStringSlice struct { + Set bool + Values []string +} + +func parseOptionalStringSliceField(raw json.RawMessage, field string) (optionalStringSlice, error) { + if len(raw) == 0 { + return optionalStringSlice{}, nil + } + if string(raw) == "null" { + return optionalStringSlice{Set: true, Values: []string{}}, nil + } + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return optionalStringSlice{}, fmt.Errorf("%s 必须是字符串数组或 null", field) + } + out := make([]string, 0, len(values)) + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + clean := strings.TrimSpace(value) + if clean == "" { + continue + } + if utf8.RuneCountInString(clean) > 40 { + return optionalStringSlice{}, fmt.Errorf("%s 单个标签不能超过 40 字符", field) + } + key := strings.ToLower(clean) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, clean) + } + if len(out) > 32 { + return optionalStringSlice{}, fmt.Errorf("%s 最多 32 个标签", field) + } + return optionalStringSlice{Set: true, Values: out}, nil +} + func parseOptionalIntegerField(raw json.RawMessage, field string, minValue, maxValue int64) (sql.NullInt64, error) { if len(raw) == 0 || string(raw) == "null" { return sql.NullInt64{}, nil @@ -2195,6 +2300,10 @@ func (h *Handler) DeleteAccount(c *gin.Context) { // 软删除:保留账号数据与事件记录,但从运行时池和 active 列表中移除。 if err := h.db.SoftDeleteAccount(ctx, id); err != nil { + if err == sql.ErrNoRows { + writeError(c, http.StatusNotFound, "账号不存在") + return + } writeError(c, http.StatusInternalServerError, "删除失败: "+err.Error()) return } @@ -3115,6 +3224,86 @@ func (h *Handler) CreateAPIKey(c *gin.Context) { }) } +type updateAPIKeyReq struct { + Name *string `json:"name"` + AllowedGroupIDs json.RawMessage `json:"allowed_group_ids"` +} + +func (h *Handler) UpdateAPIKey(c *gin.Context) { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + writeError(c, http.StatusBadRequest, "无效 ID") + return + } + var req updateAPIKeyReq + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + writeError(c, http.StatusBadRequest, "请求格式错误") + return + } + allowedGroupIDs, err := parseOptionalIntegerSliceField(req.AllowedGroupIDs, "allowed_group_ids") + if err != nil { + writeError(c, http.StatusBadRequest, err.Error()) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) + defer cancel() + row, err := h.db.GetAPIKeyByID(ctx, id) + if err != nil { + if err == sql.ErrNoRows { + writeError(c, http.StatusNotFound, "API Key 不存在") + return + } + writeInternalError(c, err) + return + } + if req.Name != nil { + name := security.SanitizeInput(*req.Name) + if strings.TrimSpace(name) == "" { + writeError(c, http.StatusBadRequest, "名称不能为空") + return + } + if utf8.RuneCountInString(name) > 100 { + writeError(c, http.StatusBadRequest, "名称长度不能超过100字符") + return + } + if security.ContainsXSS(name) { + writeError(c, http.StatusBadRequest, "名称包含非法字符") + return + } + if err := h.db.UpdateAPIKeyName(ctx, id, name); err != nil { + writeInternalError(c, err) + return + } + } + if allowedGroupIDs.Set { + missing, err := h.db.VerifyAccountGroupIDs(ctx, allowedGroupIDs.Values) + if err != nil { + writeInternalError(c, err) + return + } + if len(missing) > 0 { + values := make([]string, 0, len(missing)) + for _, value := range missing { + values = append(values, strconv.FormatInt(value, 10)) + } + writeError(c, http.StatusBadRequest, "allowed_group_ids 包含不存在的分组 ID: "+strings.Join(values, ", ")) + return + } + values := dedupeInt64(allowedGroupIDs.Values) + if err := h.db.UpdateAPIKeyAllowedGroupIDs(ctx, id, values); err != nil { + writeInternalError(c, err) + return + } + if h.store != nil { + h.store.SetAPIKeyAllowedGroups(id, values) + } + } + h.invalidateAPIKeyRuntimeCaches(ctx, row.Key) + writeMessage(c, http.StatusOK, "API Key 已更新") +} + func parseAPIKeyExpiresAt(raw string, expiresInDays *int) (sql.NullTime, error) { if expiresInDays != nil { if *expiresInDays < 0 { @@ -3617,7 +3806,10 @@ func (h *Handler) UpdateSettings(c *gin.Context) { if req.ProxyPoolEnabled != nil { h.store.SetProxyPoolEnabled(*req.ProxyPoolEnabled) if *req.ProxyPoolEnabled { - _ = h.store.ReloadProxyPool() + if err := h.store.ReloadProxyPool(); err != nil { + writeError(c, http.StatusInternalServerError, "代理池刷新失败: "+err.Error()) + return + } } log.Printf("设置已更新: proxy_pool_enabled = %t", *req.ProxyPoolEnabled) } @@ -4434,12 +4626,15 @@ func (h *Handler) AddProxies(c *gin.Context) { inserted, err := h.db.InsertProxies(ctx, cleaned, req.Label) if err != nil { - writeError(c, http.StatusInternalServerError, "添加代理失败") + writeError(c, http.StatusInternalServerError, "添加代理失败: "+err.Error()) return } // 刷新代理池 - _ = h.store.ReloadProxyPool() + if err := h.store.ReloadProxyPool(); err != nil { + writeError(c, http.StatusInternalServerError, "代理池刷新失败: "+err.Error()) + return + } c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("成功添加 %d 个代理", inserted), @@ -4460,11 +4655,18 @@ func (h *Handler) DeleteProxy(c *gin.Context) { defer cancel() if err := h.db.DeleteProxy(ctx, id); err != nil { + if err == sql.ErrNoRows { + writeError(c, http.StatusNotFound, "代理不存在") + return + } writeError(c, http.StatusInternalServerError, "删除代理失败") return } - _ = h.store.ReloadProxyPool() + if err := h.store.ReloadProxyPool(); err != nil { + writeError(c, http.StatusInternalServerError, "代理池刷新失败: "+err.Error()) + return + } c.JSON(http.StatusOK, gin.H{"message": "代理已删除"}) } @@ -4477,6 +4679,7 @@ func (h *Handler) UpdateProxy(c *gin.Context) { } var req struct { + URL *string `json:"url"` Label *string `json:"label"` Enabled *bool `json:"enabled"` } @@ -4488,12 +4691,19 @@ func (h *Handler) UpdateProxy(c *gin.Context) { ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second) defer cancel() - if err := h.db.UpdateProxy(ctx, id, req.Label, req.Enabled); err != nil { + if err := h.db.UpdateProxy(ctx, id, req.URL, req.Label, req.Enabled); err != nil { + if err == sql.ErrNoRows { + writeError(c, http.StatusNotFound, "代理不存在") + return + } writeError(c, http.StatusInternalServerError, "更新代理失败") return } - _ = h.store.ReloadProxyPool() + if err := h.store.ReloadProxyPool(); err != nil { + writeError(c, http.StatusInternalServerError, "代理池刷新失败: "+err.Error()) + return + } c.JSON(http.StatusOK, gin.H{"message": "代理已更新"}) } @@ -4575,7 +4785,10 @@ func (h *Handler) TestProxy(c *gin.Context) { if req.ID > 0 { ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second) defer cancel() - _ = h.db.UpdateProxyTestResult(ctx, req.ID, ip, location, latencyMs) + if err := h.db.UpdateProxyTestResult(ctx, req.ID, ip, location, latencyMs); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "代理测试结果保存失败: " + err.Error(), "latency_ms": latencyMs}) + return + } } c.JSON(http.StatusOK, gin.H{ diff --git a/admin/responses.go b/admin/responses.go index d4dfc994..7699310d 100644 --- a/admin/responses.go +++ b/admin/responses.go @@ -49,15 +49,16 @@ type apiKeysResponse struct { // MaskedAPIKeyRow API Key 响应(含脱敏和完整 key) type MaskedAPIKeyRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Key string `json:"key"` - RawKey string `json:"raw_key"` - QuotaLimit float64 `json:"quota_limit"` - QuotaUsed float64 `json:"quota_used"` - ExpiresAt *string `json:"expires_at"` - Status string `json:"status"` - CreatedAt string `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + RawKey string `json:"raw_key"` + QuotaLimit float64 `json:"quota_limit"` + QuotaUsed float64 `json:"quota_used"` + ExpiresAt *string `json:"expires_at"` + AllowedGroupIDs []int64 `json:"allowed_group_ids"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` } // NewMaskedAPIKeyRow 创建 API Key 响应 @@ -74,15 +75,16 @@ func NewMaskedAPIKeyRow(row *database.APIKeyRow) *MaskedAPIKeyRow { status = "quota_exhausted" } return &MaskedAPIKeyRow{ - ID: row.ID, - Name: row.Name, - Key: security.MaskAPIKey(row.Key), - RawKey: row.Key, - QuotaLimit: row.QuotaLimit, - QuotaUsed: row.QuotaUsed, - ExpiresAt: expiresAt, - Status: status, - CreatedAt: row.CreatedAt.Format(time.RFC3339), + ID: row.ID, + Name: row.Name, + Key: security.MaskAPIKey(row.Key), + RawKey: row.Key, + QuotaLimit: row.QuotaLimit, + QuotaUsed: row.QuotaUsed, + ExpiresAt: expiresAt, + AllowedGroupIDs: append([]int64(nil), row.AllowedGroupIDs...), + Status: status, + CreatedAt: row.CreatedAt.Format(time.RFC3339), } } diff --git a/auth/fast_scheduler.go b/auth/fast_scheduler.go index f496e054..992644c8 100644 --- a/auth/fast_scheduler.go +++ b/auth/fast_scheduler.go @@ -33,11 +33,12 @@ type fastSchedulerPosition struct { // 调度策略:按健康层级分桶,桶内按调度分排序后 round-robin。 // 验证过的账号只作为同分 tie-breaker,避免历史请求量盖过额度快重置优先级。 type FastScheduler struct { - mu sync.RWMutex - baseLimit int64 - buckets map[AccountHealthTier][]fastSchedulerEntry - positions map[int64]fastSchedulerPosition - cursors [3]atomic.Uint64 + mu sync.RWMutex + baseLimit int64 + buckets map[AccountHealthTier][]fastSchedulerEntry + positions map[int64]fastSchedulerPosition + cursors [3]atomic.Uint64 + groupCheck func(apiKeyID int64, account *Account) bool } func NewFastScheduler(baseLimit int64) *FastScheduler { @@ -55,6 +56,15 @@ func NewFastScheduler(baseLimit int64) *FastScheduler { } } +func (s *FastScheduler) SetGroupCheck(check func(apiKeyID int64, account *Account) bool) { + if s == nil { + return + } + s.mu.Lock() + s.groupCheck = check + s.mu.Unlock() +} + // BuildFastScheduler 用当前 Store 快照构建一个独立 scheduler。 // 该方法不会影响现有生产流量路径,只用于 POC/benchmark/灰度验证。 func (s *Store) BuildFastScheduler() *FastScheduler { @@ -226,6 +236,9 @@ func (s *FastScheduler) scanRangeLocked(expectedTier AccountHealthTier, rangeSta if !entry.acc.AllowsAPIKey(apiKeyID) { continue } + if s.groupCheck != nil && !s.groupCheck(apiKeyID, entry.acc) { + continue + } if filter != nil && !filter(entry.acc) { continue } diff --git a/auth/store.go b/auth/store.go index e0973576..36788896 100644 --- a/auth/store.go +++ b/auth/store.go @@ -112,6 +112,8 @@ type Account struct { BaseConcurrencyOverride *int64 AllowedAPIKeyIDs []int64 allowedAPIKeySet map[int64]struct{} + Tags []string + GroupIDs []int64 ModelCooldowns map[string]ModelCooldown } @@ -1452,6 +1454,8 @@ type Store struct { testModel atomic.Value // 测试连接使用的模型(string) db *database.DB tokenCache cache.TokenCache + apiKeyGroupsMu sync.RWMutex + apiKeyAllowedGroups map[int64][]int64 usageProbeMu sync.RWMutex usageProbe func(context.Context, *Account) error usageProbeBatch atomic.Bool @@ -1909,7 +1913,9 @@ func (s *Store) rebuildFastScheduler() { if s == nil || !s.fastSchedulerEnabled.Load() { return } - s.fastScheduler.Store(s.BuildFastScheduler()) + scheduler := s.BuildFastScheduler() + scheduler.SetGroupCheck(s.APIKeyAllowsAccount) + s.fastScheduler.Store(scheduler) } func (s *Store) recomputeAllAccountSchedulerState() { @@ -2267,6 +2273,7 @@ func (s *Store) loadFromDB(ctx context.Context) error { account.ScoreBiasOverride = reflectOptionalInt64Field(row, "ScoreBiasOverride") account.BaseConcurrencyOverride = reflectOptionalInt64Field(row, "BaseConcurrencyOverride") account.setAllowedAPIKeyIDsLocked(row.GetCredentialInt64Slice("allowed_api_key_ids")) + account.Tags = cloneStringSlice(row.Tags) if row.Locked { atomic.StoreInt32(&account.Locked, 1) } @@ -2349,6 +2356,14 @@ func (s *Store) loadFromDB(ctx context.Context) error { } log.Printf("从数据库加载了 %d 个账号", len(s.accounts)) + if memberships, err := s.db.ListAccountGroupMemberships(ctx); err == nil { + s.ApplyAccountGroupMemberships(memberships) + } else { + log.Printf("加载账号分组失败: %v", err) + } + if err := s.LoadAPIKeyAllowedGroups(ctx); err != nil { + log.Printf("加载 API Key 分组限制失败: %v", err) + } return nil } @@ -3031,6 +3046,106 @@ func (s *Store) ApplyAccountAllowedAPIKeys(dbID int64, allowedAPIKeyIDs []int64) return true } +func (s *Store) ApplyAccountTags(dbID int64, tags []string) bool { + acc := s.FindByID(dbID) + if acc == nil { + return false + } + acc.mu.Lock() + acc.Tags = cloneStringSlice(tags) + acc.mu.Unlock() + return true +} + +func (s *Store) ApplyAccountGroups(dbID int64, groupIDs []int64) bool { + acc := s.FindByID(dbID) + if acc == nil { + return false + } + acc.mu.Lock() + acc.GroupIDs = cloneInt64Slice(groupIDs) + acc.mu.Unlock() + s.fastSchedulerUpdate(acc) + return true +} + +func (s *Store) ApplyAccountGroupMemberships(memberships map[int64][]int64) { + for _, acc := range s.Accounts() { + acc.mu.Lock() + acc.GroupIDs = cloneInt64Slice(memberships[acc.DBID]) + acc.mu.Unlock() + s.fastSchedulerUpdate(acc) + } +} + +func (s *Store) SetAPIKeyAllowedGroups(apiKeyID int64, groupIDs []int64) { + if apiKeyID <= 0 { + return + } + s.apiKeyGroupsMu.Lock() + if s.apiKeyAllowedGroups == nil { + s.apiKeyAllowedGroups = make(map[int64][]int64) + } + if len(groupIDs) == 0 { + delete(s.apiKeyAllowedGroups, apiKeyID) + } else { + s.apiKeyAllowedGroups[apiKeyID] = cloneInt64Slice(groupIDs) + } + s.apiKeyGroupsMu.Unlock() + s.rebuildFastScheduler() +} + +func (s *Store) GetAPIKeyAllowedGroups(apiKeyID int64) []int64 { + if apiKeyID <= 0 { + return nil + } + s.apiKeyGroupsMu.RLock() + defer s.apiKeyGroupsMu.RUnlock() + return cloneInt64Slice(s.apiKeyAllowedGroups[apiKeyID]) +} + +func (s *Store) LoadAPIKeyAllowedGroups(ctx context.Context) error { + if s == nil || s.db == nil { + return nil + } + keys, err := s.db.ListAPIKeys(ctx) + if err != nil { + return err + } + s.apiKeyGroupsMu.Lock() + s.apiKeyAllowedGroups = make(map[int64][]int64, len(keys)) + for _, key := range keys { + if len(key.AllowedGroupIDs) > 0 { + s.apiKeyAllowedGroups[key.ID] = cloneInt64Slice(key.AllowedGroupIDs) + } + } + s.apiKeyGroupsMu.Unlock() + s.rebuildFastScheduler() + return nil +} + +func (s *Store) APIKeyAllowsAccount(apiKeyID int64, acc *Account) bool { + if s == nil || apiKeyID <= 0 || acc == nil { + return true + } + allowed := s.GetAPIKeyAllowedGroups(apiKeyID) + if len(allowed) == 0 { + return true + } + allowedSet := make(map[int64]struct{}, len(allowed)) + for _, id := range allowed { + allowedSet[id] = struct{}{} + } + acc.mu.RLock() + defer acc.mu.RUnlock() + for _, id := range acc.GroupIDs { + if _, ok := allowedSet[id]; ok { + return true + } + } + return false +} + func (s *Store) ApplyOpenAIResponsesConfig(dbID int64, baseURL, apiKey string, models []string, proxyURL string) bool { acc := s.FindByID(dbID) if acc == nil { @@ -3056,6 +3171,17 @@ func (s *Store) ApplyOpenAIResponsesConfig(dbID int64, baseURL, apiKey string, m return true } +func (s *Store) ApplyAccountProxyURL(dbID int64, proxyURL string) bool { + acc := s.FindByID(dbID) + if acc == nil { + return false + } + acc.mu.Lock() + acc.ProxyURL = strings.TrimSpace(proxyURL) + acc.mu.Unlock() + return true +} + func (s *Store) ApplyAccountEnabled(dbID int64, enabled bool) bool { acc := s.FindByID(dbID) if acc == nil { diff --git a/database/account_groups.go b/database/account_groups.go new file mode 100644 index 00000000..277f2ad5 --- /dev/null +++ b/database/account_groups.go @@ -0,0 +1,370 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +type AccountGroup struct { + ID int64 + Name string + Description string + Color string + SortOrder int64 + MemberCount int64 + CreatedAt time.Time + UpdatedAt time.Time +} + +func (db *DB) ListAccountGroups(ctx context.Context) ([]AccountGroup, error) { + rows, err := db.conn.QueryContext(ctx, ` + SELECT g.id, g.name, g.description, g.color, g.sort_order, + COALESCE(COUNT(m.account_id), 0), g.created_at, g.updated_at + FROM account_groups g + LEFT JOIN account_group_members m ON m.group_id = g.id + GROUP BY g.id, g.name, g.description, g.color, g.sort_order, g.created_at, g.updated_at + ORDER BY g.sort_order, g.name`) + if err != nil { + return nil, err + } + defer rows.Close() + groups := make([]AccountGroup, 0) + for rows.Next() { + var g AccountGroup + var createdRaw, updatedRaw interface{} + if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Color, &g.SortOrder, &g.MemberCount, &createdRaw, &updatedRaw); err != nil { + return nil, err + } + var parseErr error + g.CreatedAt, parseErr = parseDBTimeValue(createdRaw) + if parseErr != nil { + return nil, parseErr + } + g.UpdatedAt, parseErr = parseDBTimeValue(updatedRaw) + if parseErr != nil { + return nil, parseErr + } + groups = append(groups, g) + } + return groups, rows.Err() +} + +func (db *DB) CreateAccountGroup(ctx context.Context, name, description, color string, sortOrder ...int64) (int64, error) { + name = strings.TrimSpace(name) + if name == "" { + return 0, fmt.Errorf("group name is required") + } + order := int64(0) + if len(sortOrder) > 0 { + order = sortOrder[0] + } + if db.isSQLite() { + res, err := db.conn.ExecContext(ctx, `INSERT INTO account_groups (name, description, color, sort_order) VALUES (?, ?, ?, ?)`, name, description, color, order) + if err != nil { + if isUniqueViolation(err) { + return 0, ErrDuplicateAccountGroupName + } + return 0, err + } + return res.LastInsertId() + } + var id int64 + err := db.conn.QueryRowContext(ctx, `INSERT INTO account_groups (name, description, color, sort_order) VALUES ($1, $2, $3, $4) RETURNING id`, name, description, color, order).Scan(&id) + if err != nil { + if isUniqueViolation(err) { + return 0, ErrDuplicateAccountGroupName + } + return 0, err + } + return id, nil +} + +func (db *DB) UpdateAccountGroup(ctx context.Context, id int64, name, description, color *string, sortOrder ...*int64) error { + sets := make([]string, 0, 5) + args := make([]interface{}, 0, 6) + add := func(col string, value interface{}) { + args = append(args, value) + ph := "?" + if !db.isSQLite() { + ph = fmt.Sprintf("$%d", len(args)) + } + sets = append(sets, col+" = "+ph) + } + if name != nil { + clean := strings.TrimSpace(*name) + if clean == "" { + return fmt.Errorf("group name is required") + } + add("name", clean) + } + if description != nil { + add("description", *description) + } + if color != nil { + add("color", *color) + } + if len(sortOrder) > 0 && sortOrder[0] != nil { + add("sort_order", *sortOrder[0]) + } + if len(sets) == 0 { + return nil + } + sets = append(sets, "updated_at = CURRENT_TIMESTAMP") + args = append(args, id) + ph := "?" + if !db.isSQLite() { + ph = fmt.Sprintf("$%d", len(args)) + } + res, err := db.conn.ExecContext(ctx, "UPDATE account_groups SET "+strings.Join(sets, ", ")+" WHERE id = "+ph, args...) + if err != nil { + if isUniqueViolation(err) { + return ErrDuplicateAccountGroupName + } + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (db *DB) DeleteAccountGroup(ctx context.Context, id int64, force ...bool) error { + allowMembers := len(force) > 0 && force[0] + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + ph := "$1" + if db.isSQLite() { + ph = "?" + } + var count int64 + if err := tx.QueryRowContext(ctx, "SELECT COUNT(*) FROM account_group_members WHERE group_id = "+ph, id).Scan(&count); err != nil { + return err + } + if count > 0 && !allowMembers { + return ErrAccountGroupNotEmpty + } + if _, err := tx.ExecContext(ctx, "DELETE FROM account_group_members WHERE group_id = "+ph, id); err != nil { + return err + } + res, err := tx.ExecContext(ctx, "DELETE FROM account_groups WHERE id = "+ph, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return tx.Commit() +} + +func (db *DB) SetAccountGroups(ctx context.Context, accountID int64, groupIDs []int64) error { + tx, err := db.conn.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + ph := "$1" + insertQ := "INSERT INTO account_group_members (account_id, group_id) VALUES ($1, $2)" + if db.isSQLite() { + ph = "?" + insertQ = "INSERT INTO account_group_members (account_id, group_id) VALUES (?, ?)" + } + if _, err := tx.ExecContext(ctx, "DELETE FROM account_group_members WHERE account_id = "+ph, accountID); err != nil { + return err + } + seen := make(map[int64]struct{}, len(groupIDs)) + for _, gid := range groupIDs { + if gid <= 0 { + continue + } + if _, ok := seen[gid]; ok { + continue + } + seen[gid] = struct{}{} + if _, err := tx.ExecContext(ctx, insertQ, accountID, gid); err != nil { + return err + } + } + return tx.Commit() +} + +func (db *DB) GetAccountGroupIDs(ctx context.Context, accountID int64) ([]int64, error) { + query := "SELECT group_id FROM account_group_members WHERE account_id = $1 ORDER BY group_id" + if db.isSQLite() { + query = "SELECT group_id FROM account_group_members WHERE account_id = ? ORDER BY group_id" + } + rows, err := db.conn.QueryContext(ctx, query, accountID) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (db *DB) ListAccountIDsInGroups(ctx context.Context, groupIDs []int64) ([]int64, error) { + groupIDs = normalizeIDSlice(groupIDs) + if len(groupIDs) == 0 { + return nil, nil + } + placeholders := make([]string, len(groupIDs)) + args := make([]interface{}, len(groupIDs)) + for i, id := range groupIDs { + if db.isSQLite() { + placeholders[i] = "?" + } else { + placeholders[i] = fmt.Sprintf("$%d", i+1) + } + args[i] = id + } + rows, err := db.conn.QueryContext(ctx, fmt.Sprintf("SELECT DISTINCT account_id FROM account_group_members WHERE group_id IN (%s) ORDER BY account_id", strings.Join(placeholders, ",")), args...) + if err != nil { + return nil, err + } + defer rows.Close() + var ids []int64 + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, rows.Err() +} + +func (db *DB) ListAccountGroupMemberships(ctx context.Context) (map[int64][]int64, error) { + rows, err := db.conn.QueryContext(ctx, `SELECT account_id, group_id FROM account_group_members ORDER BY account_id, group_id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make(map[int64][]int64) + for rows.Next() { + var accountID, groupID int64 + if err := rows.Scan(&accountID, &groupID); err != nil { + return nil, err + } + out[accountID] = append(out[accountID], groupID) + } + return out, rows.Err() +} + +func (db *DB) VerifyAccountGroupIDs(ctx context.Context, ids []int64) ([]int64, error) { + ids = normalizeIDSlice(ids) + if len(ids) == 0 { + return nil, nil + } + placeholders := make([]string, len(ids)) + args := make([]interface{}, len(ids)) + for i, id := range ids { + if db.isSQLite() { + placeholders[i] = "?" + } else { + placeholders[i] = fmt.Sprintf("$%d", i+1) + } + args[i] = id + } + rows, err := db.conn.QueryContext(ctx, fmt.Sprintf("SELECT id FROM account_groups WHERE id IN (%s)", strings.Join(placeholders, ",")), args...) + if err != nil { + return nil, err + } + defer rows.Close() + exists := make(map[int64]struct{}, len(ids)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + exists[id] = struct{}{} + } + missing := make([]int64, 0) + for _, id := range ids { + if _, ok := exists[id]; !ok { + missing = append(missing, id) + } + } + return missing, rows.Err() +} + +func (db *DB) UpdateAccountTags(ctx context.Context, id int64, tags []string) error { + payload := encodeTagsJSON(tags) + query := `UPDATE accounts SET tags = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2` + if !db.isSQLite() { + query = `UPDATE accounts SET tags = $1::jsonb, updated_at = CURRENT_TIMESTAMP WHERE id = $2` + } + res, err := db.conn.ExecContext(ctx, query, payload, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (db *DB) UpdateAccountProxyURL(ctx context.Context, id int64, proxyURL string) error { + res, err := db.conn.ExecContext(ctx, `UPDATE accounts SET proxy_url = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`, strings.TrimSpace(proxyURL), id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func normalizeIDSlice(ids []int64) []int64 { + seen := make(map[int64]struct{}, len(ids)) + out := make([]int64, 0, len(ids)) + for _, id := range ids { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +var ErrDuplicateAccountGroupName = fmt.Errorf("account group name already exists") +var ErrAccountGroupNotEmpty = fmt.Errorf("account group still has members") + +func isUniqueViolation(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate key") || strings.Contains(msg, "23505") +} diff --git a/database/helpers.go b/database/helpers.go index 3f3e3579..7e249ca2 100644 --- a/database/helpers.go +++ b/database/helpers.go @@ -359,3 +359,53 @@ func (db *DB) insertRowID(ctx context.Context, postgresQuery string, sqliteQuery err := db.conn.QueryRowContext(ctx, postgresQuery, args...).Scan(&id) return id, err } + +// decodeInt64SliceValue parses a JSON array of integers from a JSONB or TEXT column. +func decodeInt64SliceValue(raw interface{}) []int64 { + data := bytesFromDBValue(raw) + if len(data) == 0 { + return nil + } + var out []int64 + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return out +} + +// encodeInt64SliceJSON marshals []int64 to JSON. Returns "[]" for nil/empty. +func encodeInt64SliceJSON(values []int64) string { + if len(values) == 0 { + return "[]" + } + b, err := json.Marshal(values) + if err != nil { + return "[]" + } + return string(b) +} + +// decodeTagsValue parses a tags column value (from JSONB on PG, TEXT on SQLite) into []string. +func decodeTagsValue(raw interface{}) []string { + data := bytesFromDBValue(raw) + if len(data) == 0 { + return nil + } + var out []string + if err := json.Unmarshal(data, &out); err != nil { + return nil + } + return out +} + +// encodeTagsJSON marshals a []string to JSON array string. Returns "[]" for nil/empty. +func encodeTagsJSON(tags []string) string { + if len(tags) == 0 { + return "[]" + } + b, err := json.Marshal(tags) + if err != nil { + return "[]" + } + return string(b) +} diff --git a/database/image_studio.go b/database/image_studio.go index c45fb7b1..ede2d6e2 100644 --- a/database/image_studio.go +++ b/database/image_studio.go @@ -586,8 +586,8 @@ func scanAPIKeyRow(scanner interface { Scan(dest ...interface{}) error }) (*APIKeyRow, error) { row := &APIKeyRow{} - var createdAtRaw, expiresAtRaw interface{} - if err := scanner.Scan(&row.ID, &row.Name, &row.Key, &createdAtRaw, &row.QuotaLimit, &row.QuotaUsed, &expiresAtRaw); err != nil { + var createdAtRaw, expiresAtRaw, allowedGroupsRaw interface{} + if err := scanner.Scan(&row.ID, &row.Name, &row.Key, &createdAtRaw, &row.QuotaLimit, &row.QuotaUsed, &expiresAtRaw, &allowedGroupsRaw); err != nil { return nil, err } createdAt, err := parseDBTimeValue(createdAtRaw) @@ -600,5 +600,6 @@ func scanAPIKeyRow(scanner interface { } row.CreatedAt = createdAt row.ExpiresAt = expiresAt + row.AllowedGroupIDs = decodeInt64SliceValue(allowedGroupsRaw) return row, nil } diff --git a/database/postgres.go b/database/postgres.go index cdc7ad79..b26c3d31 100644 --- a/database/postgres.go +++ b/database/postgres.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" "log" "strings" @@ -31,6 +32,7 @@ type AccountRow struct { Locked bool ScoreBiasOverride sql.NullInt64 BaseConcurrencyOverride sql.NullInt64 + Tags []string CreatedAt time.Time UpdatedAt time.Time } @@ -48,6 +50,14 @@ type OptionalInt64Slice struct { Values []int64 } +// AccountCredentialIndex holds pre-built sets of existing credentials for fast import dedup. +type AccountCredentialIndex struct { + RefreshTokens map[string]bool + AccessTokens map[string]bool + SessionTokens map[string]bool + AccountIDs map[string]bool +} + // GetCredential 从 credentials JSONB 获取字符串字段 func (a *AccountRow) GetCredential(key string) string { if a.Credentials == nil { @@ -104,6 +114,7 @@ type DB struct { usageLogBatchSize int64 usageLogFlushInterval int64 // ns logFlushNotify chan struct{} + accountInsertMu sync.Mutex } const ( @@ -120,6 +131,8 @@ const ( maxUsageLogFlushIntervalSeconds = 300 ) +var ErrDuplicateAccountCredential = errors.New("duplicate account credential") + func NormalizeUsageLogMode(mode string) string { switch strings.ToLower(strings.TrimSpace(mode)) { case "", UsageLogModeFull: @@ -443,6 +456,25 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE accounts ADD COLUMN IF NOT EXISTS image_quota_total INT NULL; ALTER TABLE accounts ADD COLUMN IF NOT EXISTS today_used_count INT DEFAULT 0; ALTER TABLE accounts ADD COLUMN IF NOT EXISTS image_quota_reset_at TIMESTAMPTZ NULL; + ALTER TABLE accounts ADD COLUMN IF NOT EXISTS tags JSONB DEFAULT '[]'::jsonb; + + CREATE TABLE IF NOT EXISTS account_groups ( + id SERIAL PRIMARY KEY, + name VARCHAR(80) UNIQUE NOT NULL, + description TEXT DEFAULT '', + color VARCHAR(20) DEFAULT '', + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + ); + + CREATE TABLE IF NOT EXISTS account_group_members ( + account_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + PRIMARY KEY (account_id, group_id) + ); + CREATE INDEX IF NOT EXISTS idx_account_group_members_group ON account_group_members(group_id); + CREATE INDEX IF NOT EXISTS idx_account_group_members_account ON account_group_members(account_id); UPDATE accounts SET status = 'deleted', @@ -519,6 +551,8 @@ func (db *DB) migrate(ctx context.Context) error { ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ NULL; CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at); + ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS allowed_group_ids JSONB DEFAULT '[]'::jsonb; + CREATE TABLE IF NOT EXISTS system_settings ( id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), site_name TEXT DEFAULT 'CodexProxy', @@ -738,13 +772,14 @@ func (db *DB) migrate(ctx context.Context) error { // APIKeyRow API 密钥行 type APIKeyRow struct { - ID int64 `json:"id"` - Name string `json:"name"` - Key string `json:"key"` - QuotaLimit float64 `json:"quota_limit"` - QuotaUsed float64 `json:"quota_used"` - ExpiresAt sql.NullTime `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` + ID int64 `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + QuotaLimit float64 `json:"quota_limit"` + QuotaUsed float64 `json:"quota_used"` + ExpiresAt sql.NullTime `json:"expires_at"` + AllowedGroupIDs []int64 `json:"allowed_group_ids"` + CreatedAt time.Time `json:"created_at"` } type APIKeyInput struct { @@ -755,7 +790,7 @@ type APIKeyInput struct { ExpiresAt sql.NullTime } -const apiKeySelectColumns = `id, name, key, created_at, COALESCE(quota_limit, 0), COALESCE(quota_used, 0), expires_at` +const apiKeySelectColumns = `id, name, key, created_at, COALESCE(quota_limit, 0), COALESCE(quota_used, 0), expires_at, COALESCE(allowed_group_ids, '[]')` // ListAPIKeys 获取所有 API 密钥 func (db *DB) ListAPIKeys(ctx context.Context) ([]*APIKeyRow, error) { @@ -836,6 +871,52 @@ func (row *APIKeyRow) HasAccessConstraints() bool { return row != nil && (row.QuotaLimit > 0 || row.ExpiresAt.Valid) } +// UpdateAPIKeyName updates the display name of an API key without changing the key value. +func (db *DB) UpdateAPIKeyName(ctx context.Context, id int64, name string) error { + res, err := db.conn.ExecContext(ctx, `UPDATE api_keys SET name = $1 WHERE id = $2`, name, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +// UpdateAPIKeyAllowedGroups persists the allowed-group scope for an API key. +// Empty slice clears the scope (key may schedule any account). +func (db *DB) UpdateAPIKeyAllowedGroups(ctx context.Context, id int64, groupIDs []int64) error { + payload := encodeInt64SliceJSON(groupIDs) + var ( + res sql.Result + err error + ) + if db.isSQLite() { + res, err = db.conn.ExecContext(ctx, `UPDATE api_keys SET allowed_group_ids = $1 WHERE id = $2`, payload, id) + } else { + res, err = db.conn.ExecContext(ctx, `UPDATE api_keys SET allowed_group_ids = $1::jsonb WHERE id = $2`, payload, id) + } + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil +} + +func (db *DB) UpdateAPIKeyAllowedGroupIDs(ctx context.Context, id int64, groupIDs []int64) error { + return db.UpdateAPIKeyAllowedGroups(ctx, id, groupIDs) +} + // ==================== System Settings ==================== const DefaultSiteName = "CodexProxy" @@ -1141,9 +1222,12 @@ func (db *DB) InsertProxies(ctx context.Context, urls []string, label string) (i if db.isSQLite() { res, err := db.conn.ExecContext(ctx, `INSERT INTO proxies (url, label) VALUES ($1, $2) ON CONFLICT(url) DO NOTHING`, u, label) if err != nil { - continue + return inserted, err + } + affected, err := res.RowsAffected() + if err != nil { + return inserted, err } - affected, _ := res.RowsAffected() if affected > 0 { inserted++ } @@ -1154,6 +1238,10 @@ func (db *DB) InsertProxies(ctx context.Context, urls []string, label string) (i `INSERT INTO proxies (url, label) VALUES ($1, $2) ON CONFLICT (url) DO NOTHING RETURNING id`, u, label).Scan(&id) if err == nil { inserted++ + continue + } + if !errors.Is(err, sql.ErrNoRows) { + return inserted, err } } return inserted, nil @@ -1161,8 +1249,18 @@ func (db *DB) InsertProxies(ctx context.Context, urls []string, label string) (i // DeleteProxy 删除单个代理 func (db *DB) DeleteProxy(ctx context.Context, id int64) error { - _, err := db.conn.ExecContext(ctx, `DELETE FROM proxies WHERE id = $1`, id) - return err + res, err := db.conn.ExecContext(ctx, `DELETE FROM proxies WHERE id = $1`, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil } // DeleteProxies 批量删除代理 @@ -1187,16 +1285,43 @@ func (db *DB) DeleteProxies(ctx context.Context, ids []int64) (int, error) { } // UpdateProxy 更新代理 -func (db *DB) UpdateProxy(ctx context.Context, id int64, label *string, enabled *bool) error { - if label != nil { - if _, err := db.conn.ExecContext(ctx, `UPDATE proxies SET label = $1 WHERE id = $2`, *label, id); err != nil { +func (db *DB) UpdateProxy(ctx context.Context, id int64, urlValue *string, label *string, enabled *bool) error { + if urlValue == nil && label == nil && enabled == nil { + var exists int + if err := db.conn.QueryRowContext(ctx, `SELECT 1 FROM proxies WHERE id = $1`, id).Scan(&exists); err != nil { + if err == sql.ErrNoRows { + return sql.ErrNoRows + } return err } + return nil + } + assignments := make([]string, 0, 3) + args := make([]interface{}, 0, 4) + if urlValue != nil { + args = append(args, *urlValue) + assignments = append(assignments, fmt.Sprintf("url = $%d", len(args))) + } + if label != nil { + args = append(args, *label) + assignments = append(assignments, fmt.Sprintf("label = $%d", len(args))) } if enabled != nil { - if _, err := db.conn.ExecContext(ctx, `UPDATE proxies SET enabled = $1 WHERE id = $2`, *enabled, id); err != nil { - return err - } + args = append(args, *enabled) + assignments = append(assignments, fmt.Sprintf("enabled = $%d", len(args))) + } + args = append(args, id) + query := fmt.Sprintf("UPDATE proxies SET %s WHERE id = $%d", strings.Join(assignments, ", "), len(args)) + res, err := db.conn.ExecContext(ctx, query, args...) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows } return nil } @@ -2645,7 +2770,7 @@ func (db *DB) GetAccountTimeRangeUsage(ctx context.Context, since time.Time) (ma // ListActive 获取所有未删除账号。 func (db *DB) ListActive(ctx context.Context) ([]*AccountRow, error) { query := ` - SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, created_at, updated_at + SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, COALESCE(tags, '[]'), created_at, updated_at FROM accounts WHERE status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted' ORDER BY id @@ -2661,6 +2786,7 @@ func (db *DB) ListActive(ctx context.Context) ([]*AccountRow, error) { a := &AccountRow{} var credRaw interface{} var cooldownUntilRaw interface{} + var tagsRaw interface{} var createdAtRaw interface{} var updatedAtRaw interface{} if err := rows.Scan( @@ -2678,12 +2804,14 @@ func (db *DB) ListActive(ctx context.Context) ([]*AccountRow, error) { &a.Locked, &a.ScoreBiasOverride, &a.BaseConcurrencyOverride, + &tagsRaw, &createdAtRaw, &updatedAtRaw, ); err != nil { return nil, fmt.Errorf("扫描账号行失败: %w", err) } a.Credentials = decodeCredentials(credRaw) + a.Tags = decodeTagsValue(tagsRaw) a.CooldownUntil, err = parseDBNullTimeValue(cooldownUntilRaw) if err != nil { return nil, fmt.Errorf("解析 cooldown_until 失败: %w", err) @@ -2783,7 +2911,7 @@ func (db *DB) ClearExpiredModelCooldowns(ctx context.Context) error { // GetAccountByID 获取未删除账号的完整数据库行。 func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) { query := ` - SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, created_at, updated_at + SELECT id, name, platform, type, credentials, proxy_url, status, cooldown_reason, cooldown_until, error_message, COALESCE(enabled, true), COALESCE(locked, false), score_bias_override, base_concurrency_override, COALESCE(tags, '[]'), created_at, updated_at FROM accounts WHERE id = $1 AND status <> 'deleted' AND COALESCE(error_message, '') <> 'deleted' LIMIT 1 @@ -2791,6 +2919,7 @@ func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) a := &AccountRow{} var credRaw interface{} var cooldownUntilRaw interface{} + var tagsRaw interface{} var createdAtRaw interface{} var updatedAtRaw interface{} err := db.conn.QueryRowContext(ctx, query, id).Scan( @@ -2808,6 +2937,7 @@ func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) &a.Locked, &a.ScoreBiasOverride, &a.BaseConcurrencyOverride, + &tagsRaw, &createdAtRaw, &updatedAtRaw, ) @@ -2818,6 +2948,7 @@ func (db *DB) GetAccountByID(ctx context.Context, id int64) (*AccountRow, error) return nil, fmt.Errorf("查询账号失败: %w", err) } a.Credentials = decodeCredentials(credRaw) + a.Tags = decodeTagsValue(tagsRaw) a.CooldownUntil, err = parseDBNullTimeValue(cooldownUntilRaw) if err != nil { return nil, fmt.Errorf("解析 cooldown_until 失败: %w", err) @@ -3066,8 +3197,18 @@ func (db *DB) SoftDeleteAccount(ctx context.Context, id int64) error { updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND status <> 'deleted' ` - _, err := db.conn.ExecContext(ctx, query, id) - return err + res, err := db.conn.ExecContext(ctx, query, id) + if err != nil { + return err + } + affected, err := res.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return sql.ErrNoRows + } + return nil } // BatchSoftDeleteAccounts 批量软删除账号,分批执行避免 SQL 参数过多。 diff --git a/database/sqlite.go b/database/sqlite.go index e681d376..3485bb5b 100644 --- a/database/sqlite.go +++ b/database/sqlite.go @@ -80,9 +80,24 @@ func (db *DB) migrateSQLite(ctx context.Context) error { key TEXT NOT NULL UNIQUE, quota_limit REAL DEFAULT 0, quota_used REAL DEFAULT 0, + allowed_group_ids TEXT DEFAULT '[]', expires_at TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );`, + `CREATE TABLE IF NOT EXISTS account_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + color TEXT DEFAULT '', + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE TABLE IF NOT EXISTS account_group_members ( + account_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + PRIMARY KEY (account_id, group_id) + );`, `CREATE TABLE IF NOT EXISTS account_model_cooldowns ( account_id INTEGER NOT NULL, model TEXT NOT NULL, @@ -240,6 +255,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error { {"accounts", "cooldown_until", "TIMESTAMP NULL"}, {"accounts", "score_bias_override", "INTEGER NULL"}, {"accounts", "base_concurrency_override", "INTEGER NULL"}, + {"accounts", "tags", "TEXT DEFAULT '[]'"}, {"accounts", "deleted_at", "TIMESTAMP NULL"}, {"usage_logs", "input_tokens", "INTEGER DEFAULT 0"}, {"usage_logs", "output_tokens", "INTEGER DEFAULT 0"}, @@ -269,6 +285,7 @@ func (db *DB) migrateSQLite(ctx context.Context) error { {"usage_logs", "error_message", "TEXT DEFAULT ''"}, {"api_keys", "quota_limit", "REAL DEFAULT 0"}, {"api_keys", "quota_used", "REAL DEFAULT 0"}, + {"api_keys", "allowed_group_ids", "TEXT DEFAULT '[]'"}, {"api_keys", "expires_at", "TIMESTAMP NULL"}, {"system_settings", "site_name", "TEXT DEFAULT 'CodexProxy'"}, {"system_settings", "site_logo", "TEXT DEFAULT ''"}, @@ -334,6 +351,8 @@ func (db *DB) migrateSQLite(ctx context.Context) error { `CREATE INDEX IF NOT EXISTS idx_usage_logs_account_status ON usage_logs(account_id, status_code);`, `CREATE INDEX IF NOT EXISTS idx_usage_logs_api_key_created_at ON usage_logs(api_key_id, created_at);`, `CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at);`, + `CREATE INDEX IF NOT EXISTS idx_account_group_members_group ON account_group_members(group_id);`, + `CREATE INDEX IF NOT EXISTS idx_account_group_members_account ON account_group_members(account_id);`, `CREATE INDEX IF NOT EXISTS idx_account_model_cooldowns_reset_at ON account_model_cooldowns(reset_at);`, `CREATE INDEX IF NOT EXISTS idx_account_events_created ON account_events(created_at);`, `CREATE INDEX IF NOT EXISTS idx_account_events_type_created ON account_events(event_type, created_at);`, From 685a3c97788f690fb08e450346618267b3282398 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:49:17 +0800 Subject: [PATCH 13/28] docs: add tabbed quick start guide --- frontend/src/pages/Guide.tsx | 574 ++++++++++++++++++++++------------- 1 file changed, 357 insertions(+), 217 deletions(-) diff --git a/frontend/src/pages/Guide.tsx b/frontend/src/pages/Guide.tsx index 9343245f..ef8b53d8 100644 --- a/frontend/src/pages/Guide.tsx +++ b/frontend/src/pages/Guide.tsx @@ -1,21 +1,111 @@ -import { useMemo, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Copy, Check } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import { Check, Copy, ExternalLink } from 'lucide-react' +import { api } from '../api' import PageHeader from '../components/PageHeader' -import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Card, CardContent } from '@/components/ui/card' +import { Select } from '@/components/ui/select' + +type TabOption = { + id: T + label: string +} + +type ApiKeyOption = { + name: string + key: string +} + +type ClientTool = { + id: string + name: string + badge: string + blurb: string + glyph: string + tone: string +} + +const CLIENT_TOOLS: ClientTool[] = [ + { + id: 'codex', + name: 'Codex CLI', + badge: 'Responses', + blurb: 'Write config.toml and auth.json for the OpenAI Responses wire API.', + glyph: 'CX', + tone: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', + }, + { + id: 'claude', + name: 'Claude Code', + badge: 'Anthropic', + blurb: 'Use environment variables or settings.json for the Messages endpoint.', + glyph: 'CC', + tone: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', + }, + { + id: 'cc-switch', + name: 'CC Switch', + badge: 'Deeplink', + blurb: 'Launch the desktop switcher and import this server as a provider.', + glyph: 'CS', + tone: 'bg-fuchsia-500/10 text-fuchsia-600 dark:text-fuchsia-400', + }, +] + +const FALLBACK_MODELS = ['gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex', 'claude-sonnet-4-5-20250514'] + +function encodeBase64(text: string): string { + return btoa(unescape(encodeURIComponent(text))) +} + +function buildCcSwitchUrl(baseUrl: string, apiKey: string): string { + const config = encodeURIComponent(encodeBase64(JSON.stringify({ + name: 'codex2api', + baseURL: baseUrl, + apiKey, + anthropicVersion: '2023-06-01', + }))) + return `cc-switch://import?data=${config}` +} + +function keyLabel(item: ApiKeyOption): string { + if (!item.key) return item.name || 'API Key' + const masked = item.key.length > 14 ? `${item.key.slice(0, 7)}...${item.key.slice(-4)}` : item.key + return item.name ? `${item.name} - ${masked}` : masked +} -// 带复制按钮的代码块 -function CodeBlock({ path, content }: { path: string; content: string }) { - const { t } = useTranslation() +function Tabs({ tabs, active, onChange }: { + tabs: TabOption[] + active: T + onChange: (value: T) => void +}) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ) +} + +function CodeBlock({ label, content }: { label: string; content: string }) { const [copied, setCopied] = useState(false) const handleCopy = async () => { try { await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) } catch { const textarea = document.createElement('textarea') textarea.value = content @@ -24,78 +114,44 @@ function CodeBlock({ path, content }: { path: string; content: string }) { textarea.select() document.execCommand('copy') document.body.removeChild(textarea) - setCopied(true) - setTimeout(() => setCopied(false), 2000) } + setCopied(true) + setTimeout(() => setCopied(false), 1600) } return ( -
-
- {path} +
+
+ {label}
-
+      
         {content}
       
) } -// macOS/Linux 和 Windows 的 Tab 切换 -function OsTabs({ active, onChange }: { active: 'unix' | 'windows'; onChange: (v: 'unix' | 'windows') => void }) { - return ( -
- -
- ) -} - -export default function Guide() { - const { t } = useTranslation() - const [codexOs, setCodexOs] = useState<'unix' | 'windows'>('unix') - const [claudeOs, setClaudeOs] = useState<'unix' | 'windows'>('unix') - - // 动态获取当前服务地址 - const baseUrl = useMemo(() => window.location.origin, []) - - const codexConfigDir = codexOs === 'windows' ? '%userprofile%\\.codex' : '~/.codex' - const claudeConfigDir = claudeOs === 'windows' ? '%userprofile%\\.claude' : '~/.claude' - - const codexConfigToml = `model_provider = "OpenAI" +function ClientCard({ tool, activeKey, baseUrl }: { + tool: ClientTool + activeKey: string + baseUrl: string +}) { + const [codexTab, setCodexTab] = useState<'unix' | 'windows'>('unix') + const [claudeTab, setClaudeTab] = useState<'env-unix' | 'env-windows' | 'settings'>('env-unix') + const key = activeKey || 'YOUR_API_KEY' + const codexDir = codexTab === 'windows' ? '%userprofile%\\.codex' : '~/.codex' + + const codexConfig = `model_provider = "OpenAI" model = "gpt-5.4" review_model = "gpt-5.4" model_reasoning_effort = "xhigh" @@ -110,189 +166,273 @@ base_url = "${baseUrl}" wire_api = "responses" requires_openai_auth = true` - const codexAuthJson = `{ - "OPENAI_API_KEY": "YOUR_API_KEY" -}` - - const claudeSettingsJson = `{ - "env": { - "ANTHROPIC_BASE_URL": "${baseUrl}", - "ANTHROPIC_AUTH_TOKEN": "YOUR_API_KEY", - "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" - } + const codexAuth = `{ + "OPENAI_API_KEY": "${key}" }` const claudeEnvUnix = `export ANTHROPIC_BASE_URL="${baseUrl}" -export ANTHROPIC_AUTH_TOKEN="YOUR_API_KEY" +export ANTHROPIC_AUTH_TOKEN="${key}" export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` const claudeEnvWindows = `set ANTHROPIC_BASE_URL=${baseUrl} -set ANTHROPIC_AUTH_TOKEN=YOUR_API_KEY +set ANTHROPIC_AUTH_TOKEN=${key} set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` - return ( - <> - - - {/* API 端点总览 */} - - -

{t('guide.endpoints')}

-

{t('guide.endpointsDesc')}

- -
-
-
- POST - /v1/responses -
-

{t('guide.responsesDesc')}

-
+ const claudeSettings = `{ + "env": { + "ANTHROPIC_BASE_URL": "${baseUrl}", + "ANTHROPIC_AUTH_TOKEN": "${key}", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" + } +}` -
-
- POST - /v1/chat/completions -
-

{t('guide.chatDesc')}

-
+ const ccSwitchUrl = buildCcSwitchUrl(baseUrl, key) -
-
- POST - /v1/messages -
-

{t('guide.messagesDesc')}

+ return ( + + +
+
+ {tool.glyph} +
+
+
+

{tool.name}

+ {tool.badge}
+

{tool.blurb}

- - - - {/* Codex CLI 配置 */} - - -

{t('guide.codexTitle')}

-

{t('guide.codexDesc')}

- - - -

- ⓘ {t('guide.codexConfigHint')} -

+
+ + {tool.id === 'codex' && ( + <> + +
+ + +
+ + )} + + {tool.id === 'claude' && ( + <> + + {claudeTab === 'env-unix' && } + {claudeTab === 'env-windows' && } + {claudeTab === 'settings' && } + + )} + {tool.id === 'cc-switch' && (
- - + + + {!activeKey &&

Create or select an API key before launching the deeplink.

}
+ )} +
+
+ ) +} -

- {codexOs === 'windows' ? t('guide.codexNoteWindows') : t('guide.codexNoteUnix')} -

- - - - {/* Claude Code 配置 */} - - -

{t('guide.claudeCodeTitle')}

-

{t('guide.claudeCodeDesc')}

- - - -
- -

{t('guide.claudeEnvNote')}

- - -

{t('guide.claudeSettingsNote')}

-
-
-
- - {/* 认证方式 */} - - -

{t('guide.authTitle')}

-

{t('guide.authDesc')}

-
    -
  • - 1. - {t('guide.authBearer')} -
  • -
  • - 2. - {t('guide.authXApiKey')} -
  • -
  • - 3. - {t('guide.authAnthropicToken')} -
  • -
-
-
- - {/* 模型映射说明 */} - - -

{t('guide.modelMappingTitle')}

-

{t('guide.modelMappingDesc')}

-
-
- - {/* 请求示例 */} - - -

{t('guide.exampleTitle')}

- -
-
-

{t('guide.responsesExample')}

- window.location.origin, []) + const [apiKeys, setApiKeys] = useState([]) + const [selectedKey, setSelectedKey] = useState('') + const [models, setModels] = useState(FALLBACK_MODELS) + const [selectedModel, setSelectedModel] = useState('gpt-5.4') + const [curlTab, setCurlTab] = useState<'responses' | 'chat' | 'messages'>('responses') + + useEffect(() => { + api.getAPIKeys().then((res) => { + const keys = (res.keys ?? []).map((item) => ({ name: item.name, key: item.raw_key || item.key })) + setApiKeys(keys) + if (keys[0]) setSelectedKey(keys[0].key) + }).catch(() => {}) + + api.getModels().then((res) => { + const next = (res.models?.length ? res.models : res.items?.map((item) => item.id) ?? []) + .filter((model): model is string => Boolean(model)) + if (next.length > 0) { + setModels(next) + setSelectedModel(next.includes('gpt-5.4') ? 'gpt-5.4' : next[0]) + } + }).catch(() => {}) + }, []) + + const activeKey = selectedKey || apiKeys[0]?.key || '' + const keyForSnippet = activeKey || 'YOUR_API_KEY' + const messagesModel = selectedModel.startsWith('claude-') ? selectedModel : 'claude-sonnet-4-5-20250514' + + const curlExamples = { + responses: `curl -X POST ${baseUrl}/v1/responses \\ + -H "Authorization: Bearer ${keyForSnippet}" \\ -H "Content-Type: application/json" \\ -d '{ - "model": "gpt-5.4", + "model": "${selectedModel}", "input": [{"role": "user", "content": [{"type": "input_text", "text": "Hello"}]}], "stream": true - }'`} /> -
- -
-

{t('guide.chatExample')}

- -
- -
-

{t('guide.messagesExample')}

- -
+ }'`, + } + + const tocItems = [ + ['quick-start', 'Quick Start'], + ['client-codex', 'Codex CLI'], + ['client-claude', 'Claude Code'], + ['client-cc-switch', 'CC Switch'], + ['curl-examples', 'cURL Examples'], + ['auth', 'Authentication'], + ] + + return ( + <> + + +
+
+ + +
+
+

Quick Start

+

Choose a key once; client cards and cURL examples update together.

+
+
+ + {apiKeys.length > 0 ? ( + ({ label: model, value: model }))} + /> +
+
+ + +
+
+ + + +

Authentication

+

Downstream API requests accept standard OpenAI and Anthropic header styles.

+
+
+ Header + Authorization: Bearer <key> +
+
+ Header + x-api-key: <key> +
+
+ Header + anthropic-auth-token: <key> +
+
+
+
+
+ + +
) } From afa129f322c17b0c88d4b51eca5f25c7a0b5ccbb Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Wed, 13 May 2026 11:58:51 +0800 Subject: [PATCH 14/28] docs: restore integrated documentation page --- frontend/package-lock.json | 541 +++++++++++++++++- frontend/package.json | 1 + frontend/src/App.tsx | 8 +- frontend/src/hooks/useHighlighter.ts | 47 ++ frontend/src/pages/Docs.tsx | 622 +++++++++++++++++++++ frontend/src/pages/docs/DocsTOC.tsx | 155 +++++ frontend/src/pages/docs/EndpointDoc.tsx | 375 +++++++++++++ frontend/src/pages/docs/docsContent.ts | 519 +++++++++++++++++ frontend/src/pages/docs/quickStartTools.ts | 140 +++++ 9 files changed, 2386 insertions(+), 22 deletions(-) create mode 100644 frontend/src/hooks/useHighlighter.ts create mode 100644 frontend/src/pages/Docs.tsx create mode 100644 frontend/src/pages/docs/DocsTOC.tsx create mode 100644 frontend/src/pages/docs/EndpointDoc.tsx create mode 100644 frontend/src/pages/docs/docsContent.ts create mode 100644 frontend/src/pages/docs/quickStartTools.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 77ef7306..0aea7f3a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "react-i18next": "^16.6.6", "react-router-dom": "^7.1.0", "recharts": "^3.8.0", + "shiki": "^3.23.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2" }, @@ -1801,9 +1802,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1820,9 +1818,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1839,9 +1834,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1858,9 +1850,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1877,9 +1866,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1896,9 +1882,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1981,6 +1964,73 @@ "dev": true, "license": "MIT" }, + "node_modules/@shikijs/core": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-3.23.0.tgz", + "integrity": "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.5" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-3.23.0.tgz", + "integrity": "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.4" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2323,6 +2373,24 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz", @@ -2343,12 +2411,24 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmmirror.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -2387,6 +2467,36 @@ "node": ">=10" } }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -2408,6 +2518,16 @@ "node": ">=6" } }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz", @@ -2555,6 +2675,15 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2570,6 +2699,19 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -2645,6 +2787,42 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-parse-stringify": { "version": "3.0.1", "resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", @@ -2654,6 +2832,16 @@ "void-elements": "3.1.0" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/i18next": { "version": "25.10.9", "resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.10.9.tgz", @@ -2980,6 +3168,116 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -2998,6 +3296,23 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3044,6 +3359,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/radix-ui": { "version": "1.4.3", "resolved": "https://registry.npmmirror.com/radix-ui/-/radix-ui-1.4.3.tgz", @@ -3351,6 +3676,30 @@ "redux": "^5.0.0" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, "node_modules/reselect": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/reselect/-/reselect-5.1.1.tgz", @@ -3408,6 +3757,22 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.23.0.tgz", + "integrity": "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "3.23.0", + "@shikijs/engine-javascript": "3.23.0", + "@shikijs/engine-oniguruma": "3.23.0", + "@shikijs/langs": "3.23.0", + "@shikijs/themes": "3.23.0", + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3417,6 +3782,30 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -3468,6 +3857,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", @@ -3488,6 +3887,74 @@ "node": ">=14.17" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -3540,6 +4007,34 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/victory-vendor": { "version": "37.3.6", "resolved": "https://registry.npmmirror.com/victory-vendor/-/victory-vendor-37.3.6.tgz", @@ -3647,6 +4142,16 @@ "engines": { "node": ">=0.10.0" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/frontend/package.json b/frontend/package.json index df7af35d..e112835e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,6 +21,7 @@ "react-i18next": "^16.6.6", "react-router-dom": "^7.1.0", "recharts": "^3.8.0", + "shiki": "^3.23.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2fb6d03b..8466c7e6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,8 +13,7 @@ const OperationsErrors = lazy(() => import('./pages/OperationsErrors')) const Proxies = lazy(() => import('./pages/Proxies')) const SchedulerBoard = lazy(() => import('./pages/SchedulerBoard')) const Settings = lazy(() => import('./pages/Settings')) -const Guide = lazy(() => import('./pages/Guide')) -const ApiReference = lazy(() => import('./pages/ApiReference')) +const Docs = lazy(() => import('./pages/Docs')) const APIKeys = lazy(() => import('./pages/APIKeys')) const Usage = lazy(() => import('./pages/Usage')) const ImageStudio = lazy(() => import('./pages/ImageStudio')) @@ -42,8 +41,9 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> + } /> diff --git a/frontend/src/hooks/useHighlighter.ts b/frontend/src/hooks/useHighlighter.ts new file mode 100644 index 00000000..1ad64793 --- /dev/null +++ b/frontend/src/hooks/useHighlighter.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from 'react' +import { createHighlighterCore, type HighlighterCore } from 'shiki/core' +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import darkPlus from 'shiki/themes/dark-plus.mjs' +import json from 'shiki/langs/json.mjs' +import python from 'shiki/langs/python.mjs' +import shellscript from 'shiki/langs/shellscript.mjs' +import toml from 'shiki/langs/toml.mjs' + +let highlighterPromise: Promise | null = null + +function getHighlighter() { + if (!highlighterPromise) { + highlighterPromise = createHighlighterCore({ + themes: [darkPlus], + langs: [json, toml, shellscript, python], + engine: createJavaScriptRegexEngine(), + }) + } + return highlighterPromise +} + +export function useHighlightedHtml(code: string, lang?: string) { + const [html, setHtml] = useState('') + + useEffect(() => { + let cancelled = false + const resolvedLang = lang === 'bash' || lang === 'shell' || lang === 'curl' ? 'shellscript' : (lang || 'text') + + getHighlighter().then((hl) => { + if (cancelled) return + try { + const result = hl.codeToHtml(code, { + lang: resolvedLang, + theme: 'dark-plus', + }) + setHtml(result) + } catch { + setHtml('') + } + }) + + return () => { cancelled = true } + }, [code, lang]) + + return html +} diff --git a/frontend/src/pages/Docs.tsx b/frontend/src/pages/Docs.tsx new file mode 100644 index 00000000..3caa9cb6 --- /dev/null +++ b/frontend/src/pages/Docs.tsx @@ -0,0 +1,622 @@ +import { useEffect, useMemo, useState, type ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { Copy, Check, ClipboardCheck, ExternalLink, Sparkles, Terminal, KeyRound, Wand2, Server } from 'lucide-react' +import { api } from '../api' +import PageHeader from '../components/PageHeader' +import ToastNotice from '../components/ToastNotice' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Select } from '@/components/ui/select' +import { useToast } from '../hooks/useToast' +import { CodeBlock, EndpointDoc } from './docs/EndpointDoc' +import DocsTOC, { type DocsTOCItem } from './docs/DocsTOC' +import { QUICK_TOOLS, resolveTemplate, type QuickTool } from './docs/quickStartTools' +import { buildAdminSpecs, buildDocsMarkdown, buildEndpointSpecs } from './docs/docsContent' + +const SECTION_ICON: Record = { + 'quick-start': , + 'client-config': , + 'authentication': , + 'model-api': , + 'admin-api': , +} + +const SECTION_TONE: Record = { + 'quick-start': { text: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-500/10 dark:bg-amber-500/15', ring: 'ring-amber-500/20' }, + 'client-config': { text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-500/10 dark:bg-emerald-500/15', ring: 'ring-emerald-500/20' }, + 'authentication': { text: 'text-fuchsia-600 dark:text-fuchsia-400', bg: 'bg-fuchsia-500/10 dark:bg-fuchsia-500/15', ring: 'ring-fuchsia-500/20' }, + 'model-api': { text: 'text-sky-600 dark:text-sky-400', bg: 'bg-sky-500/10 dark:bg-sky-500/15', ring: 'ring-sky-500/20' }, + 'admin-api': { text: 'text-rose-600 dark:text-rose-400', bg: 'bg-rose-500/10 dark:bg-rose-500/15', ring: 'ring-rose-500/20' }, +} + +function OsTabs({ active, onChange }: { active: 'unix' | 'windows'; onChange: (v: 'unix' | 'windows') => void }) { + const { t } = useTranslation() + return ( +
+ +
+ ) +} + +function SegmentedTabs({ tabs, active, onChange }: { + tabs: { value: T; label: string; hint?: string }[] + active: T + onChange: (value: T) => void +}) { + return ( +
+ {tabs.map((tab) => { + const selected = active === tab.value + return ( + + ) + })} +
+ ) +} + +function QuickToolCard({ tool, baseUrl, apiKey, onCopied, onLaunched }: { + tool: QuickTool + baseUrl: string + apiKey: string + onCopied: (name: string) => void + onLaunched: (name: string) => void +}) { + const { t } = useTranslation() + const [copied, setCopied] = useState(false) + + const isProtocol = tool.kind === 'protocol' + const hasKey = Boolean(apiKey) + const previewKey = hasKey ? apiKey : 'YOUR_API_KEY' + const resolved = resolveTemplate(tool, baseUrl, previewKey) + + const handleClick = async () => { + if (isProtocol) { + if (!hasKey) return + window.open(resolved, '_blank') + onLaunched(tool.name) + return + } + try { + await navigator.clipboard.writeText(resolved) + } catch { + const ta = document.createElement('textarea') + ta.value = resolved + ta.style.cssText = 'position:fixed;left:-9999px' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + } + setCopied(true) + onCopied(tool.name) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+
+
+ {tool.glyph} +
+
+
+

{tool.name}

+ + {tool.badge} + +
+

+ {tool.blurb} +

+
+
+ +
+ ) +} + +function SectionHeader({ id, icon, tone, eyebrow, title, description }: { id: string; icon: ReactNode; tone: { text: string; bg: string; ring: string }; eyebrow?: string; title: string; description?: string }) { + return ( +
+
+ + {icon} + +
+ {eyebrow ? ( +
{eyebrow}
+ ) : null} +

{title}

+ {description ?

{description}

: null} +
+
+
+ ) +} + +export default function Docs() { + const { t } = useTranslation() + const baseUrl = useMemo(() => window.location.origin, []) + const [codexOs, setCodexOs] = useState<'unix' | 'windows'>('unix') + const [claudeOs, setClaudeOs] = useState<'unix' | 'windows'>('unix') + const [firstKey, setFirstKey] = useState('') + const [allKeys, setAllKeys] = useState<{ name: string; key: string }[]>([]) + const [copyingMd, setCopyingMd] = useState(false) + const { toast, showToast } = useToast() + const [selectedKey, setSelectedKey] = useState('') + const [activeCurl, setActiveCurl] = useState<'responses' | 'chat' | 'messages'>('responses') + const [curlModel, setCurlModel] = useState('gpt-5.4') + + useEffect(() => { + api.getAPIKeys().then((res) => { + const keys = (res.keys ?? []).map((k) => ({ name: k.name, key: k.raw_key || k.key })) + setAllKeys(keys) + if (keys.length > 0) { + setFirstKey(keys[0].key) + setSelectedKey(keys[0].key) + } + }).catch(() => {}) + }, []) + + const modelEndpoints = useMemo(() => buildEndpointSpecs(baseUrl), [baseUrl]) + const adminEndpoints = useMemo(() => buildAdminSpecs(baseUrl), [baseUrl]) + + const tocItems: DocsTOCItem[] = useMemo(() => [ + { + id: 'quick-start', + label: t('docs.toc.quickStart'), + children: [ + { id: 'qs-tools', label: t('docs.toc.qsTools') }, + { id: 'qs-curl', label: t('docs.toc.qsCurl') }, + ], + }, + { + id: 'client-config', + label: t('docs.toc.clientConfig'), + children: [ + { id: 'client-codex', label: 'Codex CLI' }, + { id: 'client-claude', label: 'Claude Code' }, + { id: 'client-mapping', label: t('docs.toc.modelMapping') }, + ], + }, + { + id: 'authentication', + label: t('docs.toc.authentication'), + }, + { + id: 'model-api', + label: t('docs.toc.modelApi'), + children: modelEndpoints.map((e) => ({ id: e.id, label: e.path, method: e.method })), + }, + { + id: 'admin-api', + label: t('docs.toc.adminApi'), + children: adminEndpoints.map((e) => ({ id: e.id, label: e.path, method: e.method })), + }, + ], [t, modelEndpoints, adminEndpoints]) + + const handleCopyMarkdown = async () => { + setCopyingMd(true) + const md = buildDocsMarkdown({ + baseUrl, + quickTools: QUICK_TOOLS, + apiKeyExample: firstKey || 'YOUR_API_KEY', + }) + try { + await navigator.clipboard.writeText(md) + showToast(t('docs.markdownCopied'), 'success') + } catch { + const ta = document.createElement('textarea') + ta.value = md + ta.style.cssText = 'position:fixed;left:-9999px' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + showToast(t('docs.markdownCopied'), 'success') + } finally { + setTimeout(() => setCopyingMd(false), 1200) + } + } + + const codexConfigDir = codexOs === 'windows' ? '%userprofile%\\.codex' : '~/.codex' + const claudeConfigDir = claudeOs === 'windows' ? '%userprofile%\\.claude' : '~/.claude' + const activeKey = selectedKey || firstKey || 'YOUR_API_KEY' + + const codexConfigToml = `model_provider = "OpenAI" +model = "gpt-5.4" +review_model = "gpt-5.4" +model_reasoning_effort = "xhigh" +disable_response_storage = true +network_access = "enabled" +model_context_window = 1000000 +model_auto_compact_token_limit = 900000 + +[model_providers.OpenAI] +name = "OpenAI" +base_url = "${baseUrl}" +wire_api = "responses" +requires_openai_auth = true` + + const codexAuthJson = `{ + "OPENAI_API_KEY": "${activeKey}" +}` + + const claudeSettingsJson = `{ + "env": { + "ANTHROPIC_BASE_URL": "${baseUrl}", + "ANTHROPIC_AUTH_TOKEN": "${activeKey}", + "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1" + } +}` + + const claudeEnvUnix = `export ANTHROPIC_BASE_URL="${baseUrl}" +export ANTHROPIC_AUTH_TOKEN="${activeKey}" +export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` + + const claudeEnvWindows = `set ANTHROPIC_BASE_URL=${baseUrl} +set ANTHROPIC_AUTH_TOKEN=${activeKey} +set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` + + const responsesCurl = `curl -X POST ${baseUrl}/v1/responses \\ + -H "Authorization: Bearer ${activeKey}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "${curlModel}", + "input": [{"role": "user", "content": [{"type": "input_text", "text": "Hello"}]}], + "stream": true + }'` + const chatCurl = `curl -X POST ${baseUrl}/v1/chat/completions \\ + -H "Authorization: Bearer ${activeKey}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "model": "${curlModel}", + "messages": [{"role": "user", "content": "Hello"}], + "stream": true + }'` + const messagesCurl = `curl -X POST ${baseUrl}/v1/messages \\ + -H "x-api-key: ${activeKey}" \\ + -H "Content-Type: application/json" \\ + -H "anthropic-version: 2023-06-01" \\ + -d '{ + "model": "${curlModel.startsWith('claude-') ? curlModel : 'claude-sonnet-4-5-20250514'}", + "max_tokens": 1024, + "messages": [{"role": "user", "content": "Hello"}] + }'` + const curlExamples = { + responses: responsesCurl, + chat: chatCurl, + messages: messagesCurl, + } + + return ( + <> + void handleCopyMarkdown()} disabled={copyingMd} className="gap-1.5"> + {copyingMd ? : } + {t('docs.copyMarkdown')} + + } + /> + + + +
+
+ {/* Mobile horizontal nav */} +
+
+ {tocItems.map((parent) => ( + + {parent.label} + + ))} +
+
+ + {/* Section 1: Quick Start */} + + + + +
+
+

{t('docs.quickStart.toolsTitle')}

+

{t('docs.quickStart.toolsDesc')}

+
+
+ {allKeys.length > 0 ? ( + <> + {t('docs.quickStart.useKey')} + +
+
+ + + {activeCurl === 'responses' ? '/v1/responses' : activeCurl === 'chat' ? '/v1/chat/completions' : '/v1/messages'} + +
+ + + + + {/* Section 2: Client Config */} + + + + +

Codex CLI

+

{t('docs.clientConfig.codexDesc')}

+ +

+ ⓘ {t('docs.clientConfig.codexConfigHint')} +

+
+ + +
+

+ {codexOs === 'windows' ? t('docs.clientConfig.codexNoteWindows') : t('docs.clientConfig.codexNoteUnix')} +

+
+
+ + + +

Claude Code

+

{t('docs.clientConfig.claudeDesc')}

+ +
+ +

{t('docs.clientConfig.claudeEnvNote')}

+ +

{t('docs.clientConfig.claudeSettingsNote')}

+
+
+
+ + + +

{t('docs.clientConfig.mappingTitle')}

+

{t('docs.clientConfig.mappingDesc')}

+
+
+ + {/* Section 3: Authentication */} + + + + +
+
+ Header + Authorization: Bearer <key> + {t('docs.authentication.bearerNote')} +
+
+ Header + x-api-key: <key> + {t('docs.authentication.xApiKeyNote')} +
+
+ Header + X-Admin-Key: <admin_secret> + {t('docs.authentication.adminNote')} +
+
+
+
+ + {/* Section 4: Model API */} + + + {modelEndpoints.map((endpoint) => ( + + ))} + + {/* Section 5: Admin API */} + + + {adminEndpoints.map((endpoint) => ( + + ))} +
+ + +
+ + ) +} diff --git a/frontend/src/pages/docs/DocsTOC.tsx b/frontend/src/pages/docs/DocsTOC.tsx new file mode 100644 index 00000000..3c0580ae --- /dev/null +++ b/frontend/src/pages/docs/DocsTOC.tsx @@ -0,0 +1,155 @@ +import { useEffect, useRef, useState } from 'react' +import { ChevronRight } from 'lucide-react' + +export type DocsTOCItem = { + id: string + label: string + children?: { id: string; label: string; method?: string }[] +} + +type DocsTOCProps = { + items: DocsTOCItem[] + title: string +} + +const METHOD_COLOR: Record = { + GET: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', + POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400', + DELETE: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', +} + +export default function DocsTOC({ items, title }: DocsTOCProps) { + const [activeId, setActiveId] = useState(items[0]?.children?.[0]?.id ?? items[0]?.id ?? '') + const [expandedParent, setExpandedParent] = useState(items[0]?.id ?? '') + const userToggledRef = useRef(false) + + useEffect(() => { + const allIds: string[] = [] + items.forEach((parent) => { + allIds.push(parent.id) + parent.children?.forEach((c) => allIds.push(c.id)) + }) + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((e) => e.isIntersecting) + .sort((a, b) => (a.boundingClientRect.top - b.boundingClientRect.top)) + if (visible.length > 0) { + const topId = visible[0].target.id + setActiveId(topId) + if (!userToggledRef.current) { + const parent = items.find( + (p) => p.id === topId || p.children?.some((c) => c.id === topId) + ) + if (parent) setExpandedParent(parent.id) + } + } + }, + { rootMargin: '-72px 0px -65% 0px', threshold: 0.1 } + ) + + for (const id of allIds) { + const el = document.getElementById(id) + if (el) observer.observe(el) + } + return () => observer.disconnect() + }, [items]) + + const scrollTo = (id: string) => { + setActiveId(id) + const el = document.getElementById(id) + if (el) el.scrollIntoView({ behavior: 'smooth' }) + } + + const toggleParent = (parentId: string) => { + userToggledRef.current = true + setExpandedParent((current) => (current === parentId ? '' : parentId)) + window.setTimeout(() => { userToggledRef.current = false }, 600) + } + + return ( + + ) +} diff --git a/frontend/src/pages/docs/EndpointDoc.tsx b/frontend/src/pages/docs/EndpointDoc.tsx new file mode 100644 index 00000000..7689bd27 --- /dev/null +++ b/frontend/src/pages/docs/EndpointDoc.tsx @@ -0,0 +1,375 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Copy, Check, Play, Loader2 } from 'lucide-react' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Select } from '@/components/ui/select' +import { + Dialog, + DialogContent, +} from '@/components/ui/dialog' +import { useHighlightedHtml } from '../../hooks/useHighlighter' + +export function CodeBlock({ label, content, lang }: { label?: string; content: string; lang?: string }) { + const [copied, setCopied] = useState(false) + const highlightedHtml = useHighlightedHtml(content, lang || (label?.endsWith('.toml') ? 'toml' : label?.endsWith('.json') ? 'json' : 'bash')) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(content) + } catch { + const ta = document.createElement('textarea') + ta.value = content + ta.style.cssText = 'position:fixed;left:-9999px' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + } + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( +
+ {label && ( +
+ {label} + +
+ )} + {!label && ( +
+ +
+ )} + {highlightedHtml ? ( +
+ ) : ( +
+          {content}
+        
+ )} +
+ ) +} + +export function MethodBadge({ method, sm }: { method: string; sm?: boolean }) { + const colors: Record = { + GET: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800', + POST: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400 border-blue-200 dark:border-blue-800', + PUT: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 border-amber-200 dark:border-amber-800', + DELETE: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 border-red-200 dark:border-red-800', + } + const size = sm ? 'px-1.5 py-0.5 rounded text-[10px]' : 'px-2.5 py-1 rounded-lg text-xs' + return ( + + {method} + + ) +} + +function StatusTabs({ tabs, active, onChange }: { tabs: { code: number; label?: string }[]; active: number; onChange: (c: number) => void }) { + return ( +
+ {tabs.map(tab => { + const isActive = active === tab.code + const codeColor = tab.code < 300 ? 'text-emerald-600 dark:text-emerald-400' + : tab.code < 400 ? 'text-amber-600 dark:text-amber-400' + : 'text-red-500 dark:text-red-400' + return ( + + ) + })} +
+ ) +} + +function TryItDialog({ open, onClose, method, path, defaultBody, apiKey, baseUrl, allKeys }: { + open: boolean + onClose: () => void + method: string + path: string + defaultBody: string + apiKey: string + baseUrl: string + allKeys: { name: string; key: string }[] +}) { + const { t } = useTranslation() + const [body, setBody] = useState(defaultBody) + const [token, setToken] = useState(apiKey) + const [response, setResponse] = useState('') + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(false) + const [duration, setDuration] = useState(null) + + useEffect(() => { + if (open) { + setBody(defaultBody) + setToken(apiKey) + setResponse('') + setStatus(null) + setDuration(null) + } + }, [open, defaultBody, apiKey]) + + const handleSend = async () => { + setLoading(true) + setResponse('') + setStatus(null) + setDuration(null) + const start = performance.now() + try { + const isAdmin = path.startsWith('/api/admin') + const headers: Record = { 'Content-Type': 'application/json' } + if (isAdmin) { + headers['X-Admin-Key'] = token + } else if (path === '/v1/messages') { + headers['x-api-key'] = token + headers['anthropic-version'] = '2023-06-01' + } else { + headers['Authorization'] = `Bearer ${token}` + } + + const isGet = method === 'GET' + const url = baseUrl + path + const res = await fetch(url, { + method, + headers: isGet ? { 'Authorization': `Bearer ${token}`, 'X-Admin-Key': token } : headers, + body: isGet ? undefined : body.trim() || undefined, + }) + setStatus(res.status) + setDuration(Math.round(performance.now() - start)) + const text = await res.text() + try { + setResponse(JSON.stringify(JSON.parse(text), null, 2)) + } catch { + setResponse(text) + } + } catch (e) { + setDuration(Math.round(performance.now() - start)) + setResponse(`Error: ${e instanceof Error ? e.message : String(e)}`) + } finally { + setLoading(false) + } + } + + const statusColor = status === null ? '' : status < 300 ? 'text-emerald-600' : status < 400 ? 'text-amber-600' : 'text-red-500' + const statusBg = status === null ? '' : status < 300 ? 'bg-emerald-50 dark:bg-emerald-900/20' : status < 400 ? 'bg-amber-50 dark:bg-amber-900/20' : 'bg-red-50 dark:bg-red-900/20' + + return ( + { if (!v) onClose() }}> + +
+
+ + {path} +
+ +
+ +
+
+
+
+ Authorization +
+
+
+
+
+ + {path === '/v1/messages' ? 'x-api-key' : path.startsWith('/api/admin') ? 'X-Admin-Key' : 'Authorization'} + + string +
+ required +
+ setToken(e.target.value)} + /> +
+ {allKeys.length > 0 && ( +
+ {t('apiRef.tryIt.selectKey')} +