Author SHA1 Message Date
WorkBuddy 7c4a244ce9 fix(docker): 预创建 /app/logs 并赋属主,修复卷挂载点 root 权限拒绝
生产实测: applogs 命名卷挂到 /app/logs 时挂载点由 Docker 以 root:root
创建,而非 root 进程(profeto)写入 app.log 时 PermissionError(Errno 13),
best-effort 降级生效但文件日志实际未启用。

- Dockerfile: 镜像内预创建 /app/logs 并 chown(chown -R 已有,覆盖新目录);
  命名卷为空且首次挂载时 Docker 会复制镜像目录属主,重建镜像即可修复
- log_buffer: PermissionError 单独捕获,warning 给出可操作的排查方向
  (镜像预建目录/chown/重建空卷),其余异常仍保持原通用降级
2026-09-22 14:14:53 +08:00
shangfangjian 432bfee8ad Merge pull request 'feat(logging): 日志持久化落盘(LOG_FILE 滚动文件)' (#14) from log-persistence into main
Reviewed-on: #14
2026-09-22 13:34:46 +08:00
shangfangjian f1c586111f Merge pull request 'admin: 信息架构重组 + Settings tab 化 + 待办驱动 Dashboard + 任务历史' (#15) from admin-ux-overhaul into main
Reviewed-on: #15
2026-09-22 13:34:37 +08:00
WorkBuddy db3c64d49b test: 修复 standings 约束断言的 flaky(匿名约束 name=None)
与 log-persistence 分支同款修复:constraints 集合含匿名约束(name=None),
any() 子串匹配撞上 None 直接 TypeError,集合顺序不定导致间歇失败。
先判真值再匹配。
2026-09-22 12:46:43 +08:00
WorkBuddy d013407aa1 feat(admin): 采集任务历史列表 + 图标语言统一 + 存量样式修复
- dal 新增 fetchIngestJobs(接已有 GET /admin/ingest/jobs,此前前端未用)
- Collection 新增「最近任务」卡:最新 10 条,状态点/任务类型/结果摘要,
  任务终态后自动刷新 —— 补齐采集失败后的回看入口
- 任务选择按钮修复:brand-* 色在 tailwind 中不存在(选中态样式失效),
  改用全站 press/press-wash;去掉与报刊风方角语言不符的 rounded-lg
- 任务图标 🏆📊 → ◈◇▤⇉,与全站几何字符一致,不混用 emoji
- Logs 页描述补充 LOG_FILE 持久化边界说明
2026-09-22 12:44:29 +08:00
WorkBuddy 0ab838258c feat(admin): Settings 分区 tab 化 + Dashboard 改待办驱动
- Settings(496 行五块内容单页)拆为 4 个 tab:数据源/LLM/定时任务/登录认证;
  tab 状态写入 URL(?tab=llm)可深链;数据加载与 API 调用不变
- Dashboard 改待办驱动:死信待处理/联赛数据缺口/可结算预测,
  有待办才显示,每项直达处理页;全部清零时显示状态行
- 三步工作流卡仅在库里无比赛时显示(首次引导,老用户不再占版面)
- 修文案漂移:「数据源健康」卡不再引用已不存在的「数据源」页;
  「运行预测」链接语义修正(预测在前台比赛详情页发起)
- 数据源单源硬编码列表简化为一行;未配 Key 直达设置分区
- <a href> 全改 <Link>,不再整页刷新
2026-09-22 12:44:12 +08:00
WorkBuddy 9cedb874f7 refactor(admin): 导航按心智模型三组重排(数据/预测/系统)
- 预测历史/回测/评估移出「数据流水线」,组成「预测」组(生命周期闭环)
- 「评估与监控」拆开:评估归预测,监控归系统
- 删除 SIDEBAR_GROUP_TITLES 别名映射(分组名与面板名统一)
- 预测历史图标改 target(靶心),数据完整性改 eval(核对文档),不再与仪表盘共用 chart
2026-09-22 12:34:18 +08:00
WorkBuddy 1cdc6411f5 chore(admin): 删除合并后遗留的 4 个死代码页面(~1280 行)
DataSources/LLMConfig/Config 已合并进统一 Settings 页,
Predictions 已被 PredictionHistory 取代,均无任何路由或 import 引用。
2026-09-22 12:33:15 +08:00
12 changed files with 282 additions and 1385 deletions
+8 -1
View File
@@ -15,7 +15,14 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY src ./src COPY src ./src
RUN pip install --no-cache-dir . RUN pip install --no-cache-dir .
# 将工作目录所有权移交给非 root 用户 # 日志目录预创建: compose 运行时把 applogs 命名卷挂到 /app/logs,
# 卷挂载点默认由 Docker 以 root:root 创建 —— 镜像内不预建的话,
# 非 root 进程写日志会 PermissionError(Errno 13)。
# 命名卷为空且首次挂载时,Docker 会复制镜像内该目录的内容与属主,
# 因此在这里 mkdir + chown 即可让卷目录归 profeto 所有。
RUN mkdir -p /app/logs
# 将工作目录所有权移交给非 root 用户(含上面的日志目录)
RUN chown -R profeto:profeto /app RUN chown -R profeto:profeto /app
EXPOSE 8000 EXPOSE 8000
+11
View File
@@ -370,6 +370,17 @@ export function fetchIngestJob(jobId: string): Promise<IngestJob> {
return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`) return api.get<IngestJob>(`${API_BASE}/admin/ingest/jobs/${jobId}`)
} }
/**
* 采集任务历史列表(GET /admin/ingest/jobs,最新在前)
*/
export function fetchIngestJobs(params: { limit?: number; status?: string } = {}): Promise<IngestJob[]> {
const q = new URLSearchParams()
if (params.limit != null) q.set('limit', String(params.limit))
if (params.status) q.set('status', params.status)
const qs = q.toString()
return api.get<IngestJob[]>(`${API_BASE}/admin/ingest/jobs${qs ? `?${qs}` : ''}`)
}
/** /**
* 比赛详情(含最近预测摘要) * 比赛详情(含最近预测摘要)
*/ */
+18 -17
View File
@@ -20,18 +20,25 @@ export interface NavItem {
hideFromSidebar?: boolean hideFromSidebar?: boolean
} }
/** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。 */ /** 唯一的导航配置源。顺序 = 侧栏渲染顺序(命令面板分组内顺序与之相同)。
*
* 分组按用户心智模型: 数据(往里灌) → 预测(算出来) → 系统(保证它活着)。
* 「预测历史/回测/评估」同属预测生命周期,不再挂在「数据流水线」名下。
*/
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
{ to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' }, { to: '/admin', label: '仪表盘', group: '概览', icon: 'chart' },
{ to: '/admin/collection', label: '数据采集', group: '数据流水线', icon: 'collection' }, // ── 数据:采集、核对、管线内部视图 ──
{ to: '/admin/data-completeness', label: '数据完整性', group: '数据流水线', icon: 'chart' }, { to: '/admin/collection', label: '数据采集', group: '数据', icon: 'collection' },
{ to: '/admin/data-pipeline', label: '数据管线', group: '数据流水线', icon: 'chart', hideFromSidebar: true }, { to: '/admin/data-completeness', label: '数据完整性', group: '数据', icon: 'eval' },
{ to: '/admin/predictions', label: '预测历史', group: '数据流水线', icon: 'logs' }, { to: '/admin/data-pipeline', label: '数据管线', group: '数据', icon: 'chart', hideFromSidebar: true },
{ to: '/admin/backtest', label: '回测', group: '数据流水线', icon: 'repeat' }, // ── 预测:历史 → 回测 → 评估闭环 ──
{ to: '/admin/eval', label: '评估', group: '评估与监控', icon: 'eval' }, { to: '/admin/predictions', label: '预测历史', group: '预测', icon: 'target' },
{ to: '/admin/monitoring', label: '监控', group: '评估与监控', icon: 'monitor' }, { to: '/admin/backtest', label: '回测', group: '预测', icon: 'repeat' },
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' }, { to: '/admin/eval', label: '评估', group: '预测', icon: 'eval' },
// ── 系统:活着吗、发生了什么、怎么调 ──
{ to: '/admin/monitoring', label: '监控', group: '系统', icon: 'monitor' },
{ to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' }, { to: '/admin/logs', label: '日志', group: '系统', icon: 'logs' },
{ to: '/admin/settings', label: '设置', group: '系统', icon: 'settings' },
] ]
/** 命令面板条目(原 NAV_PAGES 的唯一来源) */ /** 命令面板条目(原 NAV_PAGES 的唯一来源) */
@@ -42,11 +49,6 @@ export const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
NAV_ITEMS.map(i => [i.to, i.label]), NAV_ITEMS.map(i => [i.to, i.label]),
) )
/** 侧栏分组标题的历史显示名(仅侧栏使用;与面板分组名不同时在此映射) */
const SIDEBAR_GROUP_TITLES: Record<string, string> = {
: '系统设置',
}
/** /**
* 侧栏分组(原 NAV_SECTIONS 的唯一来源)。 * 侧栏分组(原 NAV_SECTIONS 的唯一来源)。
* 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。 * 仪表盘(group=概览)在 AdminLayout 中独立渲染于顶部,不进分组循环。
@@ -55,13 +57,12 @@ export const NAV_SECTIONS = (() => {
const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览') const sidebarItems = NAV_ITEMS.filter(i => !i.hideFromSidebar && i.group !== '概览')
const titles: string[] = [] const titles: string[] = []
for (const i of sidebarItems) { for (const i of sidebarItems) {
const title = SIDEBAR_GROUP_TITLES[i.group] ?? i.group if (!titles.includes(i.group)) titles.push(i.group)
if (!titles.includes(title)) titles.push(title)
} }
return titles.map(title => ({ return titles.map(title => ({
title, title,
items: sidebarItems items: sidebarItems
.filter(i => (SIDEBAR_GROUP_TITLES[i.group] ?? i.group) === title) .filter(i => i.group === title)
.map(({ to, label, icon }) => ({ to, label, icon })), .map(({ to, label, icon }) => ({ to, label, icon })),
})) }))
})() })()
+55 -8
View File
@@ -11,16 +11,17 @@
*/ */
import { useEffect, useState, useCallback, useRef } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import { triggerCollection, fetchLeagues, fetchIngestJob } from '../dal' import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../dal'
import type { IngestJob, League } from '../types' import type { IngestJob, League } from '../types'
import type { CollectionRequest } from '../types' import type { CollectionRequest } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components' import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
const TASKS = [ const TASKS = [
{ value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '' }, { value: 'events', label: '比赛数据', desc: '赛程 / 比分 / 未开赛安排', icon: '' },
{ value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '🏆' }, { value: 'standings', label: '积分榜', desc: '联赛排名 / 积分 / xG差 / 近期走势', icon: '' },
{ value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '📊' }, { value: 'stats', label: '统计回填', desc: '已完赛比赛的 xG / 射门 / 控球等详细统计', icon: '' },
{ value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '⏵⏵' }, { value: 'all', label: '全量采集', desc: '依次采集比赛 + 积分榜 + 统计回填', icon: '' },
] as const ] as const
type TaskUIStatus = 'idle' | 'running' | 'done' | 'error' type TaskUIStatus = 'idle' | 'running' | 'done' | 'error'
@@ -73,6 +74,16 @@ export default function CollectionPage() {
useEffect(() => () => stopPolling(), [stopPolling]) useEffect(() => () => stopPolling(), [stopPolling])
// ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ──
// 声明须在 startJobPolling 之前(其终态回调会刷新历史)
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
const loadRecentJobs = useCallback(async () => {
try {
setRecentJobs(await fetchIngestJobs({ limit: 10 }))
} catch { /* 历史列表失败不影响主流程 */ }
}, [])
useEffect(() => { loadRecentJobs() }, [loadRecentJobs])
const startJobPolling = useCallback((id: string) => { const startJobPolling = useCallback((id: string) => {
stopPolling() stopPolling()
const tick = async () => { const tick = async () => {
@@ -82,12 +93,13 @@ export default function CollectionPage() {
if (TERMINAL_STATUSES.has(job.status)) { if (TERMINAL_STATUSES.has(job.status)) {
setTaskStatus(job.status === 'success' ? 'done' : 'error') setTaskStatus(job.status === 'success' ? 'done' : 'error')
stopPolling() stopPolling()
loadRecentJobs() // 终态后刷新历史列表
} }
} catch { /* 单次轮询失败不影响后续 */ } } catch { /* 单次轮询失败不影响后续 */ }
} }
tick() tick()
pollRef.current = setInterval(tick, 3_000) pollRef.current = setInterval(tick, 3_000)
}, [stopPolling]) }, [stopPolling, loadRecentJobs])
const isEventsTask = task === 'events' || task === 'all' const isEventsTask = task === 'events' || task === 'all'
@@ -183,9 +195,9 @@ export default function CollectionPage() {
key={t.value} key={t.value}
type="button" type="button"
onClick={() => setTask(t.value)} onClick={() => setTask(t.value)}
className={`rounded-lg border px-3 py-2 text-left text-xs transition-colors ${ className={`border px-3 py-2 text-left text-xs transition-colors ${
task === t.value task === t.value
? 'border-brand-500 bg-brand-50 text-brand-700' ? 'border-press bg-press-wash text-press'
: 'border-ink-200 text-ink-600 hover:border-ink-300' : 'border-ink-200 text-ink-600 hover:border-ink-300'
}`} }`}
> >
@@ -345,6 +357,41 @@ export default function CollectionPage() {
</CardBody> </CardBody>
</Card> </Card>
{/* 最近任务历史 */}
<Card>
<CardHeader
title="最近任务"
description="后台采集任务执行历史(最新在前)"
action={<button onClick={loadRecentJobs} className="btn btn-sm"></button>}
/>
<CardBody className="px-0">
{recentJobs === null ? (
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5"></p>
) : recentJobs.length === 0 ? (
<p className="px-4 py-2 text-xs text-ink-400 sm:px-5">,</p>
) : (
<div>
{recentJobs.map(j => {
const st = j.status
const dot = st === 'success' ? 'bg-ink-900' : st === 'failed' ? 'bg-press' : 'bg-amber-500 animate-pulse'
const label = st === 'success' ? '完成' : st === 'failed' ? '失败' : st === 'running' ? '执行中' : '排队中'
return (
<div key={j.id} className="flex items-center gap-3 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5">
<span aria-hidden="true" className={`inline-block h-1.5 w-1.5 flex-shrink-0 ${dot}`} />
<Badge status={st === 'failed' ? 'error' : 'info'}>{j.task}</Badge>
<span className="text-2xs text-ink-600">{label}</span>
<span className="ml-auto text-right text-2xs tabular-nums text-ink-400">
{j.created_at && new Date(j.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}
{' '}{jobSummary(j)?.detail ?? (j.error ? j.error.slice(0, 40) : '')}
</span>
</div>
)
})}
</div>
)}
</CardBody>
</Card>
{/* 数据源说明 */} {/* 数据源说明 */}
<Card> <Card>
<CardHeader title="采集任务说明" /> <CardHeader title="采集任务说明" />
-267
View File
@@ -1,267 +0,0 @@
/**
* 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>
)
}
+134 -72
View File
@@ -1,137 +1,199 @@
/** /**
* Admin 后台 - 仪表盘(报刊风) * Admin 后台 - 仪表盘(报刊风·待办驱动)
* *
* 展示: * 设计原则:单人管理员的注意力应该花在「现在需要处理什么」上,
* - 数据流水线状态(采集 → 预测 → 评估,每步的实际数据量) * 而不是扫描一堆常驻数字。
* - 近期预测活动(24h / 7d / 总计) * - 顶部待办行:只有真有待办才出现(死信 / 缺数据联赛 / 可结算预测),
* - 快捷操作入口(带工作流引导) * 每项直达处理页面 —— 引导出现在需要时,而不是永远占着版面
* - 三步工作流卡只在库里还没有比赛时显示(首次使用引导)
* - 数据概览合并为一卡:预测活动 + 各表数据量
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { fetchAdminStats, fetchIngestStatus, fetchDashboard } from '../dal' import { Link } from 'react-router-dom'
import { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../dal'
import type { DataCompletenessResponse, IngestFailureItem } from '../dal'
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types' import type { AdminStats, IngestSourceStatus, DashboardStats } from '../types'
import { Card, CardBody, CardHeader, Alert, SkeletonBlock } from '../components' import { Card, CardBody, CardHeader, SkeletonBlock } from '../components'
/** 工作流步骤卡片 */ /** 工作流引导(仅首次使用——库里还没有比赛时显示) */
const STEPS = [ const STEPS = [
{ to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)', icon: '◈' }, { to: '/admin/collection', step: '1', title: '采集数据', desc: 'bzzoiro: 赛程 / 积分榜 / 比赛统计(xG、射门、控球等)' },
{ to: '/admin/predictions', step: '2', title: '运行预测', desc: '调 LLM 多专家生成比分预测', icon: '◆' }, { to: '/admin/predictions', step: '2', title: '预测与结算', desc: '在前台比赛详情页发起预测;赛后回到「预测历史」结算' },
{ to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度', icon: '◈' }, { to: '/admin/eval', step: '3', title: '评估准确率', desc: '结算后查看 1X2 命中率与校准度' },
] ]
interface TodoItem {
key: string
count: number
label: string
to: string
/** 无上限确认时数字近似,展示为 N+ */
approx?: boolean
}
export default function Dashboard() { export default function Dashboard() {
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [ingest, setIngest] = useState<IngestSourceStatus[]>([]) const [ingest, setIngest] = useState<IngestSourceStatus[]>([])
const [dash, setDash] = useState<DashboardStats | null>(null) const [dash, setDash] = useState<DashboardStats | null>(null)
const [failures, setFailures] = useState<IngestFailureItem[]>([])
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
const [recentPreds, setRecentPreds] = useState<Array<{ settled?: boolean; actual_home_goals?: number | null; actual_away_goals?: number | null }>>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
const [s, i, d] = await Promise.allSettled([ // 各数据源独立容错:单接口失败只降级对应卡片,不拖垮整页
const [s, i, d, f, c, p] = await Promise.allSettled([
fetchAdminStats(), fetchAdminStats(),
fetchIngestStatus(), fetchIngestStatus(),
fetchDashboard(), fetchDashboard(),
fetchIngestFailures(),
fetchDataCompleteness(),
fetchPredictions(100),
]) ])
if (s.status === 'fulfilled') setStats(s.value) if (s.status === 'fulfilled') setStats(s.value)
if (i.status === 'fulfilled') setIngest(i.value.sources) if (i.status === 'fulfilled') setIngest(i.value.sources)
if (d.status === 'fulfilled') setDash(d.value) if (d.status === 'fulfilled') setDash(d.value)
if (f.status === 'fulfilled') setFailures(f.value)
if (c.status === 'fulfilled') setCompleteness(c.value)
if (p.status === 'fulfilled' && Array.isArray(p.value)) setRecentPreds(p.value)
setLoading(false) setLoading(false)
}, []) }, [])
useEffect(() => { load() }, [load]) useEffect(() => { load() }, [load])
// ── 待办计算 ──
const deadLetterCount = failures.filter(f => f.status !== 'resolved').length
const missingStatsLeagues = completeness?.leagues.filter(
l => l.matches.finished > 0 && l.stats.rows === 0,
).length ?? 0
const missingStandingsLeagues = completeness?.leagues.filter(
l => l.matches.total > 0 && l.standings.rows === 0,
).length ?? 0
const missingLeagues = missingStatsLeagues + missingStandingsLeagues
// 近 100 条内「比赛已出比分但未结算」的预测(列表接口有上限,数字近似)
const settleable = recentPreds.filter(
p => !p.settled && p.actual_home_goals != null && p.actual_away_goals != null,
).length
const todos: TodoItem[] = [
deadLetterCount > 0 && { key: 'deadletter', count: deadLetterCount, label: '采集失败待处理', to: '/admin/data-pipeline' },
missingLeagues > 0 && { key: 'completeness', count: missingLeagues, label: '联赛数据缺口', to: '/admin/data-completeness' },
settleable > 0 && { key: 'settle', count: settleable, label: '预测可结算', to: '/admin/predictions', approx: true },
].filter((t): t is TodoItem => t !== false)
const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s])) const sourceByName = Object.fromEntries(ingest.map(s => [s.name, s]))
const bzzoiro = sourceByName['bzzoiro'] const bzzoiro = sourceByName['bzzoiro']
const hasMatches = (stats?.matches?.total ?? 0) > 0
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ── 工作流引导(采集 → 预测 → 评估) ── */} {/* ── 待办行:只有真有待办才出现 ── */}
<div className="grid gap-4 sm:grid-cols-3"> {loading ? (
{STEPS.map((s, i) => ( <SkeletonBlock className="h-14 w-full" />
<a ) : todos.length > 0 ? (
key={s.to} <div className="grid gap-3 sm:grid-cols-3">
href={s.to} {todos.map(t => (
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100" <Link
> key={t.key}
<div className="flex items-center gap-2.5"> to={t.to}
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900"> className="group flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
{s.step} >
<span className="font-serif text-2xl font-bold tabular-nums text-press">
{t.count}{t.approx ? '+' : ''}
</span> </span>
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span> <span className="text-xs text-ink-700">{t.label}</span>
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true"></span> <span className="ml-auto text-press transition-transform group-hover:translate-x-0.5" aria-hidden="true"></span>
</div> </Link>
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p> ))}
{i < STEPS.length - 1 && <span className="sr-only"></span>} </div>
</a> ) : (
))} <p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
</div> <span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
流水线无待办:没有失败记录
</p>
)}
{/* ── 数据源健康一览 ── */} {/* ── 三步引导:仅首次使用(库里还没有比赛)时显示 ── */}
{!loading && !hasMatches && (
<div className="grid gap-4 sm:grid-cols-3">
{STEPS.map(s => (
<Link
key={s.to}
to={s.to}
className="group border border-ink-300 p-4 transition-colors hover:border-ink-900 hover:bg-paper-100"
>
<div className="flex items-center gap-2.5">
<span className="flex h-7 w-7 items-center justify-center border border-ink-900 font-serif text-xs font-bold text-ink-900">
{s.step}
</span>
<span className="font-serif text-sm font-bold text-ink-900">{s.title}</span>
<span className="ml-auto text-ink-300 transition-colors group-hover:text-press" aria-hidden="true"></span>
</div>
<p className="mt-2 text-2xs leading-relaxed text-ink-500">{s.desc}</p>
</Link>
))}
</div>
)}
{/* ── 数据源:单源一行即足,不装成列表 ── */}
<Card> <Card>
<CardHeader <CardHeader
title="数据源健康" title="数据源"
description="各源最近采集时间与数据量(只读快照,详细配置见「数据源」页)" description="bzzoiro 最近采集情况,Key 与轮换配置见「设置 → 数据源」"
/> />
<CardBody className="px-0"> <CardBody className="px-0">
{loading ? ( {loading ? (
<div className="space-y-2 px-4 sm:px-5"> <div className="px-4 sm:px-5"><SkeletonBlock className="h-8 w-full" /></div>
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-8 w-full" />)}
</div>
) : ( ) : (
<div> (() => {
{[ const st = bzzoiro
{ name: 'bzzoiro', label: 'Bzzoiro', st: bzzoiro }, const hasData = st && st.recent_count > 0
].map(({ name, label, st }) => { const keyOk = st?.key_configured !== false
const hasData = st && st.recent_count > 0 return (
const keyOk = st?.key_configured !== false <div className="flex items-center justify-between px-4 py-2.5 sm:px-5">
return ( <span className="text-xs font-medium text-ink-700">Bzzoiro</span>
<div key={name} className="flex items-center justify-between border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:px-5"> <span className="flex items-center gap-3 text-2xs">
<span className="text-xs font-medium text-ink-700">{label}</span> {hasData ? (
<span className="flex items-center gap-3 text-2xs"> <>
{hasData ? ( <span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} </span>
<> <span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span>
<span className="tabular-nums text-ink-500">{st.recent_count.toLocaleString()} </span> </>
<span className="text-ink-400">{st.last_success_at ? new Date(st.last_success_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }) : ''}</span> ) : keyOk ? (
</> <span className="text-ink-400"></span>
) : keyOk ? ( ) : (
<span className="text-ink-400"></span> <Link to="/admin/settings?tab=datasource" className="text-press underline underline-offset-2"> Key,</Link>
) : ( )}
<span className="text-press"> Key</span> <span aria-hidden="true" className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} />
)} </span>
<span </div>
aria-hidden="true" )
className={`inline-block h-1.5 w-1.5 ${hasData && keyOk ? 'bg-ink-900' : 'bg-press'}`} })()
/>
</span>
</div>
)
})}
</div>
)} )}
</CardBody> </CardBody>
</Card> </Card>
{/* ── 近期预测活动 ── */} {/* ── 数据概览:预测活动 + 数据量,合并一卡 ── */}
<Card> <Card>
<CardHeader title="近期预测活动" description="预测 API 的调用量统计" /> <CardHeader title="数据概览" description="预测调用量与各表数据量" />
<CardBody> <CardBody>
{stats ? ( {stats ? (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-3 gap-4 text-center"> <div className="grid grid-cols-3 gap-4 text-center">
<div> <div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div> <div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
<div className="mt-1 text-2xs text-ink-400"> 24 </div> <div className="mt-1 text-2xs text-ink-400"> · 24 </div>
</div> </div>
<div> <div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div> <div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
<div className="mt-1 text-2xs text-ink-400"> 7 </div> <div className="mt-1 text-2xs text-ink-400"> · 7 </div>
</div> </div>
<div> <div>
<div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div> <div className="font-serif text-3xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
<div className="mt-1 text-2xs text-ink-400"></div> <div className="mt-1 text-2xs text-ink-400"> · </div>
</div> </div>
</div> </div>
{/* F3 修复: 真实比赛计数(非 limit=100 近似) */}
<div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center"> <div className="grid grid-cols-4 gap-3 border-t border-ink-200 pt-3 text-center">
<div> <div>
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div> <div className="font-serif text-xl font-bold tabular-nums text-ink-900">{stats.matches?.total ?? 0}</div>
-376
View File
@@ -1,376 +0,0 @@
import { useEffect, useState, useCallback } from 'react'
import {
fetchDataSourceStatuses,
fetchIngestStatus,
fetchAdminStats,
fetchKeyRingStatus,
resetKeyRingCooldown,
updateSetting,
clearSetting,
testDataSourceConnection,
} from '../dal'
import type { DataSourceStatus, DataSourceTestResult, IngestSourceStatus, AdminStats } from '../types'
import type { KeyRingStatusResponse } from '../dal'
import SettingRow from '../SettingRow'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
function formatTime(iso: string | null): string {
if (!iso) return '暂无记录'
try {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
} catch {
return iso
}
}
export default function DataSourcesPage() {
const [sources, setSources] = useState<DataSourceStatus[]>([])
const [loading, setLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [ingestStats, setIngestStats] = useState<Record<string, IngestSourceStatus>>({})
const [ingestLoading, setIngestLoading] = useState(true)
const [stats, setStats] = useState<AdminStats | null>(null)
const [testingSource, setTestingSource] = useState<string | null>(null)
const [testResults, setTestResults] = useState<Record<string, DataSourceTestResult>>({})
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
const [keyRing, setKeyRing] = useState<KeyRingStatusResponse | null>(null)
const [ringLoading, setRingLoading] = useState(false)
const loadSources = useCallback(async () => {
setLoading(true)
setLoadError('')
try {
setSources(await fetchDataSourceStatuses())
} catch (err) {
setLoadError(err instanceof Error ? err.message.split('\n')[0] : '加载失败')
setSources([])
} finally {
setLoading(false)
}
}, [])
// 健康状态(只读,与配置加载并行;失败不阻塞配置页)
const loadIngest = useCallback(async () => {
setIngestLoading(true)
try {
const { sources } = await fetchIngestStatus()
setIngestStats(Object.fromEntries(sources.map(x => [x.name, x])))
} catch {
/* ignore */
} finally {
setIngestLoading(false)
}
}, [])
// 管理区统计(只读)
const loadStats = useCallback(async () => {
try {
setStats(await fetchAdminStats())
} catch {
/* ignore */
}
}, [])
// Key Ring 状态(只读)
const loadKeyRing = useCallback(async () => {
setRingLoading(true)
try {
setKeyRing(await fetchKeyRingStatus())
} catch {
/* ignore */
} finally {
setRingLoading(false)
}
}, [])
useEffect(() => {
loadSources()
loadIngest()
loadStats()
loadKeyRing()
}, [loadSources, loadIngest, loadStats, loadKeyRing])
async function handleTest(sourceName: string) {
setTestingSource(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: '测试中...' } }))
try {
const result = await testDataSourceConnection(sourceName)
setTestResults(prev => ({ ...prev, [sourceName]: result }))
} catch (err: unknown) {
const msg = err instanceof Error ? err.message.split('\n')[0] : '连接失败'
setTestResults(prev => ({ ...prev, [sourceName]: { ok: false, status: null, latency_ms: 0, detail: msg } }))
} finally {
setTestingSource(null)
}
}
// 渲染数据源健康块(最近采集 + 异常提示)
function renderHealth(sourceName: string) {
const st = ingestStats[sourceName]
if (!st) {
return (
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-400">{ingestLoading ? '加载中...' : '暂无数据'}</span>
</div>
)
}
const ago = st.last_success_at ? formatTime(st.last_success_at) : '暂无记录'
const issues: string[] = []
if (st.status === 'key_not_configured') issues.push('未配置 API Key')
else if (st.status === 'no_data') issues.push('本地无数据,建议补采')
if (st.last_failure) issues.push('近期有采集失败')
return (
<div className="space-y-1.5 border-t border-ink-200 pt-3">
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400"></span>
<span className="text-ink-600">{ago}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-ink-400">()</span>
<span className="text-ink-600">{st.recent_count.toLocaleString()} </span>
</div>
{st.note && <p className="text-2xs leading-relaxed text-ink-400">{st.note}</p>}
{issues.length > 0 && (
<p className="border-l-2 border-press bg-press-wash/40 px-2 py-1 text-2xs leading-relaxed text-press-dark">
{issues.join(' / ')}
</p>
)}
{st.last_failure && (
<p className="truncate text-2xs text-ink-400" title={st.last_failure.detail}>
: {st.last_failure.detail.slice(0, 60)}
</p>
)}
</div>
)
}
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSources()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
async function handleClear(key: string) {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSources()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
async function handleResetCooldown() {
if (!window.confirm('确定重置所有 key 的冷却状态?这可能使被限流的 key 立即恢复请求。')) return
try {
const res = await resetKeyRingCooldown()
setKeyRing(res.stats)
setRowNotice({ key: "__ring", ok: true, text: res.message })
} catch (err) {
setRowNotice({ key: "__ring", ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '重置失败' })
}
}
return (
<div className="space-y-6">
<SectionHeader
title="数据源管理"
description="数据采集源的 API 配置、健康状态与连通性测试。"
/>
{loadError && (
<Alert kind="error" title="无法加载数据源配置" message={loadError} />
)}
{/* 数据源卡片 */}
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<Card key={i}>
<CardBody>
<div className="space-y-3">
<SkeletonBlock className="h-4 w-24" />
<SkeletonBlock className="h-3 w-32" />
<SkeletonBlock className="h-8 w-full" />
</div>
</CardBody>
</Card>
))}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{sources.map(source => {
const result = testResults[source.name]
const cardKeys = source.settings.map(s => s.key)
return (
<Card key={source.name}>
<CardBody className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-x-2 gap-y-1 border-b border-ink-200 pb-3">
<h3 className="font-serif text-sm font-bold text-ink-900">{source.label}</h3>
<Badge status={source.key_configured ? 'success' : 'error'}>
{source.key_configured ? '已就绪' : '缺配置'}
</Badge>
</div>
<p className="text-2xs leading-relaxed text-ink-500">{source.description}</p>
{source.settings.length > 0 ? (
<div>
{source.settings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => { setEditingKey(setting.key); setRowNotice(null) }}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
/>
))}
</div>
) : (
<p className="text-2xs text-ink-400"> API Key</p>
)}
{rowNotice && cardKeys.includes(rowNotice.key) && (
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
)}
{/* 数据源健康:最近采集 + 异常提示 */}
{renderHealth(source.name)}
{result && testingSource !== source.name && (
<Alert
kind={result.ok ? 'ok' : 'error'}
title={result.ok ? `连接成功(${result.latency_ms}ms)` : result.status ? `HTTP ${result.status}` : '连接失败'}
message={result.ok ? undefined : result.detail}
/>
)}
<button
onClick={() => handleTest(source.name)}
disabled={testingSource === source.name}
className="btn btn-sm w-full"
>
{testingSource === source.name ? (<><Spinner /> </>) : '测试连接'}
</button>
</CardBody>
</Card>
)
})}
</div>
)}
{/* API Key 轮换环状态 */}
<Card>
<CardHeader
title="API Key 轮换环"
description={keyRing?.has_multiple
? `已配置 ${keyRing.total} 个 key,遇到限流(429)自动切换;冷却 ${keyRing.cooldown_seconds}s`
: '当前仅 1 个 key,无法轮换。建议配置多个 key 以提高限流容忍度'
}
action={
<button
onClick={handleResetCooldown}
disabled={ringLoading}
className="btn-sm btn-outline"
>
</button>
}
/>
<CardBody>
{ringLoading && !keyRing ? (
<SkeletonBlock className="h-10 w-full" />
) : keyRing && keyRing.total > 0 ? (
<div className="space-y-2">
{keyRing.keys.map((k, i) => {
const isBlocked = k.blocked_remaining > 0
return (
<div key={i} className={`flex items-center justify-between gap-3 border-b border-ink-100 py-2 last:border-b-0 ${isBlocked ? 'opacity-70' : ''}`}>
<div className="flex items-center gap-2">
<span className={`inline-block h-2 w-2 rounded-full ${isBlocked ? 'bg-amber-500' : 'bg-emerald-500'}`} />
<span className="font-mono text-xs text-ink-700">{k.masked}</span>
{i === keyRing.active_index && (
<span className="rounded bg-ink-900 px-1.5 py-0.5 text-2xs text-paper-500"></span>
)}
</div>
<span className={`text-2xs tabular-nums ${isBlocked ? 'text-amber-600' : 'text-ink-400'}`}>
{isBlocked ? `冷却中 ${k.blocked_remaining.toFixed(0)}s` : '可用'}
</span>
</div>
)
})}
</div>
) : (
<p className="text-xs text-ink-400"> key </p>
)}
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
Bzzoiro<b> / / </b> key 429 key key;
key key key
</p>
</CardBody>
</Card>
{/* 近期活动统计(只读) */}
{stats && stats.predictions && (
<Card>
<CardHeader title="近期预测活动" description="过去 24 小时 / 7 天的预测次数" />
<CardBody>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_24h}</div>
<div className="mt-1 text-2xs text-ink-400"> 24 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.last_7d}</div>
<div className="mt-1 text-2xs text-ink-400"> 7 </div>
</div>
<div>
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">{stats.predictions.total}</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
</CardBody>
</Card>
)}
<Card>
<CardHeader title="配置说明" />
<CardBody>
<div className="space-y-3 text-xs leading-relaxed text-ink-600">
<p className="border-l-2 border-ink-300 pl-3">
<code className="font-mono">app_settings</code> <b></b>, <code className="font-mono">.env</code>; .env
</p>
<p className="border-l-2 border-ink-300 pl-3">
(); Key / /
</p>
<p className="border-l-2 border-ink-300 pl-3">
;,
</p>
</div>
</CardBody>
</Card>
</div>
)
}
-275
View File
@@ -1,275 +0,0 @@
/**
* Admin 后台 - LLM 配置管理页面(报刊风)
*
* 功能:
* - 显示当前 LLM 配置(provider, model, base_url;后端暂无配置端点,当前值取自 .env 约定)
* - 测试 LLM 连接(会真实调用一次 /predict,产生 LLM 调用费用)
* - 显示 LLM 使用统计(从预测记录聚合)
* - 可用模型列表
*/
import { useEffect, useState, useCallback } from 'react'
import { testLLMConnection, fetchLLMUsageStats, fetchSettings, fetchLLMModels, updateSetting, clearSetting } from '../dal'
import type { LLMUsageStats } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
import SettingRow from '../SettingRow'
import AgentLLMCard from '../AgentLLMCard'
import type { DataSourceSetting } from '../types'
const LLM_SETTING_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
export default function LLMConfigPage() {
const [stats, setStats] = useState<LLMUsageStats | null>(null)
const [loading, setLoading] = useState(true)
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
// LLM 连接配置(运行时配置,DB 覆盖 .env)
const [llmSettings, setLlmSettings] = useState<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null)
const [busyKey, setBusyKey] = useState<string | null>(null)
const [rowNotice, setRowNotice] = useState<{ key: string; ok: boolean; text: string } | null>(null)
const loadStats = useCallback(async () => {
setLoading(true)
try {
const data = await fetchLLMUsageStats()
setStats(data)
} catch {
setStats(null)
} finally {
setLoading(false)
}
}, [])
const loadSettings = useCallback(async () => {
setSettingsLoading(true)
try {
const all = await fetchSettings()
setLlmSettings(all.filter(x => LLM_SETTING_KEYS.includes(x.key)))
} catch {
setLlmSettings([])
} finally {
setSettingsLoading(false)
}
}, [])
useEffect(() => {
loadStats()
loadSettings()
}, [loadStats, loadSettings])
/** 供 LLM_MODEL 行内检测:探测当前服务可用模型,失败抛错由行内展示 */
const detectLLMModels = useCallback(async (): Promise<string[]> => {
const r = await fetchLLMModels()
if (!r.ok) throw new Error(r.detail)
return r.models
}, [])
async function handleSave(key: string, value: string) {
setBusyKey(key)
setRowNotice(null)
try {
await updateSetting(key, value)
setRowNotice({ key, ok: true, text: '已保存,立即生效' })
setEditingKey(null)
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '保存失败' })
} finally {
setBusyKey(null)
}
}
async function handleClear(key: string) {
setBusyKey(key)
setRowNotice(null)
try {
await clearSetting(key)
setRowNotice({ key, ok: true, text: '已清除数据库覆盖,回落 .env 默认值' })
await loadSettings()
} catch (err) {
setRowNotice({ key, ok: false, text: err instanceof Error ? err.message.split('\n')[0] : '清除失败' })
} finally {
setBusyKey(null)
}
}
async function handleTest() {
setTesting(true)
setTestResult(null)
try {
await testLLMConnection()
setTestResult({ success: true, message: 'LLM 连接测试成功' })
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'LLM 连接测试失败'
setTestResult({ success: false, message: msg })
} finally {
setTesting(false)
}
}
return (
<div className="space-y-6">
<SectionHeader
title="LLM 配置"
description="大语言模型连接状态与使用统计。模型切换通过修改 .env 并重启服务完成。"
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* LLM 连接配置 */}
<Card>
<CardHeader
title="连接配置"
description="保存到数据库并立即生效,优先于服务器 .env"
action={
<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">
{settingsLoading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardBody>
{settingsLoading ? (
<div className="space-y-3">
{[1, 2, 3].map(i => <SkeletonBlock key={i} className="h-9 w-full" />)}
</div>
) : (
<div>
{llmSettings.map(setting => (
<SettingRow
key={setting.key}
setting={setting}
editing={editingKey === setting.key}
busy={busyKey === setting.key}
onEdit={() => {
setEditingKey(setting.key)
setRowNotice(null)
}}
onCancel={() => setEditingKey(null)}
onSave={v => handleSave(setting.key, v)}
onClear={() => handleClear(setting.key)}
detectModels={setting.key === 'LLM_MODEL' ? detectLLMModels : undefined}
/>
))}
</div>
)}
{rowNotice && (
<div className="mt-3">
<Alert kind={rowNotice.ok ? 'ok' : 'error'} title={rowNotice.text} />
</div>
)}
<p className="mt-3 border-l-2 border-ink-300 pl-3 text-2xs leading-relaxed text-ink-500">
模式: 多专家 (5 + ) OpenAI ( DeepSeek
) LLM
</p>
{/* 测试连接 */}
{testResult && (
<div className="mt-4">
<Alert
kind={testResult.success ? 'ok' : 'error'}
title={testResult.success ? '连接正常' : '连接失败'}
message={testResult.success ? undefined : testResult.message}
/>
</div>
)}
<button onClick={handleTest} disabled={testing} className="btn btn-sm mt-4 w-full">
{testing ? (<><Spinner /> </>) : '测试 LLM 连接'}
</button>
<p className="mt-2 text-center text-2xs text-ink-400">
LLM ,
</p>
</CardBody>
</Card>
{/* 使用统计 */}
<Card>
<CardHeader
title="使用统计"
description="从最近预测记录聚合"
action={
<button onClick={loadStats} disabled={loading} className="btn btn-sm">
{loading ? (<><Spinner /> </>) : '刷新'}
</button>
}
/>
<CardBody>
{loading ? (
<div className="space-y-3">
<SkeletonBlock className="h-16 w-full" />
<SkeletonBlock className="h-16 w-full" />
</div>
) : stats ? (
<div className="grid grid-cols-3 gap-4">
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{stats.total_predictions}
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t-2 border-ink-900 pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
{stats.avg_latency_ms > 0 ? `${(stats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
<div className="border-t-2 border-press pt-3 text-center">
<div className="font-serif text-2xl font-bold tabular-nums text-press">
{stats.success_rate.toFixed(0)}%
</div>
<div className="mt-1 text-2xs text-ink-400"></div>
</div>
</div>
) : (
<p className="py-6 text-center text-xs text-ink-400">使</p>
)}
</CardBody>
</Card>
</div>
{/* 专家与终裁独立配置 */}
<AgentLLMCard />
{/* 最近预测 */}
<Card>
<CardHeader title="最近预测记录" />
<CardBody className="px-0 sm:px-0">
{loading ? (
<div className="space-y-2 px-4 sm:px-5">
{[1, 2, 3].map(i => (
<SkeletonBlock key={i} className="h-10 w-full" />
))}
</div>
) : stats && stats.recent_predictions.length > 0 ? (
<div>
{stats.recent_predictions.map(p => (
<div
key={p.id}
className="flex flex-col gap-1.5 border-b border-ink-200 px-4 py-2.5 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:px-5"
>
<div className="flex items-baseline gap-3">
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
<span className="text-xs text-ink-800"> #{p.match_id}</span>
<span className="font-mono text-2xs text-ink-500">{p.model}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-2xs tabular-nums text-ink-400">
{p.created_at ? new Date(p.created_at).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}
</span>
{p.status === 'success' ? <Badge status="success"></Badge> : <Badge status="error"></Badge>}
</div>
</div>
))}
</div>
) : (
<p className="py-8 text-center text-xs text-ink-400"></p>
)}
</CardBody>
</Card>
</div>
)
}
+1 -1
View File
@@ -85,7 +85,7 @@ export default function LogsPage() {
<div className="space-y-6"> <div className="space-y-6">
<SectionHeader <SectionHeader
title="系统日志" title="系统日志"
description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零。" description="应用运行日志(登录、配置变更、采集、预测、异常等)。内存缓冲最近 2000 条,重启后清零;若后端已配置 LOG_FILE,完整日志同时滚动写入服务器文件(单文件 10MB × 5 份),可登录宿主机查看。"
/> />
{error && <Alert kind="error" title="无法加载日志" message={error} />} {error && <Alert kind="error" title="无法加载日志" message={error} />}
-363
View File
@@ -1,363 +0,0 @@
/**
* Admin 后台 - 预测管理页面(报刊风)
*
* 功能:
* - 触发预测(选择比赛 + 模式)
* - 结算:录入实际比分,写入评估(接 /eval/settle)
* - 预测记录列表:可展开查看终裁理由与专家摘要
*
* 响应式布局: 移动端单列,桌面端双列
*/
import { useCallback, useEffect, useMemo, useState } from 'react'
import { triggerPrediction, fetchPredictions, fetchMatches, settlePrediction } from '../dal'
import type { Match, Prediction } from '../types'
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
import { teamSidePrefix } from '../../components/TeamSideTag'
import { AgentWeightsBar } from '../components'
const AGENT_LABELS: Record<string, string> = {
h2h: '历史交锋分析专家',
form: '近期状态分析专家',
stats: '攻防数据分析专家',
home_away: '主客因素分析专家',
injuries: '阵容完整性分析专家',
}
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
function fmtTime(s?: string | null): string {
if (!s) return '—'
const d = new Date(s)
return isNaN(d.getTime())
? s
: d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
export default function PredictionsPage() {
const [matches, setMatches] = useState<Match[]>([])
const [predictions, setPredictions] = useState<Prediction[]>([])
const [matchId, setMatchId] = useState('')
const [mode, setMode] = useState<'single' | 'multi'>('multi')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [successMsg, setSuccessMsg] = useState<string | null>(null)
// 结算表单
const [settleId, setSettleId] = useState('')
const [homeGoals, setHomeGoals] = useState('')
const [awayGoals, setAwayGoals] = useState('')
const [settling, setSettling] = useState(false)
const [settleMsg, setSettleMsg] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null)
const refreshPredictions = useCallback(async () => {
const list = await fetchPredictions(50)
setPredictions(list)
}, [])
useEffect(() => {
refreshPredictions()
fetchMatches({ limit: 100 }).then(d => setMatches(d.items))
}, [refreshPredictions])
/** match_id → 中文名对阵 */
const matchName = useMemo(() => {
const map = new Map<number, string>()
for (const m of matches) {
const home = m.home_team_zh || m.home_team
const away = m.away_team_zh || m.away_team
map.set(m.id, `${teamSidePrefix('home')}${home} vs ${teamSidePrefix('away')}${away}`)
}
return map
}, [matches])
const nameOf = (id: number) => matchName.get(id) ?? `比赛 #${id}`
async function handlePredict(e: React.FormEvent) {
e.preventDefault()
if (!matchId) return
setLoading(true)
setError(null)
setSuccessMsg(null)
try {
await triggerPrediction({ match_id: parseInt(matchId), mode })
setSuccessMsg('预测任务已完成,记录已更新')
await refreshPredictions()
} catch (err: unknown) {
setError(err instanceof Error ? err.message : '预测失败')
} finally {
setLoading(false)
}
}
const unsettled = predictions.filter(p => !p.settled)
async function handleSettle(e: React.FormEvent) {
e.preventDefault()
const pid = parseInt(settleId)
const hg = parseInt(homeGoals)
const ag = parseInt(awayGoals)
if (!pid || isNaN(hg) || isNaN(ag)) return
setSettling(true)
setSettleMsg(null)
try {
await settlePrediction(pid, hg, ag)
setSettleMsg({ kind: 'ok', text: '结算完成,准确率统计已更新' })
setSettleId('')
setHomeGoals('')
setAwayGoals('')
await refreshPredictions()
} catch (err: unknown) {
setSettleMsg({
kind: 'error',
text: err instanceof Error ? err.message : '结算失败',
})
} finally {
setSettling(false)
}
}
const settleTarget = predictions.find(p => p.id === parseInt(settleId))
return (
<div className="space-y-6">
<SectionHeader
title="预测管理"
description="触发 LLM 预测;赛后录入实际比分完成结算,供准确率统计使用。"
/>
<div className="grid gap-6 lg:grid-cols-2">
{/* 新建预测 */}
<Card>
<CardHeader title="新建预测" />
<CardBody>
<form onSubmit={handlePredict} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={matchId}
onChange={e => setMatchId(e.target.value)}
className="field w-full"
>
<option value=""></option>
{matches.map(m => (
<option key={m.id} value={m.id}>
{teamSidePrefix('home')}{(m.home_team_zh || m.home_team)} vs {teamSidePrefix('away')}{(m.away_team_zh || m.away_team)}
({m.match_date?.slice(5, 10)})
</option>
))}
</select>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={mode}
onChange={e => setMode(e.target.value as 'single' | 'multi')}
className="field w-full"
>
<option value="multi"> (5 + ,)</option>
<option value="single"> ()</option>
</select>
</div>
{error && <Alert kind="error" title="预测失败" message={error} onClose={() => setError(null)} />}
{successMsg && (
<Alert kind="ok" title={successMsg} onClose={() => setSuccessMsg(null)} />
)}
<button
type="submit"
disabled={loading || !matchId}
className="btn btn-solid w-full"
>
{loading ? (<><Spinner /> , 20-60 </>) : '触发预测'}
</button>
</form>
</CardBody>
</Card>
{/* 结算 */}
<Card>
<CardHeader
title="预测结算"
description="录入实际比分,系统据此统计 1X2 准确率与比分 RMSE"
/>
<CardBody>
{unsettled.length === 0 ? (
<p className="py-6 text-center text-xs text-ink-400">
</p>
) : (
<form onSubmit={handleSettle} className="space-y-4">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<select
value={settleId}
onChange={e => setSettleId(e.target.value)}
className="field w-full"
>
<option value="">({unsettled.length} )</option>
{unsettled.map(p => (
<option key={p.id} value={p.id}>
#{p.id} {nameOf(p.match_id)} · {p.pred_home_goals ?? '-'}:{p.pred_away_goals ?? '-'}
</option>
))}
</select>
</div>
{settleTarget && (
<p className="border-l-2 border-ink-300 pl-3 text-2xs text-ink-500">
:{settleTarget.pred_home_goals ?? '-'} : {settleTarget.pred_away_goals ?? '-'}
({OUTCOME_LABEL[settleTarget.pred_1x2 ?? ''] ?? '?'})
<span className="ml-2">{fmtTime(settleTarget.created_at)}</span>
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="number"
min={0}
max={30}
value={homeGoals}
onChange={e => setHomeGoals(e.target.value)}
className="field w-full"
/>
</div>
<div>
<label className="mb-1.5 block text-xs text-ink-500"></label>
<input
type="number"
min={0}
max={30}
value={awayGoals}
onChange={e => setAwayGoals(e.target.value)}
className="field w-full"
/>
</div>
</div>
{settleMsg && (
<Alert
kind={settleMsg.kind}
title={settleMsg.kind === 'ok' ? '结算完成' : '结算失败'}
message={settleMsg.kind === 'error' ? settleMsg.text : undefined}
onClose={() => setSettleMsg(null)}
/>
)}
<button
type="submit"
disabled={settling || !settleId || homeGoals === '' || awayGoals === ''}
className="btn btn-solid w-full"
>
{settling ? (<><Spinner /> </>) : '提交结算'}
</button>
</form>
)}
</CardBody>
</Card>
</div>
{/* 最近预测 */}
<Card>
<CardHeader title="预测记录" description="点击行可展开终裁理由与专家摘要" />
<CardBody className="px-0 sm:px-0">
{predictions.length === 0 ? (
<p className="py-10 text-center text-xs text-ink-400">
,
</p>
) : (
<div>
{predictions.map(p => {
const okAgents = (p.agent_outputs ?? []).filter(a => a.status === 'ok')
return (
<details key={p.id} className="group border-b border-ink-200 last:border-b-0">
<summary className="flex cursor-pointer list-none flex-wrap items-baseline gap-x-3 gap-y-1 px-4 py-3 transition-colors hover:bg-paper-100 sm:px-5">
<span className="text-2xs tabular-nums text-ink-400">#{p.id}</span>
<span className="text-sm font-medium text-ink-900">{nameOf(p.match_id)}</span>
<span className="font-serif text-sm font-bold tabular-nums text-ink-900">
{p.pred_home_goals ?? '-'}<span className="mx-0.5 font-normal text-ink-300">:</span>{p.pred_away_goals ?? '-'}
</span>
<span className="text-2xs text-ink-500">
{OUTCOME_LABEL[p.pred_1x2 ?? ''] ?? '—'}
{p.subjective_confidence !== null && p.subjective_confidence !== undefined &&
` · ${Math.round(p.subjective_confidence * 100)}%`}
</span>
<span className="ml-auto flex items-baseline gap-3">
{p.settled ? (
<Badge status="success"></Badge>
) : (
<Badge status="pending"></Badge>
)}
{p.status === 'degraded' && (
<Badge status="error">·</Badge>
)}
{p.status === 'failed' && (
<Badge status="error"></Badge>
)}
<span className="text-2xs tabular-nums text-ink-400">{fmtTime(p.created_at)}</span>
<svg
viewBox="0 0 20 20"
className="h-3 w-3 self-center text-ink-300 transition-transform group-open:rotate-90"
fill="currentColor"
aria-hidden="true"
>
<path d="M7.3 5.3a1 1 0 011.4 0l4 4a1 1 0 010 1.4l-4 4a1 1 0 01-1.4-1.4L10.6 10 7.3 6.7a1 1 0 010-1.4z" />
</svg>
</span>
</summary>
<div className="space-y-3 px-4 pb-4 pl-8 sm:px-6 sm:pl-9">
<p className="text-2xs text-ink-500">
{p.mode === 'multi' ? `多专家 · ${okAgents.length}/${p.agent_outputs?.length ?? 0} 路有效` : '单次模式'}
{p.model && <span className="ml-2 font-mono">{p.model}</span>}
</p>
{p.reasoning && (
<blockquote className="border-l-2 border-press pl-4">
<p className="whitespace-pre-wrap font-serif text-sm leading-loose text-ink-700">
{p.reasoning}
</p>
</blockquote>
)}
{p.agent_weights && Object.keys(p.agent_weights).length > 0 && (
<AgentWeightsBar weights={p.agent_weights} okCount={okAgents.length} />
)}
{p.agent_outputs && p.agent_outputs.length > 0 && (
<ul className="space-y-1">
{p.agent_outputs.map((a, i) => (
<li key={i} className="flex items-baseline gap-2.5 text-xs">
<span className={`inline-block h-1.5 w-1.5 flex-shrink-0 self-center ${a.status === 'ok' ? 'bg-ink-900' : 'bg-ink-300'}`} aria-hidden="true" />
<span className="text-ink-800">{AGENT_LABELS[a.agent] ?? a.agent}</span>
{a.probable_score && (
<span className="font-serif font-bold tabular-nums text-ink-800">{a.probable_score}</span>
)}
<span className="text-2xs text-ink-400">
{a.status === 'ok' ? '' : a.status === 'no_data' ? '无数据' : '失败'}
</span>
</li>
))}
</ul>
)}
{p.settled && (
<p className="border-t border-ink-100 pt-2.5 text-2xs text-ink-500">
{p.actual_home_goals ?? '-'} : {p.actual_away_goals ?? '-'}
</p>
)}
</div>
</details>
)
})}
</div>
)}
</CardBody>
</Card>
</div>
)
}
+46 -5
View File
@@ -9,6 +9,7 @@
*/ */
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { import {
fetchSettings, updateSetting, clearSetting, fetchSettings, updateSetting, clearSetting,
testLLMConnection, fetchLLMUsageStats, fetchLLMModels, testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
@@ -26,7 +27,24 @@ import AgentLLMCard from '../AgentLLMCard'
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE'] const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL'] const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
/** 设置页分区(tab)。低频/高危操作靠后:安全放最后。 */
const TABS = [
{ id: 'datasource', label: '数据源' },
{ id: 'llm', label: '大语言模型' },
{ id: 'schedules', label: '定时任务' },
{ id: 'security', label: '登录认证' },
] as const
type TabId = (typeof TABS)[number]['id']
export default function SettingsPage() { export default function SettingsPage() {
// tab 状态写入 URL(?tab=llm),可深链直达、刷新保持
const [searchParams, setSearchParams] = useSearchParams()
const rawTab = searchParams.get('tab')
const tab: TabId = TABS.some(t => t.id === rawTab) ? (rawTab as TabId) : 'datasource'
const setTab = (id: TabId) =>
setSearchParams(id === 'datasource' ? {} : { tab: id }, { replace: true })
const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([]) const [allSettings, setAllSettings] = useState<DataSourceSetting[]>([])
const [settingsLoading, setSettingsLoading] = useState(true) const [settingsLoading, setSettingsLoading] = useState(true)
const [editingKey, setEditingKey] = useState<string | null>(null) const [editingKey, setEditingKey] = useState<string | null>(null)
@@ -229,12 +247,31 @@ export default function SettingsPage() {
<div className="space-y-8"> <div className="space-y-8">
<SectionHeader <SectionHeader
title="系统设置" title="系统设置"
description="数据源、LLM、认证等全部配置。保存到数据库并立即生效,优先于 .env。" description="数据源、LLM、定时任务与登录认证。保存到数据库并立即生效,优先于 .env。"
/> />
{/* ── 分区 tab(状态在 URL 上,可深链) ── */}
<div className="-mt-4 flex gap-5 overflow-x-auto border-b border-ink-200" role="tablist" aria-label="设置分区">
{TABS.map(t => (
<button
key={t.id}
role="tab"
aria-selected={tab === t.id}
onClick={() => setTab(t.id)}
className={`-mb-px flex-shrink-0 border-b-2 pb-2 text-sm transition-colors ${
tab === t.id
? 'border-press font-bold text-press'
: 'border-transparent text-ink-500 hover:text-ink-900'
}`}
>
{t.label}
</button>
))}
</div>
{/* ── 1. 数据源 ── */} {/* ── 1. 数据源 ── */}
{tab === 'datasource' && (
<section> <section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<Card> <Card>
<CardHeader <CardHeader
@@ -298,10 +335,11 @@ export default function SettingsPage() {
</Card> </Card>
</div> </div>
</section> </section>
)}
{/* ── 2. LLM ── */} {/* ── 2. LLM ── */}
{tab === 'llm' && (
<section> <section>
<h2 className="section-head mb-3"></h2>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<Card> <Card>
<CardHeader <CardHeader
@@ -372,10 +410,11 @@ export default function SettingsPage() {
<AgentLLMCard /> <AgentLLMCard />
</div> </div>
</section> </section>
)}
{/* ── 3. 认证 ── */} {/* ── 3. 认证 ── */}
{tab === 'security' && (
<section> <section>
<h2 className="section-head mb-3"></h2>
<Card> <Card>
<CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" /> <CardHeader title="修改密码" description="密码即会话签名密钥,修改后所有已登录会话失效,需重新登录" />
<CardBody> <CardBody>
@@ -413,10 +452,11 @@ export default function SettingsPage() {
</CardBody> </CardBody>
</Card> </Card>
</section> </section>
)}
{/* ── 4. 定时任务 ── */} {/* ── 4. 定时任务 ── */}
{tab === 'schedules' && (
<section> <section>
<h2 className="section-head mb-3"></h2>
<Card> <Card>
<CardHeader <CardHeader
title="采集调度" title="采集调度"
@@ -491,6 +531,7 @@ export default function SettingsPage() {
</CardBody> </CardBody>
</Card> </Card>
</section> </section>
)}
</div> </div>
) )
} }
+9
View File
@@ -112,6 +112,15 @@ def setup_logging(level: str = "INFO", log_file: str = "") -> None:
file_handler.addFilter(_SQLNoiseFilter()) file_handler.addFilter(_SQLNoiseFilter())
root.addHandler(file_handler) root.addHandler(file_handler)
logging.getLogger(__name__).info("文件日志已启用: %s", log_file) logging.getLogger(__name__).info("文件日志已启用: %s", log_file)
except PermissionError:
# 容器场景最常见:挂载卷目录属主是 root,进程是非 root 用户。
# 修复方向:镜像内预创建目录并 chown(Dockerfile),或重建空卷。
logging.getLogger(__name__).warning(
"启用文件日志失败(%s):无写权限。容器部署请确认镜像已预创建该目录"
"并 chown 给运行用户(compose 卷挂载点默认 root 属主);"
"宿主机直跑请检查目录权限。本次仅保留 stdout/内存日志。",
log_file,
)
except Exception: except Exception:
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
"启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True, "启用文件日志失败(%s),仅保留 stdout/内存日志", log_file, exc_info=True,