fix: 修复 Admin 后台多个 bug
- api.ts: 修复 URL 拼接 bug(缺少 API_BASE) 和 204 判断逻辑 - dal.ts: 修复 fetchDashboard 类型错乱,修正 API 端点 - types.ts: 对齐后端 Pydantic 模型 - 所有页面: Badge variant→status, CardHeader children→action - 移除不存在的后端端点调用
This commit is contained in:
@@ -1,245 +1,60 @@
|
||||
/**
|
||||
* Admin 后台 - 配置管理页面
|
||||
* Admin 后台 - 配置管理
|
||||
*
|
||||
* 功能:
|
||||
* - 查看当前 API Key 配置(脱敏显示)
|
||||
* - 更新 LLM / 数据源密钥
|
||||
* - 配置分类管理(LLM / 数据源 / 系统)
|
||||
* 提示: API Key 配置通过 .env 文件管理,不在前端明文存储。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchConfigs, updateConfig } from '../dal'
|
||||
import type { ConfigEntry } from '../types'
|
||||
import { Card, CardBody, CardHeader, Badge, EmptyState } from '../components'
|
||||
import { SectionHeader } from '../components'
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
llm: 'LLM 服务',
|
||||
datasource: '数据源',
|
||||
system: '系统配置',
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER = ['llm', 'datasource', 'system']
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<ConfigEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs()
|
||||
.then(setConfigs)
|
||||
.catch(err => setError(err instanceof Error ? err.message : '加载配置失败'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
function startEdit(key: string, currentValue: string) {
|
||||
setEditing(key)
|
||||
setEditValue(currentValue.replace(/\*+$/, '')) // 去掉脱敏星号
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
}
|
||||
|
||||
async function saveEdit(key: string) {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
try {
|
||||
await updateConfig({ key, value: editValue })
|
||||
setSuccessMsg(`${key} 已更新`)
|
||||
setEditing(null)
|
||||
// 刷新列表
|
||||
const updated = await fetchConfigs()
|
||||
setConfigs(updated)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 按分类分组
|
||||
const grouped = CATEGORY_ORDER.reduce(
|
||||
(acc, cat) => {
|
||||
acc[cat] = configs.filter(c => c.category === cat)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ConfigEntry[]>,
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="配置管理"
|
||||
description="管理 API Key 和系统参数(存储在 .env 文件)"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white">配置管理</h2>
|
||||
<p className="mt-1 text-sm text-gray-400">API Key 与系统配置</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-400">
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded border border-yellow-500/30 bg-yellow-500/10 p-4">
|
||||
<p className="text-sm text-yellow-300">
|
||||
⚠️ API Key 通过后端 <code className="rounded bg-gray-800 px-1">.env</code> 文件管理,不在前端明文显示。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<EmptyState text="加载配置中..." />
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : configs.length === 0 ? (
|
||||
<Card>
|
||||
<CardBody>
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-gray-500">暂无可管理的配置项</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
后端暂未实现配置管理 API,配置需通过 .env 文件手动管理
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
CATEGORY_ORDER.map(cat => {
|
||||
const items = grouped[cat]
|
||||
if (!items || items.length === 0) return null
|
||||
return (
|
||||
<Card key={cat}>
|
||||
<CardHeader title={CATEGORY_LABELS[cat]} />
|
||||
<CardBody className="p-0">
|
||||
<div className="divide-y divide-gray-800">
|
||||
{items.map(cfg => (
|
||||
<ConfigRow
|
||||
key={cfg.key}
|
||||
config={cfg}
|
||||
editing={editing === cfg.key}
|
||||
editValue={editValue}
|
||||
saving={saving}
|
||||
onEditValueChange={setEditValue}
|
||||
onStartEdit={() => startEdit(cfg.key, cfg.value)}
|
||||
onSave={() => saveEdit(cfg.key)}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* 配置说明 */}
|
||||
<Card>
|
||||
<CardHeader title="配置说明" />
|
||||
<CardBody>
|
||||
<div className="space-y-3 text-xs text-gray-500">
|
||||
<p>
|
||||
<strong className="text-gray-400">LLM 服务:</strong> 用于五路专家预测的大语言模型 API Key
|
||||
(如 OpenAI、Anthropic)
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-gray-400">数据源:</strong> 数据采集服务的认证密钥
|
||||
(如 Understat、Bzzoiro)
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-gray-400">系统配置:</strong> 数据库连接、日志级别等运行时参数
|
||||
</p>
|
||||
<p className="rounded-md border border-yellow-500/20 bg-yellow-500/5 px-3 py-2 text-yellow-400/80">
|
||||
⚠️ 配置修改后需要重启服务才能生效
|
||||
</p>
|
||||
<div className="rounded border border-gray-700 bg-gray-800 p-4">
|
||||
<h3 className="mb-3 text-sm font-medium text-white">配置项</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between border-b border-gray-700 py-2">
|
||||
<span className="text-gray-400">LLM_API_KEY</span>
|
||||
<span className="text-gray-300">通过 .env 配置</span>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConfigRow({
|
||||
config,
|
||||
editing,
|
||||
editValue,
|
||||
saving,
|
||||
onEditValueChange,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
config: ConfigEntry
|
||||
editing: boolean
|
||||
editValue: string
|
||||
saving: boolean
|
||||
onEditValueChange: (v: string) => void
|
||||
onStartEdit: () => void
|
||||
onSave: () => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-5 py-3">
|
||||
{/* 键名 */}
|
||||
<div className="w-48 flex-shrink-0">
|
||||
<div className="font-mono text-xs text-gray-400">{config.key}</div>
|
||||
{config.description && (
|
||||
<div className="mt-0.5 text-[10px] text-gray-600">{config.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 值 */}
|
||||
<div className="flex-1">
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={e => onEditValueChange(e.target.value)}
|
||||
className="flex-1 rounded-md border border-gray-600 bg-gray-800 px-2 py-1 text-xs text-gray-200 focus:border-blue-500 focus:outline-none"
|
||||
autoFocus
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') onSave()
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={saving}
|
||||
className="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded border border-gray-700 px-2 py-1 text-xs text-gray-400 hover:border-gray-600"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="font-mono text-xs text-gray-300">{config.value}</code>
|
||||
{config.updated_at && (
|
||||
<span className="text-[10px] text-gray-600">
|
||||
更新于 {new Date(config.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作 */}
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={onStartEdit}
|
||||
className="flex-shrink-0 rounded border border-gray-700 px-2 py-1 text-xs text-gray-400 hover:border-gray-600"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
<div className="flex justify-between border-b border-gray-700 py-2">
|
||||
<span className="text-gray-400">LLM_BASE_URL</span>
|
||||
<span className="text-gray-300">通过 .env 配置</span>
|
||||
</div>
|
||||
<div className="flex justify-between border-b border-gray-700 py-2">
|
||||
<span className="text-gray-400">BZZOIRO_KEY</span>
|
||||
<span className="text-gray-300">通过 .env 配置</span>
|
||||
</div>
|
||||
<div className="flex justify-between py-2">
|
||||
<span className="text-gray-400">API_FOOTBALL_KEY</span>
|
||||
<span className="text-gray-300">通过 .env 配置</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded border border-gray-700 bg-gray-800 p-4">
|
||||
<h3 className="mb-3 text-sm font-medium text-white">修改配置</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
请通过 SSH 连接到 NAS,编辑:
|
||||
</p>
|
||||
<pre className="mt-2 rounded bg-gray-900 p-3 text-xs text-green-400">
|
||||
{`cd /vol2/1000/Docker/Profeto
|
||||
# 编辑 .env 文件
|
||||
LLM_API_KEY=sk-你的真实密钥
|
||||
BZZOIRO_KEY=你的密钥
|
||||
|
||||
# 重启后端
|
||||
docker compose restart api`}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user