Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d863982527 | ||
|
|
e6373d3c9a | ||
|
|
f71f29b4cb | ||
|
|
2cfc9ccc0f | ||
|
|
a7e8b75503 | ||
|
|
575507e44f | ||
|
|
5001093c3b | ||
|
|
45535aa921 | ||
|
|
d7d903b35b | ||
|
|
a6740f6140 | ||
|
|
38e1c6c31f |
+104
@@ -0,0 +1,104 @@
|
||||
# Profeto CI —— 首次引入自动化质量防线。
|
||||
#
|
||||
# 背景:此前仓库无任何 CI。唯一的测试文件 frontend/src/lib/http.test.ts
|
||||
# 从未被自动执行过,类型检查也仅靠本地手跑 tsc。本流水线把
|
||||
# 「类型检查 + 单元测试 + 构建」固化为 MR 门禁。
|
||||
#
|
||||
# 复用项目自带的 Docker 镜像(与 docker-compose.yml 同源),
|
||||
# 避免 CI 环境与本地/线上不一致。
|
||||
|
||||
stages:
|
||||
- verify
|
||||
|
||||
default:
|
||||
image: node:22-alpine
|
||||
|
||||
# ── 前端:Lint ────────────────────────────────────────────────────
|
||||
# 拦截「机器能看出来、人容易漏掉」的问题:幻影 CSS 变体、
|
||||
# 硬编码色值、渲染期间副作用、未使用符号。
|
||||
# 当前基线为 0 error / 56 warning,故本 job 会真实失败于新增 error。
|
||||
frontend-lint:
|
||||
stage: verify
|
||||
before_script:
|
||||
- cd frontend
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
script:
|
||||
- pnpm lint
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/pnpm-lock.yaml
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── 前端:类型检查 ────────────────────────────────────────────────
|
||||
frontend-typecheck:
|
||||
stage: verify
|
||||
before_script:
|
||||
- cd frontend
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
script:
|
||||
- pnpm typecheck
|
||||
# 缓存 pnpm store,避免每次 MR 重新下载依赖
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/pnpm-lock.yaml
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── 前端:单元测试 ────────────────────────────────────────────────
|
||||
# 这是 http.test.ts 第一次被自动化调用。它覆盖 HTTP 层的
|
||||
# method/body/Content-Type 组装与 /health 免前缀豁免 —— 后者的
|
||||
# 回归会导致管理后台右上角永远显示「系统异常」。
|
||||
frontend-test:
|
||||
stage: verify
|
||||
before_script:
|
||||
- cd frontend
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
script:
|
||||
- pnpm test
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/pnpm-lock.yaml
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
|
||||
# ── 前端:生产构建 ────────────────────────────────────────────────
|
||||
# 独立于 typecheck:构建会暴露类型检查覆盖不到的问题
|
||||
# (Tailwind 配置错误、资源缺失、chunk 拆分失败等)。
|
||||
frontend-build:
|
||||
stage: verify
|
||||
before_script:
|
||||
- cd frontend
|
||||
- corepack enable
|
||||
- pnpm install --frozen-lockfile
|
||||
script:
|
||||
- pnpm build
|
||||
artifacts:
|
||||
name: "frontend-dist-$CI_COMMIT_SHORT_SHA"
|
||||
paths:
|
||||
- frontend/dist/
|
||||
expire_in: 1 week
|
||||
cache:
|
||||
key:
|
||||
files:
|
||||
- frontend/pnpm-lock.yaml
|
||||
paths:
|
||||
- frontend/node_modules/
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* ESLint 配置 —— 工程的自动化质量防线。
|
||||
*
|
||||
* 本文件的核心目的不只是「常规 lint」,而是把审查报告里
|
||||
* 「只能靠人肉 review 发现」的几类问题固化成机器规则:
|
||||
*
|
||||
* 1. 幻影变体:此前 `btn-ghost` 被使用但从未在 CSS 中定义,
|
||||
* 样式静默失效,类型检查和构建都不报错。
|
||||
* 2. 裸控件:components/ui 已提供 Button/Input/Select 基元,
|
||||
* 但页面里仍有人直接写原生标签 + 手拼 className,
|
||||
* 导致样式与无障碍行为再次分叉。
|
||||
* 3. 硬编码色值:设计令牌是唯一颜色来源,十六进制字面量
|
||||
* 会让改令牌时产生「漏网之鱼」。
|
||||
*
|
||||
* 规则原则:宁可少而准,不要多而吵。无法修复的告警只会被忽略。
|
||||
*/
|
||||
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
/**
|
||||
* CSS 变体类名总表 —— index.css 的 @layer components 里定义的全部类。
|
||||
*
|
||||
* 分两组,因为二者的处置方式完全不同:
|
||||
*
|
||||
* - `PRIMITIVE_CLASSES`:已有组件基元等价物(Button / Input / Select / Tabs)。
|
||||
* 页面里**手拼**这些类名 = 绕过基元,样式与无障碍行为会再次分叉。
|
||||
* 这类用法由 `no-restricted-syntax` 报错拦截。
|
||||
*
|
||||
* - `COMPOSITE_CLASSES`:组合型排版类(类似 Tailwind 工具类),
|
||||
* 本就设计成裸用(如 `className="section-head mb-2"`)。
|
||||
* 它们没有、也不该有组件基元,因此不拦截。
|
||||
*
|
||||
* 之所以要专门拦「单独出现」而非子串匹配:因为 `btn` 是 `btn-solid`
|
||||
* 的前缀,`tab` 是 `tab-on` 的前缀。只有被当作独立 token 使用时才是问题。
|
||||
*/
|
||||
const PRIMITIVE_CLASSES = [
|
||||
// Button 基元覆盖
|
||||
'btn',
|
||||
'btn-sm',
|
||||
'btn-solid',
|
||||
'btn-outline',
|
||||
'btn-danger',
|
||||
'btn-ghost',
|
||||
// Input / Select 基元覆盖
|
||||
'field',
|
||||
// Tabs 基元覆盖
|
||||
'tab',
|
||||
'tab-on',
|
||||
]
|
||||
|
||||
const COMPOSITE_CLASSES = [
|
||||
'section-head',
|
||||
'skeleton',
|
||||
'empty-state',
|
||||
'empty-state-title',
|
||||
'empty-state-sub',
|
||||
'empty-state-action',
|
||||
'error-banner',
|
||||
'error-banner-title',
|
||||
'error-banner-detail',
|
||||
'nav-icon',
|
||||
'masthead-rule',
|
||||
'font-brush',
|
||||
]
|
||||
|
||||
/** 保留导出,兼容既有引用;内容为全部组件类名。 */
|
||||
const CSS_VARIANT_CLASSES = [...PRIMITIVE_CLASSES, ...COMPOSITE_CLASSES]
|
||||
|
||||
/**
|
||||
* 匹配「className 值里单独出现的某个基元类名」的正则。
|
||||
*
|
||||
* 只匹配独立 token(前后必须是空白或字符串边界),避免两类误判:
|
||||
* - `btn-solid` 里的 `btn`(前缀子串,合法,由 Button 基元自己输出)
|
||||
* - `searchParams.get('tab')` 这类**非样式**字符串
|
||||
* —— 这正是不能用裸正则扫全量 Literal 的原因(第一版曾误报)。
|
||||
*
|
||||
* 该正则只配合下面的 AST 选择器使用:
|
||||
* `JSXAttribute[name.name="className"] Literal[...]` 把匹配范围
|
||||
* 严格限定在 className 属性值内,字符串里出现同名 token 不再误伤。
|
||||
*/
|
||||
function barePrimitivePattern() {
|
||||
const names = PRIMITIVE_CLASSES.join('|')
|
||||
return new RegExp(`(^|\\s)(${names})(?=\\s|$)`)
|
||||
}
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
// 只检查 TS/TSX。CSS 由 Tailwind/PostCSS 管线负责,
|
||||
// 交给 ESLint 解析只会得到 "Declaration expected" 噪声。
|
||||
// 配置类文件自身不参与 lint(它们是规则的声明方,不是被约束方)。
|
||||
ignores: ['dist', 'node_modules', '*.config.js', '*.config.mjs', '*.config.ts'],
|
||||
},
|
||||
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// ── 全局:浏览器环境 ────────────────────────────────────────────
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2022,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
|
||||
// ── hooks 规则调整 ───────────────────────────────────────────
|
||||
// `set-state-in-effect`:加载类 effect(挂载时发起请求 → 回调里
|
||||
// setState)是 React 官方文档明确认可的模式,该规则在此场景下
|
||||
// 误报率过高。当前有 19 处这类写法,逐条改写属于行为等价的
|
||||
// 大范围重构,不适合混在本次质量修复里。降级为 warn 保留可见性。
|
||||
// 注:真正的「渲染期间副作用」已由 react-hooks/purity 单独拦下
|
||||
// (Collection.tsx 里的 Date.now() 即被它捕获并已修复)。
|
||||
'react-hooks/set-state-in-effect': 'warn',
|
||||
|
||||
// ── 死代码 ───────────────────────────────────────────────────
|
||||
// tsconfig 里 noUnusedLocals/noUnusedParameters 是关的
|
||||
// (存量太多,一次性打开会淹没信号)。这里先用 ESLint 的
|
||||
// 同型规则,允许下划线前缀显式豁免,便于渐进清理。
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
|
||||
// ── 类型安全 ─────────────────────────────────────────────────
|
||||
// `any` 在数据访问层是真实的类型漏洞来源(dal.ts 曾有 14 处)。
|
||||
// 用 warn 而非 error:存量需要分批处理,但不该被遗忘。
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
// 空接口等价于无约束类型参数,通常是想写 type 而非 interface
|
||||
'@typescript-eslint/no-empty-object-type': 'warn',
|
||||
|
||||
// ── React ────────────────────────────────────────────────────
|
||||
// 本仓库是纯 SPA(无 RSC、无服务端渲染),Fast Refresh 的粒度为
|
||||
// 整个模块。兼容壳文件(admin/components.tsx、matches/ui.tsx)与
|
||||
// 混装常量+组件的文件会命中此规则,但它们本就是刻意保留的
|
||||
// 再导出层,热更新退化不影响开发体验。关闭以免噪声掩盖真问题。
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ── 组件基元层:自身必然使用原生标签,豁免相关规则 ──────────────
|
||||
{
|
||||
files: ['src/components/ui/**'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ── 禁止硬编码设计令牌色值 + 禁止手拼组件基元类名 ────────────────
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
// 组件基元层自身必然要写这些类名 —— 它是唯一的合法使用点
|
||||
ignores: ['src/components/ui/**'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
// error 级:两类问题(硬编码色值、手拼基元类名)存量均已清零,
|
||||
// 此后任何新增都应在提交前就地修正,CI 可直接阻断。
|
||||
'error',
|
||||
{
|
||||
selector: 'Literal[value=/^#[0-9a-fA-F]{3,8}$/]',
|
||||
message:
|
||||
'禁止硬编码颜色字面量。请使用设计令牌(press/ink/paper 等)或 CSS 变量。',
|
||||
},
|
||||
{
|
||||
selector: 'TemplateElement[value.raw=/rgba?\\(/]',
|
||||
message:
|
||||
'禁止在模板字符串中硬编码 rgb/rgba 颜色。请使用设计令牌或 CSS 变量。',
|
||||
},
|
||||
// ── 手拼基元类名 ────────────────────────────────────────────
|
||||
// error 级:这类写法会让组件层形同虚设,且样式/无障碍分叉
|
||||
// 只在运行时显形,类型检查完全沉默。合法写点(components/ui)
|
||||
// 已被本块的 ignores 排除。
|
||||
//
|
||||
// 用 JSXAttribute 选择器把范围钉死在 className 属性值上:
|
||||
// 裸 `Literal[...]` 会误伤 `searchParams.get('tab')` 这类
|
||||
// 恰好含同名 token 的普通字符串。两种写法分别覆盖:
|
||||
// className="field w-full" → String Literal
|
||||
// className={`btn ${x ? 'btn-solid' : ''}`} → TemplateLiteral
|
||||
{
|
||||
selector: `JSXAttribute[name.name="className"] Literal[value=/${barePrimitivePattern().source}/]`,
|
||||
message:
|
||||
'禁止手拼组件基元类名(btn/field/tab 系列)。请改用 components/ui 的 Button / Input / Select / Tabs 基元 —— 它们统一了变体与无障碍行为。',
|
||||
},
|
||||
{
|
||||
selector: `JSXAttribute[name.name="className"] TemplateElement[value.raw=/${barePrimitivePattern().source}/]`,
|
||||
message:
|
||||
'禁止在模板字符串中手拼组件基元类名(btn/field/tab 系列)。请改用 components/ui 的基元组件。',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// 色值豁免:调色板与主题定义是颜色「来源」,不是「使用点」
|
||||
{
|
||||
files: [
|
||||
'src/index.css',
|
||||
'tailwind.config.js',
|
||||
'src/components/ui/**',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-syntax': 'off',
|
||||
},
|
||||
},
|
||||
|
||||
// ── 测试文件 ────────────────────────────────────────────────────
|
||||
{
|
||||
files: ['src/**/*.test.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// 导出变体列表供后续规则扩展使用,避免魔法字符串散落
|
||||
export { CSS_VARIANT_CLASSES }
|
||||
+40
-1
@@ -3,7 +3,46 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Profeto - 足球 LLM 预测</title>
|
||||
|
||||
<!--
|
||||
SEO / 分享 / 移动端元信息。
|
||||
此前这里只有 charset + viewport + title —— 搜索引擎无摘要、
|
||||
分享到社交平台无预览卡片、iOS 添加到主屏是截图占位、
|
||||
移动端浏览器地址栏不染色。品牌在浏览器层面完全裸奔。
|
||||
-->
|
||||
<title>Profeto · 足球赛程与 LLM 预测</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Profeto 提供主流联赛赛程、实时积分榜,以及由多专家大语言模型生成的比分与胜平负预测。仅供研究参考,不构成投注建议。"
|
||||
/>
|
||||
<meta name="theme-color" content="#FDFCF8" />
|
||||
|
||||
<!-- 索引控制:公开站点允许收录,但不索引管理后台 -->
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- 图标:SVG 优先(任意缩放清晰),PNG 作为老浏览器兜底 -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Open Graph:微信 / 飞书 / 社交平台分享卡片 -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Profeto" />
|
||||
<meta property="og:title" content="Profeto · 足球赛程与 LLM 预测" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="主流联赛赛程与积分榜,多专家大语言模型预测。仅供研究参考。"
|
||||
/>
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
|
||||
<!-- Twitter / X 卡片 -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:title" content="Profeto · 足球赛程与 LLM 预测" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="主流联赛赛程与积分榜,多专家大语言模型预测。仅供研究参考。"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
Generated
+1457
-666
File diff suppressed because it is too large
Load Diff
+11
-2
@@ -5,8 +5,12 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
"build": "tsc && vite build && sh scripts/verify-tokens.sh",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"test": "node --experimental-strip-types --test \"src/**/*.test.ts\"",
|
||||
"verify:tokens": "sh scripts/verify-tokens.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
@@ -18,9 +22,14 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.12.0",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.70.1",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2601
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 650 B |
Binary file not shown.
|
After Width: | Height: | Size: 128 B |
Binary file not shown.
|
After Width: | Height: | Size: 182 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" fill="#FDFCF8"/>
|
||||
<rect x="1.5" y="1.5" width="29" height="29" fill="none" stroke="#17140F" stroke-width="2.5"/>
|
||||
<text x="16" y="23" font-family="serif" font-size="20" font-weight="700" fill="#9E1B1B" text-anchor="middle">先</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 334 B |
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# 设计令牌完整性守卫 —— 确认关键颜色类真的生成了 CSS。
|
||||
#
|
||||
# 背景:把商标色改为 CSS 变量时踩过一个坑 —— 变量若写成完整十六进制
|
||||
# (#9E1B1B)而非 RGB 三元组(158 27 27),Tailwind 的透明度修饰符
|
||||
# (bg-press-wash/60)会**静默不生成任何 CSS**。构建通过、类型检查通过、
|
||||
# 控制台无警告,只是样式没了。这类问题只能靠检查产物发现。
|
||||
#
|
||||
# 用法: pnpm build && sh scripts/verify-tokens.sh
|
||||
set -e
|
||||
|
||||
CSS=$(ls dist/assets/*.css 2>/dev/null | head -1)
|
||||
if [ -z "$CSS" ]; then
|
||||
echo "✗ 未找到构建产物 CSS,请先执行 pnpm build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
fail=0
|
||||
check() {
|
||||
if grep -q -F -- "$1" "$CSS"; then
|
||||
echo " ✓ $1"
|
||||
else
|
||||
echo " ✗ $1 缺失"
|
||||
fail=1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "校验设计令牌在构建产物中的存在性:"
|
||||
check '.bg-press{'
|
||||
check '.text-ink-900{'
|
||||
check '.bg-paper-50{'
|
||||
check '.border-press{'
|
||||
check 'press-wash\/60'
|
||||
check '.masthead-rule{'
|
||||
check '--press:'
|
||||
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "设计令牌校验失败。"
|
||||
echo "最常见原因:tailwind.config.js 中颜色值未写成"
|
||||
echo " 'rgb(var(--x) / <alpha-value>)'"
|
||||
echo "而写成了完整十六进制或 'var(--x)' —— 前者会丢掉透明度修饰符。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "设计令牌校验通过"
|
||||
+56
-29
@@ -9,49 +9,76 @@
|
||||
* - 首页报头放「评估」「管理」入口(极简,不另加导航条)
|
||||
* - 管理后台由 AdminLayout 侧边栏处理所有管理页导航
|
||||
* - 未登录访问管理 → AdminLayout 门禁 → 登录页(不静默失败)
|
||||
*
|
||||
* 代码分割:
|
||||
* - 公开页(Matches/Standings)各自 lazy,后台整体懒加载。
|
||||
* 此前所有页面打进同一个 chunk,一个只想看赛程的匿名访客也要
|
||||
* 下载 Dashboard/Collection/Logs/Eval/Backtest/... 十个后台页面。
|
||||
* - ErrorBoundary 下沉到每个路由级:某个页面渲染崩溃时降级为
|
||||
* 「该页面报错」,而不是整个应用白屏。
|
||||
*/
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import Masthead from './components/Masthead'
|
||||
import Matches from './pages/Matches'
|
||||
import Standings from './pages/Standings'
|
||||
import { SiteLayout } from './components/SiteLayout'
|
||||
import { adminRoutes } from './admin/routes'
|
||||
|
||||
function StandingsLayout({ children }: { children: React.ReactNode }) {
|
||||
// ── 路由级懒加载 ────────────────────────────────────────────────
|
||||
// 每个入口拆成独立 chunk,首屏只加载当前路由所需代码。
|
||||
const Matches = lazy(() => import('./pages/Matches'))
|
||||
const Standings = lazy(() => import('./pages/Standings'))
|
||||
|
||||
/**
|
||||
* 路由级加载占位。
|
||||
* 保持与应用一致的纸色背景与居中位置,避免切换时出现白闪。
|
||||
*/
|
||||
function RouteFallback() {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
<Masthead active="standings" />
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
数据由 bzzoiro 提供 · 仅供研究参考
|
||||
</div>
|
||||
</footer>
|
||||
<div className="flex min-h-[60vh] items-center justify-center">
|
||||
<span className="text-xs text-ink-400" role="status" aria-live="polite">
|
||||
加载中…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个路由的错误与加载边界。
|
||||
*
|
||||
* 注意 ErrorBoundary 必须包在 Suspense 外层:lazy 组件加载失败
|
||||
* (网络中断、部署后 chunk 哈希变化)会以错误形式抛出,而不是
|
||||
* 永远停在加载态。
|
||||
*/
|
||||
function RouteShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ErrorBoundary fullScreen={false}>
|
||||
<Suspense fallback={<RouteFallback />}>{children}</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
{/* ── 报头:粗线 + 居中刊名 + 日期与分区链接(共用 Masthead) ── */}
|
||||
<Masthead active="home" />
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
<SiteLayout
|
||||
active="home"
|
||||
footerNote="预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议"
|
||||
>
|
||||
<RouteShell>
|
||||
<Matches />
|
||||
</main>
|
||||
</RouteShell>
|
||||
</SiteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
预测结果由大语言模型生成 · 仅供研究参考 · 不构成任何投注建议
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
function StandingsPage() {
|
||||
return (
|
||||
<SiteLayout active="standings" footerNote="数据由 bzzoiro 提供 · 仅供研究参考">
|
||||
<RouteShell>
|
||||
<Standings />
|
||||
</RouteShell>
|
||||
</SiteLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,7 +88,7 @@ export default function App() {
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/standings" element={<StandingsLayout><Standings /></StandingsLayout>} />
|
||||
<Route path="/standings" element={<StandingsPage />} />
|
||||
{adminRoutes.map(route => (
|
||||
<Route key={route.path} path={route.path} element={route.element}>
|
||||
{route.children.map(child => (
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 后台导航图标集。
|
||||
*
|
||||
* 此前这 8 个图标是一段约 90 行的内联 `switch`,塞在 AdminLayout 里,
|
||||
* 使布局文件同时承担「页面骨架」与「图标图形库」两种职责,而且
|
||||
* `name: string` 的参数类型让拼错图标名不会报错 —— 只会静默渲染空白。
|
||||
*
|
||||
* 抽出为独立组件后:
|
||||
* - AdminLayout 只管布局
|
||||
* - 图标名收敛为联合类型,拼错即编译失败
|
||||
* - 新增图标时只改本文件
|
||||
*
|
||||
* 风格与 nav-icon 的线条规范一致:24×24 视框、currentColor 描边、圆头圆角。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 支持的图标名。与 `admin/nav.ts` 中各导航项的 icon 字段对应。 */
|
||||
export type AdminIconName =
|
||||
| 'collection'
|
||||
| 'chart'
|
||||
| 'target'
|
||||
| 'repeat'
|
||||
| 'eval'
|
||||
| 'monitor'
|
||||
| 'settings'
|
||||
| 'logs'
|
||||
|
||||
export interface AdminIconProps {
|
||||
name: AdminIconName
|
||||
/** 附加类名(尺寸/颜色覆盖用) */
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 统一的 SVG 外框属性,避免 8 份重复书写 */
|
||||
const svgProps = {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
} as const
|
||||
|
||||
const PATHS: Record<AdminIconName, ReactNode> = {
|
||||
// 数据采集:立方体/包裹
|
||||
collection: (
|
||||
<>
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</>
|
||||
),
|
||||
// 柱状图
|
||||
chart: (
|
||||
<>
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</>
|
||||
),
|
||||
// 靶心
|
||||
target: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</>
|
||||
),
|
||||
// 循环/重试
|
||||
repeat: (
|
||||
<>
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</>
|
||||
),
|
||||
// 评估:带横线的文档
|
||||
eval: (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</>
|
||||
),
|
||||
// 监控:显示器
|
||||
monitor: (
|
||||
<>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</>
|
||||
),
|
||||
// 设置:齿轮
|
||||
settings: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0-4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</>
|
||||
),
|
||||
// 日志:带横线的文档(与 eval 区分:行数位置不同)
|
||||
logs: (
|
||||
<>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" />
|
||||
<line x1="8" y1="17" x2="16" y2="17" />
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台导航图标。
|
||||
*
|
||||
* 未知名称不再静默返回 `null`(那样只会看到一个空位,难以定位),
|
||||
* 而是渲染一个可见的问号方块 —— 缺失的图标应当被一眼看见。
|
||||
*/
|
||||
export function AdminIcon({ name, className }: AdminIconProps) {
|
||||
const paths = PATHS[name]
|
||||
const cls = className ? `nav-icon ${className}` : 'nav-icon'
|
||||
|
||||
if (!paths) {
|
||||
return (
|
||||
<svg {...svgProps} className={cls} aria-hidden="true" data-icon-missing={name}>
|
||||
<rect x="3" y="3" width="18" height="18" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg {...svgProps} className={cls} aria-hidden="true">
|
||||
{paths}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminIcon
|
||||
@@ -7,86 +7,29 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom'
|
||||
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from './api'
|
||||
import { fetchHealth } from './dal'
|
||||
import { fetchAuthState, logout, UNAUTHORIZED_EVENT } from '../api/api'
|
||||
import { fetchHealth } from '../api/dal'
|
||||
import { COMMAND_PALETTE_PAGES, ROUTE_LABELS, NAV_SECTIONS } from './nav'
|
||||
import Login from './Login'
|
||||
import { useCommandPalette, CommandPalette } from './useCommandPalette'
|
||||
import { AdminIcon } from './AdminIcon'
|
||||
|
||||
// 线条风格 SVG 图标组件
|
||||
function Icon({ name }: { name: string }) {
|
||||
const common = 'nav-icon'
|
||||
switch (name) {
|
||||
case 'collection':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" />
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96" />
|
||||
<line x1="12" y1="22.08" x2="12" y2="12" />
|
||||
</svg>
|
||||
)
|
||||
case 'chart':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10" />
|
||||
<line x1="12" y1="20" x2="12" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="14" />
|
||||
</svg>
|
||||
)
|
||||
case 'target':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
)
|
||||
case 'repeat':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="17 1 21 5 17 9" />
|
||||
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
|
||||
<polyline points="7 23 3 19 7 15" />
|
||||
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
|
||||
</svg>
|
||||
)
|
||||
case 'eval':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
)
|
||||
case 'monitor':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
<line x1="8" y1="21" x2="16" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="21" />
|
||||
</svg>
|
||||
)
|
||||
case 'settings':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68 1.65 1.65 0 0 0 10 3.17V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0-4h-.09a1.65 1.65 0 0 0-1.51 1z" />
|
||||
</svg>
|
||||
)
|
||||
case 'logs':
|
||||
return (
|
||||
<svg className={common} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="8" y1="13" x2="16" y2="13" />
|
||||
<line x1="8" y1="17" x2="16" y2="17" />
|
||||
</svg>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
// 图标已抽到 ./AdminIcon —— 此前这里是约 90 行的内联 switch,
|
||||
// 让布局文件同时承担页面骨架与图标图形库两种职责。
|
||||
|
||||
/**
|
||||
* 侧栏导航项的 className 生成器。
|
||||
*
|
||||
* 此前这段字符串在文件里出现了两次(仪表盘项 + 分组循环项),
|
||||
* 逐字相同。改一次选中态样式要改两处 —— 与 App.tsx 里
|
||||
* 被抽成 SiteLayout 的重复是同一型问题,这里同样收敛为单一来源。
|
||||
*/
|
||||
const SIDEBAR_LINK_BASE = 'nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300'
|
||||
const SIDEBAR_LINK_ACTIVE = 'border-press bg-press-wash/60 font-medium text-press active'
|
||||
const SIDEBAR_LINK_IDLE = 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
|
||||
function sidebarLinkClass({ isActive }: { isActive: boolean }): string {
|
||||
return `${SIDEBAR_LINK_BASE} ${isActive ? SIDEBAR_LINK_ACTIVE : SIDEBAR_LINK_IDLE}`
|
||||
}
|
||||
|
||||
// D6: 导航三视图(侧栏/命令面板/面包屑)统一由 admin/nav.ts 的 NAV_ITEMS
|
||||
@@ -252,18 +195,8 @@ export default function AdminLayout() {
|
||||
{/* 导航:分组 + 小节标题 */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-3" aria-label="管理导航">
|
||||
{/* 仪表盘独立(始终第一项) */}
|
||||
<NavLink
|
||||
to="/admin"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
|
||||
isActive
|
||||
? 'border-press bg-press-wash/60 font-medium text-press active'
|
||||
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon name="chart" />
|
||||
<NavLink to="/admin" end className={sidebarLinkClass}>
|
||||
<AdminIcon name="chart" />
|
||||
仪表盘
|
||||
</NavLink>
|
||||
|
||||
@@ -275,17 +208,8 @@ export default function AdminLayout() {
|
||||
<ul className="space-y-0.5">
|
||||
{section.items.map(item => (
|
||||
<li key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
`nav-icon-wrap flex min-h-[40px] items-center gap-2.5 border-l-4 px-3 text-sm transition-all duration-300 ${
|
||||
isActive
|
||||
? 'border-press bg-press-wash/60 font-medium text-press active'
|
||||
: 'border-transparent text-ink-500 hover:bg-paper-100 hover:text-ink-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon name={item.icon} />
|
||||
<NavLink to={item.to} className={sidebarLinkClass}>
|
||||
<AdminIcon name={item.icon} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</li>
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchLLMAgents, updateSetting, clearSetting } from './dal'
|
||||
import type { LLMAgentConfig } from './types'
|
||||
import { fetchLLMAgents, updateSetting, clearSetting } from '../api/dal'
|
||||
import type { LLMAgentConfig } from '../api/types'
|
||||
import { Card, CardBody, CardHeader, Badge, Alert, Spinner, SkeletonBlock } from './components'
|
||||
import { Button, Input } from '../components/ui'
|
||||
|
||||
type FieldKey = 'model' | 'base_url' | 'api_key'
|
||||
|
||||
@@ -110,9 +111,9 @@ export default function AgentLLMCard() {
|
||||
title="专家与终裁 LLM 配置"
|
||||
description="可为每个角色单独指定模型、接口地址或 API Key;未覆盖的角色按「专家层/终裁层默认 → 全局」继承"
|
||||
action={
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
<Button size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardBody className="px-0 sm:px-0">
|
||||
@@ -141,9 +142,9 @@ export default function AgentLLMCard() {
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-2xs text-ink-500">{agent.effective_model}</span>
|
||||
<button onClick={() => toggleExpand(agent)} disabled={busy} className="btn btn-sm flex-shrink-0">
|
||||
<Button size="sm" className="flex-shrink-0" onClick={() => toggleExpand(agent)} disabled={busy}>
|
||||
{expanded ? '收起' : '配置'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,9 +155,10 @@ export default function AgentLLMCard() {
|
||||
const state = agent.fields[f.key]
|
||||
return (
|
||||
<div key={f.key} className="grid gap-1 sm:grid-cols-[96px_minmax(0,1fr)] sm:items-center sm:gap-3">
|
||||
<label className="text-xs text-ink-500">{f.label}</label>
|
||||
<label htmlFor={`llm-${f.key}`} className="text-xs text-ink-500">{f.label}</label>
|
||||
<div>
|
||||
<input
|
||||
<Input
|
||||
id={`llm-${f.key}`}
|
||||
type={f.sensitive ? 'password' : 'text'}
|
||||
value={form[f.key]}
|
||||
onChange={e => setForm(prev => ({ ...prev, [f.key]: e.target.value }))}
|
||||
@@ -166,7 +168,7 @@ export default function AgentLLMCard() {
|
||||
: f.hint
|
||||
}
|
||||
autoComplete="off"
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,13 +181,13 @@ export default function AgentLLMCard() {
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{hasOverride(agent) && (
|
||||
<button onClick={() => handleReset(agent)} disabled={busy} className="btn btn-sm">
|
||||
<Button size="sm" onClick={() => handleReset(agent)} disabled={busy}>
|
||||
恢复继承
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button onClick={() => handleSave(agent)} disabled={busy} className="btn btn-solid btn-sm">
|
||||
<Button variant="solid" size="sm" onClick={() => handleSave(agent)} disabled={busy}>
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ApiError, login } from './api'
|
||||
import { ApiError, login } from '../api/api'
|
||||
import { Button, Input } from '../components/ui'
|
||||
|
||||
export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -50,12 +51,12 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
<main className="flex flex-1 items-start justify-center px-5 py-10">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-sm border border-ink-300 bg-white p-6 shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]"
|
||||
className="w-full max-w-sm border border-ink-300 bg-paper-50 p-6 shadow-print"
|
||||
>
|
||||
<label htmlFor="admin-password" className="block text-xs font-medium tracking-wide text-ink-700">
|
||||
管理密码
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
id="admin-password"
|
||||
type="password"
|
||||
value={password}
|
||||
@@ -63,22 +64,27 @@ export default function Login({ onSuccess }: { onSuccess: () => void }) {
|
||||
placeholder="输入服务器 .env 中的 ADMIN_PASSWORD"
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
className="field mt-2 w-full"
|
||||
/* 错误时把输入框标记为无效并关联错误文本,
|
||||
屏幕阅读器才能知道「密码错了」以及错在哪 */
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? 'admin-password-error' : undefined}
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark">
|
||||
<p
|
||||
id="admin-password-error"
|
||||
/* role=alert 让错误出现时立即被朗读(aria-live 的 assertive 语义) */
|
||||
role="alert"
|
||||
className="mt-3 border-l-2 border-press bg-press-wash/40 px-3 py-2 text-2xs leading-relaxed text-press-dark"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!password || submitting}
|
||||
className="btn btn-solid mt-5 w-full justify-center"
|
||||
>
|
||||
<Button variant="solid" className="mt-5 w-full justify-center" type="submit" disabled={!password || submitting}>
|
||||
{submitting ? '验证中…' : '登 录'}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<p className="mt-4 border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
密码初始来自服务器 .env,可登录后在「系统配置」页修改;连续输错 5 次将锁定 10 分钟。
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DataSourceSetting } from './types'
|
||||
import type { DataSourceSetting } from '../api/types'
|
||||
import { Badge, Spinner } from './components'
|
||||
import { Button, Input } from '../components/ui'
|
||||
|
||||
export const ORIGIN_BADGE: Record<DataSourceSetting['origin'], { text: string; status: 'success' | 'info' | 'error' }> = {
|
||||
db: { text: '数据库覆盖', status: 'success' },
|
||||
@@ -74,34 +75,32 @@ export default function SettingRow({
|
||||
{setting.sensitive && <Badge status="warning">敏感</Badge>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
<Input
|
||||
type={setting.sensitive ? 'password' : 'text'}
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
placeholder={`输入新的 ${setting.label}`}
|
||||
/* 无可见 label(标题即配置项名),用 aria-label 提供可访问名称 */
|
||||
aria-label={`设置项 ${setting.label} 的值`}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
className="field flex-1"
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onSave(value)}
|
||||
disabled={!value.trim() || busy}
|
||||
className="btn btn-solid btn-sm"
|
||||
>
|
||||
<Button variant="solid" size="sm" onClick={() => onSave(value)} disabled={!value.trim() || busy}>
|
||||
{busy ? (<><Spinner /> 保存中</>) : '保存'}
|
||||
</button>
|
||||
<button onClick={onCancel} disabled={busy} className="btn btn-sm">
|
||||
</Button>
|
||||
<Button size="sm" onClick={onCancel} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detectModels && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<button onClick={handleDetect} disabled={detecting} className="btn btn-sm">
|
||||
<Button size="sm" onClick={handleDetect} disabled={detecting}>
|
||||
{detecting ? (<><Spinner /> 检测中</>) : detected ? '重新检测' : '检测可用模型'}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{detectError && (
|
||||
<p className="border-l-2 border-press bg-press-wash/40 px-3 py-1.5 text-2xs leading-relaxed text-press-dark">
|
||||
@@ -146,13 +145,13 @@ export default function SettingRow({
|
||||
{setting.configured ? setting.masked : '—'}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button onClick={onEdit} disabled={busy} className="btn btn-sm">
|
||||
<Button size="sm" onClick={onEdit} disabled={busy}>
|
||||
{setting.configured ? '更换' : '配置'}
|
||||
</button>
|
||||
</Button>
|
||||
{setting.origin === 'db' && (
|
||||
<button onClick={onClear} disabled={busy} className="btn btn-sm" title="删除数据库覆盖值,回落 .env">
|
||||
<Button size="sm" onClick={onClear} disabled={busy} title="删除数据库覆盖值,回落 .env">
|
||||
回落 .env
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,5 @@
|
||||
/**
|
||||
* Admin 后台管理系统 - 统一 API 客户端(门面)
|
||||
*
|
||||
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
|
||||
* 同源请求自动携带 Cookie,无需手动管理密钥。
|
||||
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
|
||||
*
|
||||
* 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份),
|
||||
* 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。
|
||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/api.ts`。
|
||||
* 新代码请直接从 `../api/api` 导入。
|
||||
*/
|
||||
|
||||
import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http'
|
||||
|
||||
/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */
|
||||
export { ApiError, UNAUTHORIZED_EVENT }
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */
|
||||
type ApiOpts = {
|
||||
timeoutMs?: number
|
||||
/** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */
|
||||
skipAuthHandling?: boolean
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => http.get<T>(path),
|
||||
post: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
|
||||
http.post<T>(path, body, opts),
|
||||
put: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
|
||||
http.put<T>(path, body, opts),
|
||||
delete: <T>(path: string) => http.delete<T>(path),
|
||||
}
|
||||
|
||||
// ── 认证 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */
|
||||
export function login(password: string): Promise<{ ok: boolean }> {
|
||||
return api.post(`${API_BASE}/auth/login`, { password })
|
||||
}
|
||||
|
||||
/** 退出登录,清除会话 Cookie */
|
||||
export function logout(): Promise<{ ok: boolean }> {
|
||||
return api.post(`${API_BASE}/auth/logout`)
|
||||
}
|
||||
|
||||
/** 探测当前登录状态 */
|
||||
export function fetchAuthState(): Promise<{
|
||||
authenticated: boolean
|
||||
enabled: boolean
|
||||
password_origin?: 'db' | 'env' | 'none'
|
||||
}> {
|
||||
return api.get(`${API_BASE}/auth/me`)
|
||||
}
|
||||
|
||||
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
|
||||
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
|
||||
// skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出
|
||||
return api.post(
|
||||
`${API_BASE}/auth/change-password`,
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
{ skipAuthHandling: true },
|
||||
)
|
||||
}
|
||||
|
||||
export { API_BASE }
|
||||
export * from '../api/api'
|
||||
|
||||
@@ -1,438 +1,27 @@
|
||||
/**
|
||||
* Admin 后台 - 通用 UI 组件集合(报刊风)
|
||||
* 【兼容壳】组件已合并到 `src/components/ui/`。
|
||||
*
|
||||
* 与前台共用同一套设计语言:
|
||||
* - 纸色底(paper)、墨色字(ink)、印报红唯一强调(press)
|
||||
* - 方正边框、细线分隔、宋体标题、无圆角、无彩色药丸标签
|
||||
* 历史:本文件曾是后台专属的组件库,与 `pages/matches/ui.tsx` 平行存在。
|
||||
* 二者分裂导致 Spinner 出现三份逐字节相同的副本,且前台页面要跨目录
|
||||
* `import '../admin/components'` 才能拿到通用组件。
|
||||
*
|
||||
* 现已全部归入 `src/components/ui/`,本文件保留为再导出壳,
|
||||
* 使既有 import 路径继续可用。**新代码请直接从 `components/ui` 导入。**
|
||||
*
|
||||
* 本文件不含任何组件实现。
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
export { Card, CardHeader, CardBody } from '../components/ui/Card'
|
||||
export { Badge } from '../components/ui/Badge'
|
||||
export { StatCard, ProgressBar, AgentWeightsBar } from '../components/ui/Stat'
|
||||
export { SectionHeader } from '../components/ui/SectionHeader'
|
||||
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from '../components/ui/Feedback'
|
||||
export { DataTable, MobileCardList, ResponsiveTable } from '../components/ui/DataTable'
|
||||
export { Spinner } from '../components/ui'
|
||||
|
||||
import { ApiError } from './api'
|
||||
|
||||
// ── 卡片 ────────────────────────────────────────────────────────
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`border border-ink-900 bg-paper-50 ${className}`}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
{description && <p className="mt-1 text-2xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <div className={`px-4 py-4 sm:px-5 ${className}`}>{children}</div>
|
||||
}
|
||||
|
||||
// ── 统计卡片 ────────────────────────────────────────────────────
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-ink-900 bg-paper-50 px-4 py-3.5">
|
||||
<span className="text-2xs tracking-[0.2em] text-ink-400">{label}</span>
|
||||
<div className="mt-1.5 font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="mt-1.5 text-2xs text-ink-400">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 状态标记 ────────────────────────────────────────────────────
|
||||
// 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性
|
||||
|
||||
const MARK_STYLES: Record<string, { text: string; mark: string }> = {
|
||||
success: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
completed: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
win: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
ok: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
running: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
||||
info: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
||||
queued: { text: 'text-ink-500', mark: 'border border-ink-400' },
|
||||
pending: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
||||
push: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
||||
warning: { text: 'text-press', mark: 'border border-press' },
|
||||
failed: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
error: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
loss: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const s = MARK_STYLES[status] ?? MARK_STYLES.pending
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap text-2xs ${s.text}`}>
|
||||
<span className={`inline-block h-1.5 w-1.5 ${s.mark}`} aria-hidden="true" />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 数据表格 ────────────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function DataTable<T = any>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
|
||||
{columns.map(col => (
|
||||
<th key={col.key} className="px-3 py-2 font-medium" style={{ width: col.width }}>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map(row => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
|
||||
>
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className="px-3 py-2.5 text-ink-800">
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: row != null && typeof row === 'object' && col.key in row
|
||||
? String((row as Record<string, unknown>)[col.key] ?? '—')
|
||||
: '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 进度条:同前台置信度细线 ────────────────────────────────────
|
||||
|
||||
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
|
||||
const clamped = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
|
||||
<div
|
||||
className="h-full rounded-full bg-press transition-[width] duration-500"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
|
||||
|
||||
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
|
||||
return (
|
||||
<div className="border-y border-ink-200 py-12 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">{text}</p>
|
||||
{sub && <p className="mt-1.5 text-xs text-ink-400">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MobileCardList<T = any>({
|
||||
data,
|
||||
renderCard,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
data: T[]
|
||||
renderCard: (row: T, index: number) => ReactNode
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{data.map((row, idx) => (
|
||||
<div key={idx} className="border border-ink-900 bg-paper-50 p-4">
|
||||
{renderCard(row, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 响应式表格容器 ──────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function ResponsiveTable<T = any>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
cardRender,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
cardRender: (row: T, index: number) => ReactNode
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 桌面端表格 */}
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
|
||||
</div>
|
||||
{/* 移动端卡片 */}
|
||||
<MobileCardList data={data} renderCard={cardRender} emptyText={emptyText} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 小节标题:同前台 section-head ───────────────────────────────
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="section-head text-base">{title}</h2>
|
||||
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
|
||||
|
||||
export function Alert({
|
||||
kind,
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
action,
|
||||
}: {
|
||||
kind: 'error' | 'ok' | 'info' | 'warning'
|
||||
title: string
|
||||
message?: string
|
||||
onClose?: () => void
|
||||
/** 右侧操作按钮(如「去修复」) */
|
||||
action?: ReactNode
|
||||
}) {
|
||||
const style =
|
||||
kind === 'error'
|
||||
? 'border-press bg-press-wash'
|
||||
: kind === 'warning'
|
||||
? 'border-press bg-press-wash/60'
|
||||
: kind === 'ok'
|
||||
? 'border-ink-900 bg-paper-100'
|
||||
: 'border-ink-300 bg-paper-50'
|
||||
const titleCls = kind === 'error' || kind === 'warning' ? 'text-press' : 'text-ink-900'
|
||||
|
||||
return (
|
||||
<div className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}>
|
||||
<div>
|
||||
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 ${kind === 'error' || kind === 'warning' ? 'bg-press' : 'bg-ink-900'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
</p>
|
||||
{message && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ink-400 transition-colors hover:text-ink-900"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
|
||||
|
||||
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
|
||||
export function describeError(err: unknown): { title: string; detail: string; kind: 'error' | 'warning' } {
|
||||
if (err instanceof ApiError) {
|
||||
const status = err.status
|
||||
const apiDetail = typeof err.data === 'object' && err.data && 'detail' in (err.data as object)
|
||||
? String((err.data as { detail: unknown }).detail)
|
||||
: ''
|
||||
const msg = apiDetail || err.message
|
||||
switch (status) {
|
||||
case 401:
|
||||
return { title: '登录已过期', detail: '请重新登录后继续操作。', kind: 'warning' }
|
||||
case 403:
|
||||
return { title: '无权访问', detail: msg || '当前账号没有执行该操作的权限。', kind: 'error' }
|
||||
case 429:
|
||||
return { title: '请求过于频繁', detail: msg || '每分钟最多 10 次预测,请稍后再试。', kind: 'warning' }
|
||||
case 502:
|
||||
return { title: '上游 LLM 不可用', detail: msg || 'LLM 服务暂时不可用,请稍后重试或切换到更便宜的模型。', kind: 'error' }
|
||||
case 503:
|
||||
return { title: '服务未就绪', detail: msg || '服务器鉴权未配置,请联系管理员。', kind: 'error' }
|
||||
case 0:
|
||||
return { title: '网络错误或请求超时', detail: '请检查网络连接后重试。', kind: 'warning' }
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { title: '服务器错误', detail: msg || `HTTP ${status},请稍后重试。`, kind: 'error' }
|
||||
}
|
||||
return { title: '请求失败', detail: msg || `HTTP ${status}`, kind: 'error' }
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { title: '操作失败', detail: err.message, kind: 'error' }
|
||||
}
|
||||
return { title: '未知错误', detail: String(err), kind: 'error' }
|
||||
}
|
||||
|
||||
/** 统一错误横幅:用于页面级错误展示。 */
|
||||
export function ErrorBanner({
|
||||
err,
|
||||
onClose,
|
||||
}: {
|
||||
err: unknown
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const { title, detail, kind } = describeError(err)
|
||||
return <Alert kind={kind} title={title} message={detail} onClose={onClose} />
|
||||
}
|
||||
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 骨架占位 ────────────────────────────────────────────────────
|
||||
|
||||
export function SkeletonBlock({ className = '' }: { className?: string }) {
|
||||
return <div className={`skeleton ${className}`} />
|
||||
}
|
||||
|
||||
|
||||
/** 空状态文本 */
|
||||
export function EmptyText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="py-10 text-center text-sm text-ink-400">
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Agent 权重条形图 */
|
||||
export function AgentWeightsBar({ weights, okCount }: { weights: Record<string, number>; okCount: number }) {
|
||||
const entries = Object.entries(weights).filter(([, w]) => w > 0)
|
||||
if (entries.length === 0) return null
|
||||
const total = entries.reduce((s, [, w]) => s + w, 0) || 1
|
||||
const colors = ['bg-ink-900', 'bg-ink-700', 'bg-ink-500', 'bg-press', 'bg-ink-300']
|
||||
return (
|
||||
<div className="mt-2 border-t border-ink-200 pt-2">
|
||||
<div className="mb-1 text-2xs text-ink-400">终裁专家权重</div>
|
||||
<div className="space-y-1">
|
||||
{entries.map(([k, w], i) => (
|
||||
<div key={k} className="flex items-center gap-2 text-2xs">
|
||||
<div className="h-3.5 flex-1 overflow-hidden rounded-sm bg-ink-200/60">
|
||||
<div
|
||||
className={`h-full ${colors[i % colors.length]} transition-all duration-500`}
|
||||
style={{ width: `${Math.max(3, Math.round((w / total) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 text-right tabular-nums text-ink-500">
|
||||
{Math.round((w / total) * 100)}%
|
||||
</span>
|
||||
<span className="w-24 truncate text-ink-400" title={k}>{k}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">有效专家:{okCount}/{entries.length}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* 骨架占位块。历史上的本地命名,内部已统一到基元 `Skeleton`。
|
||||
* 刻意只导出 `SkeletonBlock` 一个名字(不再顺带导出 `Skeleton`),
|
||||
* 以保持本壳的导出面 == 迁移前的导出面,避免新增符号带来歧义。
|
||||
*/
|
||||
export { Skeleton as SkeletonBlock } from '../components/ui'
|
||||
|
||||
+5
-503
@@ -1,506 +1,8 @@
|
||||
/**
|
||||
* Admin 后台 - 数据访问层
|
||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/dal.ts`。
|
||||
*
|
||||
* 封装所有 API 端点调用,返回类型安全的数据。
|
||||
* 所有端点对齐 FastAPI 后端实际实现。
|
||||
* 迁移原因见 `src/api/public.ts` 头注释:admin 目录不应承载被前台
|
||||
* 反向依赖的数据层。本文件仅为不破坏旧 import 路径而保留,
|
||||
* **新代码请直接从 `../api/dal`(admin 内)或 `../api`(其余位置)导入。*
|
||||
*/
|
||||
|
||||
import { api, API_BASE } from './api'
|
||||
import type {
|
||||
DashboardStats,
|
||||
CollectionRequest,
|
||||
BacktestRequest,
|
||||
BacktestSummary,
|
||||
League,
|
||||
Match,
|
||||
Prediction,
|
||||
EvalSummary,
|
||||
DataSourceStatus,
|
||||
DataSourceSetting,
|
||||
DataSourceTestResult,
|
||||
LLMAgentConfig,
|
||||
LogEntry,
|
||||
IngestSourceStatus,
|
||||
IngestJob,
|
||||
MatchDetailOut,
|
||||
MatchContextOut,
|
||||
AdminStats,
|
||||
} from './types'
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 从多个端点聚合仪表盘数据。
|
||||
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
|
||||
*
|
||||
* 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats,
|
||||
* 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似,
|
||||
* 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。
|
||||
*/
|
||||
export async function fetchDashboard(): Promise<DashboardStats> {
|
||||
// 并行获取各端点数据
|
||||
// predictions 返回数组
|
||||
const [leagues, predictions, health] = await Promise.allSettled([
|
||||
api.get<League[]>(`${API_BASE}/leagues`),
|
||||
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
|
||||
api.get<{ status: string }>('/health'),
|
||||
])
|
||||
|
||||
return {
|
||||
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
|
||||
// predictions 直接返回数组
|
||||
total_predictions: predictions.status === 'fulfilled' ? (predictions.value as any)?.length ?? 0 : 0,
|
||||
health: health.status === 'fulfilled' ? (health.value as any).status : 'unknown',
|
||||
db_tables: [], // 后端暂无表统计端点
|
||||
last_collection: [], // 后端暂无采集历史端点
|
||||
recent_errors: [], // 后端暂无错误日志端点
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerCollection(req: CollectionRequest): Promise<any> {
|
||||
const body: Record<string, any> = {
|
||||
leagues: req.leagues,
|
||||
date_from: req.date_from,
|
||||
date_to: req.date_to,
|
||||
status: req.status || undefined,
|
||||
task: req.task || 'events',
|
||||
limit: req.limit || 100,
|
||||
season: req.season || undefined,
|
||||
}
|
||||
return api.post(`${API_BASE}/ingest/bzzoiro`, body)
|
||||
}
|
||||
|
||||
// ── 预测管理 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<any> {
|
||||
return api.post(
|
||||
`${API_BASE}/predict`,
|
||||
{
|
||||
match_id: req.match_id,
|
||||
mode: req.mode || 'multi',
|
||||
},
|
||||
{ timeoutMs: 300_000 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchPredictions(limit = 50): Promise<any[]> {
|
||||
const res = await api.get<any>(`${API_BASE}/predictions?limit=${limit}`)
|
||||
return Array.isArray(res) ? res : (res as any)?.items ?? []
|
||||
}
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
export async function fetchEvalSummary(params: {
|
||||
limit?: number
|
||||
provider?: string
|
||||
model?: string
|
||||
prompt_version?: string
|
||||
mode?: string
|
||||
league_code?: string
|
||||
} = {}): Promise<EvalSummary | null> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
if (params.provider) sp.set('provider', params.provider)
|
||||
if (params.model) sp.set('model', params.model)
|
||||
if (params.prompt_version) sp.set('prompt_version', params.prompt_version)
|
||||
if (params.mode) sp.set('mode', params.mode)
|
||||
if (params.league_code) sp.set('league_code', params.league_code)
|
||||
try {
|
||||
return await api.get<EvalSummary>(`${API_BASE}/eval/summary?${sp}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
|
||||
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
|
||||
}
|
||||
|
||||
// ── 辅助数据 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLeagues(): Promise<League[]> {
|
||||
try {
|
||||
return await api.get<League[]>(`${API_BASE}/leagues`)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMatches(params: {
|
||||
league?: string
|
||||
status?: string
|
||||
limit?: number
|
||||
cursor?: string
|
||||
} = {}): Promise<{ items: Match[]; has_next: boolean; next_cursor: string | null }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.league) sp.set('league', params.league)
|
||||
if (params.status) sp.set('status', params.status)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
if (params.cursor) sp.set('cursor', params.cursor)
|
||||
|
||||
try {
|
||||
return await api.get<any>(`${API_BASE}/matches?${sp}`)
|
||||
} catch {
|
||||
return { items: [], has_next: false, next_cursor: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function settlePrediction(prediction_id: number, home_goals: number, away_goals: number): Promise<any> {
|
||||
return api.post(`${API_BASE}/eval/settle`, {
|
||||
prediction_id,
|
||||
home_goals,
|
||||
away_goals,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 健康检查 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchHealth(): Promise<any> {
|
||||
try {
|
||||
return await api.get<any>('/health')
|
||||
} catch {
|
||||
return { status: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据完整性 ──────────────────────────────────────────────────
|
||||
|
||||
export interface DataCompletenessResponse {
|
||||
generated_at: string
|
||||
totals: { finished_matches: number; stats_rows: number; stats_coverage_pct: number }
|
||||
issues: string[]
|
||||
leagues: Array<{
|
||||
code: string
|
||||
name: string
|
||||
country?: string
|
||||
matches: { total: number; finished: number; scheduled: number; with_source_id: number; earliest_match?: string; latest_match?: string }
|
||||
stats: {
|
||||
rows: number
|
||||
fields: Record<string, { count: number; pct: number }>
|
||||
}
|
||||
standings: { rows: number; latest_retrieved?: string }
|
||||
}>
|
||||
}
|
||||
|
||||
export async function fetchDataCompleteness(): Promise<DataCompletenessResponse> {
|
||||
return api.get<DataCompletenessResponse>(`${API_BASE}/admin/data-completeness`)
|
||||
}
|
||||
|
||||
// ── 积分榜(主站 + 管理后台共用) ─────────────────────────────────
|
||||
|
||||
export interface StandingRow {
|
||||
position: number
|
||||
team: string
|
||||
team_en: string
|
||||
played: number
|
||||
won: number
|
||||
drawn: number
|
||||
lost: number
|
||||
goals_for: number
|
||||
goals_against: number
|
||||
goal_diff: number
|
||||
points: number
|
||||
xg_for: number | null
|
||||
xg_against: number | null
|
||||
form: string | null
|
||||
zone: string | null
|
||||
}
|
||||
|
||||
export interface StandingsLeague {
|
||||
league_code: string
|
||||
league_name: string
|
||||
season: string
|
||||
retrieved_at: string | null
|
||||
rows: StandingRow[]
|
||||
}
|
||||
|
||||
export async function fetchStandings(league?: string, season?: string): Promise<{ leagues: StandingsLeague[] }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (league) sp.set('league', league)
|
||||
if (season) sp.set('season', season)
|
||||
const qs = sp.toString()
|
||||
return api.get<{ leagues: StandingsLeague[] }>(`${API_BASE}/standings${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试数据源连通性 — 后端真实请求上游一次,不触发入库
|
||||
*/
|
||||
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
|
||||
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源状态与配置(脱敏)
|
||||
*/
|
||||
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
|
||||
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测当前 LLM 服务可用模型(只读,不产生费用)
|
||||
*/
|
||||
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
|
||||
return api.get(`${API_BASE}/admin/llm/models`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 各专家/终裁的独立 LLM 配置状态
|
||||
*/
|
||||
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
|
||||
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询系统日志(内存缓冲,最新在前)
|
||||
*/
|
||||
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.level) sp.set('level', params.level)
|
||||
if (params.keyword) sp.set('keyword', params.keyword)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可配置项(脱敏),供各配置页渲染
|
||||
*/
|
||||
export function fetchSettings(): Promise<DataSourceSetting[]> {
|
||||
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
|
||||
*/
|
||||
export function updateSetting(key: string, value: string) {
|
||||
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置项的 DB 覆盖值,回落 .env
|
||||
*/
|
||||
export function clearSetting(key: string) {
|
||||
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试 LLM 连接 — 调用预测端点验证
|
||||
*/
|
||||
export async function testLLMConnection(matchId?: number): Promise<any> {
|
||||
// F4 修复: 优先使用不依赖比赛的 ping 端点
|
||||
try {
|
||||
return await api.post(`${API_BASE}/admin/llm/ping`, {})
|
||||
} catch {
|
||||
// 回退到旧方式(兼容)
|
||||
return api.post(
|
||||
`${API_BASE}/predict`,
|
||||
{ match_id: matchId || 1, mode: 'single' },
|
||||
{ timeoutMs: 300_000 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 LLM 使用统计 — 从预测列表聚合
|
||||
*/
|
||||
export async function fetchLLMUsageStats(): Promise<any> {
|
||||
try {
|
||||
const predictions = await fetchPredictions(50)
|
||||
const total = predictions.length
|
||||
const successCount = predictions.filter((p: any) => p.pred_1x2).length
|
||||
return {
|
||||
total_predictions: total,
|
||||
avg_latency_ms: 2400, // 后端暂无延迟统计
|
||||
success_rate: total > 0 ? (successCount / total) * 100 : 0,
|
||||
recent_predictions: predictions.slice(0, 10).map((p: any) => ({
|
||||
id: p.id,
|
||||
match_id: p.match_id,
|
||||
model: p.model,
|
||||
created_at: p.created_at,
|
||||
status: p.pred_1x2 ? 'success' : 'failed',
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
total_predictions: 0,
|
||||
avg_latency_ms: 0,
|
||||
success_rate: 0,
|
||||
recent_predictions: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 系统配置 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* P2-9 修复: 获取系统配置列表,从后端 /admin/settings 读取真实值(脱敏)。
|
||||
* 字段名对齐 Config.tsx 中使用的 { key, value_masked, description, is_sensitive } 格式。
|
||||
*/
|
||||
export async function fetchSystemConfig(): Promise<any[]> {
|
||||
try {
|
||||
const settings = await fetchSettings()
|
||||
return settings.map(s => ({
|
||||
key: s.key,
|
||||
value_masked: s.masked,
|
||||
description: s.description,
|
||||
is_sensitive: s.sensitive,
|
||||
}))
|
||||
} catch {
|
||||
// 后端不可用时返回空列表,Config.tsx 会显示空状态
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源健康/最近采集状态(只读,不触发采集)
|
||||
*/
|
||||
export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> {
|
||||
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集任务状态轮询(单任务)
|
||||
*/
|
||||
export function fetchIngestJob(jobId: string): Promise<IngestJob> {
|
||||
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}` : ''}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 比赛详情(含最近预测摘要)
|
||||
*/
|
||||
export function fetchMatchDetail(id: number): Promise<MatchDetailOut> {
|
||||
return api.get<MatchDetailOut>(`${API_BASE}/matches/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 比赛上下文(双方近况 + 历史交锋,只读)
|
||||
*/
|
||||
export function fetchMatchContext(id: number): Promise<MatchContextOut> {
|
||||
return api.get<MatchContextOut>(`${API_BASE}/matches/${id}/context`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理区统计(只读):近 24h/7d 预测次数
|
||||
*/
|
||||
export function fetchAdminStats(): Promise<AdminStats> {
|
||||
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
|
||||
}
|
||||
|
||||
// ── API Key 轮换环 ──────────────────────────────────────────────
|
||||
|
||||
export interface KeyRingKeyStatus {
|
||||
masked: string
|
||||
blocked_remaining: number
|
||||
}
|
||||
|
||||
export interface KeyRingStatusResponse {
|
||||
base_url: string
|
||||
total: number
|
||||
has_multiple: boolean
|
||||
cooldown_seconds: number
|
||||
active_index: number
|
||||
active_key: string | null
|
||||
keys: KeyRingKeyStatus[]
|
||||
}
|
||||
|
||||
export async function fetchKeyRingStatus(): Promise<KeyRingStatusResponse> {
|
||||
return api.get<KeyRingStatusResponse>(`${API_BASE}/admin/keyring/status`)
|
||||
}
|
||||
|
||||
export async function resetKeyRingCooldown(): Promise<{ ok: boolean; message: string; stats: KeyRingStatusResponse }> {
|
||||
return api.post<{ ok: boolean; message: string; stats: KeyRingStatusResponse }>(`${API_BASE}/admin/keyring/cooldown/reset`)
|
||||
}
|
||||
|
||||
// ── 定时任务 ────────────────────────────────────────────────────
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: string
|
||||
task: string
|
||||
cron: string
|
||||
leagues?: string
|
||||
enabled: boolean
|
||||
last_run_at?: string | null
|
||||
last_status?: string | null
|
||||
}
|
||||
|
||||
export async function fetchSchedules(): Promise<ScheduleItem[]> {
|
||||
return api.get<ScheduleItem[]>(`${API_BASE}/admin/schedules`)
|
||||
}
|
||||
|
||||
export async function createSchedule(data: { id: string; task: string; cron: string; leagues?: string; enabled: boolean }): Promise<{ ok: boolean }> {
|
||||
return api.post<{ ok: boolean }>(`${API_BASE}/admin/schedules`, data)
|
||||
}
|
||||
|
||||
export async function updateSchedule(id: string, data: Partial<ScheduleItem>): Promise<{ ok: boolean }> {
|
||||
return api.put<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteSchedule(id: string): Promise<{ ok: boolean }> {
|
||||
return api.delete<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`)
|
||||
}
|
||||
|
||||
export async function runScheduleNow(id: string): Promise<{ ok: boolean; message: string }> {
|
||||
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/schedules/${id}/run`)
|
||||
}
|
||||
|
||||
// ── 数据管线(质量检查 + 失败重试) ──────────────────────────────
|
||||
|
||||
export interface IngestFailureItem {
|
||||
id: number
|
||||
source: string
|
||||
entity_type: string
|
||||
source_record_id?: string
|
||||
error_type: string
|
||||
error_detail?: string
|
||||
retry_count: number
|
||||
status: string
|
||||
next_retry_at?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface DataQualityCheckItem {
|
||||
id: number
|
||||
check_name: string
|
||||
entity_type: string
|
||||
passed: boolean
|
||||
severity: string
|
||||
detail?: Record<string, unknown> | null
|
||||
checked_at?: string
|
||||
}
|
||||
|
||||
export interface DataQualityResponse {
|
||||
failures: IngestFailureItem[]
|
||||
checks: DataQualityCheckItem[]
|
||||
}
|
||||
|
||||
export async function fetchDataQuality(): Promise<DataQualityResponse> {
|
||||
return api.get<DataQualityResponse>(`${API_BASE}/admin/data-quality`)
|
||||
}
|
||||
|
||||
export async function runDataQualityCheck(): Promise<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }> {
|
||||
return api.post<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }>(`${API_BASE}/admin/data-quality/run`)
|
||||
}
|
||||
|
||||
export async function fetchIngestFailures(): Promise<IngestFailureItem[]> {
|
||||
return api.get<IngestFailureItem[]>(`${API_BASE}/admin/ingest-failures`)
|
||||
}
|
||||
|
||||
export async function retryIngestFailure(id: number): Promise<{ ok: boolean; message: string }> {
|
||||
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/ingest-failures/${id}/retry`)
|
||||
}
|
||||
export * from '../api/dal'
|
||||
|
||||
@@ -9,13 +9,20 @@
|
||||
* 禁止再新建平行导航清单(修改入口/新增页面只改这里)。
|
||||
*/
|
||||
|
||||
import type { AdminIconName } from './AdminIcon'
|
||||
|
||||
export interface NavItem {
|
||||
to: string
|
||||
label: string
|
||||
/** 命令面板中的分组名(展示原样) */
|
||||
group: string
|
||||
/** 侧栏图标名(见 AdminLayout 的 Icon) */
|
||||
icon: string
|
||||
/**
|
||||
* 侧栏图标名。
|
||||
*
|
||||
* 使用 AdminIcon 的联合类型而非 `string`:图标名拼错时
|
||||
* 此前不会报任何错,只会静默渲染出一个空位,难以定位。
|
||||
*/
|
||||
icon: AdminIconName
|
||||
/** 仅命令面板/面包屑可达,不进侧栏(深链页) */
|
||||
hideFromSidebar?: boolean
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../types'
|
||||
import { triggerBacktest, fetchEvalSummary, fetchLeagues } from '../../api/dal'
|
||||
import type { BacktestRequest, BacktestSummary, EvalSummary, League } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
import { Button, Input, Select } from '../../components/ui'
|
||||
|
||||
interface BacktestResultRow {
|
||||
match_id: number
|
||||
@@ -38,8 +39,6 @@ interface BacktestResponse {
|
||||
results: BacktestResultRow[]
|
||||
}
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
/** 导出回测明细为 CSV(UTF-8 BOM,Excel 可直接打开) */
|
||||
function exportCsv(rows: BacktestResultRow[]) {
|
||||
const header = [
|
||||
@@ -144,10 +143,10 @@ export default function BacktestPage() {
|
||||
<form onSubmit={handleBacktest} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||
<select
|
||||
<Select
|
||||
value={leagueId}
|
||||
onChange={e => setLeagueId(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
@@ -155,49 +154,49 @@ export default function BacktestPage() {
|
||||
{l.name_zh || l.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-date-from">起始日期</label>
|
||||
<Input id="bt-date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-date-to">结束日期</label>
|
||||
<Input id="bt-date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">场数限制</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-limit">场数限制</label>
|
||||
<Input id="bt-limit"
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={limit}
|
||||
onChange={e => setLimit(parseInt(e.target.value) || 20)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">模式</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="bt-mode">模式</label>
|
||||
<Input id="bt-mode"
|
||||
type="text"
|
||||
value="多专家 (5 路 + 终裁)"
|
||||
readOnly
|
||||
className="field w-full bg-paper-100 text-ink-500"
|
||||
className="w-full bg-paper-100 text-ink-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,20 +205,20 @@ export default function BacktestPage() {
|
||||
<label className="mb-1.5 block text-xs text-ink-500">
|
||||
指定模型(可选,空=默认 <code className="font-mono">gpt-4o</code>)
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
placeholder="如 deepseek-chat / 留空使用默认"
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <Alert kind="error" title="回测失败" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
|
||||
{loading ? (<><Spinner /> 回测中,逐场预测耗时较长</>) : '开始回测'}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -233,7 +232,7 @@ export default function BacktestPage() {
|
||||
description={`模式: ${mode}${model ? ` · 模型: ${model}` : ''} · 限 ${limit} 场`}
|
||||
action={
|
||||
result?.results?.length
|
||||
? (<button onClick={() => exportCsv(result.results)} className="btn btn-sm">导出 CSV</button>)
|
||||
? (<Button size="sm" onClick={() => exportCsv(result.results)}>导出 CSV</Button>)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -293,9 +292,9 @@ export default function BacktestPage() {
|
||||
title="模型评估"
|
||||
description="已结算预测的准确率统计"
|
||||
action={
|
||||
<button onClick={loadEval} disabled={evalLoading} className="btn btn-sm">
|
||||
<Button size="sm" onClick={loadEval} disabled={evalLoading}>
|
||||
{evalLoading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
|
||||
@@ -11,10 +11,11 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../dal'
|
||||
import type { IngestJob, League } from '../types'
|
||||
import type { CollectionRequest } from '../types'
|
||||
import { triggerCollection, fetchLeagues, fetchIngestJob, fetchIngestJobs } from '../../api/dal'
|
||||
import type { IngestJob, League } from '../../api/types'
|
||||
import type { CollectionRequest } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import { Button, Input, Select } from '../../components/ui'
|
||||
|
||||
// 图标用与全站一致的几何字符(Dashboard 工作流卡同款),不混用 emoji
|
||||
const TASKS = [
|
||||
@@ -74,6 +75,22 @@ export default function CollectionPage() {
|
||||
|
||||
useEffect(() => () => stopPolling(), [stopPolling])
|
||||
|
||||
// ── 已运行时长 ──
|
||||
// 必须放在 state 里、由定时器推进,而不是在 render 里读 Date.now()。
|
||||
// 后者是「渲染期间的副作用」:同一份 props/state 会渲染出不同结果,
|
||||
// React 的并发特性(以及未来的编译器优化)都依赖渲染幂等。
|
||||
// 采集任务在跑时每秒推进一次,既给出真实的时长,也不制造额外重渲染。
|
||||
const [elapsedSec, setElapsedSec] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskStartedAt) { setElapsedSec(0); return }
|
||||
if (taskStatus !== 'running') return
|
||||
const id = setInterval(() => {
|
||||
setElapsedSec(Math.round((Date.now() - taskStartedAt) / 1000))
|
||||
}, 1000)
|
||||
return () => clearInterval(id)
|
||||
}, [taskStartedAt, taskStatus])
|
||||
|
||||
// ── 最近任务历史(GET /admin/ingest/jobs,最新在前) ──
|
||||
// 声明须在 startJobPolling 之前(其终态回调会刷新历史)
|
||||
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
|
||||
@@ -94,6 +111,10 @@ export default function CollectionPage() {
|
||||
setTaskStatus(job.status === 'success' ? 'done' : 'error')
|
||||
stopPolling()
|
||||
loadRecentJobs() // 终态后刷新历史列表
|
||||
// 广播采集终态:数据完整性等依赖页立即刷新,不必等轮询周期
|
||||
window.dispatchEvent(new CustomEvent('profeto:ingest-done', {
|
||||
detail: { jobId: job.id, status: job.status },
|
||||
}))
|
||||
}
|
||||
} catch { /* 单次轮询失败不影响后续 */ }
|
||||
}
|
||||
@@ -170,7 +191,7 @@ export default function CollectionPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const elapsed = taskStartedAt ? Math.round((Date.now() - taskStartedAt) / 1000) : 0
|
||||
const elapsed = elapsedSec
|
||||
const summary = jobInfo ? jobSummary(jobInfo) : null
|
||||
|
||||
return (
|
||||
@@ -210,51 +231,53 @@ export default function CollectionPage() {
|
||||
|
||||
{/* 联赛选择 */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">联赛</label>
|
||||
<select
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-league">联赛</label>
|
||||
<Select
|
||||
id="col-league"
|
||||
value={leagueCode}
|
||||
onChange={e => setLeagueCode(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">全部联赛</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.name_zh || l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* events/all 任务专用: 比赛状态 + 日期 */}
|
||||
{isEventsTask && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">比赛状态</label>
|
||||
<select
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-status">比赛状态</label>
|
||||
<Select
|
||||
id="col-status"
|
||||
value={ingestStatus}
|
||||
onChange={e => setIngestStatus(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">全部(已完赛 + 未开赛)</option>
|
||||
<option value="finished">仅已完赛</option>
|
||||
<option value="scheduled">仅未开赛</option>
|
||||
</select>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">起始日期</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-from">起始日期</label>
|
||||
<Input id="col-date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={e => setDateFrom(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">结束日期</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-date-to">结束日期</label>
|
||||
<Input id="col-date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={e => setDateTo(e.target.value)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,13 +287,13 @@ export default function CollectionPage() {
|
||||
{/* standings 任务专用: 赛季 */}
|
||||
{(task === 'standings') && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">赛季(留空取当前赛季)</label>
|
||||
<input
|
||||
<label className="mb-1.5 block text-xs text-ink-500" htmlFor="col-season">赛季(留空取当前赛季)</label>
|
||||
<Input id="col-season"
|
||||
type="text"
|
||||
value={season}
|
||||
onChange={e => setSeason(e.target.value)}
|
||||
placeholder="如 2026-2027"
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -279,13 +302,13 @@ export default function CollectionPage() {
|
||||
{(task === 'stats') && (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs text-ink-500">单次最大回填比赛数(1-500)</label>
|
||||
<input
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
value={limit}
|
||||
onChange={e => setLimit(parseInt(e.target.value) || 100)}
|
||||
className="field w-full"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-ink-400">
|
||||
仅回填已有 source_event_id 且无统计的比赛(增量),上游限速约 1.2 秒/次。
|
||||
@@ -304,9 +327,9 @@ export default function CollectionPage() {
|
||||
)}
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<button type="submit" disabled={loading} className="btn btn-solid w-full">
|
||||
<Button variant="solid" className="w-full" type="submit" disabled={loading}>
|
||||
{loading ? (<><Spinner /> 采集中</>) : '触发采集'}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -362,7 +385,7 @@ export default function CollectionPage() {
|
||||
<CardHeader
|
||||
title="最近任务"
|
||||
description="后台采集任务执行历史(最新在前)"
|
||||
action={<button onClick={loadRecentJobs} className="btn btn-sm">刷新</button>}
|
||||
action={<Button size="sm" onClick={loadRecentJobs}>刷新</Button>}
|
||||
/>
|
||||
<CardBody className="px-0">
|
||||
{recentJobs === null ? (
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
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 { fetchAdminStats, fetchIngestStatus, fetchDashboard, fetchIngestFailures, fetchDataCompleteness, fetchPredictions } from '../../api/dal'
|
||||
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
|
||||
import type { AdminStats, IngestSourceStatus, DashboardStats } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, SkeletonBlock } from '../components'
|
||||
|
||||
/** 工作流引导(仅首次使用——库里还没有比赛时显示) */
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { fetchDataCompleteness } from '../dal'
|
||||
import type { DataCompletenessResponse } from '../dal'
|
||||
import { fetchDataCompleteness } from '../../api/dal'
|
||||
import type { DataCompletenessResponse } from '../../api/dal'
|
||||
import {
|
||||
Card, CardBody, CardHeader, SectionHeader, Alert,
|
||||
ProgressBar, Spinner, EmptyState,
|
||||
ProgressBar, Spinner,
|
||||
} from '../components'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
xg: 'xG 预期进球',
|
||||
@@ -55,6 +56,9 @@ export default function DataCompletenessPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [highlightedLeague, setHighlightedLeague] = useState<string | null>(null)
|
||||
// 页面文案承诺「每 5 秒自动刷新」,此前并未实现(仅挂载加载一次),
|
||||
// 采集完成后数字不动 —— 现补齐:5s 轮询 + 采集完成事件即时刷新。
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const leagueRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -72,6 +76,20 @@ export default function DataCompletenessPage() {
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
// 5s 自动轮询(与页面文案一致);已有数据时刷新不闪骨架
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return
|
||||
const t = setInterval(load, 5_000)
|
||||
return () => clearInterval(t)
|
||||
}, [autoRefresh, load])
|
||||
|
||||
// 采集页任务终态广播 → 立即刷新(不等下一个 5s 周期)
|
||||
useEffect(() => {
|
||||
const onIngestDone = () => load()
|
||||
window.addEventListener('profeto:ingest-done', onIngestDone)
|
||||
return () => window.removeEventListener('profeto:ingest-done', onIngestDone)
|
||||
}, [load])
|
||||
|
||||
// 点击问题项 → 滚动到对应联赛卡片并高亮
|
||||
const scrollToLeague = useCallback((code: string) => {
|
||||
setHighlightedLeague(code)
|
||||
@@ -92,9 +110,20 @@ export default function DataCompletenessPage() {
|
||||
title="数据完整性"
|
||||
description="按联赛统计 bzzoiro 数据采集覆盖度。每 5 秒自动刷新,或点击右上角按钮手动刷新。"
|
||||
action={
|
||||
<button onClick={load} disabled={loading} className="btn-sm btn-outline">
|
||||
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-2xs text-ink-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={e => setAutoRefresh(e.target.checked)}
|
||||
className="accent-current"
|
||||
/>
|
||||
5s 自动刷新
|
||||
</label>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? <><Spinner /> 刷新中</> : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -145,15 +174,12 @@ export default function DataCompletenessPage() {
|
||||
message={issue}
|
||||
action={action ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => scrollToLeague(action.code)}
|
||||
className="btn btn-sm whitespace-nowrap"
|
||||
>
|
||||
<Button size="sm" className="whitespace-nowrap" onClick={() => scrollToLeague(action.code)}>
|
||||
定位
|
||||
</button>
|
||||
<a href={action.href} className="btn btn-sm whitespace-nowrap">
|
||||
</Button>
|
||||
<Button href={action.href} size="sm" className="whitespace-nowrap">
|
||||
{action.label}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
@@ -13,9 +13,10 @@ import {
|
||||
runDataQualityCheck,
|
||||
fetchIngestFailures,
|
||||
retryIngestFailure,
|
||||
} from '../dal'
|
||||
import type { IngestFailureItem, DataQualityCheckItem } from '../dal'
|
||||
} from '../../api/dal'
|
||||
import type { IngestFailureItem, DataQualityCheckItem } from '../../api/dal'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
export default function DataPipelinePage() {
|
||||
const [quality, setQuality] = useState<{ failures: IngestFailureItem[]; checks: DataQualityCheckItem[] } | null>(null)
|
||||
@@ -80,9 +81,9 @@ export default function DataPipelinePage() {
|
||||
title="数据管线"
|
||||
description="采集失败重试、数据质量检查与监控"
|
||||
action={
|
||||
<button onClick={handleRunCheck} disabled={running} className="btn btn-sm">
|
||||
<Button size="sm" onClick={handleRunCheck} disabled={running}>
|
||||
{running ? <><Spinner /> 检查中</> : '运行质量检查'}
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -134,9 +135,9 @@ export default function DataPipelinePage() {
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
{(f.status === 'pending' || f.status === 'retrying') && (
|
||||
<button onClick={() => handleRetry(f.id)} className="btn btn-sm">
|
||||
<Button size="sm" onClick={() => handleRetry(f.id)}>
|
||||
重试
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
* - 空态与加载态
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchEvalSummary, fetchLeagues } from '../dal'
|
||||
import type { EvalSummary } from '../types'
|
||||
import { Card, CardBody, CardHeader, StatCard, Badge, DataTable, Alert, Spinner, EmptyText } from '../components'
|
||||
import { fetchEvalSummary, fetchLeagues } from '../../api/dal'
|
||||
import type { EvalSummary, EvalSummaryRow, EvalCalibrationBucket } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, StatCard, DataTable, Alert, Spinner, EmptyText } from '../components'
|
||||
import { Button, Input, Select } from '../../components/ui'
|
||||
|
||||
interface Filters {
|
||||
provider: string
|
||||
@@ -73,67 +74,67 @@ export default function EvalPage() {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">提供商</span>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={filters.provider}
|
||||
onChange={handleChange('provider')}
|
||||
placeholder="如 openai"
|
||||
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">模型</span>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={filters.model}
|
||||
onChange={handleChange('model')}
|
||||
placeholder="如 gpt-4o"
|
||||
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">Prompt 版本</span>
|
||||
<input
|
||||
<Input
|
||||
type="text"
|
||||
value={filters.prompt_version}
|
||||
onChange={handleChange('prompt_version')}
|
||||
placeholder="如 v1"
|
||||
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 placeholder:text-ink-300 focus:border-ink-900 focus:outline-none"
|
||||
className="mt-1 w-full"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">模式</span>
|
||||
<select
|
||||
<Select
|
||||
value={filters.mode}
|
||||
onChange={handleChange('mode')}
|
||||
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 focus:border-ink-900 focus:outline-none"
|
||||
className="mt-1 w-full"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="single">single</option>
|
||||
<option value="multi">multi</option>
|
||||
</select>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-2xs text-ink-500">联赛</span>
|
||||
<select
|
||||
<Select
|
||||
value={filters.league_code}
|
||||
onChange={handleChange('league_code')}
|
||||
className="mt-1 w-full border border-ink-300 bg-paper-50 px-2.5 py-1.5 text-sm text-ink-900 focus:border-ink-900 focus:outline-none"
|
||||
className="mt-1 w-full"
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{leagues.map(l => (
|
||||
<option key={l.code} value={l.code}>{l.name ?? l.code}</option>
|
||||
))}
|
||||
</select>
|
||||
</Select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
<Button size="sm" disabled={loading} onClick={load}>
|
||||
{loading ? '加载中…' : '应用筛选'}
|
||||
</button>
|
||||
<button onClick={handleReset} disabled={loading} className="btn btn-sm btn-ghost">
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={loading} onClick={handleReset}>
|
||||
重置
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -166,27 +167,27 @@ export default function EvalPage() {
|
||||
<EmptyText text="暂无评估数据,请调整筛选条件或先完成预测与结算" />
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<DataTable
|
||||
<DataTable<EvalSummaryRow>
|
||||
columns={[
|
||||
{ key: 'provider', label: '提供商' },
|
||||
{ key: 'model', label: '模型' },
|
||||
{ key: 'prompt_version', label: '版本', render: (row: any) => (
|
||||
{ key: 'prompt_version', label: '版本', render: (row: EvalSummaryRow) => (
|
||||
<span className="font-mono text-2xs">{row.prompt_version ?? '—'}</span>
|
||||
) },
|
||||
{ key: 'total', label: '评估条数' },
|
||||
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: any) => (
|
||||
{ key: 'accuracy_1x2', label: '1X2 准确率', render: (row: EvalSummaryRow) => (
|
||||
<span className="tabular-nums">{row.accuracy_1x2 != null ? `${row.accuracy_1x2}%` : '—'}</span>
|
||||
) },
|
||||
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: any) => (
|
||||
{ key: 'avg_score_rmse', label: '比分 RMSE', render: (row: EvalSummaryRow) => (
|
||||
<span className="tabular-nums">{row.avg_score_rmse != null ? row.avg_score_rmse.toFixed(2) : '—'}</span>
|
||||
) },
|
||||
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: any) => (
|
||||
{ key: 'avg_subjective_confidence', label: '平均置信度', render: (row: EvalSummaryRow) => (
|
||||
<span className="tabular-nums">{row.avg_subjective_confidence != null ? row.avg_subjective_confidence.toFixed(2) : '—'}</span>
|
||||
) },
|
||||
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: any) => (
|
||||
{ key: 'calibration', label: '置信度校准(桶命中率)', render: (row: EvalSummaryRow) => (
|
||||
row.calibration ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-2xs">
|
||||
{Object.entries(row.calibration).map(([name, b]: [string, any]) => (
|
||||
{Object.entries(row.calibration).map(([name, b]: [string, EvalCalibrationBucket]) => (
|
||||
<span key={name} className="inline-flex items-center gap-1">
|
||||
<span className="text-ink-400">{name}:</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
@@ -200,7 +201,7 @@ export default function EvalPage() {
|
||||
) },
|
||||
]}
|
||||
data={summary}
|
||||
rowKey={(row: any) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
||||
rowKey={(row: EvalSummaryRow) => `${row.provider}-${row.model}-${row.prompt_version ?? ''}`}
|
||||
emptyText="暂无评估数据"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { fetchLogs } from '../dal'
|
||||
import type { LogEntry } from '../types'
|
||||
import { fetchLogs } from '../../api/dal'
|
||||
import type { LogEntry } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
import { Button, Input } from '../../components/ui'
|
||||
|
||||
const LEVELS = ['', 'INFO', 'WARNING', 'ERROR'] as const
|
||||
|
||||
@@ -106,9 +107,9 @@ export default function LogsPage() {
|
||||
/>
|
||||
10s 自动刷新
|
||||
</label>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
<Button size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? (<><Spinner /> 加载中</>) : '刷新'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -117,20 +118,22 @@ export default function LogsPage() {
|
||||
<div className="flex flex-col gap-2 border-b border-ink-200 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{LEVELS.map(lv => (
|
||||
<button
|
||||
<Button
|
||||
key={lv || 'all'}
|
||||
onClick={() => setLevel(lv)}
|
||||
className={`btn btn-sm ${level === lv ? 'btn-solid' : ''}`}
|
||||
variant={level === lv ? 'solid' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
{lv || '全部'}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
placeholder="搜索关键字(消息 / logger)…"
|
||||
className="field w-full sm:w-64"
|
||||
aria-label="搜索日志关键字"
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
/**
|
||||
* Admin 后台 - 监控面板(报刊风)
|
||||
* Admin 后台 - 监控页(报刊风·三分区)
|
||||
*
|
||||
* 功能:
|
||||
* - /health 存活检查(自动:30 秒一轮;可手动刷新)
|
||||
* - /health/ready 数据库就绪检查
|
||||
* - 服务名 / 版本 / 运行时间 / 检查项(后端返回什么就展示什么)
|
||||
* 此前只有存活/DB 两个布尔,与顶栏健康点重复,信息丰度不足。
|
||||
* 现在聚合全站已有信号,回答三个问题:
|
||||
* 1. 基础设施活着吗 —— 服务存活 / DB 就绪 / 版本 / 运行时间 / 上游 bzzoiro 可达性
|
||||
* 2. 数据管线健康吗 —— 最近采集成功率 / 死信 / 数据缺口
|
||||
* 3. LLM 服务正常吗 —— 预测成功率 / 平均延迟
|
||||
* 顶部「需要关注」聚合条:任何一项异常即亮红并直达处理页。
|
||||
* 30s 自动巡检 + 手动巡检。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchHealth } from '../dal'
|
||||
import { api } from '../api'
|
||||
import { Card, CardBody, CardHeader, SectionHeader, Alert, Spinner, Badge } from '../components'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchHealth, fetchUpstreamProbe, fetchIngestJobs, fetchIngestFailures, fetchDataCompleteness, fetchLLMUsageStats } from '../../api/dal'
|
||||
import type { DataCompletenessResponse, IngestFailureItem } from '../../api/dal'
|
||||
import type { IngestJob, LLMUsageStats, HealthProbe } from '../../api/types'
|
||||
import { api } from '../../api/api'
|
||||
import { Alert, SectionHeader, Spinner } from '../components'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
type UpstreamProbe = { ok: boolean; status_code?: number; latency_ms: number; endpoint: string; error?: string }
|
||||
// 直接引用 DAL 的权威类型,不要再本地手写一份 ——
|
||||
// 本地别名会把 `avg_latency_ms: number | null` 悄悄收窄回 `number`,
|
||||
// 使「未接入」在页面上重新退化成假数字。
|
||||
type LLMStats = LLMUsageStats
|
||||
|
||||
interface TodoItem {
|
||||
key: string
|
||||
label: string
|
||||
to: string
|
||||
}
|
||||
|
||||
function MetricCard({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">{label}</div>
|
||||
<div className="mt-2">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [health, setHealth] = useState<any>(null)
|
||||
const [health, setHealth] = useState<HealthProbe | null>(null)
|
||||
const [ready, setReady] = useState<'ready' | 'not_ready' | null>(null)
|
||||
const [upstream, setUpstream] = useState<UpstreamProbe | null>(null)
|
||||
const [recentJobs, setRecentJobs] = useState<IngestJob[] | null>(null)
|
||||
const [failures, setFailures] = useState<IngestFailureItem[]>([])
|
||||
const [completeness, setCompleteness] = useState<DataCompletenessResponse | null>(null)
|
||||
const [llmStats, setLlmStats] = useState<LLMStats | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastCheck, setLastCheck] = useState<string>('')
|
||||
@@ -22,20 +55,28 @@ export default function MonitoringPage() {
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [h, r] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
api.get<{ status: string }>('/health/ready'),
|
||||
])
|
||||
setHealth(h.status === 'fulfilled' ? h.value : null)
|
||||
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
|
||||
if (h.status === 'rejected') {
|
||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||
}
|
||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
// 各信号独立容错:单项失败只降级对应卡片
|
||||
const [h, r, u, j, f, c, l] = await Promise.allSettled([
|
||||
fetchHealth(),
|
||||
api.get<{ status: string }>('/health/ready'),
|
||||
fetchUpstreamProbe(),
|
||||
fetchIngestJobs({ limit: 10 }),
|
||||
fetchIngestFailures(),
|
||||
fetchDataCompleteness(),
|
||||
fetchLLMUsageStats() as Promise<LLMStats>,
|
||||
])
|
||||
setHealth(h.status === 'fulfilled' ? h.value : null)
|
||||
setReady(r.status === 'fulfilled' ? (r.value?.status as 'ready' | 'not_ready') : null)
|
||||
setUpstream(u.status === 'fulfilled' ? u.value : null)
|
||||
setRecentJobs(j.status === 'fulfilled' && Array.isArray(j.value) ? j.value : null)
|
||||
setFailures(f.status === 'fulfilled' ? f.value : [])
|
||||
setCompleteness(c.status === 'fulfilled' ? c.value : null)
|
||||
setLlmStats(l.status === 'fulfilled' ? l.value : null)
|
||||
if (h.status === 'rejected') {
|
||||
setError(h.reason instanceof Error ? h.reason.message : '无法连接到后端')
|
||||
}
|
||||
setLastCheck(new Date().toLocaleTimeString('zh-CN', { hour12: false }))
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,108 +86,197 @@ export default function MonitoringPage() {
|
||||
}, [refresh])
|
||||
|
||||
const alive = health?.status === 'healthy' || health?.status === 'ok'
|
||||
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 recentFailedJobs = useMemo(
|
||||
() => (recentJobs ?? []).filter(j => j.status === 'failed').length,
|
||||
[recentJobs],
|
||||
)
|
||||
|
||||
// ── 「需要关注」聚合:任何一项异常即亮红 ──
|
||||
//
|
||||
// 过滤条件必须是「真值」而非 `t !== false`。因为这些短路表达式在条件
|
||||
// 不成立时返回的**不只是 false**:`upstream && !upstream.ok && {...}`
|
||||
// 在 upstream 为 null 时整条求值为 null,而 `null !== false` 为 true,
|
||||
// 于是 null 会穿过过滤器,渲染时 `t.to` 直接抛错并让整页崩进 ErrorBoundary。
|
||||
const todos = (
|
||||
[
|
||||
!alive && { key: 'alive', label: '服务存活异常', to: '/admin/logs' },
|
||||
ready === 'not_ready' && { key: 'db', label: '数据库未就绪', to: '/admin/logs' },
|
||||
upstream && !upstream.ok && { key: 'upstream', label: '上游 bzzoiro 不可达', to: '/admin/logs' },
|
||||
recentFailedJobs > 0 && { key: 'jobs', label: `最近采集失败 ${recentFailedJobs} 次`, to: '/admin/collection' },
|
||||
deadLetterCount > 0 && { key: 'deadletter', label: `死信待处理 ${deadLetterCount} 条`, to: '/admin/data-pipeline' },
|
||||
missingStatsLeagues > 0 && { key: 'missing', label: `${missingStatsLeagues} 个联赛缺统计`, to: '/admin/data-completeness' },
|
||||
] as Array<TodoItem | false | null | undefined>
|
||||
).filter((t): t is TodoItem => Boolean(t))
|
||||
|
||||
const okJobs = (recentJobs ?? []).filter(j => j.status === 'success').length
|
||||
const runJobs = (recentJobs ?? []).filter(j => j.status === 'success' || j.status === 'failed').length
|
||||
const lastJobTime = recentJobs?.[0]?.created_at
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<SectionHeader
|
||||
title="系统监控"
|
||||
description="存活与数据库就绪检查,每 30 秒自动巡检一次。"
|
||||
description="基础设施 / 数据管线 / LLM 三区巡检,每 30 秒自动刷新。"
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xs text-ink-400">{lastCheck && `最近巡检 ${lastCheck}`}</span>
|
||||
<Button size="sm" onClick={refresh} disabled={loading}>
|
||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-2xs text-ink-400">
|
||||
{lastCheck && `最近巡检 ${lastCheck}`}
|
||||
</span>
|
||||
<button onClick={refresh} disabled={loading} className="btn btn-sm">
|
||||
{loading ? (<><Spinner /> 检查中</>) : '立即巡检'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
kind="error"
|
||||
title="无法连接到后端"
|
||||
message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`}
|
||||
/>
|
||||
<Alert kind="error" title="无法连接到后端" message={`${error}\n请确认服务是否正常运行,以及登录会话是否已过期。`} />
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 存活状态 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">存活状态</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
|
||||
{health ? (alive ? '正常' : String(health.status)) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据库就绪 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">数据库就绪</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}
|
||||
{/* ── 需要关注:任何一项异常即亮红并直达处理页 ── */}
|
||||
{!loading && todos.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{todos.map(t => (
|
||||
<Link
|
||||
key={t.key}
|
||||
to={t.to}
|
||||
className="flex items-center gap-3 border border-press bg-press-wash/40 px-4 py-3 transition-colors hover:bg-press-wash"
|
||||
>
|
||||
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-press" aria-hidden="true" />
|
||||
<span className="text-xs text-ink-800">{t.label}</span>
|
||||
<span className="ml-auto text-press" aria-hidden="true">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
!loading && (
|
||||
<p className="flex items-center gap-2 border-b border-ink-200 pb-3 text-2xs text-ink-400">
|
||||
<span className="inline-block h-1.5 w-1.5 bg-ink-900" aria-hidden="true" />
|
||||
各项巡检正常:服务、数据库、上游、采集与数据完整性均无异常。
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 服务名 */}
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">服务</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold text-ink-900">
|
||||
{health?.service || 'profeto'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 版本 */}
|
||||
{health?.version && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">版本</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{health.version}
|
||||
{/* ── 基础设施 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">基础设施</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard label="存活状态">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 ${alive ? 'bg-ink-900' : 'bg-press'}`} aria-hidden="true" />
|
||||
<span className={`font-serif text-xl font-bold ${alive ? 'text-ink-900' : 'text-press'}`}>
|
||||
{health ? (alive ? '正常' : health.status) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
|
||||
{/* 运行时间 */}
|
||||
{health?.uptime_seconds != null && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">运行时间</div>
|
||||
<div className="mt-2 font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{Math.floor(health.uptime_seconds / 3600)}h{' '}
|
||||
{Math.floor((health.uptime_seconds % 3600) / 60)}m
|
||||
<MetricCard label="数据库就绪">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 ${ready === 'ready' ? 'bg-ink-900' : ready === null ? 'bg-ink-300' : 'bg-press'}`} aria-hidden="true" />
|
||||
<span className={`font-serif text-xl font-bold ${ready === 'not_ready' ? 'text-press' : 'text-ink-900'}`}>
|
||||
{ready === 'ready' ? '就绪' : ready === 'not_ready' ? '未就绪' : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
|
||||
{/* 检查项 */}
|
||||
{health?.checks && Object.keys(health.checks).length > 0 && (
|
||||
<div className="border border-ink-900 bg-paper-50 p-5 sm:col-span-2 lg:col-span-1">
|
||||
<div className="text-2xs tracking-[0.2em] text-ink-400">健康检查</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{Object.entries(health.checks).map(([key, val]) => (
|
||||
<div key={key} className="flex items-center justify-between text-sm">
|
||||
<span className="text-2xs text-ink-500">{key}</span>
|
||||
<Badge status={String(val) === 'pass' ? 'success' : 'error'}>
|
||||
{String(val)}
|
||||
</Badge>
|
||||
<MetricCard label="版本 / 运行时间">
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{health?.version ?? '—'}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
||||
{health?.uptime_seconds != null
|
||||
? `已运行 ${Math.floor(health.uptime_seconds / 3600)}h ${Math.floor((health.uptime_seconds % 3600) / 60)}m`
|
||||
: '—'}
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="上游 bzzoiro">
|
||||
{upstream ? (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`inline-block h-2 w-2 ${upstream.ok ? 'bg-ok-500' : 'bg-press'}`} aria-hidden="true" />
|
||||
<span className={`font-serif text-xl font-bold ${upstream.ok ? 'text-ink-900' : 'text-press'}`}>
|
||||
{upstream.ok ? '可达' : '不可达'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-0.5 text-2xs tabular-nums text-ink-400">
|
||||
{upstream.ok
|
||||
? `HTTP ${upstream.status_code} · ${upstream.latency_ms}ms`
|
||||
: upstream.error?.slice(0, 40) || `HTTP ${upstream.status_code}`}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-serif text-xl text-ink-300">—</span>
|
||||
)}
|
||||
</MetricCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── 数据管线 ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">数据管线</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetricCard label="最近采集">
|
||||
{recentJobs && runJobs > 0 ? (
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{okJobs}/{runJobs} <span className="text-xs font-normal text-ink-500">成功</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-serif text-xl text-ink-300">—</div>
|
||||
)}
|
||||
<div className="mt-0.5 text-2xs text-ink-400">
|
||||
{lastJobTime ? `最近 ${new Date(lastJobTime).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false })}` : '暂无记录'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="死信待处理">
|
||||
<div className={`font-serif text-xl font-bold tabular-nums ${deadLetterCount > 0 ? 'text-press' : 'text-ink-900'}`}>
|
||||
{deadLetterCount}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">失败记录,可重试</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="数据缺口">
|
||||
<div className={`font-serif text-xl font-bold tabular-nums ${missingStatsLeagues > 0 ? 'text-press' : 'text-ink-900'}`}>
|
||||
{missingStatsLeagues}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">联赛有完赛缺统计</div>
|
||||
</MetricCard>
|
||||
|
||||
<MetricCard label="统计覆盖">
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{completeness ? `${completeness.totals.stats_coverage_pct}%` : '—'}
|
||||
</div>
|
||||
<div className="mt-0.5 text-2xs text-ink-400">有统计 / 已完赛</div>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── LLM ── */}
|
||||
<section>
|
||||
<h2 className="section-head mb-3">LLM 服务</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<MetricCard label="预测总数">
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{llmStats ? llmStats.total_predictions : '—'}
|
||||
</div>
|
||||
</MetricCard>
|
||||
<MetricCard label={llmStats?.avg_latency_ms != null ? '平均延迟' : '平均延迟(未接入)'}>
|
||||
<div className="font-serif text-xl font-bold tabular-nums text-ink-900">
|
||||
{llmStats?.avg_latency_ms != null
|
||||
? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s`
|
||||
: '—'}
|
||||
</div>
|
||||
</MetricCard>
|
||||
<MetricCard label="有效率">
|
||||
<div className={`font-serif text-xl font-bold tabular-nums ${llmStats && llmStats.success_rate < 80 ? 'text-warn-700' : 'text-ink-900'}`}>
|
||||
{llmStats ? `${llmStats.success_rate.toFixed(0)}%` : '—'}
|
||||
</div>
|
||||
</MetricCard>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,10 +8,11 @@
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { fetchPredictions, settlePrediction } from '../dal'
|
||||
import type { Prediction } from '../types'
|
||||
import { fetchPredictions, settlePrediction } from '../../api/dal'
|
||||
import type { Prediction } from '../../api/types'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner } from '../components'
|
||||
import TeamSideTag from '../../components/TeamSideTag'
|
||||
import { Button } from '../../components/ui'
|
||||
|
||||
const OUTCOME_LABEL: Record<string, string> = { '1': '主胜', X: '平局', '2': '客胜' }
|
||||
|
||||
@@ -45,7 +46,7 @@ export default function PredictionHistoryPage() {
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const handleSettle = async (p: Prediction) => {
|
||||
const match = (p as any).match
|
||||
const match = p.match
|
||||
if (!match || match.home_goals == null || match.away_goals == null) return
|
||||
setSettlingId(p.id)
|
||||
try {
|
||||
@@ -110,13 +111,14 @@ export default function PredictionHistoryPage() {
|
||||
{ v: 'unsettled', label: '待结算' },
|
||||
{ v: 'settled', label: '已结算' },
|
||||
] as const).map(opt => (
|
||||
<button
|
||||
<Button
|
||||
key={opt.v}
|
||||
onClick={() => setFilter(opt.v)}
|
||||
className={`btn btn-sm ${filter === opt.v ? 'btn-solid' : ''}`}
|
||||
variant={filter === opt.v ? 'solid' : 'default'}
|
||||
size="sm"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -154,7 +156,7 @@ export default function PredictionHistoryPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map(p => {
|
||||
const match = (p as any).match
|
||||
const match = p.match
|
||||
const matchDate = match?.match_date
|
||||
const homeName = match?.home_team_zh || match?.home_team || '?'
|
||||
const awayName = match?.away_team_zh || match?.away_team || '?'
|
||||
@@ -216,20 +218,13 @@ export default function PredictionHistoryPage() {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
{!p.settled && hasActual && (
|
||||
<button
|
||||
onClick={() => handleSettle(p)}
|
||||
disabled={settlingId === p.id}
|
||||
className="btn btn-sm btn-solid"
|
||||
>
|
||||
<Button variant="solid" size="sm" onClick={() => handleSettle(p)} disabled={settlingId === p.id}>
|
||||
{settlingId === p.id ? <Spinner /> : '结算'}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setExpandedId(isExpanded ? null : p.id)}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Button size="sm" onClick={() => setExpandedId(isExpanded ? null : p.id)}>
|
||||
{isExpanded ? '收起' : '详情'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -246,7 +241,7 @@ export default function PredictionHistoryPage() {
|
||||
{expandedId && (() => {
|
||||
const p = predictions.find(pr => pr.id === expandedId)
|
||||
if (!p) return null
|
||||
const match = (p as any).match
|
||||
const match = p.match
|
||||
const reports = p.agent_outputs ?? []
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -15,14 +15,15 @@ import {
|
||||
testLLMConnection, fetchLLMUsageStats, fetchLLMModels,
|
||||
fetchKeyRingStatus, resetKeyRingCooldown,
|
||||
fetchSchedules, createSchedule, updateSchedule, deleteSchedule, runScheduleNow,
|
||||
} from '../dal'
|
||||
import type { ScheduleItem } from '../dal'
|
||||
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../api'
|
||||
import type { LLMUsageStats, DataSourceSetting } from '../types'
|
||||
import type { KeyRingStatusResponse } from '../dal'
|
||||
} from '../../api/dal'
|
||||
import type { ScheduleItem } from '../../api/dal'
|
||||
import { changePassword, fetchAuthState, UNAUTHORIZED_EVENT } from '../../api/api'
|
||||
import type { LLMUsageStats, DataSourceSetting } from '../../api/types'
|
||||
import type { KeyRingStatusResponse } from '../../api/dal'
|
||||
import { Card, CardBody, CardHeader, Badge, SectionHeader, Alert, Spinner, SkeletonBlock } from '../components'
|
||||
import SettingRow from '../SettingRow'
|
||||
import AgentLLMCard from '../AgentLLMCard'
|
||||
import { Button, Input } from '../../components/ui'
|
||||
|
||||
const DATA_SOURCE_KEYS = ['BZZOIRO_KEY', 'BZZOIRO_BASE']
|
||||
const LLM_KEYS = ['LLM_API_KEY', 'LLM_BASE_URL', 'LLM_MODEL']
|
||||
@@ -277,7 +278,7 @@ export default function SettingsPage() {
|
||||
<CardHeader
|
||||
title="Bzzoiro API"
|
||||
description="赛程 / 比分 / 积分榜 / 比赛统计的唯一数据源"
|
||||
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</button>}
|
||||
action={<Button size="sm" onClick={loadSettings} disabled={settingsLoading}>{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</Button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{settingsLoading ? (
|
||||
@@ -307,7 +308,7 @@ export default function SettingsPage() {
|
||||
<CardHeader
|
||||
title="API Key 轮换环"
|
||||
description={keyRing?.has_multiple ? `已配置 ${keyRing.total} 个 key,遇限流自动切换` : '当前仅 1 个 key,无法轮换'}
|
||||
action={<button onClick={handleResetCooldown} disabled={ringLoading} className="btn-sm btn-outline">重置冷却</button>}
|
||||
action={<Button variant="outline" size="sm" onClick={handleResetCooldown} disabled={ringLoading}>重置冷却</Button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{keyRing && keyRing.total > 0 ? (
|
||||
@@ -345,7 +346,7 @@ export default function SettingsPage() {
|
||||
<CardHeader
|
||||
title="连接配置"
|
||||
description="OpenAI 兼容接口(DeepSeek / 智谱 / 通义等)"
|
||||
action={<button onClick={loadSettings} disabled={settingsLoading} className="btn btn-sm">{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</button>}
|
||||
action={<Button size="sm" onClick={loadSettings} disabled={settingsLoading}>{settingsLoading ? <><Spinner /> 加载中</> : '刷新'}</Button>}
|
||||
/>
|
||||
<CardBody>
|
||||
{settingsLoading ? (
|
||||
@@ -373,15 +374,15 @@ export default function SettingsPage() {
|
||||
<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">
|
||||
<Button size="sm" className="mt-4 w-full" onClick={handleTest} disabled={testing}>
|
||||
{testing ? <><Spinner /> 测试中</> : '测试 LLM 连接'}
|
||||
</button>
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-2xs text-ink-400">测试会真实调用一次 LLM 预测,产生费用。</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<button onClick={loadLlmStats} disabled={llmLoading} className="btn btn-sm">{llmLoading ? <><Spinner /> 加载中</> : '刷新'}</button>} />
|
||||
<CardHeader title="使用统计" description="从最近预测记录聚合" action={<Button size="sm" onClick={loadLlmStats} disabled={llmLoading}>{llmLoading ? <><Spinner /> 加载中</> : '刷新'}</Button>} />
|
||||
<CardBody>
|
||||
{llmLoading ? (
|
||||
<div className="space-y-3"><SkeletonBlock className="h-16 w-full" /><SkeletonBlock className="h-16 w-full" /></div>
|
||||
@@ -392,8 +393,16 @@ export default function SettingsPage() {
|
||||
<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">{llmStats.avg_latency_ms > 0 ? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s` : '—'}</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">平均延迟</div>
|
||||
{/* 延迟统计后端未接入 → 明确显示「未接入」而非留白,
|
||||
免得被误读成「延迟为 0,性能极好」 */}
|
||||
<div className="font-serif text-2xl font-bold tabular-nums text-ink-900">
|
||||
{llmStats.avg_latency_ms != null
|
||||
? `${(llmStats.avg_latency_ms / 1000).toFixed(1)}s`
|
||||
: '—'}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">
|
||||
{llmStats.avg_latency_ms != null ? '平均延迟' : '平均延迟(未接入)'}
|
||||
</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">{llmStats.success_rate.toFixed(0)}%</div>
|
||||
@@ -429,24 +438,24 @@ export default function SettingsPage() {
|
||||
<form onSubmit={handleChangePassword} className="space-y-3">
|
||||
<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" />
|
||||
<label htmlFor="pwd-current" className="mb-1 block text-2xs text-ink-500">当前密码</label>
|
||||
<Input id="pwd-current" type="password" value={currentPwd} onChange={e => setCurrentPwd(e.target.value)} autoComplete="current-password" className="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" />
|
||||
<label htmlFor="pwd-new" className="mb-1 block text-2xs text-ink-500">新密码(至少 8 位)</label>
|
||||
<Input id="pwd-new" type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} autoComplete="new-password" className="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" />
|
||||
<label htmlFor="pwd-confirm" className="mb-1 block text-2xs text-ink-500">确认新密码</label>
|
||||
<Input id="pwd-confirm" type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} autoComplete="new-password" className="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">
|
||||
<Button variant="solid" size="sm" className="flex-shrink-0" type="submit" disabled={pwdBusy || !currentPwd || !newPwd || !confirmPwd}>
|
||||
{pwdBusy ? <><Spinner /> 修改中</> : '修改密码'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardBody>
|
||||
@@ -462,15 +471,9 @@ export default function SettingsPage() {
|
||||
title="采集调度"
|
||||
description="配置 cron 表达式定时触发采集任务"
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false })
|
||||
.then(() => fetchSchedules().then(setSchedules))
|
||||
}}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Button size="sm" onClick={() => { createSchedule({ id: `schedule-${Date.now()}`, task: 'events', cron: '0 8 * * *', leagues: undefined, enabled: false }) .then(() => fetchSchedules().then(setSchedules)) }}>
|
||||
+ 新建
|
||||
</button>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
@@ -502,27 +505,20 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleRunSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Button size="sm" onClick={() => handleRunSchedule(s)} disabled={scheduleBusyId === s.id}>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : '立即执行'}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleToggleSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className={`btn btn-sm ${s.enabled ? '' : 'btn-solid'}`}
|
||||
variant={s.enabled ? 'default' : 'solid'}
|
||||
size="sm"
|
||||
>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : (s.enabled ? '禁用' : '启用')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSchedule(s)}
|
||||
disabled={scheduleBusyId === s.id}
|
||||
className="btn btn-sm btn-danger"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => handleDeleteSchedule(s)} disabled={scheduleBusyId === s.id}>
|
||||
{scheduleBusyId === s.id ? <Spinner /> : '删除'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -3,36 +3,71 @@
|
||||
*
|
||||
* 所有 Admin 页面的路由定义,使用嵌套路由。
|
||||
* 挂载路径: /admin/*
|
||||
*
|
||||
* 全部页面采用 `lazy` 动态导入。
|
||||
*
|
||||
* 此前这里是 10 个静态 import:App.tsx 引入 adminRoutes 就把
|
||||
* Dashboard/Collection/Logs/Eval/Backtest/Settings/Monitoring/
|
||||
* DataCompleteness/DataPipeline/PredictionHistory 连同它们的
|
||||
* 数据访问层一次性拉进首屏 —— 一个只想看赛程的匿名访客,
|
||||
* 要为完整的后台界面付出流量。改为 lazy 后,后台代码只在实际
|
||||
* 访问 /admin/* 时才下载。
|
||||
*
|
||||
* AdminLayout 保持静态引入:它是所有后台页面的公共外壳,
|
||||
* 若也 lazy 则会与页面 chunk 形成串行请求(先下载 layout,
|
||||
* 再下载页面),反而更慢。
|
||||
*/
|
||||
|
||||
import { lazy, Suspense } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import AdminLayout from './AdminLayout'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import CollectionPage from './pages/Collection'
|
||||
import DataCompletenessPage from './pages/DataCompleteness'
|
||||
import PredictionsPage from './pages/PredictionHistory'
|
||||
import BacktestPage from './pages/Backtest'
|
||||
import MonitoringPage from './pages/Monitoring'
|
||||
import SettingsPage from './pages/Settings'
|
||||
import LogsPage from './pages/Logs'
|
||||
import EvalPage from './pages/EvalPage'
|
||||
import DataPipelinePage from './pages/DataPipeline'
|
||||
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const CollectionPage = lazy(() => import('./pages/Collection'))
|
||||
const DataCompletenessPage = lazy(() => import('./pages/DataCompleteness'))
|
||||
const DataPipelinePage = lazy(() => import('./pages/DataPipeline'))
|
||||
const PredictionsPage = lazy(() => import('./pages/PredictionHistory'))
|
||||
const BacktestPage = lazy(() => import('./pages/Backtest'))
|
||||
const MonitoringPage = lazy(() => import('./pages/Monitoring'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
const LogsPage = lazy(() => import('./pages/Logs'))
|
||||
const EvalPage = lazy(() => import('./pages/EvalPage'))
|
||||
|
||||
/**
|
||||
* 页面级懒加载的兜底:后台各页共用。
|
||||
* 保持极简,避免后台切换时出现大块占位跳动。
|
||||
*/
|
||||
function AdminPageFallback() {
|
||||
return (
|
||||
<div className="flex min-h-[40vh] items-center justify-center">
|
||||
<span className="text-xs text-ink-400" role="status" aria-live="polite">
|
||||
加载中…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 用 Suspense 包住单个后台页面 */
|
||||
function page(node: ReactNode): ReactNode {
|
||||
return <Suspense fallback={<AdminPageFallback />}>{node}</Suspense>
|
||||
}
|
||||
|
||||
export const adminRoutes = [
|
||||
{
|
||||
path: '/admin',
|
||||
element: <AdminLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: 'collection', element: <CollectionPage /> },
|
||||
{ path: 'data-completeness', element: <DataCompletenessPage /> },
|
||||
{ path: 'data-pipeline', element: <DataPipelinePage /> },
|
||||
{ path: 'predictions', element: <PredictionsPage /> },
|
||||
{ path: 'backtest', element: <BacktestPage /> },
|
||||
{ path: 'monitoring', element: <MonitoringPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'logs', element: <LogsPage /> },
|
||||
{ path: 'eval', element: <EvalPage /> },
|
||||
{ index: true, element: page(<Dashboard />) },
|
||||
{ path: 'collection', element: page(<CollectionPage />) },
|
||||
{ path: 'data-completeness', element: page(<DataCompletenessPage />) },
|
||||
{ path: 'data-pipeline', element: page(<DataPipelinePage />) },
|
||||
{ path: 'predictions', element: page(<PredictionsPage />) },
|
||||
{ path: 'backtest', element: page(<BacktestPage />) },
|
||||
{ path: 'monitoring', element: page(<MonitoringPage />) },
|
||||
{ path: 'settings', element: page(<SettingsPage />) },
|
||||
{ path: 'logs', element: page(<LogsPage />) },
|
||||
{ path: 'eval', element: page(<EvalPage />) },
|
||||
{ path: '*', element: <Navigate to="/admin" replace /> },
|
||||
],
|
||||
},
|
||||
|
||||
+3
-387
@@ -1,389 +1,5 @@
|
||||
/**
|
||||
* Admin 后台 - TypeScript 类型定义
|
||||
*
|
||||
* 与 FastAPI 后端 Pydantic 模型对齐
|
||||
* 【兼容壳 · 已废弃】实现已迁至 `src/api/types.ts`。
|
||||
* 新代码请直接从 `../api/types` 导入。
|
||||
*/
|
||||
|
||||
// ── 系统健康 ────────────────────────────────────────────────────
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'degraded' | 'error'
|
||||
version?: string
|
||||
uptime_seconds?: number
|
||||
checks: Record<string, 'pass' | 'fail' | 'warn'>
|
||||
}
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardStats {
|
||||
leagues: League[]
|
||||
// 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT,
|
||||
// 不要再用列表 items.length 近似(上限 100 会严重失真)
|
||||
total_predictions: number
|
||||
health: string
|
||||
db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[]
|
||||
last_collection: { source: string; league_code: string | null; started_at: string; finished_at: string | null; status: string; records_count: number | null; error_message: string | null }[]
|
||||
recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[]
|
||||
}
|
||||
|
||||
// ── 联赛 & 比赛 ─────────────────────────────────────────────────
|
||||
|
||||
export interface League {
|
||||
id?: number
|
||||
code: string
|
||||
name: string
|
||||
name_zh?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
id: number
|
||||
league_code?: string
|
||||
season?: string | null
|
||||
home_team: string
|
||||
away_team: string
|
||||
home_team_zh?: string | null
|
||||
away_team_zh?: string | null
|
||||
match_date: string
|
||||
match_status: string
|
||||
home_goals?: number | null
|
||||
away_goals?: number | null
|
||||
match_stage?: string | null
|
||||
}
|
||||
|
||||
// ── 预测 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 列表接口返回的单路专家摘要字段 */
|
||||
export interface PredictionAgentOutput {
|
||||
agent: string
|
||||
status: string
|
||||
analysis?: string | null
|
||||
probable_score?: string | null
|
||||
subjective_confidence?: number | null
|
||||
}
|
||||
|
||||
export interface Prediction {
|
||||
id: number
|
||||
match_id: number
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version?: string
|
||||
mode?: string
|
||||
pred_home_goals?: number | null
|
||||
pred_away_goals?: number | null
|
||||
pred_1x2?: string | null
|
||||
subjective_confidence?: number | null
|
||||
reasoning?: string | null
|
||||
agent_outputs?: PredictionAgentOutput[] | null
|
||||
agent_weights?: Record<string, number> | null
|
||||
status?: 'success' | 'failed' | 'degraded'
|
||||
created_at: string
|
||||
actual_home_goals?: number | null
|
||||
actual_away_goals?: number | null
|
||||
settled?: boolean
|
||||
}
|
||||
|
||||
export interface PredictRequest {
|
||||
match_id: number
|
||||
mode?: 'single' | 'multi'
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export interface CollectionRequest {
|
||||
status?: string
|
||||
source: 'bzzoiro'
|
||||
leagues?: string[]
|
||||
task?: 'events' | 'standings' | 'stats' | 'all'
|
||||
limit?: number
|
||||
season?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
export interface EvalCalibrationBucket {
|
||||
total: number
|
||||
/** 该桶命中率,百分数;样本不足为 null */
|
||||
hit_rate: number | null
|
||||
}
|
||||
|
||||
export interface EvalSummaryRow {
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version: string | null
|
||||
total: number
|
||||
/** 1X2 准确率,百分数 0-100 */
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number | null
|
||||
avg_subjective_confidence?: number | null
|
||||
/** 置信度校准:按主观置信度分桶的命中率 */
|
||||
calibration?: Record<string, EvalCalibrationBucket>
|
||||
}
|
||||
|
||||
export interface EvalSummary {
|
||||
summary: Array<EvalSummaryRow>
|
||||
/** 全量已结算数 */
|
||||
total_settled: number
|
||||
/** 应用筛选后的已结算数 */
|
||||
filtered_settled: number
|
||||
/** 实际评估条数(status=success 且比分齐全) */
|
||||
evaluated: number
|
||||
/** 跳过的 degraded 条数 */
|
||||
skipped_degraded: number
|
||||
/** 跳过的比分不全条数 */
|
||||
skipped_incomplete?: number
|
||||
}
|
||||
|
||||
export interface BacktestRequest {
|
||||
league_id?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
mode?: 'single' | 'multi'
|
||||
limit?: number
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface BacktestSummary {
|
||||
total: number
|
||||
scored: number
|
||||
success: number
|
||||
degraded: number
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number
|
||||
avg_subjective_confidence?: number
|
||||
}
|
||||
|
||||
// ── 数据源配置 ──────────────────────────────────────────────────
|
||||
|
||||
export interface DataSourceSetting {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
sensitive: boolean
|
||||
configured: boolean
|
||||
masked: string
|
||||
origin: 'db' | 'env' | 'none'
|
||||
}
|
||||
|
||||
export interface DataSourceStatus {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
key_configured: boolean
|
||||
last_ingestion: string | null
|
||||
settings: DataSourceSetting[]
|
||||
}
|
||||
|
||||
export interface DataSourceTestResult {
|
||||
ok: boolean
|
||||
status: number | null
|
||||
latency_ms: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface DataSourceTestRequest {
|
||||
source: 'bzzoiro' | 'understat' | 'injuries'
|
||||
}
|
||||
|
||||
export interface IngestionHistoryEntry {
|
||||
id: string
|
||||
source: string
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
status: 'success' | 'running' | 'failed'
|
||||
records_count: number | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
|
||||
export interface LLMConfig {
|
||||
provider: string
|
||||
model: string
|
||||
base_url: string
|
||||
api_key_configured: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
export interface LLMUsageStats {
|
||||
total_predictions: number
|
||||
avg_latency_ms: number
|
||||
success_rate: number
|
||||
recent_predictions: Array<{
|
||||
id: number
|
||||
match_id: number
|
||||
model: string
|
||||
created_at: string
|
||||
latency_ms?: number
|
||||
status: 'success' | 'failed'
|
||||
}>
|
||||
}
|
||||
|
||||
// ── 系统配置 ────────────────────────────────────────────────────
|
||||
|
||||
export interface SystemConfigEntry {
|
||||
key: string
|
||||
value_masked: string
|
||||
description: string
|
||||
is_sensitive: boolean
|
||||
}
|
||||
|
||||
// ── 专家/终裁独立 LLM 配置 ────────────────────────────────────────
|
||||
|
||||
export interface LLMAgentFieldState {
|
||||
configured: boolean
|
||||
masked: string
|
||||
origin: 'db' | 'env' | 'none'
|
||||
}
|
||||
|
||||
export interface LLMAgentConfig {
|
||||
id: string
|
||||
label: string
|
||||
effective_model: string
|
||||
fields: {
|
||||
model: LLMAgentFieldState
|
||||
base_url: LLMAgentFieldState
|
||||
api_key: LLMAgentFieldState
|
||||
}
|
||||
}
|
||||
|
||||
// ── 系统日志 ─────────────────────────────────────────────────────
|
||||
|
||||
export interface LogEntry {
|
||||
ts: number
|
||||
level: string
|
||||
logger: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// ── 数据源健康/最近采集状态 ─────────────────────────────────────
|
||||
|
||||
export interface IngestLastFailure {
|
||||
at: string
|
||||
logger: string
|
||||
detail: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface IngestSourceStatus {
|
||||
name: string
|
||||
label: string
|
||||
key_configured: boolean
|
||||
base_url?: string
|
||||
reachable: boolean | null
|
||||
status?: 'key_not_configured' | 'no_data' | 'has_data'
|
||||
last_success_at: string | null
|
||||
latest_match_date?: string | null
|
||||
recent_count: number
|
||||
note: string
|
||||
last_failure: IngestLastFailure | null
|
||||
}
|
||||
|
||||
// ── 采集任务状态 ──────────────────────────────────────────────
|
||||
|
||||
export interface IngestJob {
|
||||
id: string
|
||||
task: string
|
||||
params: Record<string, unknown>
|
||||
status: 'pending' | 'running' | 'success' | 'failed'
|
||||
result: Record<string, unknown> | null
|
||||
error: string | null
|
||||
created_at: string | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
// ── 比赛详情 ─────────────────────────────────────────────────────
|
||||
|
||||
export interface MatchRecentPrediction {
|
||||
id: number
|
||||
provider: string
|
||||
model: string
|
||||
mode: string
|
||||
pred_home_goals: number | null
|
||||
pred_away_goals: number | null
|
||||
alt_pred_home_goals: number | null
|
||||
alt_pred_away_goals: number | null
|
||||
pred_1x2: string | null
|
||||
subjective_confidence: number | null
|
||||
reasoning: string | null
|
||||
status: string
|
||||
settled: boolean
|
||||
correct_1x2?: boolean
|
||||
created_at: string
|
||||
actual_home_goals: number | null
|
||||
actual_away_goals: number | null
|
||||
agent_outputs?: Array<Record<string, any>> | null
|
||||
agent_weights?: Record<string, number> | null
|
||||
}
|
||||
|
||||
export interface MatchDetailOut {
|
||||
id: number
|
||||
league_code: string | null
|
||||
season: string | null
|
||||
home_team: string
|
||||
away_team: string
|
||||
home_team_zh: string | null
|
||||
away_team_zh: string | null
|
||||
match_date: string
|
||||
match_status: string
|
||||
home_goals: number | null
|
||||
away_goals: number | null
|
||||
match_stage: string | null
|
||||
home_xg: number | null
|
||||
away_xg: number | null
|
||||
stats: MatchStatsDetail | null
|
||||
recent_predictions: MatchRecentPrediction[]
|
||||
}
|
||||
|
||||
/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */
|
||||
export interface MatchStatsDetail {
|
||||
home_xg: number | null
|
||||
away_xg: number | null
|
||||
home_shots: number | null
|
||||
away_shots: number | null
|
||||
home_shots_on_target: number | null
|
||||
away_shots_on_target: number | null
|
||||
home_corners: number | null
|
||||
away_corners: number | null
|
||||
home_possession: number | null
|
||||
home_yellow_cards: number | null
|
||||
away_yellow_cards: number | null
|
||||
home_red_cards: number | null
|
||||
away_red_cards: number | null
|
||||
home_big_chances: number | null
|
||||
away_big_chances: number | null
|
||||
home_fouls: number | null
|
||||
away_fouls: number | null
|
||||
}
|
||||
|
||||
export interface TeamRecentMatch {
|
||||
match_date: string | null
|
||||
home_team: string | null
|
||||
away_team: string | null
|
||||
home_goals: number | null
|
||||
away_goals: number | null
|
||||
}
|
||||
|
||||
export interface MatchContextOut {
|
||||
home_recent: TeamRecentMatch[]
|
||||
away_recent: TeamRecentMatch[]
|
||||
h2h: TeamRecentMatch[]
|
||||
}
|
||||
|
||||
// ── 管理区统计 ─────────────────────────────────────────────────
|
||||
|
||||
export interface AdminStats {
|
||||
predictions: {
|
||||
total: number
|
||||
last_24h: number
|
||||
last_7d: number
|
||||
}
|
||||
matches?: { total: number; finished: number }
|
||||
stats?: { total: number }
|
||||
standings?: { total: number }
|
||||
}
|
||||
export * from '../api/types'
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
* r — 刷新当前页面数据(通用)
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Modal } from '../components/ui'
|
||||
|
||||
interface CommandItem {
|
||||
id: string
|
||||
@@ -94,17 +95,16 @@ export function CommandPalette({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-start justify-center bg-ink-900/50 p-4 pt-[15vh]"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="命令面板"
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
label="命令面板"
|
||||
animate={false}
|
||||
overlayClassName="z-[60] pt-[15vh]"
|
||||
panelClassName="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-lg border border-ink-900 bg-paper-50 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 面板内点击不冒泡到遮罩(Modal 已做 target 判断,此处仅阻止穿透) */}
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
{/* 搜索框 */}
|
||||
<div className="flex items-center gap-2 border-b border-ink-200 px-3 py-2.5">
|
||||
<span className="text-ink-400">⌘</span>
|
||||
@@ -112,6 +112,8 @@ export function CommandPalette({
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="输入页面名或路径…"
|
||||
/* 搜索框无可见标签,用 aria-label 提供可访问名称 */
|
||||
aria-label="搜索页面"
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-ink-400"
|
||||
/>
|
||||
@@ -126,7 +128,7 @@ export function CommandPalette({
|
||||
Object.entries(grouped).map(([group, groupItems]) => (
|
||||
<div key={group}>
|
||||
<p className="px-3 py-1 text-2xs font-medium uppercase tracking-widest text-ink-400">{group}</p>
|
||||
{groupItems.map((item, i) => (
|
||||
{groupItems.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => { item.action(); onClose() }}
|
||||
@@ -150,6 +152,6 @@ export function CommandPalette({
|
||||
<span>ESC 关闭</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* 应用统一 API 客户端(门面)—— 2026-09 自 admin/api.ts 迁入。
|
||||
*
|
||||
* 鉴权:通过 POST /api/v1/auth/login 用密码换取 HttpOnly Cookie 会话,
|
||||
* 同源请求自动携带 Cookie,无需手动管理密钥。
|
||||
* 收到 401 时广播 `profeto:unauthorized` 事件,由 AdminLayout 切回登录页。
|
||||
*
|
||||
* 实现已收敛到共享层 lib/http.ts(超时/错误解析/401 广播只此一份),
|
||||
* 本文件仅保留 Admin 侧的门面签名与认证接口,供既有页面按原路径导入。
|
||||
*/
|
||||
|
||||
import { http, ApiError, UNAUTHORIZED_EVENT } from '../lib/http'
|
||||
|
||||
/** Admin 侧兼容导出:错误类型与会话失效事件名的规范来源在 lib/http */
|
||||
export { ApiError, UNAUTHORIZED_EVENT }
|
||||
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
/** Admin 请求可覆盖项(与 lib/http RequestOptions 对齐的子集) */
|
||||
type ApiOpts = {
|
||||
timeoutMs?: number
|
||||
/** 改密接口的 401 表示「当前密码错误」,非会话过期,置 true 跳过登出广播 */
|
||||
skipAuthHandling?: boolean
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => http.get<T>(path),
|
||||
post: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
|
||||
http.post<T>(path, body, opts),
|
||||
put: <T>(path: string, body?: unknown, opts?: ApiOpts) =>
|
||||
http.put<T>(path, body, opts),
|
||||
delete: <T>(path: string) => http.delete<T>(path),
|
||||
}
|
||||
|
||||
// ── 认证 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 密码登录,成功后服务端写入 HttpOnly 会话 Cookie */
|
||||
export function login(password: string): Promise<{ ok: boolean }> {
|
||||
return api.post(`${API_BASE}/auth/login`, { password })
|
||||
}
|
||||
|
||||
/** 退出登录,清除会话 Cookie */
|
||||
export function logout(): Promise<{ ok: boolean }> {
|
||||
return api.post(`${API_BASE}/auth/logout`)
|
||||
}
|
||||
|
||||
/** 探测当前登录状态 */
|
||||
export function fetchAuthState(): Promise<{
|
||||
authenticated: boolean
|
||||
enabled: boolean
|
||||
password_origin?: 'db' | 'env' | 'none'
|
||||
}> {
|
||||
return api.get(`${API_BASE}/auth/me`)
|
||||
}
|
||||
|
||||
/** 修改管理员密码(成功后所有会话失效,需重新登录) */
|
||||
export function changePassword(currentPassword: string, newPassword: string): Promise<{ ok: boolean; message: string }> {
|
||||
// skipAuthHandling: 改密接口的 401 表示「当前密码错误」,非会话过期,不要触发登出
|
||||
return api.post(
|
||||
`${API_BASE}/auth/change-password`,
|
||||
{ current_password: currentPassword, new_password: newPassword },
|
||||
{ skipAuthHandling: true },
|
||||
)
|
||||
}
|
||||
|
||||
export { API_BASE }
|
||||
@@ -0,0 +1,481 @@
|
||||
/**
|
||||
* 应用数据访问层 —— 需要鉴权的管理端点集合。
|
||||
*
|
||||
* 封装所有 API 端点调用,返回类型安全的数据,
|
||||
* 所有端点对齐 FastAPI 后端实际实现。
|
||||
*
|
||||
* 归属说明:2026-09 从 `admin/dal.ts` 迁入 `src/api/`。
|
||||
* 公开(免登录)端点在 `./public.ts`;admin/ 下仅留兼容壳。
|
||||
*/
|
||||
|
||||
import { api, API_BASE } from './api'
|
||||
import type {
|
||||
DashboardStats,
|
||||
CollectionRequest,
|
||||
BacktestRequest,
|
||||
BacktestSummary,
|
||||
League,
|
||||
Match,
|
||||
Prediction,
|
||||
EvalSummary,
|
||||
DataSourceStatus,
|
||||
DataSourceSetting,
|
||||
DataSourceTestResult,
|
||||
LLMAgentConfig,
|
||||
LogEntry,
|
||||
IngestSourceStatus,
|
||||
IngestJob,
|
||||
AdminStats,
|
||||
LLMUsageStats,
|
||||
IngestTriggerResult,
|
||||
PredictionJobRef,
|
||||
LLMPingResult,
|
||||
SettleResult,
|
||||
HealthProbe,
|
||||
} from './types'
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 从多个端点聚合仪表盘数据。
|
||||
* 后端暂无专用仪表盘端点,这里组合 health + 各列表端点。
|
||||
*
|
||||
* 注: 比赛真实总量请用 fetchAdminStats()(GET /admin/stats,
|
||||
* 后端 COUNT(*) 精确计数)。此处曾用 matches items.length 近似,
|
||||
* 已随仪表盘切换真实计数而移除,防止误用 100 上限的假总量。
|
||||
*/
|
||||
export async function fetchDashboard(): Promise<DashboardStats> {
|
||||
// 并行获取各端点数据
|
||||
// predictions 返回数组
|
||||
const [leagues, predictions, health] = await Promise.allSettled([
|
||||
api.get<League[]>(`${API_BASE}/leagues`),
|
||||
api.get<Prediction[]>(`${API_BASE}/predictions?limit=100`),
|
||||
api.get<{ status: string }>('/health'),
|
||||
])
|
||||
|
||||
return {
|
||||
leagues: leagues.status === 'fulfilled' ? leagues.value : [],
|
||||
// predictions 直接返回数组
|
||||
total_predictions: predictions.status === 'fulfilled' ? predictions.value?.length ?? 0 : 0,
|
||||
health: health.status === 'fulfilled' ? health.value.status : 'unknown',
|
||||
// 以下三项目前无对应后端端点。返回 null 而非空数组:
|
||||
// 空数组会被读成「查过了,没有数据」,而事实是「还没接」。
|
||||
db_tables: null,
|
||||
last_collection: null,
|
||||
recent_errors: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerCollection(req: CollectionRequest): Promise<IngestTriggerResult> {
|
||||
// 后端 IngestBzzoiroRequest 的字段全部为可选,用 Partial 显式表达
|
||||
// 「未选中的筛选项不发」,而不是靠 `Record<string, any>` 蒙混。
|
||||
const body: Partial<CollectionRequest> & { task: string; limit: number } = {
|
||||
leagues: req.leagues,
|
||||
date_from: req.date_from,
|
||||
date_to: req.date_to,
|
||||
status: req.status || undefined,
|
||||
task: req.task || 'events',
|
||||
limit: req.limit || 100,
|
||||
season: req.season || undefined,
|
||||
}
|
||||
return api.post<IngestTriggerResult>(`${API_BASE}/ingest/bzzoiro`, body)
|
||||
}
|
||||
|
||||
// ── 预测管理 ────────────────────────────────────────────────────
|
||||
|
||||
export async function triggerPrediction(req: { match_id: number; mode?: string }): Promise<PredictionJobRef> {
|
||||
return api.post<PredictionJobRef>(`${API_BASE}/predict`, {
|
||||
match_id: req.match_id,
|
||||
mode: req.mode || 'multi',
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchPredictions(limit = 50): Promise<Prediction[]> {
|
||||
const res = await api.get<Prediction[] | { items?: Prediction[] }>(
|
||||
`${API_BASE}/predictions?limit=${limit}`,
|
||||
)
|
||||
// 后端声明为 list[PredictionOut];保留 items 分支兼容网关包装层。
|
||||
if (Array.isArray(res)) return res
|
||||
return res?.items ?? []
|
||||
}
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
export async function fetchEvalSummary(params: {
|
||||
limit?: number
|
||||
provider?: string
|
||||
model?: string
|
||||
prompt_version?: string
|
||||
mode?: string
|
||||
league_code?: string
|
||||
} = {}): Promise<EvalSummary | null> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
if (params.provider) sp.set('provider', params.provider)
|
||||
if (params.model) sp.set('model', params.model)
|
||||
if (params.prompt_version) sp.set('prompt_version', params.prompt_version)
|
||||
if (params.mode) sp.set('mode', params.mode)
|
||||
if (params.league_code) sp.set('league_code', params.league_code)
|
||||
try {
|
||||
return await api.get<EvalSummary>(`${API_BASE}/eval/summary?${sp}`)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerBacktest(req: BacktestRequest): Promise<BacktestSummary> {
|
||||
return api.post<BacktestSummary>(`${API_BASE}/backtest`, req, { timeoutMs: 300_000 })
|
||||
}
|
||||
|
||||
// ── 辅助数据 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchLeagues(): Promise<League[]> {
|
||||
try {
|
||||
return await api.get<League[]>(`${API_BASE}/leagues`)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchMatches(params: {
|
||||
league?: string
|
||||
status?: string
|
||||
limit?: number
|
||||
cursor?: string
|
||||
} = {}): Promise<{ items: Match[]; has_next: boolean; next_cursor: string | null }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.league) sp.set('league', params.league)
|
||||
if (params.status) sp.set('status', params.status)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
if (params.cursor) sp.set('cursor', params.cursor)
|
||||
|
||||
try {
|
||||
return await api.get<{ items: Match[]; has_next: boolean; next_cursor: string | null }>(
|
||||
`${API_BASE}/matches?${sp}`,
|
||||
)
|
||||
} catch {
|
||||
return { items: [], has_next: false, next_cursor: null }
|
||||
}
|
||||
}
|
||||
|
||||
export async function settlePrediction(
|
||||
prediction_id: number,
|
||||
home_goals: number,
|
||||
away_goals: number,
|
||||
): Promise<SettleResult> {
|
||||
return api.post<SettleResult>(`${API_BASE}/eval/settle`, {
|
||||
prediction_id,
|
||||
home_goals,
|
||||
away_goals,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 健康检查 ────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchHealth(): Promise<HealthProbe> {
|
||||
try {
|
||||
return await api.get<HealthProbe>('/health')
|
||||
} catch {
|
||||
return { status: 'unknown' }
|
||||
}
|
||||
}
|
||||
|
||||
// ── 数据完整性 ──────────────────────────────────────────────────
|
||||
|
||||
export interface DataCompletenessResponse {
|
||||
generated_at: string
|
||||
totals: { finished_matches: number; stats_rows: number; stats_coverage_pct: number }
|
||||
issues: string[]
|
||||
leagues: Array<{
|
||||
code: string
|
||||
name: string
|
||||
country?: string
|
||||
matches: { total: number; finished: number; scheduled: number; with_source_id: number; earliest_match?: string; latest_match?: string }
|
||||
stats: {
|
||||
rows: number
|
||||
fields: Record<string, { count: number; pct: number }>
|
||||
}
|
||||
standings: { rows: number; latest_retrieved?: string }
|
||||
}>
|
||||
}
|
||||
|
||||
export async function fetchDataCompleteness(): Promise<DataCompletenessResponse> {
|
||||
return api.get<DataCompletenessResponse>(`${API_BASE}/admin/data-completeness`)
|
||||
}
|
||||
|
||||
// ── 积分榜 & 比赛详情 ───────────────────────────────────────────
|
||||
//
|
||||
// 这几者与它们的类型已迁到 `src/api/public.ts` —— 它们是**无需登录**
|
||||
// 的公开端点,却是前台页面的数据来源。留在这里会让公开页反向依赖
|
||||
// admin 层,并把 41 个后台端点一起拖进用户首屏的 chunk。
|
||||
//
|
||||
// 此处保留再导出,使既有 `from './dal'` 的调用路径继续可用;
|
||||
// 新代码请直接从 `src/api/public` 导入。
|
||||
|
||||
export { fetchStandings, fetchMatchDetail, fetchMatchContext } from '../api/public'
|
||||
export type { StandingRow, StandingsLeague } from '../api/public'
|
||||
|
||||
// ── 数据源管理 ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试数据源连通性 — 后端真实请求上游一次,不触发入库
|
||||
*/
|
||||
export function testDataSourceConnection(name: string): Promise<DataSourceTestResult> {
|
||||
return api.post<DataSourceTestResult>(`${API_BASE}/admin/datasources/${name}/test`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取数据源状态与配置(脱敏)
|
||||
*/
|
||||
export function fetchDataSourceStatuses(): Promise<DataSourceStatus[]> {
|
||||
return api.get<DataSourceStatus[]>(`${API_BASE}/admin/datasources`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 探测当前 LLM 服务可用模型(只读,不产生费用)
|
||||
*/
|
||||
export function fetchLLMModels(): Promise<{ ok: boolean; models: string[]; latency_ms?: number; detail: string }> {
|
||||
return api.get(`${API_BASE}/admin/llm/models`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 各专家/终裁的独立 LLM 配置状态
|
||||
*/
|
||||
export function fetchLLMAgents(): Promise<LLMAgentConfig[]> {
|
||||
return api.get<LLMAgentConfig[]>(`${API_BASE}/admin/llm/agents`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询系统日志(内存缓冲,最新在前)
|
||||
*/
|
||||
export function fetchLogs(params: { level?: string; keyword?: string; limit?: number } = {}): Promise<{ entries: LogEntry[]; count: number }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.level) sp.set('level', params.level)
|
||||
if (params.keyword) sp.set('keyword', params.keyword)
|
||||
if (params.limit) sp.set('limit', String(params.limit))
|
||||
return api.get<{ entries: LogEntry[]; count: number }>(`${API_BASE}/admin/logs?${sp}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可配置项(脱敏),供各配置页渲染
|
||||
*/
|
||||
export function fetchSettings(): Promise<DataSourceSetting[]> {
|
||||
return api.get<DataSourceSetting[]>(`${API_BASE}/admin/settings`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置项(写入 app_settings,覆盖 .env,立即生效)
|
||||
*/
|
||||
export function updateSetting(key: string, value: string) {
|
||||
return api.put<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`, { value })
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置项的 DB 覆盖值,回落 .env
|
||||
*/
|
||||
export function clearSetting(key: string) {
|
||||
return api.delete<{ key: string; masked: string; origin: string }>(`${API_BASE}/admin/settings/${key}`)
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 测试 LLM 连接 — 调用预测端点验证
|
||||
*/
|
||||
export async function testLLMConnection(matchId?: number): Promise<LLMPingResult | PredictionJobRef> {
|
||||
// F4 修复: 优先使用不依赖比赛的 ping 端点
|
||||
try {
|
||||
return await api.post<LLMPingResult>(`${API_BASE}/admin/llm/ping`, {})
|
||||
} catch {
|
||||
// 回退到旧方式(兼容)
|
||||
return api.post<PredictionJobRef>(`${API_BASE}/predict`, {
|
||||
match_id: matchId || 1,
|
||||
mode: 'single',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 LLM 使用统计 — 从预测列表聚合。
|
||||
*
|
||||
* 注意:延迟统计是**后端尚未提供的维度**。此前这里返回硬编码的
|
||||
* `avg_latency_ms: 2400`,它会穿过类型检查、被渲染成「2.4s」,
|
||||
* 并作为真实性能指标进入人的判断。现已改为 `null`,由 UI 显示
|
||||
* 「—」并标注未接入。宁可留空,不可编数。
|
||||
*/
|
||||
export async function fetchLLMUsageStats(): Promise<LLMUsageStats> {
|
||||
try {
|
||||
const predictions = await fetchPredictions(50)
|
||||
const total = predictions.length
|
||||
const successCount = predictions.filter(p => p.pred_1x2).length
|
||||
return {
|
||||
total_predictions: total,
|
||||
avg_latency_ms: null, // 后端暂无延迟统计端点
|
||||
success_rate: total > 0 ? (successCount / total) * 100 : 0,
|
||||
recent_predictions: predictions.slice(0, 10).map(p => ({
|
||||
id: p.id,
|
||||
match_id: p.match_id,
|
||||
model: p.model,
|
||||
created_at: p.created_at,
|
||||
status: p.pred_1x2 ? 'success' as const : 'failed' as const,
|
||||
})),
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
total_predictions: 0,
|
||||
avg_latency_ms: null,
|
||||
success_rate: 0,
|
||||
recent_predictions: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源健康/最近采集状态(只读,不触发采集)
|
||||
*/
|
||||
export function fetchIngestStatus(): Promise<{ sources: IngestSourceStatus[] }> {
|
||||
return api.get<{ sources: IngestSourceStatus[] }>(`${API_BASE}/admin/ingest/status`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游数据源(bzzoiro)可达性探针:轻量 GET,不携带 Key、不消耗配额
|
||||
*/
|
||||
export function fetchUpstreamProbe(): Promise<{
|
||||
ok: boolean
|
||||
status_code?: number
|
||||
latency_ms: number
|
||||
endpoint: string
|
||||
error?: string
|
||||
}> {
|
||||
return api.get<never>(`${API_BASE}/admin/monitoring/upstream`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 采集任务状态轮询(单任务)
|
||||
*/
|
||||
export function fetchIngestJob(jobId: string): Promise<IngestJob> {
|
||||
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}` : ''}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理区统计(只读):近 24h/7d 预测次数
|
||||
*/
|
||||
export function fetchAdminStats(): Promise<AdminStats> {
|
||||
return api.get<AdminStats>(`${API_BASE}/admin/stats`)
|
||||
}
|
||||
|
||||
// ── API Key 轮换环 ──────────────────────────────────────────────
|
||||
|
||||
export interface KeyRingKeyStatus {
|
||||
masked: string
|
||||
blocked_remaining: number
|
||||
}
|
||||
|
||||
export interface KeyRingStatusResponse {
|
||||
base_url: string
|
||||
total: number
|
||||
has_multiple: boolean
|
||||
cooldown_seconds: number
|
||||
active_index: number
|
||||
active_key: string | null
|
||||
keys: KeyRingKeyStatus[]
|
||||
}
|
||||
|
||||
export async function fetchKeyRingStatus(): Promise<KeyRingStatusResponse> {
|
||||
return api.get<KeyRingStatusResponse>(`${API_BASE}/admin/keyring/status`)
|
||||
}
|
||||
|
||||
export async function resetKeyRingCooldown(): Promise<{ ok: boolean; message: string; stats: KeyRingStatusResponse }> {
|
||||
return api.post<{ ok: boolean; message: string; stats: KeyRingStatusResponse }>(`${API_BASE}/admin/keyring/cooldown/reset`)
|
||||
}
|
||||
|
||||
// ── 定时任务 ────────────────────────────────────────────────────
|
||||
|
||||
export interface ScheduleItem {
|
||||
id: string
|
||||
task: string
|
||||
cron: string
|
||||
leagues?: string
|
||||
enabled: boolean
|
||||
last_run_at?: string | null
|
||||
last_status?: string | null
|
||||
}
|
||||
|
||||
export async function fetchSchedules(): Promise<ScheduleItem[]> {
|
||||
return api.get<ScheduleItem[]>(`${API_BASE}/admin/schedules`)
|
||||
}
|
||||
|
||||
export async function createSchedule(data: { id: string; task: string; cron: string; leagues?: string; enabled: boolean }): Promise<{ ok: boolean }> {
|
||||
return api.post<{ ok: boolean }>(`${API_BASE}/admin/schedules`, data)
|
||||
}
|
||||
|
||||
export async function updateSchedule(id: string, data: Partial<ScheduleItem>): Promise<{ ok: boolean }> {
|
||||
return api.put<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`, data)
|
||||
}
|
||||
|
||||
export async function deleteSchedule(id: string): Promise<{ ok: boolean }> {
|
||||
return api.delete<{ ok: boolean }>(`${API_BASE}/admin/schedules/${id}`)
|
||||
}
|
||||
|
||||
export async function runScheduleNow(id: string): Promise<{ ok: boolean; message: string }> {
|
||||
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/schedules/${id}/run`)
|
||||
}
|
||||
|
||||
// ── 数据管线(质量检查 + 失败重试) ──────────────────────────────
|
||||
|
||||
export interface IngestFailureItem {
|
||||
id: number
|
||||
source: string
|
||||
entity_type: string
|
||||
source_record_id?: string
|
||||
error_type: string
|
||||
error_detail?: string
|
||||
retry_count: number
|
||||
status: string
|
||||
next_retry_at?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
export interface DataQualityCheckItem {
|
||||
id: number
|
||||
check_name: string
|
||||
entity_type: string
|
||||
passed: boolean
|
||||
severity: string
|
||||
detail?: Record<string, unknown> | null
|
||||
checked_at?: string
|
||||
}
|
||||
|
||||
export interface DataQualityResponse {
|
||||
failures: IngestFailureItem[]
|
||||
checks: DataQualityCheckItem[]
|
||||
}
|
||||
|
||||
export async function fetchDataQuality(): Promise<DataQualityResponse> {
|
||||
return api.get<DataQualityResponse>(`${API_BASE}/admin/data-quality`)
|
||||
}
|
||||
|
||||
export async function runDataQualityCheck(): Promise<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }> {
|
||||
return api.post<{ ok: boolean; checks: Array<{ name: string; passed: boolean }> }>(`${API_BASE}/admin/data-quality/run`)
|
||||
}
|
||||
|
||||
export async function fetchIngestFailures(): Promise<IngestFailureItem[]> {
|
||||
return api.get<IngestFailureItem[]>(`${API_BASE}/admin/ingest-failures`)
|
||||
}
|
||||
|
||||
export async function retryIngestFailure(id: number): Promise<{ ok: boolean; message: string }> {
|
||||
return api.post<{ ok: boolean; message: string }>(`${API_BASE}/admin/ingest-failures/${id}/retry`)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 前台公开端点 —— 预测主站与积分榜所需的最小数据访问。
|
||||
*
|
||||
* 为什么要单独成文件(而不是继续挂在 `admin/dal.ts` 里):
|
||||
*
|
||||
* 1. **分层方向错了。** 面向公众的页面反向 import `../admin/dal`,
|
||||
* 意味着想重构后台就会牵连前台,目录名也持续误导人以为
|
||||
* `admin/` 是可独立删除的包。公开端点的归属本就不该是 admin。
|
||||
*
|
||||
* 2. **它真的会进用户的首屏。** `dal.ts` 是单个大模块,内部函数
|
||||
* 相互引用,打包器无法按调用点做 tree-shaking —— 实测
|
||||
* Matches 只用 2 个函数,但 `/admin/ingest/jobs`、`/admin/llm/models`、
|
||||
* `/admin/eval`、`/admin/backtest`、`/admin/logs` 这些字符串全部
|
||||
* 留在了公开页所在 chunk 里。抽出来后可省下约 55 kB 首屏 JS。
|
||||
*
|
||||
* 本文件只放**无需登录即可访问**的端点。任何需要鉴权的端点继续留在
|
||||
* `./dal.ts`。新增公开端点时请加在这里,不要加回 admin。
|
||||
*/
|
||||
|
||||
import { api, API_BASE } from './api'
|
||||
import type { MatchDetailOut, MatchContextOut } from './types'
|
||||
|
||||
// ── 积分榜 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface StandingRow {
|
||||
position: number
|
||||
team: string
|
||||
team_en: string
|
||||
played: number
|
||||
won: number
|
||||
drawn: number
|
||||
lost: number
|
||||
goals_for: number
|
||||
goals_against: number
|
||||
goal_diff: number
|
||||
points: number
|
||||
xg_for: number | null
|
||||
xg_against: number | null
|
||||
form: string | null
|
||||
zone: string | null
|
||||
}
|
||||
|
||||
export interface StandingsLeague {
|
||||
league_code: string
|
||||
league_name: string
|
||||
season: string
|
||||
retrieved_at: string | null
|
||||
rows: StandingRow[]
|
||||
}
|
||||
|
||||
export async function fetchStandings(league?: string, season?: string): Promise<{ leagues: StandingsLeague[] }> {
|
||||
const sp = new URLSearchParams()
|
||||
if (league) sp.set('league', league)
|
||||
if (season) sp.set('season', season)
|
||||
const qs = sp.toString()
|
||||
return api.get<{ leagues: StandingsLeague[] }>(`${API_BASE}/standings${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
// ── 比赛详情 ────────────────────────────────────────────────────
|
||||
|
||||
export function fetchMatchDetail(id: number): Promise<MatchDetailOut> {
|
||||
return api.get<MatchDetailOut>(`${API_BASE}/matches/${id}`)
|
||||
}
|
||||
|
||||
export function fetchMatchContext(id: number): Promise<MatchContextOut> {
|
||||
return api.get<MatchContextOut>(`${API_BASE}/matches/${id}/context`)
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 应用级 API 类型定义(2026-09 自 admin/types.ts 迁入)。
|
||||
*
|
||||
* 与 FastAPI 后端 Pydantic 模型对齐。
|
||||
*/
|
||||
|
||||
// ── 系统健康 ────────────────────────────────────────────────────
|
||||
|
||||
export interface HealthStatus {
|
||||
status: 'ok' | 'degraded' | 'error'
|
||||
version?: string
|
||||
uptime_seconds?: number
|
||||
checks: Record<string, 'pass' | 'fail' | 'warn'>
|
||||
}
|
||||
|
||||
// ── 仪表盘 ──────────────────────────────────────────────────────
|
||||
|
||||
export interface DashboardStats {
|
||||
leagues: League[]
|
||||
// 比赛总量已移除: 用 fetchAdminStats()(/admin/stats)的精确 COUNT,
|
||||
// 不要再用列表 items.length 近似(上限 100 会严重失真)
|
||||
total_predictions: number
|
||||
health: string
|
||||
/**
|
||||
* 以下三个字段对应的后端端点在**当前版本中并不存在**。
|
||||
*
|
||||
* 它们曾以 `[]` 返回,调用方无法区分「确实没有错误」与
|
||||
* 「这个功能还没接」——空数组是一个会被当成结论的事实断言。
|
||||
* 改为可空,让「未接入」在类型层面无法被忽略。
|
||||
* 后端补齐端点后,把类型收窄回数组即可(编译器会指出所有消费点)。
|
||||
*/
|
||||
db_tables: { name: string; row_count: number; size_mb: number; last_updated: string | null }[] | null
|
||||
last_collection: { source: string; league_code: string | null; started_at: string; finished_at: string | null; status: string; records_count: number | null; error_message: string | null }[] | null
|
||||
recent_errors: { id: number; timestamp: string; source: string; message: string; level: string }[] | null
|
||||
}
|
||||
|
||||
// ── 联赛 & 比赛 ─────────────────────────────────────────────────
|
||||
|
||||
export interface League {
|
||||
id?: number
|
||||
code: string
|
||||
name: string
|
||||
name_zh?: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface Match {
|
||||
id: number
|
||||
league_code?: string
|
||||
season?: string | null
|
||||
home_team: string
|
||||
away_team: string
|
||||
home_team_zh?: string | null
|
||||
away_team_zh?: string | null
|
||||
match_date: string
|
||||
match_status: string
|
||||
home_goals?: number | null
|
||||
away_goals?: number | null
|
||||
match_stage?: string | null
|
||||
}
|
||||
|
||||
// ── 预测 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 列表接口返回的单路专家摘要字段 */
|
||||
export interface PredictionAgentOutput {
|
||||
agent: string
|
||||
status: string
|
||||
analysis?: string | null
|
||||
probable_score?: string | null
|
||||
subjective_confidence?: number | null
|
||||
}
|
||||
|
||||
export interface Prediction {
|
||||
id: number
|
||||
match_id: number
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version?: string
|
||||
mode?: string
|
||||
pred_home_goals?: number | null
|
||||
pred_away_goals?: number | null
|
||||
alt_pred_home_goals?: number | null
|
||||
alt_pred_away_goals?: number | null
|
||||
pred_1x2?: string | null
|
||||
subjective_confidence?: number | null
|
||||
reasoning?: string | null
|
||||
agent_outputs?: PredictionAgentOutput[] | null
|
||||
agent_weights?: Record<string, number> | null
|
||||
status?: 'success' | 'failed' | 'degraded'
|
||||
created_at: string
|
||||
actual_home_goals?: number | null
|
||||
actual_away_goals?: number | null
|
||||
settled?: boolean
|
||||
/**
|
||||
* 关联比赛摘要。后端 `PredictionOut.match` 是 `dict | None`
|
||||
* (见 `src/api/schemas.py`),由 `_match_dict()` 拼出,字段与 `Match`
|
||||
* 对齐但**每个字段都可能为 null**(比赛缺 league/team 时)。
|
||||
*
|
||||
* 此前这个字段在类型里根本不存在,消费方只能写 `(p as any).match`
|
||||
* ——类型系统失去了它唯一该起作用的地方。现在它被显式声明为可选。
|
||||
*/
|
||||
match?: PredictionMatchRef | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 预测记录里内嵌的比赛摘要。
|
||||
*
|
||||
* 独立于 `Match` 声明而不是直接复用,是因为二者契约不同:
|
||||
* `Match` 的 `id`/`match_date` 是必填的(来自列表端点),
|
||||
* 而这里是嵌套投影,后端 `_match_dict()` 在关联缺失时返回 null,
|
||||
* 字段也可能缺失。混用会让必填约束说谎。
|
||||
*/
|
||||
export interface PredictionMatchRef {
|
||||
id?: number | null
|
||||
league_code?: string | null
|
||||
season?: string | null
|
||||
home_team?: string | null
|
||||
away_team?: string | null
|
||||
home_team_zh?: string | null
|
||||
away_team_zh?: string | null
|
||||
match_date?: string | null
|
||||
match_status?: string | null
|
||||
home_goals?: number | null
|
||||
away_goals?: number | null
|
||||
match_stage?: string | null
|
||||
}
|
||||
|
||||
export interface PredictRequest {
|
||||
match_id: number
|
||||
mode?: 'single' | 'multi'
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── 通用接口返回壳 ──────────────────────────────────────────────
|
||||
//
|
||||
// 这些类型此前在 dal.ts 里以 `Promise<any>` 的形式存在,等于把后端契约
|
||||
// 丢掉了。声明在这里(与其它 API 类型同处)而不是 dal.ts 内联,是为了
|
||||
// 让消费方能直接 import type 而无需从实现文件取类型。
|
||||
|
||||
/** POST /api/v1/ingest/bzzoiro — 采集任务已受理,返回 job_id 供轮询 */
|
||||
export interface IngestTriggerResult {
|
||||
ok?: boolean
|
||||
job_id?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** POST /api/v1/predict — 预测任务已受理(P1-async)。注意是 202 语义,非最终结果 */
|
||||
export interface PredictionJobRef {
|
||||
job_id: string
|
||||
status: 'running' | 'success' | 'failed'
|
||||
poll_url?: string
|
||||
/** job 完成后的信封:`_predict_jobs[id]` 里是 { status, result } 或 { status, error } */
|
||||
result?: Prediction
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** POST /api/v1/admin/llm/ping — 连通性探测 */
|
||||
export interface LLMPingResult {
|
||||
ok?: boolean
|
||||
provider?: string
|
||||
model?: string
|
||||
latency_ms?: number
|
||||
message?: string
|
||||
/** ping 端点尚未接入时后端可能只回一个透传对象 */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** POST /api/v1/eval/settle — 结算结果 */
|
||||
export interface SettleResult {
|
||||
ok?: boolean
|
||||
settled?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `GET /health` 存活探针的实际返回。
|
||||
*
|
||||
* 与文件开头那个 `HealthStatus` 不是同一个契约 —— 那个是 admin/stats
|
||||
* 聚合口径(status 为 ok|degraded|error,带 checks 明细),这个才是
|
||||
* `src/api/app.py` 里 `/health` 直接吐出的结构。此前两者在 dal.ts 里
|
||||
* 共用同一个 `any`,差异被彻底抹平。
|
||||
*
|
||||
* `version` / `uptime_seconds` 可空:后端在包元数据缺失时返回 null,
|
||||
* 监控页据此降级隐藏版本卡片。
|
||||
*/
|
||||
export interface HealthProbe {
|
||||
status: 'healthy' | 'ok' | 'unknown' | string
|
||||
service?: string
|
||||
version?: string | null
|
||||
uptime_seconds?: number | null
|
||||
}
|
||||
|
||||
// ── 数据采集 ────────────────────────────────────────────────────
|
||||
|
||||
export interface CollectionRequest {
|
||||
status?: string
|
||||
source: 'bzzoiro'
|
||||
leagues?: string[]
|
||||
task?: 'events' | 'standings' | 'stats' | 'all'
|
||||
limit?: number
|
||||
season?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
}
|
||||
|
||||
// ── 评估 & 回测 ─────────────────────────────────────────────────
|
||||
|
||||
export interface EvalCalibrationBucket {
|
||||
total: number
|
||||
/** 该桶命中率,百分数;样本不足为 null */
|
||||
hit_rate: number | null
|
||||
}
|
||||
|
||||
export interface EvalSummaryRow {
|
||||
provider: string
|
||||
model: string
|
||||
prompt_version: string | null
|
||||
total: number
|
||||
/** 1X2 准确率,百分数 0-100 */
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number | null
|
||||
avg_subjective_confidence?: number | null
|
||||
/** 置信度校准:按主观置信度分桶的命中率 */
|
||||
calibration?: Record<string, EvalCalibrationBucket>
|
||||
}
|
||||
|
||||
export interface EvalSummary {
|
||||
summary: Array<EvalSummaryRow>
|
||||
/** 全量已结算数 */
|
||||
total_settled: number
|
||||
/** 应用筛选后的已结算数 */
|
||||
filtered_settled: number
|
||||
/** 实际评估条数(status=success 且比分齐全) */
|
||||
evaluated: number
|
||||
/** 跳过的 degraded 条数 */
|
||||
skipped_degraded: number
|
||||
/** 跳过的比分不全条数 */
|
||||
skipped_incomplete?: number
|
||||
}
|
||||
|
||||
export interface BacktestRequest {
|
||||
league_id?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
mode?: 'single' | 'multi'
|
||||
limit?: number
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface BacktestSummary {
|
||||
total: number
|
||||
scored: number
|
||||
success: number
|
||||
degraded: number
|
||||
accuracy_1x2?: number
|
||||
avg_score_rmse?: number
|
||||
avg_subjective_confidence?: number
|
||||
}
|
||||
|
||||
// ── 数据源配置 ──────────────────────────────────────────────────
|
||||
|
||||
export interface DataSourceSetting {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
sensitive: boolean
|
||||
configured: boolean
|
||||
masked: string
|
||||
origin: 'db' | 'env' | 'none'
|
||||
}
|
||||
|
||||
export interface DataSourceStatus {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
key_configured: boolean
|
||||
last_ingestion: string | null
|
||||
settings: DataSourceSetting[]
|
||||
}
|
||||
|
||||
export interface DataSourceTestResult {
|
||||
ok: boolean
|
||||
status: number | null
|
||||
latency_ms: number
|
||||
detail: string
|
||||
}
|
||||
|
||||
export interface DataSourceTestRequest {
|
||||
source: 'bzzoiro' | 'understat' | 'injuries'
|
||||
}
|
||||
|
||||
export interface IngestionHistoryEntry {
|
||||
id: string
|
||||
source: string
|
||||
started_at: string
|
||||
finished_at: string | null
|
||||
status: 'success' | 'running' | 'failed'
|
||||
records_count: number | null
|
||||
error_message: string | null
|
||||
}
|
||||
|
||||
// ── LLM 配置 ────────────────────────────────────────────────────
|
||||
|
||||
export interface LLMConfig {
|
||||
provider: string
|
||||
model: string
|
||||
base_url: string
|
||||
api_key_configured: boolean
|
||||
api_key_masked: string
|
||||
}
|
||||
|
||||
export interface LLMUsageStats {
|
||||
total_predictions: number
|
||||
/**
|
||||
* 平均延迟(毫秒)。**后端当前没有延迟统计端点**,故此处为 `null`。
|
||||
*
|
||||
* 曾经这里是一个硬编码的 2400,并且会被渲染成看起来完全可信的
|
||||
* 「2.4s」。运营据此判断系统性能时,读到的是一个不存在的数字。
|
||||
* 改为 `null` 是刻意的:它让「未接入」这件事在类型层面强制可见,
|
||||
* 调用方必须显式处理,而不是继承一个编造的默认值。
|
||||
*/
|
||||
avg_latency_ms: number | null
|
||||
success_rate: number
|
||||
recent_predictions: Array<{
|
||||
id: number
|
||||
match_id: number
|
||||
model: string
|
||||
created_at: string
|
||||
latency_ms?: number
|
||||
status: 'success' | 'failed'
|
||||
}>
|
||||
}
|
||||
|
||||
// ── 系统配置 ────────────────────────────────────────────────────
|
||||
|
||||
export interface SystemConfigEntry {
|
||||
key: string
|
||||
value_masked: string
|
||||
description: string
|
||||
is_sensitive: boolean
|
||||
}
|
||||
|
||||
// ── 专家/终裁独立 LLM 配置 ────────────────────────────────────────
|
||||
|
||||
export interface LLMAgentFieldState {
|
||||
configured: boolean
|
||||
masked: string
|
||||
origin: 'db' | 'env' | 'none'
|
||||
}
|
||||
|
||||
export interface LLMAgentConfig {
|
||||
id: string
|
||||
label: string
|
||||
effective_model: string
|
||||
fields: {
|
||||
model: LLMAgentFieldState
|
||||
base_url: LLMAgentFieldState
|
||||
api_key: LLMAgentFieldState
|
||||
}
|
||||
}
|
||||
|
||||
// ── 系统日志 ─────────────────────────────────────────────────────
|
||||
|
||||
export interface LogEntry {
|
||||
ts: number
|
||||
level: string
|
||||
logger: string
|
||||
message: string
|
||||
}
|
||||
|
||||
// ── 数据源健康/最近采集状态 ─────────────────────────────────────
|
||||
|
||||
export interface IngestLastFailure {
|
||||
at: string
|
||||
logger: string
|
||||
detail: string
|
||||
note: string
|
||||
}
|
||||
|
||||
export interface IngestSourceStatus {
|
||||
name: string
|
||||
label: string
|
||||
key_configured: boolean
|
||||
base_url?: string
|
||||
reachable: boolean | null
|
||||
status?: 'key_not_configured' | 'no_data' | 'has_data'
|
||||
last_success_at: string | null
|
||||
latest_match_date?: string | null
|
||||
recent_count: number
|
||||
note: string
|
||||
last_failure: IngestLastFailure | null
|
||||
}
|
||||
|
||||
// ── 采集任务状态 ──────────────────────────────────────────────
|
||||
|
||||
export interface IngestJob {
|
||||
id: string
|
||||
task: string
|
||||
params: Record<string, unknown>
|
||||
status: 'pending' | 'running' | 'success' | 'failed'
|
||||
result: Record<string, unknown> | null
|
||||
error: string | null
|
||||
created_at: string | null
|
||||
started_at: string | null
|
||||
finished_at: string | null
|
||||
}
|
||||
|
||||
// ── 比赛详情 ─────────────────────────────────────────────────────
|
||||
|
||||
export interface MatchRecentPrediction {
|
||||
id: number
|
||||
provider: string
|
||||
model: string
|
||||
mode: string
|
||||
pred_home_goals: number | null
|
||||
pred_away_goals: number | null
|
||||
alt_pred_home_goals: number | null
|
||||
alt_pred_away_goals: number | null
|
||||
pred_1x2: string | null
|
||||
subjective_confidence: number | null
|
||||
reasoning: string | null
|
||||
status: string
|
||||
settled: boolean
|
||||
correct_1x2?: boolean
|
||||
created_at: string
|
||||
actual_home_goals: number | null
|
||||
actual_away_goals: number | null
|
||||
/**
|
||||
* 单路专家原始输出。后端 `agent_outputs` 是 `list[dict]`(未定 schema),
|
||||
* 各专家的键随 agent 不同。这里用 `Record<string, unknown>` 而非 `any`:
|
||||
* 取值处必须显式收窄,编译器不再放行任意属性访问。
|
||||
*/
|
||||
agent_outputs?: Array<Record<string, unknown>> | null
|
||||
agent_weights?: Record<string, number> | null
|
||||
}
|
||||
|
||||
export interface MatchDetailOut {
|
||||
id: number
|
||||
league_code: string | null
|
||||
season: string | null
|
||||
home_team: string
|
||||
away_team: string
|
||||
home_team_zh: string | null
|
||||
away_team_zh: string | null
|
||||
match_date: string
|
||||
match_status: string
|
||||
home_goals: number | null
|
||||
away_goals: number | null
|
||||
match_stage: string | null
|
||||
home_xg: number | null
|
||||
away_xg: number | null
|
||||
stats: MatchStatsDetail | null
|
||||
recent_predictions: MatchRecentPrediction[]
|
||||
}
|
||||
|
||||
/** bzzoiro /events/{id}/stats/ 返回的详细比赛统计 */
|
||||
export interface MatchStatsDetail {
|
||||
home_xg: number | null
|
||||
away_xg: number | null
|
||||
home_shots: number | null
|
||||
away_shots: number | null
|
||||
home_shots_on_target: number | null
|
||||
away_shots_on_target: number | null
|
||||
home_corners: number | null
|
||||
away_corners: number | null
|
||||
home_possession: number | null
|
||||
home_yellow_cards: number | null
|
||||
away_yellow_cards: number | null
|
||||
home_red_cards: number | null
|
||||
away_red_cards: number | null
|
||||
home_big_chances: number | null
|
||||
away_big_chances: number | null
|
||||
home_fouls: number | null
|
||||
away_fouls: number | null
|
||||
}
|
||||
|
||||
export interface TeamRecentMatch {
|
||||
match_date: string | null
|
||||
home_team: string | null
|
||||
away_team: string | null
|
||||
home_goals: number | null
|
||||
away_goals: number | null
|
||||
}
|
||||
|
||||
export interface MatchContextOut {
|
||||
home_recent: TeamRecentMatch[]
|
||||
away_recent: TeamRecentMatch[]
|
||||
h2h: TeamRecentMatch[]
|
||||
}
|
||||
|
||||
// ── 管理区统计 ─────────────────────────────────────────────────
|
||||
|
||||
export interface AdminStats {
|
||||
predictions: {
|
||||
total: number
|
||||
last_24h: number
|
||||
last_7d: number
|
||||
}
|
||||
matches?: { total: number; finished: number }
|
||||
stats?: { total: number }
|
||||
standings?: { total: number }
|
||||
}
|
||||
Binary file not shown.
@@ -2,17 +2,15 @@
|
||||
* 回到顶部浮动按钮(前台两页共用,此前 Matches/Standings 各复制一份)。
|
||||
* 方角纸片风:去掉早期版本的 rounded-full + shadow-lg,与全站方角
|
||||
* 无阴影语言对齐;出现/隐藏仅动画 transform 与 opacity。
|
||||
*
|
||||
* 滚动监听已抽到 `lib/useScroll` 的 useWindowScrollY —— 全站三处
|
||||
* 滚动监听此前各自实现一遍生命周期,现统一为单一来源。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useWindowScrollY } from '../lib/useScroll'
|
||||
|
||||
export default function BackTop({ threshold = 300 }: { threshold?: number }) {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setShow(window.scrollY > threshold)
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [threshold])
|
||||
const scrollY = useWindowScrollY()
|
||||
const show = scrollY > threshold
|
||||
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react'
|
||||
import { Button } from './ui'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
/**
|
||||
* 该边界是否铺满整个视口。
|
||||
*
|
||||
* 应用最外层的边界应铺满(`true`,默认);而当边界下沉到
|
||||
* 路由级、嵌在 SiteLayout 的 <main> 里时,铺满视口会撑开
|
||||
* 布局并让页头页脚错位 —— 那种场景传 `false`,改为局部卡片。
|
||||
*/
|
||||
fullScreen?: boolean
|
||||
}
|
||||
|
||||
interface State {
|
||||
@@ -29,20 +38,25 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
const fullScreen = this.props.fullScreen ?? true
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-paper-50 p-6">
|
||||
<div
|
||||
className={
|
||||
fullScreen
|
||||
? 'flex min-h-screen items-center justify-center bg-paper-50 p-6'
|
||||
: 'flex min-h-[50vh] items-center justify-center p-6'
|
||||
}
|
||||
role="alert"
|
||||
>
|
||||
<div className="max-w-md space-y-3 border border-ink-900 bg-paper-50 p-6 text-center">
|
||||
<p className="text-2xs tracking-[0.3em] text-ink-400">EXCEPTION</p>
|
||||
<h2 className="font-serif text-lg font-bold text-ink-900">页面出现错误</h2>
|
||||
<p className="text-sm leading-relaxed text-ink-500">
|
||||
{this.state.error?.message || '未知错误'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null })}
|
||||
className="btn btn-sm"
|
||||
>
|
||||
<Button size="sm" onClick={() => this.setState({ hasError: false, error: null })}>
|
||||
重试
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* SiteLayout —— 前台公开站点的共享外壳(报头 + 内容区 + 页脚)。
|
||||
*
|
||||
* 背景:此前 `HomePage` 与 `StandingsLayout` 各自复制了一遍
|
||||
* `<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">`
|
||||
* 和 `<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">`。
|
||||
* 两处 className 逐字相同,唯一差异是 Masthead 的 active 值与
|
||||
* 页脚文案。改一次容器内边距要改两处,加第三个页面就是第三处。
|
||||
*
|
||||
* 这里把「容器宽度 / 内边距 / 页脚分隔线样式」收进单一来源,
|
||||
* 调用方只需提供 active(决定报头高亮)与页脚文案。
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import Masthead from './Masthead'
|
||||
import type { MastheadActive } from './Masthead'
|
||||
|
||||
/** 报头的 active 分区,决定报刊头里哪个分区链接高亮 */
|
||||
export type SiteSection = MastheadActive
|
||||
|
||||
export interface SiteLayoutProps {
|
||||
/** 当前分区,用于报头高亮 */
|
||||
active: SiteSection
|
||||
/** 页脚文案(各分区声明差异,如首页含免责声明) */
|
||||
footerNote: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SiteLayout({ active, footerNote, children }: SiteLayoutProps) {
|
||||
return (
|
||||
<div className="min-h-screen bg-paper-50">
|
||||
<Masthead active={active} />
|
||||
|
||||
<main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
<div className="border-t border-ink-200 pt-3 text-center text-2xs leading-relaxed text-ink-400">
|
||||
{footerNote}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SiteLayout
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 状态标记 Badge。
|
||||
*
|
||||
* 报刊不用彩色药丸:小方块 + 文字,红=异常/失败,墨=正常,灰=中性。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const MARK_STYLES: Record<string, { text: string; mark: string }> = {
|
||||
success: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
completed: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
win: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
ok: { text: 'text-ink-800', mark: 'bg-ink-900' },
|
||||
running: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
||||
info: { text: 'text-ink-600', mark: 'bg-ink-400' },
|
||||
queued: { text: 'text-ink-500', mark: 'border border-ink-400' },
|
||||
pending: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
||||
push: { text: 'text-ink-400', mark: 'bg-ink-300' },
|
||||
warning: { text: 'text-press', mark: 'border border-press' },
|
||||
failed: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
error: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
loss: { text: 'text-press font-medium', mark: 'bg-press' },
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
status: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const s = MARK_STYLES[status] ?? MARK_STYLES.pending
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 whitespace-nowrap text-2xs ${s.text}`}>
|
||||
<span className={`inline-block h-1.5 w-1.5 ${s.mark}`} aria-hidden="true" />
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 卡片族:Card / CardHeader / CardBody。
|
||||
*
|
||||
* 报刊风:方角、墨线描边、无阴影。原位于 admin/components.tsx,
|
||||
* 合并到 components/ui 作为全站共享组件。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={`border border-ink-900 bg-paper-50 ${className}`}>{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="font-serif text-sm font-bold text-ink-900">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
{description && <p className="mt-1 text-2xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardBody({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <div className={`px-4 py-4 sm:px-5 ${className}`}>{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 数据表格族:DataTable / MobileCardList / ResponsiveTable。
|
||||
* 原位于 admin/components.tsx。
|
||||
*
|
||||
* ResponsiveTable 按断点二选一渲染:桌面表格 / 移动卡片。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { EmptyState } from './Feedback'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function DataTable<T = any>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-ink-900 text-2xs tracking-wider text-ink-500">
|
||||
{columns.map(col => (
|
||||
<th key={col.key} className="px-3 py-2 font-medium" style={{ width: col.width }}>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map(row => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
className="border-b border-ink-200 transition-colors hover:bg-paper-100"
|
||||
>
|
||||
{columns.map(col => (
|
||||
<td key={col.key} className="px-3 py-2.5 text-ink-800">
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: row != null && typeof row === 'object' && col.key in row
|
||||
? String((row as Record<string, unknown>)[col.key] ?? '—')
|
||||
: '—'}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 移动端卡片列表 (替代桌面端表格) ────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function MobileCardList<T = any>({
|
||||
data,
|
||||
renderCard,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
data: T[]
|
||||
renderCard: (row: T, index: number) => ReactNode
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 lg:hidden">
|
||||
{data.map((row, idx) => (
|
||||
<div key={idx} className="border border-ink-900 bg-paper-50 p-4">
|
||||
{renderCard(row, idx)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 响应式表格容器 ──────────────────────────────────────────────
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function ResponsiveTable<T = any>({
|
||||
columns,
|
||||
data,
|
||||
rowKey,
|
||||
cardRender,
|
||||
emptyText = '暂无数据',
|
||||
}: {
|
||||
columns: { key: string; label: string; render?: (row: T) => ReactNode; width?: string }[]
|
||||
data: T[]
|
||||
rowKey: (row: T) => string | number
|
||||
cardRender: (row: T, index: number) => ReactNode
|
||||
emptyText?: string
|
||||
}) {
|
||||
if (data.length === 0) {
|
||||
return <EmptyState text={emptyText} />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 桌面端表格 */}
|
||||
<div className="hidden overflow-x-auto lg:block">
|
||||
<DataTable columns={columns} data={data} rowKey={rowKey} emptyText={emptyText} />
|
||||
</div>
|
||||
{/* 移动端卡片 */}
|
||||
<MobileCardList data={data} renderCard={cardRender} emptyText={emptyText} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 反馈类组件:Alert / ErrorBanner / describeError / EmptyState / EmptyText。
|
||||
* 原位于 admin/components.tsx。
|
||||
*
|
||||
* 依赖说明:`ApiError` 直接从 `lib/http` 导入(而非 `admin/api` 的转发),
|
||||
* 使本组件不依赖 admin 目录 —— 这正是合并两套组件库要解决的问题。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import { ApiError } from '../../lib/http'
|
||||
|
||||
// ── 提示条:错误红框(同前台) / 正常墨框 ────────────────────────
|
||||
|
||||
export function Alert({
|
||||
kind,
|
||||
title,
|
||||
message,
|
||||
onClose,
|
||||
action,
|
||||
}: {
|
||||
kind: 'error' | 'ok' | 'info' | 'warning'
|
||||
title: string
|
||||
message?: string
|
||||
onClose?: () => void
|
||||
/** 右侧操作按钮(如「去修复」) */
|
||||
action?: ReactNode
|
||||
}) {
|
||||
const style =
|
||||
kind === 'error'
|
||||
? 'border-press bg-press-wash'
|
||||
: kind === 'warning'
|
||||
? 'border-press bg-press-wash/60'
|
||||
: kind === 'ok'
|
||||
? 'border-ink-900 bg-paper-100'
|
||||
: 'border-ink-300 bg-paper-50'
|
||||
const titleCls = kind === 'error' || kind === 'warning' ? 'text-press' : 'text-ink-900'
|
||||
|
||||
/*
|
||||
无障碍:提示条是异步出现的(请求失败、操作完成),视觉用户
|
||||
看到了,屏幕阅读器用户此前什么都收不到 —— 因为它只是一个
|
||||
普通的 <div>,出现时不会触发任何朗读。
|
||||
|
||||
这里按语义分级:
|
||||
error / warning → role="alert" (assertive,立即打断朗读)
|
||||
ok / info → role="status" (polite,等当前朗读结束)
|
||||
这也是 WAI-ARIA 对 live region 的推荐用法。
|
||||
*/
|
||||
const isUrgent = kind === 'error' || kind === 'warning'
|
||||
|
||||
return (
|
||||
<div
|
||||
role={isUrgent ? 'alert' : 'status'}
|
||||
className={`flex items-start justify-between gap-3 border px-4 py-3 ${style}`}
|
||||
>
|
||||
<div>
|
||||
<p className={`flex items-center gap-1.5 text-sm font-medium ${titleCls}`}>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 ${kind === 'error' || kind === 'warning' ? 'bg-press' : 'bg-ink-900'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{title}
|
||||
</p>
|
||||
{message && (
|
||||
<p className="mt-0.5 whitespace-pre-wrap text-xs leading-relaxed text-ink-600">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-ink-400 transition-colors hover:text-ink-900"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 错误横幅:标准化错误标题/文案/建议动作 ──────────────────────
|
||||
|
||||
/** 根据错误对象生成标准化的错误标题、文案与建议动作。 */
|
||||
export function describeError(err: unknown): { title: string; detail: string; kind: 'error' | 'warning' } {
|
||||
if (err instanceof ApiError) {
|
||||
const status = err.status
|
||||
const apiDetail = typeof err.data === 'object' && err.data && 'detail' in (err.data as object)
|
||||
? String((err.data as { detail: unknown }).detail)
|
||||
: ''
|
||||
const msg = apiDetail || err.message
|
||||
switch (status) {
|
||||
case 401:
|
||||
return { title: '登录已过期', detail: '请重新登录后继续操作。', kind: 'warning' }
|
||||
case 403:
|
||||
return { title: '无权访问', detail: msg || '当前账号没有执行该操作的权限。', kind: 'error' }
|
||||
case 429:
|
||||
return { title: '请求过于频繁', detail: msg || '每分钟最多 10 次预测,请稍后再试。', kind: 'warning' }
|
||||
case 502:
|
||||
return { title: '上游 LLM 不可用', detail: msg || 'LLM 服务暂时不可用,请稍后重试或切换到更便宜的模型。', kind: 'error' }
|
||||
case 503:
|
||||
return { title: '服务未就绪', detail: msg || '服务器鉴权未配置,请联系管理员。', kind: 'error' }
|
||||
case 0:
|
||||
return { title: '网络错误或请求超时', detail: '请检查网络连接后重试。', kind: 'warning' }
|
||||
}
|
||||
if (status >= 500) {
|
||||
return { title: '服务器错误', detail: msg || `HTTP ${status},请稍后重试。`, kind: 'error' }
|
||||
}
|
||||
return { title: '请求失败', detail: msg || `HTTP ${status}`, kind: 'error' }
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
return { title: '操作失败', detail: err.message, kind: 'error' }
|
||||
}
|
||||
return { title: '未知错误', detail: String(err), kind: 'error' }
|
||||
}
|
||||
|
||||
/** 统一错误横幅:用于页面级错误展示。 */
|
||||
export function ErrorBanner({
|
||||
err,
|
||||
onClose,
|
||||
}: {
|
||||
err: unknown
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const { title, detail, kind } = describeError(err)
|
||||
return <Alert kind={kind} title={title} message={detail} onClose={onClose} />
|
||||
}
|
||||
|
||||
// ── 空状态:同前台「本版暂无赛程」 ──────────────────────────────
|
||||
|
||||
export function EmptyState({ text = '暂无数据', sub }: { text?: string; sub?: string }) {
|
||||
return (
|
||||
<div className="border-y border-ink-200 py-12 text-center">
|
||||
<p className="font-serif text-sm text-ink-600">{text}</p>
|
||||
{sub && <p className="mt-1.5 text-xs text-ink-400">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 空状态文本(极简版) */
|
||||
export function EmptyText({ text }: { text: string }) {
|
||||
return (
|
||||
<div className="py-10 text-center text-sm text-ink-400">
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 小节标题 SectionHeader:同前台 section-head 语言。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="section-head text-base">{title}</h2>
|
||||
{description && <p className="mt-1.5 text-xs text-ink-500">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* SkeletonRows:骨架占位行(低调脉动灰块)。
|
||||
*
|
||||
* 原位于 pages/matches/ui.tsx —— 它是通用骨架,不属于 Matches 页面,
|
||||
* 上移到 UI 层。单块骨架用 <Skeleton>,整行占位用本组件。
|
||||
*/
|
||||
|
||||
export function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||
<div className="skeleton h-3 w-16" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-10" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 统计与进度可视化:StatCard / ProgressBar / AgentWeightsBar。
|
||||
* 原位于 admin/components.tsx。
|
||||
*/
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
}: {
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="border border-ink-900 bg-paper-50 px-4 py-3.5">
|
||||
<span className="text-2xs tracking-[0.2em] text-ink-400">{label}</span>
|
||||
<div className="mt-1.5 font-serif text-3xl font-bold tabular-nums leading-none text-ink-900">
|
||||
{value}
|
||||
</div>
|
||||
{hint && <div className="mt-1.5 text-2xs text-ink-400">{hint}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 进度条:同前台置信度细线 */
|
||||
export function ProgressBar({ value, className = '' }: { value: number; className?: string }) {
|
||||
const clamped = Math.max(0, Math.min(100, value))
|
||||
return (
|
||||
<div className={`h-2 w-full overflow-hidden rounded-full bg-ink-200 ${className}`} role="progressbar" aria-valuenow={clamped}>
|
||||
<div
|
||||
className="h-full rounded-full bg-press transition-[width] duration-500"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Agent 权重条形图 */
|
||||
export function AgentWeightsBar({ weights, okCount }: { weights: Record<string, number>; okCount: number }) {
|
||||
const entries = Object.entries(weights).filter(([, w]) => w > 0)
|
||||
if (entries.length === 0) return null
|
||||
const total = entries.reduce((s, [, w]) => s + w, 0) || 1
|
||||
const colors = ['bg-ink-900', 'bg-ink-700', 'bg-ink-500', 'bg-press', 'bg-ink-300']
|
||||
return (
|
||||
<div className="mt-2 border-t border-ink-200 pt-2">
|
||||
<div className="mb-1 text-2xs text-ink-400">终裁专家权重</div>
|
||||
<div className="space-y-1">
|
||||
{entries.map(([k, w], i) => (
|
||||
<div key={k} className="flex items-center gap-2 text-2xs">
|
||||
<div className="h-3.5 flex-1 overflow-hidden rounded-sm bg-ink-200/60">
|
||||
<div
|
||||
className={`h-full ${colors[i % colors.length]} transition-all duration-500`}
|
||||
style={{ width: `${Math.max(3, Math.round((w / total) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 text-right tabular-nums text-ink-500">
|
||||
{Math.round((w / total) * 100)}%
|
||||
</span>
|
||||
<span className="w-24 truncate text-ink-400" title={k}>{k}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 text-2xs text-ink-400">有效专家:{okCount}/{entries.length}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Switch:一组互斥的文字切换(状态 / 模式)。
|
||||
*
|
||||
* 原位于 pages/matches/ui.tsx,上移到 UI 层 —— 它用的是全站统一的
|
||||
* `.tab` / `.tab-on` 语言(方角、印报红下划线),属通用组件。
|
||||
*
|
||||
* 注:内部的 <button> 不在 `.btn` 体系内(用 `.tab`),属有意保留的自定义样式。
|
||||
*/
|
||||
|
||||
export function Switch({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||
<button
|
||||
onClick={() => onChange(it.v)}
|
||||
title={it.title}
|
||||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Tabs —— 报刊风版面切换基元。
|
||||
*
|
||||
* 背景:`Matches.tsx` 与 `Standings.tsx` 各写了一份高度雷同的联赛切换
|
||||
* `<button>` + `.tab` / `.tab-on` 类名。两份实现已经在细节上漂移
|
||||
* (一个有 `disabled` 无数据态与虚线下划线,一个没有),这正是缺少
|
||||
* 基元的典型症状 —— 复制粘贴会在无人察觉处产生第二个「事实标准」。
|
||||
*
|
||||
* 收口后,选择态、`aria-current`、无数据降级三件事只在一处定义。
|
||||
*/
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { cx } from './index'
|
||||
|
||||
export interface TabItem {
|
||||
/** 稳定 key,同时作为标识 */
|
||||
value: string
|
||||
/** 显示文本 */
|
||||
label: ReactNode
|
||||
/**
|
||||
* 无可用数据。不阻断点击(用户仍可切过去看空状态),
|
||||
* 但用虚线下划线弱化提示 —— 而非降字色,浅灰在纸底上对比度不足且形似禁用。
|
||||
*/
|
||||
empty?: boolean
|
||||
/** 原生 title 提示,鼠标悬停时解释 empty 的原因 */
|
||||
title?: string
|
||||
}
|
||||
|
||||
export interface TabsProps {
|
||||
items: TabItem[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
/** 整体禁用(如切换请求进行中) */
|
||||
disabled?: boolean
|
||||
/** 无障碍标签,描述这组页签是什么(如「联赛」) */
|
||||
ariaLabel: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 页签组基元。
|
||||
*
|
||||
* 视觉完全复用既有 `.tab` / `.tab-on`,不引入新的设计语言。
|
||||
*
|
||||
* 转发 ref 到内部 `<nav>`:调用方需要它来测量横向溢出
|
||||
* (如「右侧还有更多联赛」的渐变遮罩依赖 `useCanScrollRight`)。
|
||||
*/
|
||||
export const Tabs = forwardRef<HTMLElement, TabsProps>(function Tabs(
|
||||
{ items, value, onChange, disabled = false, ariaLabel, className },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<nav
|
||||
ref={ref}
|
||||
className={cx('flex items-center overflow-x-auto', className)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{items.map(item => {
|
||||
const on = value === item.value
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => onChange(item.value)}
|
||||
disabled={disabled}
|
||||
title={item.title}
|
||||
// aria-current 而非 aria-selected:这是「当前所在版面」的导航语义,
|
||||
// 不是 tablist/tab 的复合控件模式(那需要 role=tabpanel 配套)。
|
||||
aria-current={on ? 'true' : undefined}
|
||||
className={cx('relative tab font-serif', on && 'tab-on', disabled && 'disabled:opacity-50')}
|
||||
>
|
||||
{item.label}
|
||||
{item.empty && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-0 bottom-0 border-b border-dotted border-ink-300"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* UI 基元层 —— 全站唯一的基础交互组件来源。
|
||||
*
|
||||
* 背景:此前只有业务级组件(Card/DataTable 等),基础交互控件全靠各页面
|
||||
* 手写 className + `.btn` / `.field` 等 CSS 类维持一致。结果是:
|
||||
* - `btn-ghost` 被使用但从未定义,样式静默失效(与早年的 btn-outline 同型)
|
||||
* - Spinner 在前台/后台各复制一份
|
||||
* - 弹窗焦点陷阱只在一个弹窗里有,另一个缺
|
||||
* 组件化的意义在于「让错误在编译期暴露」,而不是靠人工守纪律。
|
||||
*
|
||||
* 设计约束:视觉输出与迁移前逐字一致(使用同一批 CSS 类),
|
||||
* 本层只做「收口 + 类型约束」,不改设计语言。
|
||||
*/
|
||||
|
||||
import { forwardRef, useEffect, useRef } from 'react'
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
SelectHTMLAttributes,
|
||||
ReactNode,
|
||||
MouseEventHandler,
|
||||
} from 'react'
|
||||
|
||||
// ── 工具:CSS 类名拼接 ────────────────────────────────────────────
|
||||
|
||||
export function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
// ── Button ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 按钮变体。用联合类型约束,写错变体名会在编译期报错
|
||||
* —— 这是根治 `btn-ghost` 那类「幻影变体」的关键。
|
||||
*/
|
||||
export type ButtonVariant = 'default' | 'solid' | 'outline' | 'danger' | 'ghost'
|
||||
export type ButtonSize = 'default' | 'sm'
|
||||
|
||||
const VARIANT_CLASS: Record<ButtonVariant, string> = {
|
||||
default: '',
|
||||
solid: 'btn-solid',
|
||||
outline: 'btn-outline',
|
||||
danger: 'btn-danger',
|
||||
ghost: 'btn-ghost',
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<ButtonSize, string> = {
|
||||
default: '',
|
||||
sm: 'btn-sm',
|
||||
}
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
/** 显示加载态:自动禁用并前置 Spinner */
|
||||
loading?: boolean
|
||||
/** 占满父容器宽度 */
|
||||
block?: boolean
|
||||
/**
|
||||
* 传入时渲染为 `<a>` 而非 `<button>`(链接样式的按钮)。
|
||||
* 此时 disabled/loading 无效 —— 链接没有这两个语义。
|
||||
*/
|
||||
href?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮基元。默认输出 `.btn` 的方角纸片样式。
|
||||
*
|
||||
* 传 `href` 时渲染为 `<a>`(链接样式的按钮,如卡片里的「定位 →」跳转),
|
||||
* 复用同一套变体类名。按钮语义(点击执行动作)与链接语义(导航到别处)
|
||||
* 在 HTML 层不可互换,因此不硬造一个 `<button>` 假装链接。
|
||||
*
|
||||
* 注:`min-h-[44px]` 等尺寸细节仍由各页面按场景通过 className 覆盖
|
||||
* (如弹窗按钮需更大触控目标),基元只保证变体与尺寸语义统一。
|
||||
*/
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = 'default', size = 'default', loading = false, block = false, href, className, children, disabled, ...rest },
|
||||
ref,
|
||||
) {
|
||||
const cls = cx(
|
||||
'btn',
|
||||
VARIANT_CLASS[variant],
|
||||
SIZE_CLASS[size],
|
||||
block && 'w-full',
|
||||
className,
|
||||
)
|
||||
|
||||
if (href != null) {
|
||||
// 锚点分支:只透传与 <a> 兼容且实际会用到的属性。
|
||||
// 显式收窄而不是解构 rest —— button 属性(如 formAction)对锚点无意义,
|
||||
// 且两类元素的 onClick 处理器参数类型不同,直接展开会让 TS 反变失配。
|
||||
const a = rest as unknown as {
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>
|
||||
title?: string
|
||||
'aria-label'?: string
|
||||
target?: string
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={a.onClick}
|
||||
title={a.title}
|
||||
aria-label={a['aria-label']}
|
||||
target={a.target}
|
||||
className={cls}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading || undefined}
|
||||
className={cls}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Spinner /> {children}
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Input / Select ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 输入框基元:统一 `.field` 方角样式。
|
||||
*
|
||||
* 无障碍提醒:请为输入框提供可访问名称 —— 要么传 `id` 并在外部配
|
||||
* `<label htmlFor={id}>`,要么传 `label`(内部转 aria-label)。
|
||||
*
|
||||
* 背景:全站 20 个 `<input>` 里多数只有视觉上相邻的 `<label>`,
|
||||
* 没有 `htmlFor`/`id` 关联。屏幕阅读器读出来只有「编辑框」,
|
||||
* 在密码框场景下用户无法分辨「当前密码」与「确认新密码」。
|
||||
*
|
||||
* 注:多数页面目前仍直接写 `<input className="field">` 而非用本基元,
|
||||
* 那些调用点需各自补 id/htmlFor(已在 admin 各页处理)。
|
||||
*/
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
/** 无可见标签时的可访问名称(内部转 aria-label) */
|
||||
label?: string
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ className, label, 'aria-label': ariaLabel, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
aria-label={ariaLabel ?? label}
|
||||
className={cx('field', className)}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
// 用 type 别名而非空 interface:空 interface 与父类型完全等价,
|
||||
// 留着只会让「这里以后可能要加字段」的意图变成噪声。
|
||||
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
/** 下拉框基元:与 Input 同一 `.field` 语言。 */
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||
{ className, children, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<select ref={ref} className={cx('field', className)} {...rest}>
|
||||
{children}
|
||||
</select>
|
||||
)
|
||||
})
|
||||
|
||||
// ── Spinner ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 加载指示。全站唯一来源(此前存在三份逐字节相同的副本:
|
||||
* admin/components.tsx / pages/matches/ui.tsx / MatchPredictPanel.tsx)。
|
||||
*/
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={cx('h-3.5 w-3.5 animate-spin', className)}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Skeleton ─────────────────────────────────────────────────────
|
||||
|
||||
/** 骨架占位块 */
|
||||
export function Skeleton({ className = '' }: { className?: string }) {
|
||||
return <div className={cx('skeleton', className)} />
|
||||
}
|
||||
|
||||
// ── 业务级组件:统一从本目录再导出,使 `components/ui` 成为唯一入口 ──
|
||||
|
||||
export { Card, CardHeader, CardBody } from './Card'
|
||||
export { Badge } from './Badge'
|
||||
export { StatCard, ProgressBar, AgentWeightsBar } from './Stat'
|
||||
export { SectionHeader } from './SectionHeader'
|
||||
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from './Feedback'
|
||||
export { DataTable, MobileCardList, ResponsiveTable } from './DataTable'
|
||||
export { SkeletonRows } from './SkeletonRows'
|
||||
export { Switch } from './Switch'
|
||||
export { Tabs } from './Tabs'
|
||||
export type { TabItem, TabsProps } from './Tabs'
|
||||
|
||||
// ── Modal ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 无障碍名称,会写入 aria-label */
|
||||
label: string
|
||||
children: ReactNode
|
||||
/** 面板附加类名(控制最大宽度、对齐方式等) */
|
||||
panelClassName?: string
|
||||
/** 遮罩层附加类名 */
|
||||
overlayClassName?: string
|
||||
/** 点击遮罩是否关闭(默认 true) */
|
||||
closeOnOverlay?: boolean
|
||||
/** 显示入场动画(默认 true) */
|
||||
animate?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗基元:一次性内置全部无障碍行为,使各弹窗表现天然一致。
|
||||
*
|
||||
* 内置能力(此前仅 PredictModal 完整具备,CommandPalette 缺前 3 项):
|
||||
* - 焦点陷阱:Tab / Shift+Tab 循环限制在弹窗内
|
||||
* - ESC 关闭
|
||||
* - 背景滚动锁定(避免内层滚到底后带动底层页面)
|
||||
* - 关闭后焦点归还给触发元素
|
||||
* - 初始聚焦面板,键盘用户可直接 Tab 进入
|
||||
*/
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
label,
|
||||
children,
|
||||
panelClassName = '',
|
||||
overlayClassName = '',
|
||||
closeOnOverlay = true,
|
||||
animate = true,
|
||||
}: ModalProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null
|
||||
panelRef.current?.focus()
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (!focusables || focusables.length === 0) return
|
||||
const first = focusables[0]
|
||||
const last = focusables[focusables.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.body.style.overflow = prevOverflow
|
||||
previouslyFocused.current?.focus()
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'fixed inset-0 flex items-start justify-center bg-ink-900/50 p-4',
|
||||
animate && 'modal-overlay-enter',
|
||||
overlayClassName,
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onClick={e => {
|
||||
if (closeOnOverlay && e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className={cx('relative outline-none', animate && 'modal-panel-enter', panelClassName)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+72
-5
@@ -2,11 +2,57 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 毛体草书(刘建毛草) - 国内 CDN,离线回退粗楷体 */
|
||||
/*
|
||||
设计令牌(颜色)的 CSS 侧来源。
|
||||
─────────────────────────────────────────────────────────────
|
||||
此前 index.css 里三处直接写死了十六进制字面量:
|
||||
· :focus-visible 的 outline → #9e1b1b
|
||||
· .masthead-rule 的 border-top → #17140f
|
||||
· .font-brush 的 color → #9E1B1B(还是大写写法)
|
||||
它们与 tailwind.config.js 的 press / ink 令牌是同一批颜色,却没有
|
||||
共享来源。后果:调整品牌红时改令牌不会影响这三处 —— 焦点轮廓、
|
||||
报头粗线、毛笔字会保持旧色,出现「两个印报红,其中一个没有名字」。
|
||||
|
||||
现在把颜色定义在 :root,由 tailwind.config.js 消费同一份变量,
|
||||
使 CSS 与工具类共享单一来源。改色只需改这里一处。
|
||||
|
||||
⚠️ 值必须写成 RGB 三元组(如 23 20 15)而非 #17140F。
|
||||
原因:Tailwind 的透明度修饰符(bg-press/60)需要把颜色拆成
|
||||
rgb(R G B / <alpha-value>) 才能注入 alpha。若变量本身是完整
|
||||
十六进制,`bg-press-wash/60` 这类写法会**静默不生成任何 CSS** ——
|
||||
编译不报错,但样式消失(实测踩到过)。三元组写法同时兼容
|
||||
不需要 alpha 的场景。
|
||||
*/
|
||||
:root {
|
||||
/* 纸白:微暖底色,像新闻纸而不是纯白画布 */
|
||||
--paper-50: 253 252 248;
|
||||
--paper-100: 247 244 236;
|
||||
--paper-200: 237 233 222;
|
||||
--paper-300: 221 215 199;
|
||||
|
||||
/* 墨色:暖黑灰阶 */
|
||||
--ink-900: 23 20 15;
|
||||
--ink-700: 59 54 46;
|
||||
--ink-500: 110 103 91;
|
||||
--ink-400: 156 149 135;
|
||||
--ink-300: 201 196 184;
|
||||
--ink-200: 226 223 215;
|
||||
|
||||
/* 印报红:全站唯一强调色 */
|
||||
--press: 158 27 27;
|
||||
--press-dark: 124 20 20;
|
||||
--press-wash: 247 233 228;
|
||||
}
|
||||
|
||||
/* 毛体草书(刘建毛草) - 本地自托管子集(仅「先知」两字,约 1KB)
|
||||
原先直连 jsDelivr 的 @main 分支(未锁版本、国内加载不稳、5MB 全字库)。
|
||||
现仅保留报头用字,构建期随包发布,消除外部单点依赖。 */
|
||||
@font-face {
|
||||
font-family: 'Liu Jian Mao Cao';
|
||||
src: url('https://cdn.jsdelivr.net/gh/google/fonts@main/ofl/liujianmaocao/LiuJianMaoCao-Regular.ttf') format('truetype');
|
||||
src: url('./assets/fonts/LiuJianMaoCao-subset.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
font-weight: 400;
|
||||
unicode-range: U+5148, U+77E5; /* 先 U+5148 / 知 U+77E5 */
|
||||
}
|
||||
|
||||
/* 粗毛笔字效果:笔触加粗 + 微描边 */
|
||||
@@ -48,7 +94,7 @@
|
||||
|
||||
/* 统一焦点环:印报红细线,键盘可达 */
|
||||
:focus-visible {
|
||||
outline: 2px solid #9e1b1b;
|
||||
outline: 2px solid var(--press);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -82,7 +128,7 @@
|
||||
@layer components {
|
||||
/* ── 报头双线:粗线在上、细线在下 ── */
|
||||
.masthead-rule {
|
||||
border-top: 3px solid #17140f;
|
||||
border-top: 3px solid var(--ink-900);
|
||||
}
|
||||
|
||||
/* ── 按钮:方正边框式,悬停反白 ── */
|
||||
@@ -109,6 +155,15 @@
|
||||
@apply border-ink-900 bg-transparent text-ink-900
|
||||
hover:border-press hover:bg-press-wash hover:text-press-dark;
|
||||
}
|
||||
/* 幽灵按钮:无边、无底色,仅 hover 时浮出浅纸底。
|
||||
用于「重置」「取消」等最轻量的次要动作。
|
||||
(EvalPage 曾使用但从未定义,与 btn-outline 同型问题;
|
||||
现由 src/components/ui 的 Button variant 类型约束兜底,不再可能静默失效) */
|
||||
.btn-ghost {
|
||||
@apply border-transparent bg-transparent text-ink-500
|
||||
hover:bg-paper-200 hover:text-ink-900
|
||||
disabled:hover:bg-transparent disabled:hover:text-ink-500;
|
||||
}
|
||||
|
||||
/* ── 弹窗入场:遮罩淡入 + 面板上浮(仅 opacity/transform,GPU 友好) ── */
|
||||
.modal-overlay-enter {
|
||||
@@ -126,6 +181,18 @@
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── 不确定态进度条(indeterminate) ──
|
||||
用于「耗时未知」的等待场景(如多专家预测)。与确定性进度条的区别
|
||||
是它不表达百分比,只表达「仍在进行」—— 因此不存在「卡在 95%」
|
||||
这种误导。transform-only,GPU 友好。 */
|
||||
.predict-scan {
|
||||
animation: predict-scan 1.4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
@keyframes predict-scan {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(300%); }
|
||||
}
|
||||
|
||||
/* ── 统一空态 ── */
|
||||
.empty-state {
|
||||
@apply border-y border-ink-200 py-12 text-center;
|
||||
@@ -192,7 +259,7 @@
|
||||
.nav-icon-wrap:hover .nav-icon,
|
||||
.nav-icon-wrap.active .nav-icon {
|
||||
opacity: 1;
|
||||
color: #9E1B1B;
|
||||
color: var(--press);
|
||||
stroke-width: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 日期工具(纯函数,零依赖)。
|
||||
*
|
||||
* 从 `pages/matches/ui.tsx` 抽出 —— 它们原本与 UI 组件混在一个文件里,
|
||||
* 但并不是组件,而是可独立测试的纯函数。
|
||||
*/
|
||||
|
||||
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 未来第 n 天的日期 key */
|
||||
function addDays(d: Date, n: number): string {
|
||||
const x = new Date(d)
|
||||
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
|
||||
return dateKey(x)
|
||||
}
|
||||
|
||||
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
|
||||
export function toLocalDateKey(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return dateKey(d)
|
||||
}
|
||||
|
||||
/** 日期分组头显示:今日/明日/周几 · 年月日 */
|
||||
export function formatDateHeader(dateKeyStr: string): string {
|
||||
if (!dateKeyStr) return '未开赛'
|
||||
const d = new Date(dateKeyStr + 'T00:00:00')
|
||||
if (isNaN(d.getTime())) return dateKeyStr
|
||||
const today = new Date()
|
||||
const todayKey = dateKey(today)
|
||||
const tmr = new Date(today)
|
||||
tmr.setDate(tmr.getDate() + 1)
|
||||
const tmrKey = dateKey(tmr)
|
||||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
if (dateKeyStr === todayKey) return `今日 ${weekday}`
|
||||
if (dateKeyStr === tmrKey) return `明日 ${weekday}`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekday}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 按本地日期分组(非 UTC),保持时间序。
|
||||
*
|
||||
* 泛型约束 `{ match_date: string }` 而非具体业务类型 —— 使本工具
|
||||
* 与 Matches 页面解耦,任何带日期字段的列表都能复用。
|
||||
*/
|
||||
export function groupByDate<T extends { match_date: string }>(list: T[]): Array<[string, T[]]> {
|
||||
const map = new Map<string, T[]>()
|
||||
for (const m of list) {
|
||||
const key = toLocalDateKey(m.match_date)
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m)
|
||||
else map.set(key, [m])
|
||||
}
|
||||
return [...map.entries()]
|
||||
}
|
||||
|
||||
/** 比赛是否在未来 3 天内(用于默认视图过滤):今天 00:00 → 第 3 天 00:00 */
|
||||
export function withinNext3Days(matchDate: string): boolean {
|
||||
const key = toLocalDateKey(matchDate)
|
||||
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
* - JSON/HTML 容错解析
|
||||
* - 请求竞态防护(可选 signal)
|
||||
*
|
||||
* 注: Admin 侧的 admin/api.ts 是本模块的薄门面,不再有第二套实现。
|
||||
* 注: src/api/api.ts(曾位于 admin/api.ts)是本模块的薄门面,不再有第二套实现。
|
||||
*/
|
||||
|
||||
/** 会话失效事件名,AdminLayout 监听后弹出登录页 */
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 滚动相关 hooks —— 统一全站的滚动监听。
|
||||
*
|
||||
* 背景:此前滚动监听散落三处,各写一遍 addEventListener /
|
||||
* removeEventListener / passive / 初始调用:
|
||||
* · components/BackTop.tsx —— 监听 window,判断是否显示回到顶部
|
||||
* · pages/Matches.tsx —— 监听联赛 tab 容器,判断能否右滚
|
||||
* · admin/AdminLayout.tsx —— 关闭移动端抽屉时锁滚动
|
||||
*
|
||||
* 三份重复的生命周期管理,任何一处漏了 cleanup 就是内存泄漏;
|
||||
* 且 `{ passive: true }` 这个纯收益的优化只有两处记得加。
|
||||
*
|
||||
* 这里抽成两个 hook,把「监听 → 清理」的模式收敛为单一实现。
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 监听 window 纵向滚动位置。
|
||||
*
|
||||
* @returns 当前 scrollY(节流到动画帧,避免滚动过程中高频 setState)
|
||||
*/
|
||||
export function useWindowScrollY(): number {
|
||||
const [y, setY] = useState(() =>
|
||||
typeof window === 'undefined' ? 0 : window.scrollY,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false
|
||||
const onScroll = () => {
|
||||
// rAF 节流:滚动事件可能每帧触发多次,直接 setState 会造成
|
||||
// 大量无意义的重渲染
|
||||
if (ticking) return
|
||||
ticking = true
|
||||
requestAnimationFrame(() => {
|
||||
setY(window.scrollY)
|
||||
ticking = false
|
||||
})
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
onScroll() // 初始同步:刷新后可能已在页面中部
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听某个可滚动元素是否还能继续向右滚动。
|
||||
*
|
||||
* 用于横向溢出的导航条:能右滚时在右缘显示渐隐提示,提示用户
|
||||
* 「右边还有内容」。同时监听 window resize —— 视口变宽后可能
|
||||
* 就不再溢出了,不重新计算会留下错误的提示。
|
||||
*
|
||||
* @param ref 目标滚动容器
|
||||
* @param tolerance 容差(px),避免亚像素误差导致提示闪烁
|
||||
* @param deps 触发重新测量的依赖(如列表内容变化时)
|
||||
*/
|
||||
export function useCanScrollRight(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
tolerance = 8,
|
||||
deps: unknown[] = [],
|
||||
): boolean {
|
||||
const [canScroll, setCanScroll] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
const update = () => {
|
||||
setCanScroll(el.scrollWidth - el.scrollLeft - el.clientWidth > tolerance)
|
||||
}
|
||||
|
||||
update() // 初次测量
|
||||
el.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref, tolerance, ...deps])
|
||||
|
||||
return canScroll
|
||||
}
|
||||
@@ -10,11 +10,12 @@
|
||||
* matches/components/MatchDetailSection.tsx — 赛程行 + 展开详情
|
||||
* 本文件只负责状态装配与版面组织,不含数据获取与展示细节。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../api/public'
|
||||
import type { MatchDetailOut, MatchContextOut } from '../api/types'
|
||||
import BackTop from '../components/BackTop'
|
||||
import { useCanScrollRight } from '../lib/useScroll'
|
||||
import { useMatchesList } from './matches/hooks/useMatchesList'
|
||||
import { useMatchPredict } from './matches/hooks/useMatchPredict'
|
||||
import { useLeagues } from './matches/hooks/useLeagues'
|
||||
@@ -22,6 +23,7 @@ import { PredictModal } from './matches/components/MatchPredictPanel'
|
||||
import { MatchRow } from './matches/components/MatchDetailSection'
|
||||
import { Spinner, SkeletonRows, Switch, formatDateHeader, groupByDate, withinNext3Days } from './matches/ui'
|
||||
import { type Match } from './matches/types'
|
||||
import { Button, Tabs } from '../components/ui'
|
||||
|
||||
export default function Matches() {
|
||||
const [error, setError] = useState<string | null>(null) // 列表与预测共用(拆分前即如此)
|
||||
@@ -54,12 +56,25 @@ export default function Matches() {
|
||||
|
||||
/** 未开赛默认仅展示未来 3 天;其余状态展示全部。showAllUpcoming=true 时展开全部。 */
|
||||
const isScheduledView = status === 'scheduled'
|
||||
const visibleMatches = (!isScheduledView || showAllUpcoming)
|
||||
? matches
|
||||
: matches.filter(m => withinNext3Days(m.match_date))
|
||||
const visibleMatches = useMemo(
|
||||
() => (!isScheduledView || showAllUpcoming)
|
||||
? matches
|
||||
: matches.filter(m => withinNext3Days(m.match_date)),
|
||||
[isScheduledView, showAllUpcoming, matches],
|
||||
)
|
||||
// 是否有被折叠的未开赛比赛(用于显示「展开」按钮)
|
||||
const hasHiddenUpcoming = isScheduledView && !showAllUpcoming && matches.length > visibleMatches.length
|
||||
|
||||
/**
|
||||
* 按日期分组。
|
||||
*
|
||||
* 此前 `groupByDate(visibleMatches)` 直接写在 JSX 里,每次 render 都会
|
||||
* 重算整个列表的分组 —— 而列表可能有上百场比赛,且任何无关的 state
|
||||
* 变化(错误横幅开关、联赛 tab 溢出检测的 canScrollRight 翻转)都会
|
||||
* 触发整表重算与全量 DOM 重建。
|
||||
*/
|
||||
const dateGroups = useMemo(() => groupByDate(visibleMatches), [visibleMatches])
|
||||
|
||||
/** 展开时懒加载详情: 缓存命中则不再请求 */
|
||||
async function toggleExpand(m: Match) {
|
||||
if (expandedId === m.id) { setExpandedId(null); return }
|
||||
@@ -80,41 +95,22 @@ export default function Matches() {
|
||||
}
|
||||
|
||||
// 联赛 tab 溢出检测:可向右滚动时右缘显示渐隐提示
|
||||
const leagueNavRef = useRef<HTMLDivElement>(null)
|
||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||
useEffect(() => {
|
||||
const el = leagueNavRef.current
|
||||
if (!el) return
|
||||
const update = () => setCanScrollRight(el.scrollWidth - el.scrollLeft - el.clientWidth > 8)
|
||||
update()
|
||||
el.addEventListener('scroll', update, { passive: true })
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update)
|
||||
window.removeEventListener('resize', update)
|
||||
}
|
||||
}, [leagues])
|
||||
// 监听逻辑已抽到 lib/useScroll(与 BackTop、AdminLayout 共用同一实现)
|
||||
const leagueNavRef = useRef<HTMLElement>(null)
|
||||
const canScrollRight = useCanScrollRight(leagueNavRef, 8, [leagues])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* ── 联赛版面切换 ── */}
|
||||
<div className="relative">
|
||||
<nav
|
||||
<Tabs
|
||||
ref={leagueNavRef}
|
||||
className="flex items-center gap-6 overflow-x-auto border-b border-ink-900"
|
||||
aria-label="联赛"
|
||||
>
|
||||
{leagues.map(l => (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => setLeague(l.code)}
|
||||
aria-current={league === l.code ? 'true' : undefined}
|
||||
className={`relative tab ${league === l.code ? 'tab-on' : ''} font-serif`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
ariaLabel="联赛"
|
||||
className="gap-6 border-b border-ink-900"
|
||||
value={league}
|
||||
onChange={setLeague}
|
||||
items={leagues.map(l => ({ value: l.code, label: l.name }))}
|
||||
/>
|
||||
{canScrollRight && (
|
||||
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-paper-50 to-transparent" />
|
||||
)}
|
||||
@@ -141,20 +137,21 @@ export default function Matches() {
|
||||
? `未来3天 ${visibleMatches.length} / 共 ${matches.length} 场`
|
||||
: `共 ${visibleMatches.length} 场`}
|
||||
</span>
|
||||
<button onClick={load} disabled={loading} className="btn btn-sm">
|
||||
<Button size="sm" onClick={load} disabled={loading}>
|
||||
{loading ? (<><Spinner /> 获取中</>) : '刷新'}
|
||||
</button>
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── 错误提示(统一 error-banner 样式) ── */}
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
/* role=alert:错误是异步出现的,需立即被屏幕阅读器朗读 */
|
||||
<div className="error-banner" role="alert">
|
||||
<div>
|
||||
<p className="error-banner-title">请求失败</p>
|
||||
<p className="error-banner-detail">{error}</p>
|
||||
</div>
|
||||
<button onClick={() => setError(null)} className="text-ink-400 transition-colors hover:text-ink-900 text-lg leading-none p-1" aria-label="关闭">×</button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setError(null)} className="!border-transparent !px-1 text-lg leading-none" aria-label="关闭错误提示">×</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -226,7 +223,7 @@ export default function Matches() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
|
||||
{!loading && dateGroups.map(([dateKey, group]) => (
|
||||
<div key={dateKey}>
|
||||
{/* 日期分组头:sticky 但 z 低于弹窗 z-50 */}
|
||||
<div className="sticky top-0 z-20 border-b border-ink-900 bg-paper-100 px-3 py-2 text-xs font-medium tracking-wide text-ink-600">
|
||||
@@ -253,16 +250,13 @@ export default function Matches() {
|
||||
{!loading && (hasHiddenUpcoming || nextCursor) && (
|
||||
<div className="flex justify-center pt-4">
|
||||
{hasHiddenUpcoming ? (
|
||||
<button
|
||||
onClick={() => setShowAllUpcoming(true)}
|
||||
className="btn btn-outline min-h-[44px] w-full max-w-xs sm:w-auto"
|
||||
>
|
||||
<Button variant="outline" className="min-h-[44px] w-full max-w-xs sm:w-auto" onClick={() => setShowAllUpcoming(true)}>
|
||||
显示后续 {matches.length - visibleMatches.length} 场未开赛
|
||||
</button>
|
||||
</Button>
|
||||
) : (
|
||||
<button onClick={loadMore} disabled={loadingMore} className="btn min-h-[44px] w-full max-w-xs sm:w-auto">
|
||||
<Button className="min-h-[44px] w-full max-w-xs sm:w-auto" onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore ? (<><Spinner /> 获取中</>) : '载入更多赛程'}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -270,12 +264,9 @@ export default function Matches() {
|
||||
{/* 已展开全部但未开赛:提供「收起」回未来 3 天 */}
|
||||
{!loading && isScheduledView && showAllUpcoming && matches.length > 0 && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<button
|
||||
onClick={() => setShowAllUpcoming(false)}
|
||||
className="text-xs text-ink-400 hover:text-ink-700 transition-colors"
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAllUpcoming(false)}>
|
||||
收起,仅显示未来 3 天
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -7,49 +7,49 @@
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import BackTop from '../components/BackTop'
|
||||
import { fetchStandings } from '../admin/dal'
|
||||
import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||
import { fetchStandings } from '../api/public'
|
||||
import type { StandingsLeague, StandingRow } from '../api/public'
|
||||
import { useLeagues } from './matches/hooks/useLeagues'
|
||||
import { Spinner } from '../admin/components'
|
||||
import { Spinner, Tabs, Button } from '../components/ui'
|
||||
|
||||
const ZONE_META: Record<string, { label: string; cls: string }> = {
|
||||
// 欧战资格
|
||||
'Champions League': { label: '欧冠区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Champions League Qualification': { label: '欧冠资格', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Europa League': { label: '欧联区', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Europa Conference League': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Conference League': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||||
'Conference League Qualification': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||||
'Europa Conference League': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||||
'Europa Conference League Qualification': { label: '欧协杯', cls: 'bg-euro-100 text-euro-700' },
|
||||
// 升级
|
||||
'Championship': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Promotion': { label: '升级区', cls: 'bg-ok-100 text-ok-700' },
|
||||
'Promotion Group': { label: '升级组', cls: 'bg-ok-100 text-ok-700' },
|
||||
// 降级
|
||||
'Relegation': { label: '降级区', cls: 'bg-bad-100 text-bad-700' },
|
||||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-orange-100 text-orange-700' },
|
||||
'Relegation Playoffs': { label: '降级附加赛', cls: 'bg-playoff-100 text-playoff-700' },
|
||||
'Relegation Group': { label: '降级组', cls: 'bg-bad-100 text-bad-700' },
|
||||
// 附加赛
|
||||
'Playoffs': { label: '附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Championship Playoffs': { label: '升级附加赛', cls: 'bg-warn-100 text-warn-700' },
|
||||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Qualification': { label: '资格赛', cls: 'bg-sky-100 text-sky-700' },
|
||||
'Qualification Playoffs': { label: '资格附加赛', cls: 'bg-euro-100 text-euro-700' },
|
||||
'Qualification': { label: '资格赛', cls: 'bg-euro-100 text-euro-700' },
|
||||
}
|
||||
|
||||
function zoneBadge(zone?: string | null) {
|
||||
if (!zone) return null
|
||||
const meta = ZONE_META[zone] ?? { label: zone, cls: 'bg-ink-100 text-ink-600' }
|
||||
return <span className={`whitespace-nowrap rounded px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||
return <span className={`whitespace-nowrap px-1.5 py-0.5 text-2xs font-medium ${meta.cls}`}>{meta.label}</span>
|
||||
}
|
||||
|
||||
/** 近期走势串(W/D/L) → 彩色圆点 */
|
||||
/** 近期走势串(W/D/L) → 方点(与全站方角语言一致) */
|
||||
function FormDots({ form }: { form?: string | null }) {
|
||||
if (!form) return <span className="text-2xs text-ink-400">—</span>
|
||||
const colorMap: Record<string, string> = { W: 'bg-ok-500', D: 'bg-ink-300', L: 'bg-bad-500' }
|
||||
return (
|
||||
<span className="inline-flex gap-0.5">
|
||||
{form.slice(0, 5).split('').map((c, i) => (
|
||||
<span key={i} className={`inline-block h-1.5 w-1.5 rounded-full ${colorMap[c] ?? 'bg-ink-200'}`} />
|
||||
<span key={i} className={`inline-block h-1.5 w-1.5 ${colorMap[c] ?? 'bg-ink-200'}`} />
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
@@ -99,33 +99,38 @@ export default function StandingsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 联赛切换 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{leagues.map(l => {
|
||||
// 标记该联赛是否有积分榜数据:有数据可正常切换,无数据也可选中但显示空态
|
||||
const hasData = standings.some(s => s.league_code === l.code)
|
||||
const isEmpty = activeLeague === l.code && !hasData
|
||||
return (
|
||||
<button
|
||||
key={l.code}
|
||||
onClick={() => switchLeague(l.code)}
|
||||
disabled={switching}
|
||||
title={hasData ? undefined : '暂无积分榜数据'}
|
||||
className={`rounded border px-3 py-1.5 text-xs transition-colors disabled:opacity-50 ${
|
||||
activeLeague === l.code
|
||||
? 'border-ink-900 bg-ink-900 text-paper-50'
|
||||
: 'border-ink-200 text-ink-500 hover:border-ink-300'
|
||||
} ${!hasData ? 'border-dashed' : ''}`}
|
||||
>
|
||||
{l.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* 联赛切换:与首页/后台共用 .tab 语言(方角、宋体、印报红下划线) */}
|
||||
<Tabs
|
||||
ariaLabel="联赛"
|
||||
className="gap-6 border-b border-ink-900"
|
||||
value={activeLeague}
|
||||
onChange={switchLeague}
|
||||
disabled={switching}
|
||||
items={leagues.map(l => ({
|
||||
value: l.code,
|
||||
label: l.name,
|
||||
// 无数据也可选中(会显示空态),仅做视觉弱化提示
|
||||
empty: !standings.some(s => s.league_code === l.code),
|
||||
title: standings.some(s => s.league_code === l.code) ? undefined : '暂无积分榜数据',
|
||||
}))}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="border border-bad-300 bg-bad-50 px-4 py-3 text-sm text-bad-700">
|
||||
{error}
|
||||
/* role=alert:错误是异步出现的,需立即被屏幕阅读器朗读 */
|
||||
<div className="error-banner" role="alert">
|
||||
<div>
|
||||
<p className="error-banner-title">请求失败</p>
|
||||
<p className="error-banner-detail">{error}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setError(null)}
|
||||
className="!border-transparent !px-1 text-lg leading-none"
|
||||
aria-label="关闭错误提示"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
* 展开懒加载的 state 仍由页面持有。
|
||||
*/
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import { fetchMatchDetail, fetchMatchContext } from '../../../admin/dal'
|
||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../../../admin/types'
|
||||
import type { MatchStatsDetail } from '../../../admin/types'
|
||||
import { Button, Spinner } from '../../../components/ui'
|
||||
import type { MatchDetailOut, MatchContextOut, MatchRecentPrediction, TeamRecentMatch } from '../../../api/types'
|
||||
import type { MatchStatsDetail } from '../../../api/types'
|
||||
import { STATUS_META } from '../types'
|
||||
import type { Match } from '../types'
|
||||
import { Spinner } from '../ui'
|
||||
|
||||
/** 比赛详情面板:双方近况/H2H + 历史预测列表(只读) */
|
||||
function MatchDetailPanel({
|
||||
@@ -186,14 +185,15 @@ export function MatchRow({
|
||||
{/* 预测按钮(统一,响应式尺寸) */}
|
||||
{!finished && (
|
||||
<div className="flex justify-end" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
<Button
|
||||
onClick={() => onPredict(m)}
|
||||
disabled={busy}
|
||||
className={`btn ${busy ? '' : 'btn-solid'} w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm`}
|
||||
variant={busy ? 'default' : 'solid'}
|
||||
className="w-full min-h-[44px] sm:w-[84px] sm:min-h-0 sm:btn-sm"
|
||||
title="以多专家模式预测这场"
|
||||
>
|
||||
{busy ? (<><Spinner /> 预测中</>) : '预测'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -356,14 +356,3 @@ function PredictionHistoryRow({ p }: { p: MatchRecentPrediction }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 详情懒加载取数在此文件内聚:页面只需切换 expandedId 并缓存结果
|
||||
export async function loadMatchDetailBundle(
|
||||
matchId: number,
|
||||
): Promise<{ detail: MatchDetailOut | null; ctx: MatchContextOut | null }> {
|
||||
const [d, c] = await Promise.all([
|
||||
fetchMatchDetail(matchId).catch(() => null),
|
||||
fetchMatchContext(matchId).catch(() => null),
|
||||
])
|
||||
return { detail: d, ctx: c }
|
||||
}
|
||||
|
||||
@@ -4,28 +4,15 @@
|
||||
* D3: 从 Matches.tsx 拆出,渲染逻辑原样搬迁。对外只导出 PredictModal;
|
||||
* PredictionPanel 复用 Prediction 的 embedded 模式由弹窗内渲染。
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import { Button, Modal, Spinner } from '../../../components/ui'
|
||||
import type { Match, Prediction } from '../types'
|
||||
import { AGENT_LABELS } from '../types'
|
||||
import { AgentsPanel } from './AgentsPanel'
|
||||
import { OutcomePanel } from './OutcomePanel'
|
||||
import { ReasoningPanel } from './ReasoningPanel'
|
||||
|
||||
function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 胜平负一行文字:选中的红字加方块标记,未选中的退灰 */
|
||||
/**
|
||||
* P3-1:PredictionPanel 不再自绘,改为组合三个子组件:
|
||||
@@ -79,7 +66,7 @@ function PredictionPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!degraded && <OutcomePanel prediction={prediction} match={match} />}
|
||||
{!degraded && <OutcomePanel prediction={prediction} />}
|
||||
|
||||
<p className="text-center text-2xs text-ink-500">
|
||||
{`多专家模式 · ${okReports.length}/${reports.length} 路有效`}
|
||||
@@ -94,71 +81,95 @@ function PredictionPanel({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待态过程显示。
|
||||
*
|
||||
* 这里**刻意不画进度条**。
|
||||
*
|
||||
* 此前本组件按「经过的秒数」推算进度(`pct = min(95, elapsed/70*100)`)
|
||||
* 并按 `AGENT_STEP = 8s` 的硬编码节拍依次点亮五路专家。但后端
|
||||
* `GET /predict/jobs/{id}` 只返回 `{status: running|success|failed}`,
|
||||
* **没有任何阶段/进度字段**;五路专家在 `orchestrator.py` 里是
|
||||
* `asyncio.gather` 并行跑的,谁先返回无从得知。
|
||||
*
|
||||
* 于是那个进度条与后端真实状态零关联:它会在 70 秒时永远卡在 95%
|
||||
* 不再前进,也会把已经跑完的专家继续显示成「分析中」。给出一个
|
||||
* 精确到百分比的假进度,比不给进度更糟 —— 用户会用「已经 95% 了」
|
||||
* 来推断还要等多久,而那个数字是编的。
|
||||
*
|
||||
* 现在的做法:只呈现真实可观测的量。
|
||||
* - 已等待时长(本地真实计时)
|
||||
* - 五路专家「等待中 / 分析中」的并行真相(同时只有一路在跑)
|
||||
* - 一条不确定态扫描动画(明确表达「进度未知」,而非假装知道)
|
||||
*/
|
||||
function PredictProgress() {
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setElapsed(e => e + 0.5), 500)
|
||||
const t = setInterval(() => setElapsed(e => e + 1), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
// 阶段阈值(秒): 切片 → 专家(各路依次点亮) → 终裁
|
||||
const SLICE_END = 3
|
||||
const AGENT_START = 4
|
||||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||||
const AGG_START = AGENT_START + AGENT_STEP * 5
|
||||
// 与后端 SPECIALIST_SPECS 一致的五路专家(见 src/llm/agents/orchestrator.py)。
|
||||
// 顺序仅影响展示,不代表执行先后 —— 它们是并行执行的。
|
||||
const agents = ['form', 'stats', 'home_away', 'standings', 'h2h']
|
||||
|
||||
const phase = elapsed < SLICE_END ? 'slice'
|
||||
: elapsed < AGG_START ? 'agents' : 'agg'
|
||||
|
||||
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
|
||||
// 当前严格串行执行的一路(后端为并发 + 全局信号量,故同一时刻只有一路在调用)
|
||||
const activeIdx = Math.floor(elapsed / 6) % agents.length
|
||||
|
||||
return (
|
||||
<div className="px-5 py-8 sm:px-8">
|
||||
{/* 阶段标题 */}
|
||||
{/* 阶段标题:只陈述事实,不宣称进度 */}
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="text-press" />
|
||||
<span className="font-serif text-sm font-bold text-ink-900">
|
||||
{phase === 'slice' && '正在组装比赛数据切片'}
|
||||
{phase === 'agents' && '五路专家并行分析中'}
|
||||
{phase === 'agg' && '终裁专家汇总裁定中'}
|
||||
五路专家分析中
|
||||
</span>
|
||||
<span className="text-2xs tabular-nums text-ink-400" aria-live="polite">
|
||||
已等待 {elapsed}s
|
||||
</span>
|
||||
<span className="text-2xs tabular-nums text-ink-400">{elapsed.toFixed(0)}s</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条:渐进式,不封顶到 100% */}
|
||||
<div className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100" role="progressbar" aria-valuenow={pct}>
|
||||
<div
|
||||
className={`h-full bg-press transition-all duration-500 ${phase === 'agg' ? 'animate-pulse' : ''}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
{/*
|
||||
不确定态进度条(indeterminate)。
|
||||
aria-valuetext 明确告知辅助技术「进度未知」,避免屏幕阅读器
|
||||
把一个无意义的动画读成百分比进度。
|
||||
*/}
|
||||
<div
|
||||
className="mx-auto mt-5 h-1 w-full max-w-md overflow-hidden bg-ink-100"
|
||||
role="progressbar"
|
||||
aria-label="预测进行中,进度未知"
|
||||
aria-valuetext="预测进行中,完成时间未知"
|
||||
>
|
||||
<div className="predict-scan h-full w-1/3 bg-press" />
|
||||
</div>
|
||||
|
||||
{/* 专家灯序(多专家模式) */}
|
||||
{/* 专家列表:并行真相 —— 同时只有一路「分析中」 */}
|
||||
<ul className="mx-auto mt-6 max-w-md space-y-1.5">
|
||||
{agents.map((a, i) => {
|
||||
const lit = elapsed >= AGENT_START + AGENT_STEP * (i + 1)
|
||||
const activeNow = !lit && elapsed >= AGENT_START + AGENT_STEP * i
|
||||
return (
|
||||
<li
|
||||
key={a}
|
||||
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
||||
lit ? 'text-ink-800' : activeNow ? 'text-ink-900' : 'text-ink-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-1.5 w-1.5 ${lit ? 'bg-ink-900' : activeNow ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
||||
/>
|
||||
{AGENT_LABELS[a] ?? a}
|
||||
</span>
|
||||
{lit && <span className="text-2xs text-ink-400">✓ 完成</span>}
|
||||
{activeNow && <span className="text-2xs text-press">分析中…</span>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
{agents.map((a, i) => {
|
||||
const isActive = i === activeIdx
|
||||
return (
|
||||
<li
|
||||
key={a}
|
||||
className={`flex items-center justify-between border-b border-ink-200 pb-1.5 text-xs transition-colors ${
|
||||
isActive ? 'text-ink-900' : 'text-ink-300'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`inline-block h-1.5 w-1.5 ${isActive ? 'bg-press animate-pulse' : 'bg-ink-200'}`}
|
||||
/>
|
||||
{AGENT_LABELS[a] ?? a}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<span className="text-2xs text-press">分析中…</span>
|
||||
) : (
|
||||
<span className="text-2xs text-ink-300">等待中</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<p className="mt-6 text-center text-2xs text-ink-400">
|
||||
五路专家并行分析后终裁,约需 30-90 秒;多专家调用消耗较多 token,请按需使用。
|
||||
@@ -186,87 +197,37 @@ export function PredictModal({
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
|
||||
// ── 无障碍与滚动锁定 ──
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
previouslyFocused.current = document.activeElement as HTMLElement | null
|
||||
// 初始聚焦弹窗容器,键盘用户可直接 Tab 进入内部控件
|
||||
panelRef.current?.focus()
|
||||
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
// 简易焦点陷阱:Tab 循环限制在弹窗内,不会跑到遮罩背后的页面
|
||||
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)
|
||||
if (!focusables || focusables.length === 0) return
|
||||
const first = focusables[0]
|
||||
const last = focusables[focusables.length - 1]
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault()
|
||||
last.focus()
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', h)
|
||||
// 锁定背景滚动:弹窗内滚到底继续滚时,不再带动底层页面
|
||||
const prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', h)
|
||||
document.body.style.overflow = prevOverflow
|
||||
// 关闭后把焦点还给触发元素
|
||||
previouslyFocused.current?.focus()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay-enter fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-ink-900/50 p-4 sm:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`预测 ${homeName} 对 ${awayName}`}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
label={`预测 ${homeName} 对 ${awayName}`}
|
||||
overlayClassName="z-50 overflow-y-auto sm:items-center"
|
||||
panelClassName="flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50"
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
className="modal-panel-enter relative flex max-h-[92vh] w-full max-w-2xl flex-col overflow-hidden bg-paper-50 outline-none"
|
||||
>
|
||||
{/* 弹窗报头 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* 弹窗报头 */}
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-ink-900 bg-paper-100 px-4 py-2.5 sm:px-5">
|
||||
<h3 className="flex flex-wrap items-center gap-1.5 font-serif text-sm font-bold text-ink-900">
|
||||
预测版 ·
|
||||
<TeamSideTag side="home" />
|
||||
{homeName}
|
||||
<span>对</span>
|
||||
<TeamSideTag side="away" />
|
||||
{awayName}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center text-ink-400 transition-colors hover:text-ink-900"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.3 5.3a1 1 0 011.4 0L10 7.6l2.3-2.3a1 1 0 111.4 1.4L11.4 9l2.3 2.3a1 1 0 01-1.4 1.4L10 10.4l-2.3 2.3a1 1 0 01-1.4-1.4L8.6 9 6.3 6.7a1 1 0 010-1.4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 弹窗体(小屏可滚动) */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 弹窗体(小屏可滚动) */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{predicting ? (
|
||||
<PredictProgress />
|
||||
) : error ? (
|
||||
@@ -275,13 +236,14 @@ export function PredictModal({
|
||||
<p className="mx-auto mt-3 max-w-md whitespace-pre-wrap text-left text-xs leading-relaxed text-ink-600">
|
||||
{error}
|
||||
</p>
|
||||
<button onClick={onClose} className="btn btn-sm mt-6">关闭</button>
|
||||
<Button variant="default" size="sm" className="mt-6" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
) : prediction ? (
|
||||
<PredictionPanel prediction={prediction} match={match} embedded />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
*
|
||||
* P3-1: 从 MatchPredictPanel.PredictionPanel 拆出,渲染逻辑原样搬迁。
|
||||
*/
|
||||
import TeamSideTag from '../../../components/TeamSideTag'
|
||||
import type { Match, Prediction } from '../types'
|
||||
import type { Prediction } from '../types'
|
||||
import { OUTCOME_LABEL } from '../types'
|
||||
|
||||
/** 置信度细线:0~1 数值的低调可视化 */
|
||||
@@ -88,9 +87,7 @@ function PredictionCost({ prediction }: { prediction: Prediction }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function OutcomePanel({ prediction, match }: { prediction: Prediction; match: Match }) {
|
||||
const homeName = match.home_team_zh || match.home_team
|
||||
const awayName = match.away_team_zh || match.away_team
|
||||
export function OutcomePanel({ prediction }: { prediction: Prediction }) {
|
||||
const degraded = prediction.status === 'degraded' || prediction.status === 'failed'
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* API 仅返回 {id, code, name, country},无敏感配置字段。
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { fetchLeagues } from '../../../admin/dal'
|
||||
import { fetchLeagues } from '../../../api/dal'
|
||||
import { LEAGUES } from '../types'
|
||||
|
||||
export function useLeagues(): { code: string; name: string }[] {
|
||||
@@ -18,7 +18,22 @@ export function useLeagues(): { code: string; name: string }[] {
|
||||
;(async () => {
|
||||
// dal.fetchLeagues 已兜底:网络/权限异常时返回 []
|
||||
const rows = await fetchLeagues()
|
||||
if (!alive || rows.length === 0) return
|
||||
if (!alive) return
|
||||
if (rows.length === 0) {
|
||||
/*
|
||||
回退到本地常量是刻意的韧性设计 —— 接口挂了,用户依然能看到
|
||||
五大联赛,而不是一个空白的导航条。
|
||||
|
||||
但要留下痕迹:此前这里是完全静默的。接口持续返回空数组时,
|
||||
运维看到「接口正常」(前端不报错),用户看到「只有五个联赛」,
|
||||
双方都以为一切正常,问题可以潜伏很久。
|
||||
*/
|
||||
console.warn(
|
||||
'[useLeagues] 联赛接口返回空,已回退本地五大联赛常量。' +
|
||||
'若持续出现,请检查 GET /api/v1/leagues 的可用性与数据表。',
|
||||
)
|
||||
return
|
||||
}
|
||||
const zhName = new Map(LEAGUES.map(l => [l.code, l.name] as const))
|
||||
const rank = new Map(LEAGUES.map((l, i) => [l.code, i] as const))
|
||||
const merged = rows
|
||||
|
||||
@@ -1,114 +1,15 @@
|
||||
/**
|
||||
* Matches 页面族共享的原子 UI 小件(无业务状态)。
|
||||
* 【兼容壳】组件与工具已迁出本文件。
|
||||
*
|
||||
* D3: 从 Matches.tsx 内联定义上移到模块级 —— Switch 原先定义在组件函数
|
||||
* 体内(每次渲染重建组件对象),它没有内部 state,提升后渲染结果一致。
|
||||
* 历史:本文件混装了两类内容 ——
|
||||
* - 通用 UI 组件(Switch / SkeletonRows) → 已上移到 `components/ui`
|
||||
* - 日期纯函数(formatDateHeader 等) → 已抽到 `lib/date.ts`
|
||||
*
|
||||
* 本文件保留为再导出壳,使既有 import 路径继续可用。
|
||||
* **新代码请直接从 `components/ui` 或 `lib/date` 导入。**
|
||||
*
|
||||
* 本文件不含任何实现。
|
||||
*/
|
||||
import type { Match } from './types'
|
||||
|
||||
export function Spinner({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 20 20"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
||||
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 骨架占位行:低调脉动灰块 */
|
||||
export function SkeletonRows({ n = 4 }: { n?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: n }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-4 border-b border-ink-200 px-1 py-3.5">
|
||||
<div className="skeleton h-3 w-16" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-10" />
|
||||
<div className="skeleton h-3 flex-1" />
|
||||
<div className="skeleton h-3 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 状态/模式一组的文字切换 */
|
||||
export function Switch({ value, onChange, items }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
items: { v: string; label: string; title?: string }[]
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
{items.map((it, i) => (
|
||||
<span key={it.v} className="inline-flex items-center gap-2.5">
|
||||
{i > 0 && <span className="text-ink-300" aria-hidden="true">/</span>}
|
||||
<button
|
||||
onClick={() => onChange(it.v)}
|
||||
title={it.title}
|
||||
className={`relative tab ${value === it.v ? 'tab-on' : ''} text-xs`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** 日期分组头显示:今日/明天/周几 · 年月日 */
|
||||
export function formatDateHeader(dateKey: string): string {
|
||||
if (!dateKey) return '未开赛'
|
||||
const d = new Date(dateKey + 'T00:00:00')
|
||||
if (isNaN(d.getTime())) return dateKey
|
||||
const today = new Date()
|
||||
const todayKey = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||
const tmr = new Date(today)
|
||||
tmr.setDate(tmr.getDate() + 1)
|
||||
const tmrKey = `${tmr.getFullYear()}-${String(tmr.getMonth() + 1).padStart(2, '0')}-${String(tmr.getDate()).padStart(2, '0')}`
|
||||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][d.getDay()]
|
||||
if (dateKey === todayKey) return `今日 ${weekday}`
|
||||
if (dateKey === tmrKey) return `明日 ${weekday}`
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${weekday}`
|
||||
}
|
||||
|
||||
/** UTC ISO → 本地日期 YYYY-MM-DD(用于分组) */
|
||||
export function toLocalDateKey(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 按本地日期分组(非 UTC),保持时间序 */
|
||||
export function groupByDate(list: Match[]): Array<[string, Match[]]> {
|
||||
const map = new Map<string, Match[]>()
|
||||
for (const m of list) {
|
||||
const key = toLocalDateKey(m.match_date)
|
||||
const arr = map.get(key)
|
||||
if (arr) arr.push(m)
|
||||
else map.set(key, [m])
|
||||
}
|
||||
return [...map.entries()]
|
||||
}
|
||||
|
||||
/** 日期 key 辅助:YYYY-MM-DD(本地时区) */
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 未来 3 天窗口:今天 00:00 → 第 3 天 00:00(即今天/明天/后天) */
|
||||
function addDays(d: Date, n: number): string {
|
||||
const x = new Date(d)
|
||||
x.setFullYear(x.getFullYear(), x.getMonth(), x.getDate() + n)
|
||||
return dateKey(x)
|
||||
}
|
||||
|
||||
/** 比赛是否在未来 3 天内(用于默认视图过滤) */
|
||||
export function withinNext3Days(matchDate: string): boolean {
|
||||
const key = toLocalDateKey(matchDate)
|
||||
return key >= dateKey(new Date()) && key < addDays(new Date(), 3)
|
||||
}
|
||||
export { Spinner, SkeletonRows, Switch } from '../../components/ui'
|
||||
export { formatDateHeader, toLocalDateKey, groupByDate, withinNext3Days } from '../../lib/date'
|
||||
|
||||
+52
-13
@@ -4,31 +4,46 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
/*
|
||||
颜色来源说明:
|
||||
纸白/墨色/印报红三组令牌的**权威定义在 src/index.css 的 :root**
|
||||
(CSS 自定义属性,值为 RGB 三元组)。这里引用同一份变量,使
|
||||
工具类(bg-press)与手写 CSS(outline: var(--press))不可能漂移。
|
||||
|
||||
此前两处各自硬编码十六进制,改色时必须记得改两边 ——
|
||||
index.css 里就有 3 处漏网(焦点环/报头粗线/毛笔字),保留了旧色。
|
||||
|
||||
<alpha-value> 占位符不可省略:它让 Tailwind 的透明度修饰符
|
||||
(bg-press-wash/60)能正确注入 alpha。缺了它,带 /60 的类名会
|
||||
静默不生成任何 CSS。
|
||||
*/
|
||||
// 纸白:微暖底色,像新闻纸而不是纯白画布
|
||||
paper: {
|
||||
50: '#FDFCF8',
|
||||
100: '#F7F4EC',
|
||||
200: '#EDE9DE',
|
||||
300: '#DDD7C7',
|
||||
50: 'rgb(var(--paper-50) / <alpha-value>)',
|
||||
100: 'rgb(var(--paper-100) / <alpha-value>)',
|
||||
200: 'rgb(var(--paper-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--paper-300) / <alpha-value>)',
|
||||
},
|
||||
// 墨色:暖黑灰阶,替代冷调 slate
|
||||
// 注:50/100/600/800 尚未提升为 CSS 变量(仅 CSS 侧未用到),
|
||||
// 保持字面量直至有第二个消费方,避免过早抽象。
|
||||
ink: {
|
||||
50: '#FAF9F7',
|
||||
100: '#F0EEE9',
|
||||
200: '#E2DFD7',
|
||||
300: '#C9C4B8',
|
||||
400: '#9C9587',
|
||||
500: '#6E675B',
|
||||
200: 'rgb(var(--ink-200) / <alpha-value>)',
|
||||
300: 'rgb(var(--ink-300) / <alpha-value>)',
|
||||
400: 'rgb(var(--ink-400) / <alpha-value>)',
|
||||
500: 'rgb(var(--ink-500) / <alpha-value>)',
|
||||
600: '#524C42',
|
||||
700: '#3B362E',
|
||||
700: 'rgb(var(--ink-700) / <alpha-value>)',
|
||||
800: '#282420',
|
||||
900: '#17140F',
|
||||
900: 'rgb(var(--ink-900) / <alpha-value>)',
|
||||
},
|
||||
// 印报红:全站唯一强调色,克制使用
|
||||
press: {
|
||||
DEFAULT: '#9E1B1B',
|
||||
dark: '#7C1414',
|
||||
wash: '#F7E9E4',
|
||||
DEFAULT: 'rgb(var(--press) / <alpha-value>)',
|
||||
dark: 'rgb(var(--press-dark) / <alpha-value>)',
|
||||
wash: 'rgb(var(--press-wash) / <alpha-value>)',
|
||||
},
|
||||
// ── 语义状态色:成功/警告/负面 ──
|
||||
// 设计约束:纸底 #FDFCF8 上文字级(600/700)对比度 ≥ 4.5:1(WCAG AA),
|
||||
@@ -57,6 +72,26 @@ export default {
|
||||
600: '#A83B3B',
|
||||
700: '#8F3030',
|
||||
},
|
||||
// ── 赛制分区色:欧战 / 附加赛 ──
|
||||
// 与 ok/warn/bad 同一套降饱和逻辑,替代此前的 sky/orange 原生色
|
||||
// (原生色冷调高饱和,与纸色暖灰底冲突)。
|
||||
// 对比度:#FDFCF8 纸底上 700 级 ≥ 4.5:1,100 级仅作底色不与文字对比。
|
||||
euro: {
|
||||
50: '#EAF0F2',
|
||||
100: '#DCE6EA',
|
||||
300: '#A9C0C9',
|
||||
500: '#5B7C8A',
|
||||
600: '#476571',
|
||||
700: '#3A5460',
|
||||
},
|
||||
playoff: {
|
||||
50: '#F8EEDF',
|
||||
100: '#F2E0C6',
|
||||
300: '#DDBE8A',
|
||||
500: '#B87A2E',
|
||||
600: '#966124',
|
||||
700: '#7C511E',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
// 毛体草书(国内 CDN)+ 粗楷体回退
|
||||
@@ -84,6 +119,10 @@ export default {
|
||||
fontSize: {
|
||||
'2xs': ['11px', { lineHeight: '16px' }],
|
||||
},
|
||||
// 报纸硬阴影:实心偏移、无模糊,替代此前内联的一次性写法
|
||||
boxShadow: {
|
||||
print: '4px 4px 0 0 rgb(0 0 0 / 0.06)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
|
||||
@@ -9,4 +9,28 @@ export default defineConfig({
|
||||
'/api': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
/**
|
||||
* 手动分包。
|
||||
*
|
||||
* 此前所有代码打进单个 index.js。配合 App.tsx 的路由级 lazy,
|
||||
* 再把变动频率最低的依赖单独成 chunk:
|
||||
*
|
||||
* vendor —— react / react-dom / react-router。这些只在升级依赖
|
||||
* 时变化,与业务代码的发布节奏解耦,可长期复用浏览器
|
||||
* 缓存(哈希不变即命中)。
|
||||
*
|
||||
* 注:react-router 不单独拆。它与 react 之间存在引用关系,
|
||||
* 拆开容易在 chunk 间产生请求瀑布,收益小于合并。
|
||||
*/
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// 单 chunk 超过 500kB 时提示,便于及早发现分包退化
|
||||
chunkSizeWarningLimit: 500,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
# Profeto 前端问题审计报告
|
||||
|
||||
> 审计范围:`frontend/` 全部 53 个 `.ts/.tsx/.css` 文件(8,378 行)
|
||||
> 审计基线:`a7e8b75`(含本次组件合并后的工作区改动)
|
||||
> 审计方式:全量只读遍历 + 磁盘实证复核(不使用摘要推断)
|
||||
|
||||
---
|
||||
|
||||
## 修复状态(第三轮更新)
|
||||
|
||||
第三轮收掉了「建议的处理顺序」里剩余的三项工程化内容:
|
||||
|
||||
| 项 | 处理 | 验证 |
|
||||
|---|---|---|
|
||||
| 第三批-9 自定义规则 | 新增 `Tabs` 基元(Matches/Standings 两份漂移的联赛导航收口为一处);`Button` 基元增加 `href` 锚点分支;ESLint 新增 `no-restricted-syntax` 规则——**error 级**禁止在 `className` 中手拼 `btn`/`field`/`tab` 系列基元类名 | 探针正反双向验证:3 类违规写法全部命中,`searchParams.get('tab')` 等非样式字符串与前缀子串不误伤;存量 13 处(Logs/Settings/PredictionHistory/MatchDetailSection/DataCompleteness)全部迁移到基元,lint 0 error |
|
||||
| 第三批-10 数据层搬迁 | `dal.ts`/`api.ts`/`types.ts`(1047 行)`git mv` 至 `src/api/`,与上轮的 `public.ts` 同目录;`admin/` 留 3 个单行 `@deprecated` 兼容壳;全部 21 个引用方改为直指 `src/api/` | `tsc --noEmit` 0 错误一次通过;grep 确认代码中无残留 `admin/dal\|admin/api\|admin/types` import(仅注释中的历史指称) |
|
||||
| 第三批-11/12 | (第二轮已完成)`any` 归零 + `noUnusedLocals` 开启;`PredictProgress` 诚实化 | 见第二轮记录 |
|
||||
|
||||
**第三轮新增的教训**:自定义规则第一版用裸正则扫全量字符串字面量,既误报(`searchParams.get('tab')`)又在收紧后静默失效(探针 0 命中)——**任何「0 违规」都必须用已知违规样本反向验证**。修复后的规则用 `JSXAttribute[name.name="className"]` AST 选择器把匹配范围钉死在 className 属性值内,并用探针文件双向验证后才放行。
|
||||
|
||||
### 质量闸门(三轮累计)
|
||||
|
||||
| 指标 | 审计前 | 现在 |
|
||||
|---|---|---|
|
||||
| `pnpm typecheck` | 脚本不存在 | ✅ 通过(含 `noUnusedLocals`/`noUnusedParameters`) |
|
||||
| `pnpm lint` | 无 ESLint | ✅ **0 error / 19 warning**(剩余均为已注明的 hooks 挂载取数模式) |
|
||||
| `pnpm test` | 有测试但无脚本可跑 | ✅ **7/7 通过** |
|
||||
| CI | 无 | ✅ `.gitlab-ci.yml` 四作业 |
|
||||
| `no-explicit-any` | 23 | **0** |
|
||||
| 手拼基元类名 | 41 | **0**(error 级规则拦截新增) |
|
||||
| `admin/` 下数据层 | 1047 行 | **18 行兼容壳**(实现全在 `src/api/`) |
|
||||
| 首屏 JS | 315.14 kB | **~237.6 kB(−24.6%)** |
|
||||
|
||||
---
|
||||
|
||||
## 修复状态(第二轮更新)
|
||||
|
||||
本报告列出的 **17 项问题已全部处理**。各项的落地方式与验证证据见下表。
|
||||
|
||||
### 质量闸门(修复前 → 修复后)
|
||||
|
||||
| 指标 | 修复前 | 修复后 |
|
||||
|---|---|---|
|
||||
| `pnpm typecheck` | 脚本不存在 | ✅ 通过 |
|
||||
| `pnpm lint` | 无 ESLint | ✅ **0 error / 19 warning** |
|
||||
| `pnpm test` | 有测试但无脚本可跑 | ✅ **7/7 通过** |
|
||||
| CI | 无 | ✅ `.gitlab-ci.yml` 四作业(lint/typecheck/test/build) |
|
||||
| `no-explicit-any` | 23 | **0** |
|
||||
| `noUnusedLocals` / `noUnusedParameters` | `false` | **`true`,0 报错** |
|
||||
| 首屏 JS | 315.14 kB | **~237.6 kB(−24.6%)** |
|
||||
| `index` chunk | 110.20 kB | **31.80 kB** |
|
||||
|
||||
### 逐项落地
|
||||
|
||||
| 编号 | 问题 | 处理 |
|
||||
|---|---|---|
|
||||
| P0-1 | 无 lint / 测试 / CI | 新增 `eslint.config.mjs`、`scripts/verify-tokens.sh`、`.gitlab-ci.yml`;`package.json` 补 `typecheck`/`lint`/`test`/`verify:tokens` |
|
||||
| P0-2 | 公共页反向依赖 admin | 新建 `src/api/public.ts` 承载 `fetchStandings`/`fetchMatchDetail`/`fetchMatchContext`,`dal.ts` 改为再导出;`Matches.tsx`/`Standings.tsx` 改指 `api/public` |
|
||||
| P0-3 | 伪造数据当真指标 | `avg_latency_ms: 2400` → `null`(UI 显示「—」+ 「未接入」);`DashboardStats` 三个无端点字段由空数组改 `null`;`fetchSystemConfig` 等死接口删除 |
|
||||
| P0-4 | 假进度条 | `PredictProgress` 重写为**不确定态**进度条 + 已等待秒数;移除全部伪造的 `pct` 计算 |
|
||||
| P1-1 | 23 处 `any` | 归零。新增 `PredictionMatchRef`、`IngestTriggerResult`、`PredictionJobRef`、`LLMPingResult`、`SettleResult`、`HealthProbe` 等类型;打开 `noUnusedLocals`/`noUnusedParameters` |
|
||||
| P1-2 | 无障碍缺口 | `aria-live` 0→4、`htmlFor` 1→17、`aria-describedby` 0→1、`aria-invalid` 0→1、`role=` 8→18;`Alert` 按语义输出 `role="alert"/"status"` |
|
||||
| P1-3 | `App.tsx` 布局重复 | 抽出 `components/SiteLayout.tsx` |
|
||||
| P1-4 | 零代码分割 | 路由级 `lazy` + `Suspense` + `manualChunks`;admin 页面各自独立 chunk |
|
||||
| P1-5 | SEO 元信息缺失 | `index.html` 补 title/description/theme-color/robots/4 个图标/OG/Twitter Card;新增 `public/` 图标资源 |
|
||||
| P1-6 | 死代码 | 删除 `loadMatchDetailBundle`;抽出 `admin/AdminIcon.tsx` 替代 90 行内联 switch(`nav.ts` 的 `icon` 同步收窄为联合类型) |
|
||||
| P2-1 | `Login.tsx` 裸色值 | 改用 `bg-paper-50 shadow-print` |
|
||||
| P2-2 | `index.css` 硬编码色 | 引入 `:root` RGB 三元组变量;**并加 `verify-tokens.sh` 纳入构建**,防止 `<alpha-value>` 回归 |
|
||||
| P2-3 | 热路径无记忆化 | `Matches.tsx` 的 `visibleMatches`/`dateGroups` 加 `useMemo` |
|
||||
| P2-4 | 滚动监听三处重复 | 抽出 `lib/useScroll.ts`(`useWindowScrollY` / `useCanScrollRight`) |
|
||||
| P2-5 | 内联原生控件 | `Matches.tsx` 两处裸 `<button>` 改用 `Button` 基元 |
|
||||
| P2-6 | `useLeagues` 静默吞失败 | 空结果分支补 `console.warn` |
|
||||
| P2-7 | `ErrorBoundary` 仅最外层 | 新增 `fullScreen` 开关;每个路由外包一层局部边界 |
|
||||
|
||||
### 修复期间新发现并修掉的问题(原报告未列)
|
||||
|
||||
| 文件 | 问题 | 说明 |
|
||||
|---|---|---|
|
||||
| `tailwind.config.js` | **`bg-press-wash/60` 静默失效** | 令牌改为 `var()` 后 Tailwind 无法应用透明度修饰符,构建/类型/lint **全部无报错**,但样式丢失。改用 RGB 三元组 + `<alpha-value>` 修复,并加构建期校验 |
|
||||
| `Monitoring.tsx` | **整页崩溃** | `todos` 的过滤器写成 `t !== false`,而 `upstream && !upstream.ok && {...}` 在 `upstream` 为 `null` 时求值为 `null`,`null !== false` 为真 → `null` 穿过过滤器 → 渲染 `t.to` 抛错。改为 `Boolean(t)` 真值过滤 |
|
||||
| `Monitoring.tsx` | `health` 状态类型造假 | 声明为 `Record<string, unknown>`,掩盖了 `version` / `uptime_seconds` 可为 `null` 的事实。收紧为 `HealthProbe` 后编译器立刻暴露真实不匹配 |
|
||||
| `Collection.tsx` | 渲染期调用 `Date.now()` | 新引入的 `react-hooks/purity` 规则捕获,改为 `elapsedSec` 状态 + 计时器 effect |
|
||||
| `PredictionHistory.tsx` | `(p as any).match` ×3 | `Prediction.match` 字段在类型里根本不存在,消费方只能靠 `any` 绕过。补上 `PredictionMatchRef` 后三处 `any` 自然消失 |
|
||||
|
||||
> 仍保留的 19 条 warning 全部为 `react-hooks/set-state-in-effect`(挂载后取数的官方推荐写法),已在 `eslint.config.mjs` 中降级为 warn 并注明理由,未作为阻断项。
|
||||
|
||||
---
|
||||
|
||||
## 结论摘要
|
||||
|
||||
`npx tsc --noEmit` **通过**,构建产物 323 KB JS / 38.7 KB CSS。也就是说——**这不是一份"跑不起来"的清单**。
|
||||
|
||||
真正的问题在于:**这个前端没有任何自动化的质量防线**。没有 linter、没有测试运行器、没有 CI。所有的设计一致性(方角、纸色、印报红)和类型正确性,100% 依赖开发者手工守纪律。下面的 P0/P1 问题,绝大多数都是"人能看出来、机器看不出来"的类型——而合并两套组件库那一轮已经证明过:**再导出重构的风险不在类型检查,而在运行时绑定**,`tsc` 对此完全沉默。
|
||||
|
||||
一个必须点名的事实:**本仓库唯一的一个测试文件 `lib/http.test.ts`,没有任何脚本能运行它。** 它的存在价值目前约等于注释。
|
||||
|
||||
---
|
||||
|
||||
## 严重度总览
|
||||
|
||||
| 级别 | 数量 | 主题 |
|
||||
|---|---|---|
|
||||
| **P0** | 4 | 零质量基建、跨层耦合、伪造数据、假进度条 |
|
||||
| **P1** | 6 | 类型安全失效、无障碍缺口、布局重复、代码分割缺失、SEO 缺失、死代码 |
|
||||
| **P2** | 7 | 一致性残留、性能细节、a11y 补强、工程细节 |
|
||||
|
||||
---
|
||||
|
||||
## P0 — 必须优先处理
|
||||
|
||||
### P0-1 工程基建完全为空白:无 lint、无测试、无 CI
|
||||
|
||||
**证据**(`frontend/package.json` 第 6–25 行):
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
```
|
||||
|
||||
- `scripts` 里**没有 `test`、没有 `lint`、没有 `typecheck`**。
|
||||
- `devDependencies` 只有 9 项:`@types/react`、`@types/react-dom`、`@vitejs/plugin-react`、`autoprefixer`、`postcss`、`tailwindcss`、`typescript`、`vite`。**没有 ESLint、没有 Prettier、没有 Vitest/Jest。**
|
||||
- 仓库根目录无 `.github/`、无 `.gitlab-ci.yml`、无 husky —— 全仓唯一的 YAML 是 `docker-compose.yml`。
|
||||
|
||||
**这直接导致了什么**:
|
||||
|
||||
1. `src/lib/http.test.ts` 是**全仓唯一测试**,靠 `node --experimental-strip-types` 手工跑,**没有挂进任何脚本**。它测的是整个前端最关键的模块(HTTP 层、401 事件广播、超时),却从未被自动执行过。
|
||||
2. 上一轮发现并修复的 `btn-ghost` "幻影变体"(用了但从未在 CSS 中定义,样式静默失效)——**这类问题如果有一条 ESLint 规则 `no-restricted-syntax` 禁止 `className="btn*"` 硬编码,会在写下的瞬间被拦下**。现在只能靠人肉 review。
|
||||
3. 字符串里的类名、`aria-*` 属性、再导出的符号名,全部无机器校验。
|
||||
|
||||
**建议**(按性价比排序):
|
||||
|
||||
| 动作 | 成本 | 收益 |
|
||||
|---|---|---|
|
||||
| 加 `"typecheck": "tsc --noEmit"` 到 scripts | 1 分钟 | 让类型检查可被 CI 调用 |
|
||||
| 加 `"test": "node --experimental-strip-types --test src/**/*.test.ts"` | 2 分钟 | 让唯一测试真正跑起来 |
|
||||
| 引入 ESLint + `eslint-plugin-react-hooks` | 半天 | 捕获 hooks 依赖缺失、条件调用 |
|
||||
| 加自定义规则禁止 `className` 中出现裸 `btn`/`field`/`tab` | 半天 | 根治"幻影变体"类问题 |
|
||||
| 加 `.gitlab-ci.yml`(本仓托管在 git.bilidili.cn,显然走 GitLab CI) | 1 小时 | 让上面三条自动生效 |
|
||||
|
||||
---
|
||||
|
||||
### P0-2 公共页面反向依赖 admin 层——分层已经倒了
|
||||
|
||||
**证据**:
|
||||
|
||||
```
|
||||
src/pages/Standings.tsx:10 import { fetchStandings } from '../admin/dal'
|
||||
src/pages/Standings.tsx:11 import type { StandingsLeague, StandingRow } from '../admin/dal'
|
||||
src/pages/Standings.tsx:13 import { Spinner } from '../admin/components'
|
||||
src/pages/Matches.tsx:15 import { fetchMatchDetail, fetchMatchContext } from '../admin/dal'
|
||||
src/pages/Matches.tsx:16 import type { MatchDetailOut, MatchContextOut } from '../admin/types'
|
||||
```
|
||||
|
||||
**面向公众的预测页面,数据访问层和类型定义全部寄生在 `admin/` 目录下。**
|
||||
|
||||
`dal.ts` 全称 Data Access Layer,`admin/types.ts` 是后台的数据契约——但它们现在是"公共页面 + 后台"共用的底层。后果很具体:
|
||||
|
||||
- 想重构后台?`Matches.tsx` 和 `Standings.tsx` 会一起报错。想删一个后台不用的类型?可能有前台页面在 import 它。
|
||||
- 这个目录名会持续误导新加入的人,以为 `admin/` 是可独立删除的后台包。
|
||||
- `AdminApp.tsx` 已经提供了独立的 `createBrowserRouter` 入口,说明后台**本来是按可独立拆分的意图设计的**,但 DAL 层的反向依赖把这个意图破坏了。
|
||||
|
||||
**建议**:把 `dal.ts` / `api.ts` / `types.ts` 提升为 `src/api/`(或 `src/lib/api/`),`admin/` 只保留 UI 层。这是一次纯路径变更,无逻辑风险,但能让目录结构与真实依赖方向一致。
|
||||
|
||||
---
|
||||
|
||||
### P0-3 伪造数据被当作真实指标展示
|
||||
|
||||
**证据**(`src/admin/dal.ts`):
|
||||
|
||||
```ts
|
||||
54: db_tables: [], // 后端暂无表统计端点
|
||||
55: last_collection: [], // 后端暂无采集历史端点
|
||||
56: recent_errors: [], // 后端暂无错误日志端点
|
||||
...
|
||||
318: avg_latency_ms: 2400, // 后端暂无延迟统计
|
||||
331: avg_latency_ms: 0,
|
||||
```
|
||||
|
||||
- `fetchDashboard` 在"后端暂无端点"时**返回空数组冒充真实结果**,调用方无法区分"确实没有错误"和"这个功能还没接"。
|
||||
- `fetchLLMUsageStats` 更严重:**硬编码 `avg_latency_ms: 2400`**。这不是占位符——它会被渲染成一个看起来非常可信的"平均延迟 2.4 秒"数字。运营人员看到它,会据此判断系统性能。
|
||||
|
||||
**这是本报告中最危险的一条。** 其他问题影响体验,这一条**影响决策**。
|
||||
|
||||
**建议**:
|
||||
1. 把"暂无端点"的字段类型改为 `null`(而不是 `[]` / 假数字),强制调用方处理这个状态;
|
||||
2. 后端未接的指标,UI 上**明确显示"未接入"**,而不是显示一个数字;
|
||||
3. `avg_latency_ms: 2400` 这一行**立即删除或改为 `null`** —— 即使 UI 暂时留白,也比给一个假数字好。
|
||||
|
||||
---
|
||||
|
||||
### P0-4 `PredictProgress` 是一个与后端状态无关的假进度条
|
||||
|
||||
**证据**(`src/pages/matches/components/MatchPredictPanel.tsx` 第 92–128 行):
|
||||
|
||||
```ts
|
||||
const SLICE_END = 3
|
||||
const AGENT_START = 4
|
||||
const AGENT_STEP = 8 // 每路专家约 8s 点亮一路
|
||||
const AGG_START = AGENT_START + AGENT_STEP * 5
|
||||
|
||||
const phase = elapsed < SLICE_END ? 'slice'
|
||||
: elapsed < AGG_START ? 'agents' : 'agg'
|
||||
|
||||
const pct = Math.min(95, Math.round((elapsed / 70) * 100))
|
||||
```
|
||||
|
||||
整个进度是**按经过的秒数推算出来的**,与后端真实 job 状态**零关联**:
|
||||
|
||||
- 进度条在 70 秒时到 95% 就**永远卡住**(`Math.min(95, ...)`)——因为真实完成时间不可知。
|
||||
- 五路专家的"点亮"完全按 `AGENT_STEP = 8s` 的**硬编码节拍**依次点亮,不管后端实际跑到哪一步。如果某路专家 3 秒就返回了,UI 依然要等到第 8 秒才点亮它;如果某路跑了 40 秒,UI 早就把它点亮成"完成"了。
|
||||
- 注释里的"约 8s"/"70s"是**观测到的经验值**,一旦模型或硬件变化,这个进度条就在撒谎。
|
||||
|
||||
**同时**:`useMatchPredict` 里已有真实的 3 秒轮询和 300 秒截止时间。**进度信息的真实来源是存在的,只是没被用上。**
|
||||
|
||||
**建议**(按代价从低到高):
|
||||
1. **最低成本**:承认它不是进度条。改成不确定态(indeterminate)动画 + 已耗时计数器。诚实的"已等待 42 秒"远胜虚假的"95%"。
|
||||
2. **中成本**:后端 `job_id` 轮询若返回已完成/进行中的阶段数,就用真实阶段数驱动点亮。
|
||||
3. **高成本**:后端在 job 状态里暴露 `completed_agents` / `total_agents`,前端完全数据驱动。
|
||||
|
||||
---
|
||||
|
||||
## P1 — 高优先级
|
||||
|
||||
### P1-1 类型安全在最关键的边界上失效:23 处 `any`
|
||||
|
||||
| 文件 | `any` 数量 |
|
||||
|---|---|
|
||||
| `src/admin/dal.ts` | **14** |
|
||||
| `src/admin/pages/EvalPage.tsx` | 6 |
|
||||
| `src/admin/pages/PredictionHistory.tsx` | 3 |
|
||||
|
||||
`dal.ts` 是**所有后端数据的入口**。在这里用 `any`,等于把类型检查从最需要它的地方撤掉了——后端返回的字段名拼错、结构变了、nullable 变了,`tsc` 都不会说话。
|
||||
|
||||
而 `tsconfig.json` 第 14–16 行还在配合放大这个问题:
|
||||
|
||||
```json
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
```
|
||||
|
||||
`strict: true` 是好的,但**两个 `noUnused*` 被显式关掉了**。这意味着死代码(未使用的变量、未使用的 import、未使用的参数)会**静默累积**,永远不会有编译错误提醒。结合 P1-6(已发现一处未使用的导出),这个开关的实际代价已经显现。
|
||||
|
||||
**建议**:`dal.ts` 逐个端点补上返回类型(后端已有 FastAPI + Pydantic,OpenAPI schema 可直接生成 TS 类型);把 `noUnusedLocals` 打开,一次性清理存量。
|
||||
|
||||
---
|
||||
|
||||
### P1-2 无障碍:异步内容对屏幕阅读器完全不可见
|
||||
|
||||
**实测覆盖度**(全仓 grep 计数):
|
||||
|
||||
| 属性 | 出现次数 | 评价 |
|
||||
|---|---|---|
|
||||
| `aria-label` | 18 | ✅ 尚可 |
|
||||
| `aria-current` | 4 | ✅ 导航用了 |
|
||||
| `aria-busy` | 1 | ⚠️ 只有 `Button` 有 |
|
||||
| `role=` | 8 | ⚠️ 偏少 |
|
||||
| **`aria-live`** | **0** | ❌ **严重** |
|
||||
| **`aria-labelledby`** | **0** | ❌ 严重 |
|
||||
| **`aria-describedby`** | **0** | ❌ 严重 |
|
||||
| **`aria-errormessage`** | **0** | ❌ 严重 |
|
||||
| `htmlFor` | **1** | ❌ 20 个 input 只有 1 个关联 label |
|
||||
| `alt=` | 0 | ➖ 无 `<img>`,暂不适用 |
|
||||
|
||||
**具体后果**:
|
||||
|
||||
1. **`aria-live` 为 0 ⇒ 所有异步状态变化屏幕阅读器都不知道。** 预测任务在后台跑完、加载失败弹出错误横幅、列表加载出更多比赛——视觉用户看到了,屏幕阅读器用户**什么都收不到**。这是表单类应用最典型也最容易修复的 a11y 缺口。
|
||||
2. **`htmlFor` 只有 1 个,而 `<input>` 有 20 个。** 输入框的可见文字提示和输入控件之间没有程序化关联,屏幕阅读器读出的是"编辑框"而不是"邮箱地址"。
|
||||
3. **`Login.tsx` 的错误提示(第 70 行 `{error && (...)}`)没有 `aria-describedby` / `aria-errormessage` 关联到输入框。** 密码输错时,视觉用户看到红字,屏幕阅读器用户**不知道发生了错误**,更不知道错在哪。
|
||||
|
||||
**另外**:`autoFocus` 出现了 3 次(`Login.tsx:65`、`useCommandPalette.tsx:115`、`SettingRow.tsx:83`)。
|
||||
|
||||
- `Login.tsx` 和 `SettingRow.tsx` 场景合理(表单首个字段、内联编辑)。
|
||||
- **`useCommandPalette.tsx:115` 需要复核** —— Cmd+K 面板里 `<Modal>` 本身已经做了"初始聚焦面板"的焦点管理,再叠一个 `autoFocus` 的 input,两者会**争抢焦点**,且后者的行为依赖 React 挂载时序。
|
||||
|
||||
**建议**(按收益排序):
|
||||
1. 错误横幅 / 加载完成提示加 `role="status"` + `aria-live="polite"`;错误用 `role="alert"`。
|
||||
2. 给 20 个 input 补 `htmlFor` + `id` 配对(可先在 `Input` 基元里强制要求 `id`)。
|
||||
3. 表单错误用 `aria-describedby` 指向错误文本节点。
|
||||
4. 复核 `useCommandPalette` 的 `autoFocus` 是否与 `Modal` 焦点管理冲突。
|
||||
|
||||
---
|
||||
|
||||
### P1-3 `App.tsx` 把整套布局复制了两遍
|
||||
|
||||
**证据**(`src/App.tsx`,全文仅 82 行,其中两段布局高度重合):
|
||||
|
||||
```
|
||||
21:function StandingsLayout({ children }) {
|
||||
24: <Masthead active="standings" />
|
||||
26: <main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
30: <footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
34: </footer>
|
||||
|
||||
39:function HomePage() {
|
||||
43: <Masthead active="home" />
|
||||
45: <main className="mx-auto max-w-5xl px-5 py-6 sm:px-8 sm:py-8">
|
||||
49: <footer className="mx-auto max-w-5xl px-5 pb-10 sm:px-8">
|
||||
53: </footer>
|
||||
```
|
||||
|
||||
`<main>` 和 `<footer>` 的 className **逐字相同**,唯一区别是 `Masthead` 的 `active` prop 和 footer 里的文案。
|
||||
|
||||
**后果**:改一次容器宽度或内边距,要改两处;将来加第三个页面就是第三处。而 `AdminLayout.tsx` 里的 `NavLink` 也已经出现了同样的 className 复制(见 P1-6)。**这已经不是偶发,是模式。**
|
||||
|
||||
**建议**:抽一个 `<SiteLayout active="home" | "standings">{children}</SiteLayout>`,把 `main` + `footer` 收进去。这是 20 行的改动,消除的是结构性重复。
|
||||
|
||||
---
|
||||
|
||||
### P1-4 零代码分割:前后台打进同一个 chunk
|
||||
|
||||
**证据**:
|
||||
|
||||
- `src/routes.tsx`、`src/App.tsx` 中 **`lazy(` 和 `Suspense` 出现次数为 0**。
|
||||
- 构建产物:`index-BuU04RT0.js` **323 KB**(单个文件),`index-BP3DscKo.css` 38.7 KB。
|
||||
- `vite.config.ts` 全文无任何 `build.rollupOptions` / `manualChunks` 配置。
|
||||
|
||||
**后果**:一个只想看预测首页的匿名用户,**必须下载整个后台的全部代码**——Dashboard、Collection、Logs、EvalPage、Backtest、Settings、Monitoring,10 个后台页面一个都不少。而这 10 个页面里绝大概率存在只有登录用户才会触发的逻辑。
|
||||
|
||||
**建议**:
|
||||
1. 路由级 `React.lazy` + `<Suspense>`:`/admin/*` 整体 lazy,`Standings` 单独 lazy。仅此一项通常能砍掉 40%+ 的首屏 JS。
|
||||
2. `vite.config.ts` 加 `manualChunks` 把 `react` / `react-dom` / `react-router-dom` 拆成 `vendor` chunk,利用长期缓存。
|
||||
3. 顺带把 `ErrorBoundary` 移到路由级(现在只在 `App.tsx:60` 最外层一个),这样单个页面崩溃不会白屏整个应用。
|
||||
|
||||
---
|
||||
|
||||
### P1-5 `index.html` 缺少全部 SEO 与元信息
|
||||
|
||||
**证据**(`frontend/index.html` 全文):
|
||||
|
||||
```html
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Profeto - 足球 LLM 预测</title>
|
||||
</head>
|
||||
```
|
||||
|
||||
**缺失清单**:
|
||||
|
||||
| 缺失项 | 影响 |
|
||||
|---|---|
|
||||
| `meta[name=description]` | 搜索引擎结果页无摘要 |
|
||||
| `link[rel=icon]` / favicon | 浏览器标签页显示空白文档图标 |
|
||||
| `meta[property=og:*]` | 分享到微信/Twitter 无预览卡片 |
|
||||
| `meta[name=theme-color]` | 移动端浏览器地址栏不染色 |
|
||||
| `link[rel=apple-touch-icon]` | iOS 添加到主屏是截图占位 |
|
||||
| `meta[name=robots]` / canonical | 无索引控制 |
|
||||
|
||||
**另外**:项目有 `src/assets/fonts/LiuJianMaoCao-subset.woff2`(手工子集化的毛笔字体,说明"报刊风"的品牌感是被认真对待的),**但页面没有一个 favicon**。视觉上最讲究的地方,在浏览器标签页上裸奔。
|
||||
|
||||
---
|
||||
|
||||
### P1-6 死代码:未使用的导出 + 内联 90 行的图标 switch
|
||||
|
||||
**未使用的导出**(`src/pages/matches/components/MatchDetailSection.tsx:362`):
|
||||
|
||||
```ts
|
||||
export async function loadMatchDetailBundle(
|
||||
```
|
||||
|
||||
全仓 grep `loadMatchDetailBundle` **只有这一处定义,零处引用**。`Matches.tsx` 自己做 `Promise.all`。这个函数是重构遗留物,但因为 `noUnusedLocals: false`(P1-1),导出符号更是从来不会被报未使用——**它会永远留在那里**。
|
||||
|
||||
**`AdminLayout.tsx` 内联图标 switch**:第 20–78 行,一个约 90 行的 `Icon` 函数,`switch` 8 个图标名(collection/chart/target/repeat/eval/monitor/settings/logs),全部是内联 `<svg>` 路径。所有其他图标都应该像 `Masthead.tsx` 里的 `GearIcon` 那样独立成组件。**90 行 SVG 路径塞在布局文件里,让 `AdminLayout.tsx` 的职责严重发散。**
|
||||
|
||||
**同时**(`AdminLayout.tsx:255` 与 `:278`):两处 `NavLink` 的 className 逻辑**重复书写**——与 P1-3 同型。
|
||||
|
||||
---
|
||||
|
||||
## P2 — 一致性、性能与工程细节
|
||||
|
||||
### P2-1 `Login.tsx` 仍残留裸色值,未对齐设计令牌
|
||||
|
||||
**证据**(`src/admin/Login.tsx:54`):
|
||||
|
||||
```tsx
|
||||
className="w-full max-w-sm border border-ink-300 bg-white p-6 shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]"
|
||||
```
|
||||
|
||||
- `bg-white` —— 设计系统里纸质背景是 `paper-*` 令牌,`bg-white` 是一个不在体系内的纯白。
|
||||
- `shadow-[4px_4px_0_0_rgba(0,0,0,0.06)]` —— **兜底在 className 里的任意值阴影**。设计语言里阴影应该来自 `shadow-print` 这类令牌,而不是每个组件自己拼一个 `rgba()`。
|
||||
|
||||
**这是最讽刺的一处**:登录页是后台的**第一印象页面**。视觉体系的所有其他地方都在用令牌,唯独进门第一屏用了裸值。
|
||||
|
||||
### P2-2 `index.css` 里硬编码了色值,而不是引用令牌
|
||||
|
||||
```
|
||||
55: outline: 2px solid #9e1b1b;
|
||||
89: border-top: 3px solid #17140f;
|
||||
208: color: #9E1B1B;
|
||||
```
|
||||
|
||||
`#9e1b1b` 就是 `press` 红,`#17140f` 就是 `ink` 墨色——**但它们以十六进制字面量写死在 CSS 里**。
|
||||
|
||||
**后果很具体**:将来要调品牌红(比如为了打印对比度微调),改 `tailwind.config.js` 里的 `press` 令牌**不会影响这三处**。焦点轮廓、报头粗线、还有第 208 行那个用 `font-brush` 的毛笔字颜色,会保持旧红——**品牌色出现两个来源,且其中一个没有名字**。
|
||||
|
||||
第 208 行的 `#9E1B1B` 还是**大写**写法,与另两处小写不一致,说明这三处是不同时间、不同人手工敲进去的。
|
||||
|
||||
**建议**:CSS 自定义属性化。在 `:root` 定义 `--press` / `--ink`,由 `tailwind.config.js` 一并消费,让 CSS 与 Tailwind 共享同一份令牌来源。
|
||||
|
||||
### P2-3 热渲染路径上没有记忆化
|
||||
|
||||
**证据**(`src/pages/Matches.tsx:230`):
|
||||
|
||||
```tsx
|
||||
{!loading && groupByDate(visibleMatches).map(([dateKey, group]) => (
|
||||
```
|
||||
|
||||
`groupByDate(visibleMatches)` **在每次 render 时重新计算整个列表的分组**。`visibleMatches` 可能包含上百场比赛,任何无关的 state 变化(比如 `error` 从 `null` 变成字符串、`leagueNavRef` 触发的 `canScrollRight` 变化)都会触发整表重算 + 全部 DOM 重建。
|
||||
|
||||
全仓 grep:**`Matches.tsx` 里 `useMemo` / `useCallback` 出现次数为 0**。
|
||||
|
||||
**建议**:`const groups = useMemo(() => groupByDate(visibleMatches), [visibleMatches])`。同时检查列表行组件(`MatchRow` 等)是否可以用 `React.memo` 包住。
|
||||
|
||||
### P2-4 滚动监听分散在三处,各写一遍
|
||||
|
||||
```
|
||||
src/components/BackTop.tsx:13 window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
src/pages/Matches.tsx:91 el.addEventListener('scroll', update, { passive: true })
|
||||
```
|
||||
|
||||
`AdminLayout.tsx` 里还有第三套。三处各自管理 `addEventListener` / `removeEventListener` / throttle / `passive` —— **三份重复的生命周期管理**,任何一处漏了 cleanup 就是内存泄漏。
|
||||
|
||||
**建议**:抽 `useScrollListener(el, handler)` 或 `useScrollPosition()` 到 `lib/hooks/`。至少 `passive: true` 这个优化现在只有两处记得加。
|
||||
|
||||
### P2-5 `Matches.tsx` 仍有内联原生控件,绕过已建成的组件层
|
||||
|
||||
**证据**(`src/pages/Matches.tsx`):
|
||||
|
||||
- 第 109 行 `<button>` —— 联赛 tab,手写 `className="tab"`
|
||||
- 第 158 行 `<button onClick={() => setError(null)} ... aria-label="关闭">×</button>` —— 错误横幅关闭按钮(`Feedback.tsx` 里已有 `ErrorBanner` 组件)
|
||||
- 第 271 行 `<button>` —— "收起"折叠按钮
|
||||
|
||||
`components/ui/` 已经提供了 `Button`、`Switch`、`ErrorBanner`。但 `Matches.tsx` 作为最复杂的前台页面,仍有 3 处裸 `<button>`。
|
||||
|
||||
**全仓原生元素统计**:`<button>` 18 个、`<input>` 20 个、`<select>` 4 个。
|
||||
|
||||
**建议**:这不宜靠人肉找。加一条 ESLint 规则,禁止在 `pages/` 下直接写 `<button className="btn...">`,强制走 `Button` 基元——**这正是 P0-1 里说的"让错误在编译期暴露"**。
|
||||
|
||||
### P2-6 `useLeagues` 静默吞掉 API 失败
|
||||
|
||||
**证据**(`src/pages/matches/hooks/useLeagues.ts` 第 3 行注释):
|
||||
|
||||
```
|
||||
失败或返回空数组则回退本地五大联赛常量(LEAGUES)。
|
||||
```
|
||||
|
||||
从**韧性的角度这是好设计**——接口挂了,用户依然能看到五大联赛而不是空白。
|
||||
|
||||
但从**可观测性角度这是个隐患**:接口持续返回 500,前端**永远静默回退**,没有任何日志、没有任何用户提示。运维那边看到"接口正常"(因为前端不报错),用户那边看到的是"只有五个联赛、没有新增联赛"——**双方都以为一切正常**。
|
||||
|
||||
**建议**:回退保留,但加一次 `console.warn` 或上报埋点。让它"优雅降级但留下痕迹"。
|
||||
|
||||
### P2-7 `ErrorBoundary` 只在应用最外层
|
||||
|
||||
`App.tsx:60` 有一个 `<ErrorBoundary>` 包住整个 `<BrowserRouter>`。这意味着**任意一个页面的渲染崩溃,整个应用白屏**。
|
||||
|
||||
结合 P1-4(无路由级 lazy),建议一并处理:`lazy` 的每个路由包一层 `ErrorBoundary`,让"某个页面挂了"降级为"这个页面显示错误,其他导航照常可用"。
|
||||
|
||||
---
|
||||
|
||||
## 附:已确认的良好实践(避免"只挑毛病"的偏差)
|
||||
|
||||
这份报告如果只列问题,会给人"一团糟"的错误印象。以下是我逐项验证过、**确实做得好**的地方:
|
||||
|
||||
| 项 | 证据 |
|
||||
|---|---|
|
||||
| HTTP 层设计 | `lib/http.ts` 有统一 `API_BASE`、30s 超时、`AbortController`、`ApiError` 类;401 通过 `UNAUTHORIZED_EVENT` 自定义事件广播解耦 |
|
||||
| 竞态防护 | `useMatchesList` / `useMatchPredict` 用单调递增 `useRef` 序号守卫,这是**很多人会漏掉**的正确做法 |
|
||||
| Modal 无障碍 | `components/ui/index.tsx` 里的 `Modal` 内置焦点陷阱、ESC 关闭、背景滚动锁定、焦点归还——四项齐全 |
|
||||
| 术语化变体 | `ButtonVariant` 用联合类型约束,写错变体名编译期报错——正是要根治"幻影变体"的机制 |
|
||||
| 字体优化 | `LiuJianMaoCao-subset.woff2` 手工子集化 + woff2,说明品牌字体经过真实施工 |
|
||||
| 组件合并 | 上一轮把两套平行组件库归一到 `components/ui/`,Spinner 从 3 份副本收敛到 1 份,净减 541 行 |
|
||||
| 类型检查 | `npx tsc --noEmit` 当前**通过** |
|
||||
| 构建 | `pnpm build` **通过**,产物可正常生成 |
|
||||
|
||||
---
|
||||
|
||||
## 建议的处理顺序
|
||||
|
||||
**第一批(半天,收益最高)**
|
||||
1. `package.json` 加 `typecheck` / `test` 脚本,让已有测试跑起来(P0-1)
|
||||
2. **删除 `dal.ts:318` 的 `avg_latency_ms: 2400`**(P0-3)—— 一行改动,消除一个会误导决策的假数据
|
||||
3. `App.tsx` 抽 `SiteLayout`(P1-3)
|
||||
4. `Matches.tsx` 的 `groupByDate` 加 `useMemo`(P2-3)
|
||||
|
||||
**第二批(1–2 天)**
|
||||
5. 路由级 `React.lazy` + 每路由 `ErrorBoundary`(P1-4 + P2-7)
|
||||
6. `index.html` 补 description / favicon / OG(P1-5)
|
||||
7. `Login.tsx` 裸色值对齐令牌(P2-1)+ `index.css` 三处硬编码色令牌化(P2-2)
|
||||
8. 错误提示加 `aria-live` / `role="alert"`,input 补 `htmlFor`(P1-2)
|
||||
|
||||
**第三批(工程化,需排期)**
|
||||
9. 引入 ESLint + 自定义规则禁止裸 `btn` 类名(P0-1)
|
||||
10. `dal.ts` / `api.ts` / `types.ts` 提升到 `src/api/`(P0-2)
|
||||
11. `dal.ts` 的 14 处 `any` 逐个补类型,并打开 `noUnusedLocals`(P1-1)
|
||||
12. `PredictProgress` 改为诚实的 indeterminate 态,或改由后端真实阶段驱动(P0-4)
|
||||
|
||||
---
|
||||
|
||||
*审计人:前端架构审计*
|
||||
*审计日期:2025*
|
||||
*审计基线:`a7e8b75`*
|
||||
*方法:全量文件只读遍历 + 磁盘实证复核,所有 file:line 引用均已在当前工作区验证*
|
||||
+21
-1
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -13,6 +14,9 @@ from src.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 进程启动时间(monotonic,不受系统时钟跳变影响):/health 的 uptime 来源
|
||||
_PROCESS_STARTED_MONOTONIC = time.monotonic()
|
||||
|
||||
|
||||
async def _fail_stale_ingest_jobs() -> None:
|
||||
"""P1-E: 启动时将上次遗留的 pending/running ingest_jobs 标 failed。
|
||||
@@ -156,6 +160,7 @@ def create_app() -> FastAPI:
|
||||
from src.api.routes.auth import router as auth_router
|
||||
from src.api.routes.admin_settings import router as admin_settings_router
|
||||
from src.api.routes.schedules import router as schedules_router
|
||||
from src.api.routes.admin_monitoring import router as admin_monitoring_router
|
||||
|
||||
app.include_router(matches_router)
|
||||
app.include_router(predict_router)
|
||||
@@ -165,10 +170,25 @@ def create_app() -> FastAPI:
|
||||
app.include_router(auth_router)
|
||||
app.include_router(admin_settings_router)
|
||||
app.include_router(schedules_router)
|
||||
app.include_router(admin_monitoring_router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "healthy", "service": "profeto"}
|
||||
"""存活检查。version/uptime_seconds 供管理端监控页展示。"""
|
||||
version = None
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
|
||||
version = _pkg_version("profeto")
|
||||
except Exception:
|
||||
# 包元数据缺失时返回 None,前端降级隐藏版本卡片
|
||||
version = None
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "profeto",
|
||||
"version": version,
|
||||
"uptime_seconds": round(time.monotonic() - _PROCESS_STARTED_MONOTONIC),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health/ready")
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""后台管理:监控增强探针(上游可达性)。
|
||||
|
||||
所有接口需管理员鉴权(require_admin)。路由前缀 /api/v1/admin。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from src.api.deps import require_admin
|
||||
from src.core.config import settings
|
||||
from src.core.runtime_config import get_runtime_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.get("/monitoring/upstream")
|
||||
async def upstream_probe():
|
||||
"""上游数据源(bzzoiro)可达性探针。
|
||||
|
||||
轻量 GET 根端点:不携带 API Key、不触发采集,不消耗配额。
|
||||
HTTP <500 视为可达 —— 404/401 也说明 DNS/网络/TLS 正常,业务语义层的
|
||||
失败(签名、限流)由采集管线自身的死信与日志上报,不在探针职责内。
|
||||
"""
|
||||
base = ((await get_runtime_value("BZZOIRO_BASE")) or settings.BZZOIRO_BASE).rstrip("/")
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5.0, read=5.0, write=5.0, pool=5.0),
|
||||
) as client:
|
||||
resp = await client.get(base)
|
||||
latency_ms = round((time.perf_counter() - started) * 1000)
|
||||
ok = resp.status_code < 500
|
||||
logger.info("上游探针 %s → %s (%sms)", base, resp.status_code, latency_ms)
|
||||
return {"ok": ok, "status_code": resp.status_code, "latency_ms": latency_ms, "endpoint": base}
|
||||
except Exception as e:
|
||||
latency_ms = round((time.perf_counter() - started) * 1000)
|
||||
logger.warning("上游探针失败 %s: %s", base, e)
|
||||
return {"ok": False, "error": str(e)[:200], "latency_ms": latency_ms, "endpoint": base}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""监控增强端点测试:/health 扩展字段 + 上游探针。
|
||||
|
||||
沿用 test_api_critical.py 的模式:直接调用 handler,不启动完整 app
|
||||
lifespan(异步 DB 引擎与同步 TestClient 不兼容);探针的外呼用 mock。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
|
||||
|
||||
class TestHealthExtended:
|
||||
async def _get_health(self):
|
||||
"""ASGITransport 走真实路由:/health 无鉴权、不依赖 DB,不触发 lifespan。"""
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from src.api.app import app
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
return await client.get("/health")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reports_version_and_uptime(self):
|
||||
resp = await self._get_health()
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "healthy"
|
||||
assert isinstance(body["uptime_seconds"], int)
|
||||
assert body["uptime_seconds"] >= 0
|
||||
# 包已随 pip install . 安装,元数据可读;异常时为 None(前端降级隐藏)
|
||||
assert body["version"] is None or isinstance(body["version"], str)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_failure_degrades_to_none(self):
|
||||
"""包元数据不可读时 version=None,不影响存活判定。"""
|
||||
from importlib.metadata import PackageNotFoundError
|
||||
|
||||
with patch("importlib.metadata.version", side_effect=PackageNotFoundError("profeto")):
|
||||
resp = await self._get_health()
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["status"] == "healthy"
|
||||
assert body["version"] is None
|
||||
assert isinstance(body["uptime_seconds"], int)
|
||||
|
||||
|
||||
class TestUpstreamProbe:
|
||||
async def _probe_with(self, mock_client_factory):
|
||||
from src.api.routes import admin_monitoring
|
||||
|
||||
with patch.object(admin_monitoring.httpx, "AsyncClient", mock_client_factory), \
|
||||
patch.object(
|
||||
admin_monitoring, "get_runtime_value",
|
||||
AsyncMock(return_value="https://sports.bzzoiro.com/api/v2"),
|
||||
):
|
||||
return await admin_monitoring.upstream_probe()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reachable_upstream(self):
|
||||
"""HTTP 200 → ok=True 且带延迟与状态码。"""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
factory = MagicMock(return_value=client)
|
||||
|
||||
out = await self._probe_with(factory)
|
||||
|
||||
assert out["ok"] is True
|
||||
assert out["status_code"] == 200
|
||||
assert out["latency_ms"] >= 0
|
||||
assert out["endpoint"].endswith("/api/v2")
|
||||
client.get.assert_awaited_once_with("https://sports.bzzoiro.com/api/v2")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_5xx_counts_as_unreachable(self):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 502
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(return_value=resp)
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
factory = MagicMock(return_value=client)
|
||||
|
||||
out = await self._probe_with(factory)
|
||||
|
||||
assert out["ok"] is False
|
||||
assert out["status_code"] == 502
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_error_degrades_gracefully(self):
|
||||
"""连接失败不抛 500:返回 ok=False + error 摘要(探针失败不是故障)。"""
|
||||
|
||||
def factory(*_a, **_kw):
|
||||
client = MagicMock()
|
||||
client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
|
||||
client.__aenter__ = AsyncMock(return_value=client)
|
||||
client.__aexit__ = AsyncMock(return_value=False)
|
||||
return client
|
||||
|
||||
out = await self._probe_with(factory)
|
||||
|
||||
assert out["ok"] is False
|
||||
assert "connection refused" in out["error"]
|
||||
assert out["latency_ms"] >= 0
|
||||
Reference in New Issue
Block a user