feat: Implement admin settings UI, enhance admin authentication with password hashing, and add new streaming runtime logic for Claude and OpenAI adapters with extensive compatibility tests.

This commit is contained in:
CJACK
2026-02-19 02:45:38 +08:00
parent d21aedac83
commit 7307a5cc9a
64 changed files with 4078 additions and 967 deletions

View File

@@ -11,6 +11,7 @@ import {
Key,
Upload,
Cloud,
Settings as SettingsIcon,
LogOut,
Menu,
X,
@@ -23,12 +24,13 @@ import AccountManager from './components/AccountManager'
import ApiTester from './components/ApiTester'
import BatchImport from './components/BatchImport'
import VercelSync from './components/VercelSync'
import Settings from './components/Settings'
import Login from './components/Login'
import LandingPage from './components/LandingPage'
import LanguageToggle from './components/LanguageToggle'
import { useI18n } from './i18n'
function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message }) {
function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message, onForceLogout }) {
const { t } = useI18n()
const [activeTab, setActiveTab] = useState('accounts')
const [sidebarOpen, setSidebarOpen] = useState(false)
@@ -39,6 +41,7 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message
{ id: 'test', label: t('nav.test.label'), icon: Server, description: t('nav.test.desc') },
{ id: 'import', label: t('nav.import.label'), icon: Upload, description: t('nav.import.desc') },
{ id: 'vercel', label: t('nav.vercel.label'), icon: Cloud, description: t('nav.vercel.desc') },
{ id: 'settings', label: t('nav.settings.label'), icon: SettingsIcon, description: t('nav.settings.desc') },
]
const authFetch = async (url, options = {}) => {
@@ -65,6 +68,8 @@ function Dashboard({ token, onLogout, config, fetchConfig, showMessage, message
return <BatchImport onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} />
case 'vercel':
return <VercelSync onMessage={showMessage} authFetch={authFetch} />
case 'settings':
return <Settings onRefresh={fetchConfig} onMessage={showMessage} authFetch={authFetch} onForceLogout={onForceLogout} />
default:
return null
}
@@ -314,6 +319,7 @@ export default function App() {
fetchConfig={fetchConfig}
showMessage={showMessage}
message={message}
onForceLogout={handleLogout}
/>
) : (
<div className="min-h-screen flex flex-col bg-background relative overflow-hidden">

View File

@@ -0,0 +1,376 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { AlertTriangle, Download, Lock, Save, Upload } from 'lucide-react'
import { useI18n } from '../i18n'
export default function Settings({ onRefresh, onMessage, authFetch, onForceLogout }) {
const { t } = useI18n()
const apiFetch = authFetch || fetch
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [changingPassword, setChangingPassword] = useState(false)
const [importing, setImporting] = useState(false)
const [exportData, setExportData] = useState(null)
const [importMode, setImportMode] = useState('merge')
const [importText, setImportText] = useState('')
const [newPassword, setNewPassword] = useState('')
const [settingsMeta, setSettingsMeta] = useState({ default_password_warning: false, env_backed: false, needs_vercel_sync: false })
const [form, setForm] = useState({
admin: { jwt_expire_hours: 24 },
runtime: { account_max_inflight: 2, account_max_queue: 10, global_max_inflight: 10 },
toolcall: { mode: 'feature_match', early_emit_confidence: 'high' },
responses: { store_ttl_seconds: 900 },
embeddings: { provider: '' },
claude_mapping_text: '{\n "fast": "deepseek-chat",\n "slow": "deepseek-reasoner"\n}',
model_aliases_text: '{}',
})
const parseJSONMap = (raw, fieldName) => {
const text = String(raw || '').trim()
if (!text) {
return {}
}
let parsed
try {
parsed = JSON.parse(text)
} catch (_e) {
throw new Error(t('settings.invalidJsonField', { field: fieldName }))
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(t('settings.invalidJsonField', { field: fieldName }))
}
return parsed
}
const loadSettings = useCallback(async () => {
setLoading(true)
try {
const res = await apiFetch('/admin/settings')
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.loadFailed'))
return
}
setSettingsMeta({
default_password_warning: Boolean(data.admin?.default_password_warning),
env_backed: Boolean(data.env_backed),
needs_vercel_sync: Boolean(data.needs_vercel_sync),
})
setForm({
admin: { jwt_expire_hours: Number(data.admin?.jwt_expire_hours || 24) },
runtime: {
account_max_inflight: Number(data.runtime?.account_max_inflight || 2),
account_max_queue: Number(data.runtime?.account_max_queue || 10),
global_max_inflight: Number(data.runtime?.global_max_inflight || 10),
},
toolcall: {
mode: data.toolcall?.mode || 'feature_match',
early_emit_confidence: data.toolcall?.early_emit_confidence || 'high',
},
responses: {
store_ttl_seconds: Number(data.responses?.store_ttl_seconds || 900),
},
embeddings: {
provider: data.embeddings?.provider || '',
},
claude_mapping_text: JSON.stringify(data.claude_mapping || {}, null, 2),
model_aliases_text: JSON.stringify(data.model_aliases || {}, null, 2),
})
} catch (e) {
onMessage('error', t('settings.loadFailed'))
// eslint-disable-next-line no-console
console.error(e)
} finally {
setLoading(false)
}
}, [apiFetch, onMessage, t])
useEffect(() => {
loadSettings()
}, [loadSettings])
const saveSettings = async () => {
let claudeMapping = {}
let modelAliases = {}
try {
claudeMapping = parseJSONMap(form.claude_mapping_text, 'claude_mapping')
modelAliases = parseJSONMap(form.model_aliases_text, 'model_aliases')
} catch (e) {
onMessage('error', e.message)
return
}
const payload = {
admin: { jwt_expire_hours: Number(form.admin.jwt_expire_hours) },
runtime: {
account_max_inflight: Number(form.runtime.account_max_inflight),
account_max_queue: Number(form.runtime.account_max_queue),
global_max_inflight: Number(form.runtime.global_max_inflight),
},
toolcall: {
mode: String(form.toolcall.mode || '').trim(),
early_emit_confidence: String(form.toolcall.early_emit_confidence || '').trim(),
},
responses: { store_ttl_seconds: Number(form.responses.store_ttl_seconds) },
embeddings: { provider: String(form.embeddings.provider || '').trim() },
claude_mapping: claudeMapping,
model_aliases: modelAliases,
}
setSaving(true)
try {
const res = await apiFetch('/admin/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.saveFailed'))
return
}
onMessage('success', t('settings.saveSuccess'))
if (typeof onRefresh === 'function') {
onRefresh()
}
await loadSettings()
} catch (e) {
onMessage('error', t('settings.saveFailed'))
// eslint-disable-next-line no-console
console.error(e)
} finally {
setSaving(false)
}
}
const updatePassword = async () => {
if (String(newPassword || '').trim().length < 4) {
onMessage('error', t('settings.passwordTooShort'))
return
}
setChangingPassword(true)
try {
const res = await apiFetch('/admin/settings/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_password: newPassword.trim() }),
})
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.passwordUpdateFailed'))
return
}
onMessage('success', t('settings.passwordUpdated'))
setNewPassword('')
if (typeof onForceLogout === 'function') {
onForceLogout()
}
} catch (e) {
onMessage('error', t('settings.passwordUpdateFailed'))
} finally {
setChangingPassword(false)
}
}
const loadExportData = async () => {
try {
const res = await apiFetch('/admin/config/export')
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.exportFailed'))
return
}
setExportData(data)
onMessage('success', t('settings.exportLoaded'))
} catch (e) {
onMessage('error', t('settings.exportFailed'))
}
}
const doImport = async () => {
if (!String(importText || '').trim()) {
onMessage('error', t('settings.importEmpty'))
return
}
let parsed
try {
parsed = JSON.parse(importText)
} catch (_e) {
onMessage('error', t('settings.importInvalidJson'))
return
}
setImporting(true)
try {
const res = await apiFetch(`/admin/config/import?mode=${encodeURIComponent(importMode)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: parsed, mode: importMode }),
})
const data = await res.json()
if (!res.ok) {
onMessage('error', data.detail || t('settings.importFailed'))
return
}
onMessage('success', t('settings.importSuccess', { mode: importMode }))
if (typeof onRefresh === 'function') {
onRefresh()
}
await loadSettings()
} catch (e) {
onMessage('error', t('settings.importFailed'))
} finally {
setImporting(false)
}
}
const syncHintVisible = useMemo(() => settingsMeta.env_backed || settingsMeta.needs_vercel_sync, [settingsMeta.env_backed, settingsMeta.needs_vercel_sync])
return (
<div className="space-y-6">
{settingsMeta.default_password_warning && (
<div className="p-4 rounded-lg border border-amber-300/30 bg-amber-500/10 text-amber-700 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{t('settings.defaultPasswordWarning')}</span>
</div>
)}
{syncHintVisible && (
<div className="p-4 rounded-lg border border-amber-300/30 bg-amber-500/10 text-amber-700 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm">{t('settings.vercelSyncHint')}</span>
</div>
)}
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.securityTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.jwtExpireHours')}</span>
<input
type="number"
min={1}
max={720}
value={form.admin.jwt_expire_hours}
onChange={(e) => setForm((prev) => ({ ...prev, admin: { ...prev.admin, jwt_expire_hours: Number(e.target.value || 1) } }))}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.newPassword')}</span>
<div className="flex gap-2">
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t('settings.newPasswordPlaceholder')}
className="w-full bg-background border border-border rounded-lg px-3 py-2"
/>
<button
type="button"
onClick={updatePassword}
disabled={changingPassword}
className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-1"
>
<Lock className="w-4 h-4" />
{changingPassword ? t('settings.updating') : t('settings.updatePassword')}
</button>
</div>
</label>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.runtimeTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.accountMaxInflight')}</span>
<input type="number" min={1} value={form.runtime.account_max_inflight} onChange={(e) => setForm((prev) => ({ ...prev, runtime: { ...prev.runtime, account_max_inflight: Number(e.target.value || 1) } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.accountMaxQueue')}</span>
<input type="number" min={1} value={form.runtime.account_max_queue} onChange={(e) => setForm((prev) => ({ ...prev, runtime: { ...prev.runtime, account_max_queue: Number(e.target.value || 1) } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.globalMaxInflight')}</span>
<input type="number" min={1} value={form.runtime.global_max_inflight} onChange={(e) => setForm((prev) => ({ ...prev, runtime: { ...prev.runtime, global_max_inflight: Number(e.target.value || 1) } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.behaviorTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.toolcallMode')}</span>
<select value={form.toolcall.mode} onChange={(e) => setForm((prev) => ({ ...prev, toolcall: { ...prev.toolcall, mode: e.target.value } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2">
<option value="feature_match">feature_match</option>
<option value="off">off</option>
</select>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.earlyEmitConfidence')}</span>
<select value={form.toolcall.early_emit_confidence} onChange={(e) => setForm((prev) => ({ ...prev, toolcall: { ...prev.toolcall, early_emit_confidence: e.target.value } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2">
<option value="high">high</option>
<option value="low">low</option>
<option value="off">off</option>
</select>
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.responsesTTL')}</span>
<input type="number" min={30} value={form.responses.store_ttl_seconds} onChange={(e) => setForm((prev) => ({ ...prev, responses: { ...prev.responses, store_ttl_seconds: Number(e.target.value || 30) } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.embeddingsProvider')}</span>
<input type="text" value={form.embeddings.provider} onChange={(e) => setForm((prev) => ({ ...prev, embeddings: { ...prev.embeddings, provider: e.target.value } }))} className="w-full bg-background border border-border rounded-lg px-3 py-2" />
</label>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.modelTitle')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.claudeMapping')}</span>
<textarea value={form.claude_mapping_text} onChange={(e) => setForm((prev) => ({ ...prev, claude_mapping_text: e.target.value }))} rows={8} className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs" />
</label>
<label className="text-sm space-y-2">
<span className="text-muted-foreground">{t('settings.modelAliases')}</span>
<textarea value={form.model_aliases_text} onChange={(e) => setForm((prev) => ({ ...prev, model_aliases_text: e.target.value }))} rows={8} className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs" />
</label>
</div>
</div>
<div className="bg-card border border-border rounded-xl p-5 space-y-4">
<h3 className="font-semibold">{t('settings.backupTitle')}</h3>
<div className="flex flex-wrap items-center gap-3">
<button type="button" onClick={loadExportData} className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2">
<Download className="w-4 h-4" />
{t('settings.loadExport')}
</button>
<select value={importMode} onChange={(e) => setImportMode(e.target.value)} className="bg-background border border-border rounded-lg px-3 py-2 text-sm">
<option value="merge">{t('settings.importModeMerge')}</option>
<option value="replace">{t('settings.importModeReplace')}</option>
</select>
<button type="button" onClick={doImport} disabled={importing} className="px-3 py-2 rounded-lg bg-secondary border border-border hover:bg-secondary/80 text-sm flex items-center gap-2">
<Upload className="w-4 h-4" />
{importing ? t('settings.importing') : t('settings.importNow')}
</button>
</div>
<textarea value={importText} onChange={(e) => setImportText(e.target.value)} rows={8} className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs" placeholder={t('settings.importPlaceholder')} />
{exportData && (
<div className="space-y-2">
<label className="text-sm text-muted-foreground">{t('settings.exportJson')}</label>
<textarea value={exportData.json || ''} readOnly rows={6} className="w-full bg-background border border-border rounded-lg px-3 py-2 font-mono text-xs" />
</div>
)}
</div>
<div className="flex justify-end">
<button type="button" onClick={saveSettings} disabled={loading || saving} className="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 flex items-center gap-2">
<Save className="w-4 h-4" />
{saving ? t('settings.saving') : t('settings.save')}
</button>
</div>
</div>
)
}

View File

@@ -20,6 +20,10 @@
"vercel": {
"label": "Vercel Sync",
"desc": "Sync configuration to Vercel"
},
"settings": {
"label": "Settings",
"desc": "Edit runtime and security settings online"
}
},
"sidebar": {
@@ -192,6 +196,51 @@
"importComplete": "Import complete",
"importSummary": "Imported {keys} API keys and updated {accounts} accounts."
},
"settings": {
"loadFailed": "Failed to load settings.",
"save": "Save settings",
"saving": "Saving...",
"saveSuccess": "Settings saved and hot reloaded.",
"saveFailed": "Failed to save settings.",
"securityTitle": "Security",
"jwtExpireHours": "JWT expiry (hours)",
"newPassword": "New admin password",
"newPasswordPlaceholder": "Enter new password (min 4 chars)",
"updatePassword": "Update password",
"updating": "Updating...",
"passwordTooShort": "Password must be at least 4 characters.",
"passwordUpdated": "Password updated. Please sign in again.",
"passwordUpdateFailed": "Failed to update password.",
"runtimeTitle": "Concurrency & Queue",
"accountMaxInflight": "Per-account max inflight",
"accountMaxQueue": "Account max queue size",
"globalMaxInflight": "Global max inflight",
"behaviorTitle": "Behavior",
"toolcallMode": "Toolcall mode",
"earlyEmitConfidence": "Early emit confidence",
"responsesTTL": "Responses store TTL (seconds)",
"embeddingsProvider": "Embeddings provider",
"modelTitle": "Model mapping",
"claudeMapping": "Claude mapping (JSON)",
"modelAliases": "Model aliases (JSON)",
"backupTitle": "Backup & Restore",
"loadExport": "Load current export",
"importModeMerge": "Merge import (default)",
"importModeReplace": "Replace all import",
"importNow": "Import now",
"importing": "Importing...",
"importPlaceholder": "Paste config JSON to import",
"importEmpty": "Please input import JSON.",
"importInvalidJson": "Import JSON is invalid.",
"importFailed": "Import failed.",
"importSuccess": "Config imported (mode: {mode}).",
"exportFailed": "Export failed.",
"exportLoaded": "Current export loaded.",
"exportJson": "Export JSON",
"invalidJsonField": "{field} is not a valid JSON object.",
"defaultPasswordWarning": "You are using the default admin password \"admin\". Please change it.",
"vercelSyncHint": "Configuration changed. For Vercel deployments, sync manually in Vercel Sync and redeploy."
},
"login": {
"welcome": "Welcome back",
"subtitle": "Enter your admin key to continue",

View File

@@ -20,6 +20,10 @@
"vercel": {
"label": "Vercel 同步",
"desc": "同步配置到 Vercel"
},
"settings": {
"label": "设置中心",
"desc": "在线修改系统设置与配置"
}
},
"sidebar": {
@@ -192,6 +196,51 @@
"importComplete": "导入操作已完成",
"importSummary": "成功导入了 {keys} 个 API 密钥,并更新了 {accounts} 个账号。"
},
"settings": {
"loadFailed": "加载设置失败",
"save": "保存设置",
"saving": "保存中...",
"saveSuccess": "设置已保存并热更新生效",
"saveFailed": "保存设置失败",
"securityTitle": "安全设置",
"jwtExpireHours": "JWT 有效期(小时)",
"newPassword": "面板新密码",
"newPasswordPlaceholder": "输入新密码(至少 4 位)",
"updatePassword": "修改密码",
"updating": "更新中...",
"passwordTooShort": "新密码至少 4 位",
"passwordUpdated": "密码已更新,需重新登录",
"passwordUpdateFailed": "密码更新失败",
"runtimeTitle": "并发与队列",
"accountMaxInflight": "每账号并发上限",
"accountMaxQueue": "账号等待队列上限",
"globalMaxInflight": "全局并发上限",
"behaviorTitle": "行为设置",
"toolcallMode": "Toolcall 模式",
"earlyEmitConfidence": "早发置信度",
"responsesTTL": "Responses 缓存 TTL",
"embeddingsProvider": "Embeddings Provider",
"modelTitle": "模型映射",
"claudeMapping": "Claude 映射JSON",
"modelAliases": "模型别名JSON",
"backupTitle": "备份与恢复",
"loadExport": "加载当前导出",
"importModeMerge": "合并导入(默认)",
"importModeReplace": "全量覆盖导入",
"importNow": "立即导入",
"importing": "导入中...",
"importPlaceholder": "粘贴要导入的 JSON 配置",
"importEmpty": "请先输入导入 JSON",
"importInvalidJson": "导入 JSON 格式无效",
"importFailed": "导入失败",
"importSuccess": "配置导入成功(模式:{mode}",
"exportFailed": "导出失败",
"exportLoaded": "已加载当前配置导出",
"exportJson": "导出 JSON",
"invalidJsonField": "{field} 不是有效 JSON 对象",
"defaultPasswordWarning": "当前使用默认密码 admin请尽快在此修改。",
"vercelSyncHint": "当前配置已更新。Vercel 部署请到 Vercel 同步页面手动同步并重部署。"
},
"login": {
"welcome": "欢迎回来",
"subtitle": "请输入管理员密钥以继续",