- crypto.py: API Key 加密/解密工具 - runtime_config.py: 运行时动态配置管理 - log_buffer.py: 内存日志缓冲区 - config.py: 新增加密配置项 - http_client.py: 增强重试和错误处理
268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
/**
|
|
* Admin 后台 - 系统配置管理页面(报刊风)
|
|
*
|
|
* 功能:
|
|
* - 登录与鉴权说明(ADMIN_PASSWORD,HttpOnly 会话 Cookie)
|
|
* - 显示当前 .env 配置(脱敏;后端暂无配置端点,为静态说明)
|
|
* - 配置修改指南
|
|
*/
|
|
|
|
import { useEffect, useState, useCallback } from 'react'
|
|
import { fetchSystemConfig } from '../dal'
|
|
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
|
|
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
|
|
|
export default function ConfigPage() {
|
|
const [config, setConfig] = useState<any[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [passwordOrigin, setPasswordOrigin] = useState<'db' | 'env' | 'none' | null>(null)
|
|
|
|
// 修改密码表单
|
|
const [currentPwd, setCurrentPwd] = useState('')
|
|
const [newPwd, setNewPwd] = useState('')
|
|
const [confirmPwd, setConfirmPwd] = useState('')
|
|
const [pwdBusy, setPwdBusy] = useState(false)
|
|
const [pwdNotice, setPwdNotice] = useState<{ ok: boolean; text: string } | null>(null)
|
|
|
|
const loadConfig = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await fetchSystemConfig()
|
|
setConfig(data)
|
|
} catch {
|
|
setConfig([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadConfig()
|
|
fetchAuthState()
|
|
.then(s => setPasswordOrigin(s.password_origin ?? null))
|
|
.catch(() => setPasswordOrigin(null))
|
|
}, [loadConfig])
|
|
|
|
async function handleChangePassword(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setPwdNotice(null)
|
|
if (newPwd !== confirmPwd) {
|
|
setPwdNotice({ ok: false, text: '两次输入的新密码不一致' })
|
|
return
|
|
}
|
|
setPwdBusy(true)
|
|
try {
|
|
const res = await changePassword(currentPwd, newPwd)
|
|
setPwdNotice({ ok: true, text: res.message })
|
|
// 密码即会话密钥,修改后所有会话失效:主动切回登录页
|
|
setTimeout(() => window.dispatchEvent(new CustomEvent(UNAUTHORIZED_EVENT)), 1500)
|
|
} catch (err) {
|
|
setPwdNotice({ ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '修改失败' })
|
|
} finally {
|
|
setPwdBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SectionHeader
|
|
title="系统配置"
|
|
description="登录鉴权说明与系统参数查看。敏感配置一律通过服务器 .env 文件管理。"
|
|
/>
|
|
|
|
{/* 登录与鉴权 */}
|
|
<Card>
|
|
<CardHeader
|
|
title="登录与鉴权"
|
|
description="本后台通过密码登录保护,会话以 HttpOnly Cookie 保存,有效期默认 7 天"
|
|
/>
|
|
<CardBody>
|
|
<Alert kind="ok" title="已通过密码登录" />
|
|
<p className="mt-3 text-2xs leading-relaxed text-ink-500">
|
|
密码初始来自服务器 <code className="font-mono">.env</code> 的{' '}
|
|
<code className="font-mono">ADMIN_PASSWORD</code>(启动时自动转为哈希),在下方修改后以{' '}
|
|
<code className="font-mono">scrypt</code> 哈希安全存入数据库并立即生效,明文不再留存。
|
|
密码即会话签名密钥,修改后所有已登录会话失效,需用新密码重新登录。
|
|
脚本直连接口可改用 <code className="font-mono">ADMIN_API_KEY</code>(请求头 X-API-Key)。
|
|
</p>
|
|
{passwordOrigin && (
|
|
<p className="mt-2 flex items-center gap-2 text-2xs text-ink-500">
|
|
当前密码来源:
|
|
{passwordOrigin === 'db' ? (
|
|
<Badge status="success">数据库(scrypt 哈希)</Badge>
|
|
) : passwordOrigin === 'env' ? (
|
|
<Badge status="info">.env 初始值</Badge>
|
|
) : (
|
|
<Badge status="error">未配置</Badge>
|
|
)}
|
|
</p>
|
|
)}
|
|
|
|
{/* 修改密码表单 */}
|
|
<form onSubmit={handleChangePassword} className="mt-5 space-y-3 border-t border-ink-200 pt-4">
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<div>
|
|
<label className="mb-1 block text-2xs text-ink-500">当前密码</label>
|
|
<input
|
|
type="password"
|
|
value={currentPwd}
|
|
onChange={e => setCurrentPwd(e.target.value)}
|
|
autoComplete="current-password"
|
|
className="field w-full"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-2xs text-ink-500">新密码(至少 8 位)</label>
|
|
<input
|
|
type="password"
|
|
value={newPwd}
|
|
onChange={e => setNewPwd(e.target.value)}
|
|
autoComplete="new-password"
|
|
className="field w-full"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-2xs text-ink-500">确认新密码</label>
|
|
<input
|
|
type="password"
|
|
value={confirmPwd}
|
|
onChange={e => setConfirmPwd(e.target.value)}
|
|
autoComplete="new-password"
|
|
className="field w-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{pwdNotice && (
|
|
<Alert kind={pwdNotice.ok ? 'ok' : 'error'} title={pwdNotice.text} />
|
|
)}
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="text-2xs text-ink-400">修改成功后会自动退出登录,请用新密码重新登录。</p>
|
|
<button
|
|
type="submit"
|
|
disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd}
|
|
className="btn btn-solid btn-sm flex-shrink-0"
|
|
>
|
|
{pwdBusy ? (<><Spinner /> 修改中</>) : '修改密码'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 快速导航 */}
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<a
|
|
href="/admin/data-sources"
|
|
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
>
|
|
<div>
|
|
<div className="font-serif text-sm font-bold text-ink-900">数据源配置</div>
|
|
<div className="mt-0.5 text-2xs text-ink-500">管理采集源 API Key</div>
|
|
</div>
|
|
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
|
</a>
|
|
<a
|
|
href="/admin/llm-config"
|
|
className="group flex items-center justify-between border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
|
|
>
|
|
<div>
|
|
<div className="font-serif text-sm font-bold text-ink-900">LLM 配置</div>
|
|
<div className="mt-0.5 text-2xs text-ink-500">管理模型连接与统计</div>
|
|
</div>
|
|
<span className="text-ink-300 transition-colors group-hover:text-press" aria-hidden="true">→</span>
|
|
</a>
|
|
</div>
|
|
|
|
{/* 配置列表 */}
|
|
<Card>
|
|
<CardHeader
|
|
title="当前配置"
|
|
description="脱敏展示,实际值在服务器 .env 文件中"
|
|
action={
|
|
<button onClick={loadConfig} disabled={loading} className="btn btn-sm">
|
|
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
|
</button>
|
|
}
|
|
/>
|
|
<CardBody className="px-0 sm:px-0">
|
|
{loading ? (
|
|
<div className="space-y-3 px-4 sm:px-5">
|
|
{[1, 2, 3, 4, 5].map(i => (
|
|
<SkeletonBlock key={i} className="h-9 w-full" />
|
|
))}
|
|
</div>
|
|
) : config.length > 0 ? (
|
|
<div>
|
|
{config.map(item => (
|
|
<div
|
|
key={item.key}
|
|
className="flex flex-col gap-1 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:grid sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)_minmax(0,2fr)] sm:items-baseline sm:gap-4 sm:px-5"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs text-ink-800">{item.key}</span>
|
|
{item.is_sensitive && <Badge status="warning">敏感</Badge>}
|
|
</div>
|
|
<div className="break-all font-mono text-2xs text-ink-500">{item.value_masked}</div>
|
|
<div className="text-2xs text-ink-400">{item.description}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="py-8 text-center text-xs text-ink-400">无法加载配置信息</p>
|
|
)}
|
|
</CardBody>
|
|
</Card>
|
|
|
|
{/* 修改指南 */}
|
|
<Card>
|
|
<CardHeader title="修改配置指南" />
|
|
<CardBody className="space-y-5">
|
|
<div>
|
|
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900">通过 SSH 修改 .env</h4>
|
|
<pre className="overflow-x-auto border border-ink-200 bg-paper-100 p-3 font-mono text-2xs leading-relaxed text-ink-700">
|
|
{`# 连接到部署主机
|
|
ssh user@your-server-ip
|
|
|
|
# 进入项目目录
|
|
cd /vol2/1000/Docker/Profeto
|
|
|
|
# 编辑 .env 文件
|
|
nano .env
|
|
|
|
# 修改后重启后端服务
|
|
docker compose restart api
|
|
|
|
# 查看日志确认生效
|
|
docker compose logs -f api`}
|
|
</pre>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="mb-2 font-serif text-sm font-bold text-ink-900">常用配置项说明</h4>
|
|
<div className="space-y-2">
|
|
{[
|
|
['LLM_API_KEY', 'LLM 服务商的 API 密钥,用于调用大模型'],
|
|
['LLM_MODEL', '使用的模型名称,如 gpt-4o、claude-3-5-sonnet'],
|
|
['LLM_BASE_URL', 'API 基础地址,支持兼容 OpenAI 协议的服务商'],
|
|
['ADMIN_PASSWORD', '管理后台登录密码,修改后重启 api 容器生效'],
|
|
['ADMIN_API_KEY', '脚本直连接口的鉴权密钥(请求头 X-API-Key)'],
|
|
['BZZOIRO_KEY', 'Bzzoiro 数据源 API 密钥'],
|
|
['DATABASE_URL', 'PostgreSQL 数据库连接字符串'],
|
|
].map(([key, desc]) => (
|
|
<div key={key} className="flex items-start gap-2.5">
|
|
<code className="flex-shrink-0 border border-ink-200 bg-paper-100 px-1.5 py-0.5 font-mono text-2xs text-ink-800">
|
|
{key}
|
|
</code>
|
|
<span className="text-xs leading-relaxed text-ink-600">{desc}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardBody>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|