mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-10 03:07:41 +08:00
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:
@@ -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">
|
||||
|
||||
376
webui/src/components/Settings.jsx
Normal file
376
webui/src/components/Settings.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "请输入管理员密钥以继续",
|
||||
|
||||
Reference in New Issue
Block a user