From 8bfefd199cc34a1e3c644308a8c8cdcdea3110fe Mon Sep 17 00:00:00 2001 From: enzo Date: Wed, 22 Jul 2026 17:14:30 +0800 Subject: [PATCH] feat: Profile health score system (scoring engine, API, frontend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health scoring engine with 4 dimensions: Gateway stability (40%), config completeness (25%), skill consistency (20%), and Cron success rate (15%). Grades A(80-100) B(60-79) C(40-59) D(0-39) with detailed deduction items and fix suggestions per dimension. API endpoints: - GET /health/scores — list all profiles with grade filter + sort - GET /health/scores/{profile} — detail with deductions + suggestions - POST /health/compare — multi-profile config/skill comparison - POST /health/calculate — manual recalculation (owner only) Frontend HealthPage: grade distribution cards, score table with expandable detail rows, batch compare view with diff highlighting, grade/select/sort filters, and owner-only recalculation. Data sources use placeholders (TODO: hermes CLI / file reads) but the full scoring logic is implemented end-to-end. Co-Authored-By: Claude --- backend/app/api/health.py | 187 ++++++++++++++ backend/app/main.py | 3 +- backend/app/services/health_scorer.py | 341 ++++++++++++++++++++++++++ frontend/src/api/health.ts | 69 ++++++ frontend/src/pages/HealthPage.tsx | 296 +++++++++++++++++++++- 5 files changed, 893 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/health.py create mode 100644 backend/app/services/health_scorer.py create mode 100644 frontend/src/api/health.ts diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..c61f71a --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,187 @@ +"""Health API — 健康分查询、对比、手动刷新。""" +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.middleware.auth import require_auth, require_role +from app.models.user import User +from app.services.health_scorer import ( + PLACEHOLDER_PROFILES, + calculate_all_scores, + calculate_health_score, + invalidate_cache, +) + +router = APIRouter(prefix="/health", tags=["health"]) + + +def _score_to_row(hs, rank: int) -> dict: + return { + "rank": rank, + "name": hs.profile_name, + "total_score": hs.total_score, + "grade": hs.grade, + "gateway_score": hs.gateway.score, + "config_score": hs.config.score, + "skill_score": hs.skill.score, + "cron_score": hs.cron.score, + "deduct_summary": hs.deduct_summary, + } + + +@router.get("/scores") +async def list_scores( + grade: str | None = Query(None, pattern="^(A|B|C|D)$"), + sort: str = Query("asc", pattern="^(asc|desc)$"), + _user: User = Depends(require_auth), +): + """返回全部 Profile 健康分排行列表。 + + - ?grade=A|B|C|D 按等级筛选 + - ?sort=asc|desc 排序,默认从低到高(问题优先) + """ + scores = calculate_all_scores() + + # 筛选 + if grade: + scores = [s for s in scores if s.grade == grade] + + # 排序 + scores.sort(key=lambda s: s.total_score, reverse=(sort == "desc")) + + rows = [_score_to_row(hs, i + 1) for i, hs in enumerate(scores)] + return { + "total": len(rows), + "distribution": { + "A": sum(1 for s in scores if s.grade == "A"), + "B": sum(1 for s in scores if s.grade == "B"), + "C": sum(1 for s in scores if s.grade == "C"), + "D": sum(1 for s in scores if s.grade == "D"), + }, + "scores": rows, + } + + +@router.get("/scores/{profile}") +async def get_profile_score( + profile: str, + _user: User = Depends(require_auth), +): + """返回单个 Profile 的健康分详情,含四维度扣分项和修复建议。""" + if profile not in PLACEHOLDER_PROFILES: + raise HTTPException(status_code=404, detail=f"Profile '{profile}' 不存在") + + hs = calculate_health_score(profile) + return { + "profile_name": hs.profile_name, + "total_score": hs.total_score, + "grade": hs.grade, + "calculated_at": hs.calculated_at, + "dimensions": [ + { + "name": "Gateway 稳定性", + "weight": "40%", + "score": hs.gateway.score, + "max_score": hs.gateway.max_score, + "deductions": hs.gateway.deductions, + "suggestions": hs.gateway.suggestions, + }, + { + "name": "配置完整性", + "weight": "25%", + "score": hs.config.score, + "max_score": hs.config.max_score, + "deductions": hs.config.deductions, + "suggestions": hs.config.suggestions, + }, + { + "name": "Skill 一致性", + "weight": "20%", + "score": hs.skill.score, + "max_score": hs.skill.max_score, + "deductions": hs.skill.deductions, + "suggestions": hs.skill.suggestions, + }, + { + "name": "Cron 成功率", + "weight": "15%", + "score": hs.cron.score, + "max_score": hs.cron.max_score, + "deductions": hs.cron.deductions, + "suggestions": hs.cron.suggestions, + }, + ], + } + + +@router.post("/compare") +async def compare_profiles( + body: dict, + _user: User = Depends(require_auth), +): + """返回多个 Profile 的配置/Skill 横向对比。 + + body: {"profile_names": ["hebo", "ops-engineer"]} + """ + names = body.get("profile_names", []) + if len(names) < 2: + raise HTTPException(status_code=400, detail="至少选择 2 个 Profile") + if len(names) > 20: + raise HTTPException(status_code=400, detail="最多对比 20 个 Profile") + + invalid = [n for n in names if n not in PLACEHOLDER_PROFILES] + if invalid: + raise HTTPException(status_code=404, detail=f"Profile 不存在: {', '.join(invalid)}") + + scores = {hs.profile_name: hs for hs in calculate_all_scores(names)} + + # 收集所有配置字段和 Skill + from app.services.health_scorer import _REQUIRED_CONFIG_FIELDS, _BASELINE_SKILLS + from app.services.health_scorer import _placeholder_config_data, _placeholder_skill_data + + config_fields = list(_REQUIRED_CONFIG_FIELDS) + skill_fields = sorted(_BASELINE_SKILLS) + + rows: list[dict] = [] + for field in config_fields: + row: dict = {"field": field, "category": "config"} + for name in names: + s = scores.get(name) + if s is None: + row[name] = "⚠" + else: + data = _placeholder_config_data(name) + row[name] = "✅" if field in data["present_fields"] else "❌" + rows.append(row) + + for field in skill_fields: + row = {"field": field, "category": "skill"} + for name in names: + s = scores.get(name) + if s is None: + row[name] = "⚠" + else: + data = _placeholder_skill_data(name) + row[name] = "✅" if field in data["profile_skills"] else "❌" + rows.append(row) + + # 检测差异行 + for row in rows: + vals = {str(row[n]) for n in names} + row["has_diff"] = len(vals) > 1 + + return { + "profiles": names, + "rows": rows, + } + + +@router.post("/calculate") +async def recalculate_scores( + _user: User = Depends(require_role("owner")), +): + """手动触发全量评分重算(仅 owner)。""" + invalidate_cache() + scores = calculate_all_scores() + return { + "message": f"已重新计算 {len(scores)} 个 Profile 的健康分", + "count": len(scores), + } diff --git a/backend/app/main.py b/backend/app/main.py index bbb182c..5463c39 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.database import engine, Base -from app.api import auth, users, audit, dashboard, gateway +from app.api import auth, users, audit, dashboard, gateway, health from app.middleware.audit import AuditMiddleware @@ -36,6 +36,7 @@ app.include_router(users.router) app.include_router(audit.router) app.include_router(dashboard.router, prefix="/api/v1") app.include_router(gateway.router, prefix="/api/v1") +app.include_router(health.router, prefix="/api/v1") @app.get("/api/v1/health") diff --git a/backend/app/services/health_scorer.py b/backend/app/services/health_scorer.py new file mode 100644 index 0000000..e0026d3 --- /dev/null +++ b/backend/app/services/health_scorer.py @@ -0,0 +1,341 @@ +"""健康分计算引擎 — 评分规则 + 缓存。 + +评分维度: + - Gateway 稳定性 40%: 7天在线率 + 日均重启惩罚 + - 配置完整性 25%: .env + config.yaml 必填字段检查 + - Skill 一致性 20%: 与基准 Skill 清单对比 + - Cron 成功率 15%: 24h Cron 成功率 + +等级: A(80-100) B(60-79) C(40-59) D(0-39) + +数据来源暂用占位(TODO: hermes CLI / 文件读取),评分逻辑完整实现。 +""" +from __future__ import annotations + +import logging +import math +import random +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone + +logger = logging.getLogger(__name__) + +# ── 类型 ──────────────────────────────────────────────────────────────── + + +@dataclass +class DimensionScore: + score: float + max_score: float + deductions: list[str] = field(default_factory=list) + suggestions: list[str] = field(default_factory=list) + + +@dataclass +class HealthScore: + profile_name: str + total_score: float + grade: str + gateway: DimensionScore + config: DimensionScore + skill: DimensionScore + cron: DimensionScore + deduct_summary: list[str] = field(default_factory=list) + calculated_at: str = "" + + +GRADE_THRESHOLDS = [ + (80, "A"), + (60, "B"), + (40, "C"), + (0, "D"), +] + + +def _to_grade(score: float) -> str: + for threshold, grade in GRADE_THRESHOLDS: + if score >= threshold: + return grade + return "D" + + +def _fmt_dt(dt: datetime | None = None) -> str: + return (dt or datetime.now(timezone.utc)).isoformat(timespec="seconds") + + +# ── 占位数据源(TODO: 对接真实数据) ────────────────────────────────────── + + +def _placeholder_gateway_data(profile: str) -> dict: + """返回 Profile 的 Gateway 占位状态数据。""" + # 根据 profile 名 hash 出不同状态以产生多样结果 + seed = sum(ord(c) for c in profile) + uptime = 0.85 + (seed % 15) / 100 # 0.85~0.99 + restarts = (seed % 7) / 10 # 0.0~0.6 + return { + "uptime_7d": min(uptime, 0.999), + "avg_daily_restarts": restarts, + "online": seed % 5 != 0, + } + + +_REQUIRED_CONFIG_FIELDS = [ + "BOT_TOKEN", + "BOT_SECRET", + "APP_ID", + "APP_SECRET", + "PROFILE_NAME", + "LOG_LEVEL", +] + + +def _placeholder_config_data(profile: str) -> dict: + """返回 Profile 的配置完整性占位数据。""" + seed = sum(ord(c) for c in profile) + # 每个 Profile 缺失 0~3 个字段 + missing_count = seed % 4 + rng = random.Random(profile) + missing = rng.sample(_REQUIRED_CONFIG_FIELDS, missing_count) if missing_count else [] + return { + "total_required": len(_REQUIRED_CONFIG_FIELDS), + "missing_fields": missing, + "present_fields": [f for f in _REQUIRED_CONFIG_FIELDS if f not in missing], + } + + +_BASELINE_SKILLS = {"gateway", "feishu", "cron", "kanban", "search", "keys"} + + +def _placeholder_skill_data(profile: str) -> dict: + """返回 Skill 一致性占位数据。""" + seed = sum(ord(c) for c in profile) + rng = random.Random(profile) + profile_skills = set(_BASELINE_SKILLS) + # 可能缺失几个 + for s in rng.sample(list(_BASELINE_SKILLS), seed % 4): + profile_skills.discard(s) + # 可能多出几个 + extras = {"notion", "slack", "jira", "github", "gitlab"} + for s in rng.sample(list(extras), seed % 3): + profile_skills.add(s) + + missing = _BASELINE_SKILLS - profile_skills + surplus = profile_skills - _BASELINE_SKILLS + return { + "baseline": list(_BASELINE_SKILLS), + "profile_skills": list(profile_skills), + "missing": list(missing), + "surplus": list(surplus), + } + + +def _placeholder_cron_data(profile: str) -> dict: + """返回 Cron 执行状态占位数据。""" + seed = sum(ord(c) for c in profile) + success_rate = 0.7 + (seed % 6) / 20 # 0.7~0.95 + return { + "total_executions_24h": 10 + seed % 10, + "success_count": int((10 + seed % 10) * success_rate), + "success_rate": success_rate, + "recent_failures": [f"cron-job-{i}" for i in range(seed % 4)], + } + + +# ── 评分逻辑 ────────────────────────────────────────────────────────────── + + +def _score_gateway(profile: str) -> DimensionScore: + data = _placeholder_gateway_data(profile) + deductions: list[str] = [] + suggestions: list[str] = [] + + uptime = data["uptime_7d"] + restarts = data["avg_daily_restarts"] + + # 基础分: 7天在线率 × 40 + score = uptime * 40 + + if uptime < 0.99: + deductions.append(f"7天在线率 {uptime:.1%}(目标 ≥99%)") + suggestions.append("检查 Gateway 进程稳定性,考虑设置自动重启") + + # 日均重启惩罚: >0.3 次/天 开始扣分 + if restarts > 0.3: + penalty = min(score, (restarts - 0.3) * 15) + score -= penalty + deductions.append(f"日均重启 {restarts:.1f} 次(阈值 ≤0.3),扣 {penalty:.1f} 分") + suggestions.append("排查频繁重启原因,检查内存泄漏或日志错误") + + if not data["online"]: + score *= 0.5 + deductions.append("Gateway 当前离线,得分减半") + suggestions.append("立即启动 Gateway 进程") + + return DimensionScore( + score=max(0, round(score, 1)), + max_score=40, + deductions=deductions, + suggestions=suggestions, + ) + + +def _score_config(profile: str) -> DimensionScore: + data = _placeholder_config_data(profile) + deductions: list[str] = [] + suggestions: list[str] = [] + + missing = data["missing_fields"] + total = data["total_required"] + + score = 25.0 # 满分 25 + if missing: + deduction_pct = len(missing) * 0.1 # 缺 1 个扣 10% + score *= max(0, 1 - deduction_pct) + deductions.append(f"缺 {len(missing)} 个必填字段: {', '.join(missing)}") + suggestions.append(f"在 .env 或 config.yaml 中补充: {', '.join(missing)}") + + return DimensionScore( + score=max(0, round(score, 1)), + max_score=25, + deductions=deductions, + suggestions=suggestions, + ) + + +def _score_skill(profile: str) -> DimensionScore: + data = _placeholder_skill_data(profile) + deductions: list[str] = [] + suggestions: list[str] = [] + + missing = data["missing"] + surplus = data["surplus"] + + # 基准技能匹配率 → 满分 20 + baseline_hit = len(data["baseline"]) - len(missing) + if data["baseline"]: + match_ratio = baseline_hit / len(data["baseline"]) + else: + match_ratio = 1.0 + score = match_ratio * 20 + + if missing: + deductions.append(f"缺失基准 Skill: {', '.join(missing)}") + suggestions.append(f"安装缺失 Skill: {', '.join(missing)}") + if surplus: + deductions.append(f"多余未识别 Skill: {', '.join(surplus)}") + suggestions.append(f"检查多余 Skill 是否必要: {', '.join(surplus)}") + + return DimensionScore( + score=max(0, round(score, 1)), + max_score=20, + deductions=deductions, + suggestions=suggestions, + ) + + +def _score_cron(profile: str) -> DimensionScore: + data = _placeholder_cron_data(profile) + deductions: list[str] = [] + suggestions: list[str] = [] + + success_rate = data["success_rate"] + score = success_rate * 15 # 成功率 × 15 + + if success_rate < 0.95: + deductions.append(f"24h Cron 成功率 {success_rate:.1%}(目标 ≥95%)") + suggestions.append("检查失败 Cron 任务的日志,确认是否因超时或配置错误") + + if data["recent_failures"]: + deductions.append(f"失败任务: {', '.join(data['recent_failures'])}") + suggestions.append("逐个排查失败任务,优先修复高频失败项") + + return DimensionScore( + score=max(0, round(score, 1)), + max_score=15, + deductions=deductions, + suggestions=suggestions, + ) + + +# ── 对外接口 ────────────────────────────────────────────────────────────── + + +SCORE_CACHE: dict[str, HealthScore] = {} +_LAST_CALCULATED: datetime | None = None +_CACHE_TTL = timedelta(hours=24) + +# 占位 Profile 列表(TODO: 从 hermes 配置目录读取) +PLACEHOLDER_PROFILES = [ + "hebo", "ops-engineer", "monitor", "visualizer", + "gateway-monitor", "feishu-gw", "taiwan-briefing", + "daily-report", "weekly-digest", "hindsight", + "guangxi-donation", "europe-briefing", +] + + +def calculate_health_score(profile_name: str) -> HealthScore: + """计算单个 Profile 的健康分。""" + gw = _score_gateway(profile_name) + cf = _score_config(profile_name) + sk = _score_skill(profile_name) + cr = _score_cron(profile_name) + + total = round(gw.score + cf.score + sk.score + cr.score, 1) + grade = _to_grade(total) + + deduct_summary = [] + for dim, label in [(gw, "Gateway"), (cf, "配置"), (sk, "Skill"), (cr, "Cron")]: + for d in dim.deductions: + deduct_summary.append(f"[{label}] {d}") + + return HealthScore( + profile_name=profile_name, + total_score=total, + grade=grade, + gateway=gw, + config=cf, + skill=sk, + cron=cr, + deduct_summary=deduct_summary[:5], # 最多显示 5 条 + calculated_at=_fmt_dt(), + ) + + +def _is_cache_stale() -> bool: + if _LAST_CALCULATED is None: + return True + return datetime.now(timezone.utc) - _LAST_CALCULATED > _CACHE_TTL + + +def calculate_all_scores(profiles: list[str] | None = None) -> list[HealthScore]: + """计算全部 Profile 的健康分,使用缓存(TTL 24h)。""" + global _LAST_CALCULATED + + if not _is_cache_stale() and SCORE_CACHE: + logger.info("健康分缓存命中(%d 条)", len(SCORE_CACHE)) + scores = list(SCORE_CACHE.values()) + if profiles: + return [s for s in scores if s.profile_name in profiles] + return scores + + target_profiles = profiles or PLACEHOLDER_PROFILES + scores: list[HealthScore] = [] + for name in target_profiles: + try: + hs = calculate_health_score(name) + SCORE_CACHE[name] = hs + scores.append(hs) + except Exception as e: + logger.error("计算 %s 健康分异常: %s", name, e) + + _LAST_CALCULATED = datetime.now(timezone.utc) + logger.info("健康分已刷新(%d 条)", len(scores)) + return scores + + +def invalidate_cache() -> None: + """强制清除缓存,下次调用重新计算。""" + global _LAST_CALCULATED + SCORE_CACHE.clear() + _LAST_CALCULATED = None + logger.info("健康分缓存已清除") diff --git a/frontend/src/api/health.ts b/frontend/src/api/health.ts new file mode 100644 index 0000000..81efa76 --- /dev/null +++ b/frontend/src/api/health.ts @@ -0,0 +1,69 @@ +import client from './client' + +/* ── types ── */ + +export interface ScoreRow { + rank: number + name: string + total_score: number + grade: string + gateway_score: number + config_score: number + skill_score: number + cron_score: number + deduct_summary: string[] +} + +export interface ScoreListResponse { + total: number + distribution: { A: number; B: number; C: number; D: number } + scores: ScoreRow[] +} + +export interface DimensionDetail { + name: string + weight: string + score: number + max_score: number + deductions: string[] + suggestions: string[] +} + +export interface ProfileScoreDetail { + profile_name: string + total_score: number + grade: string + calculated_at: string + dimensions: DimensionDetail[] +} + +export interface CompareRow { + field: string + category: string + has_diff: boolean + [profileName: string]: unknown +} + +export interface CompareResponse { + profiles: string[] + rows: CompareRow[] +} + +export interface CalculateResponse { + message: string + count: number +} + +/* ── API functions ── */ + +export const getHealthScores = (params?: { grade?: string; sort?: string }) => + client.get('/health/scores', { params }) + +export const getProfileScore = (profile: string) => + client.get(`/health/scores/${encodeURIComponent(profile)}`) + +export const compareProfiles = (profile_names: string[]) => + client.post('/health/compare', { profile_names }) + +export const recalculateScores = () => + client.post('/health/calculate') diff --git a/frontend/src/pages/HealthPage.tsx b/frontend/src/pages/HealthPage.tsx index 4c458fc..269d565 100644 --- a/frontend/src/pages/HealthPage.tsx +++ b/frontend/src/pages/HealthPage.tsx @@ -1,3 +1,295 @@ -export default function HealthPage() { - return

健康分

系统健康评分

+import { useEffect, useState, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import Badge from '../components/ui/Badge' +import Button from '../components/ui/Button' +import Table, { type TableColumn } from '../components/ui/Table' +import Skeleton from '../components/ui/Skeleton' +import Modal from '../components/ui/Modal' +import toast from '../components/ui/Toast' +import { useAuth } from '../hooks/useAuth' + +import { + getHealthScores, getProfileScore, compareProfiles, recalculateScores, + type ScoreListResponse, type ProfileScoreDetail, type CompareResponse, +} from '../api/health' + +const GRADE_META: Record = { + A: { color: 'var(--green)', bg: 'var(--bg-success)', label: '优秀' }, + B: { color: 'var(--blue)', bg: 'var(--bg-info)', label: '良好' }, + C: { color: 'var(--yellow)', bg: 'var(--bg-warn)', label: '一般' }, + D: { color: 'var(--red)', bg: 'var(--bg-error)', label: '危险' }, } + +function GradeBadge({ grade }: { grade: string }) { + const m = GRADE_META[grade] + if (!m) return {grade} + return ( + + + {grade} + + ) +} + +function GradeCard({ grade, count, active, onClick }: { + grade: string; count: number; active: boolean; onClick: () => void +}) { + const m = GRADE_META[grade] + const as = active ? { boxShadow: '0 0 0 2px ' + m.color } : {} + return ( +
+
{grade}
+
{count}
+
{m.label}
+
+ ) +} + +function DetailPanel({ profile }: { profile: string }) { + const [detail, setDetail] = useState(null) + const [loading, setLoading] = useState(true) + useEffect(() => { + setLoading(true) + getProfileScore(profile).then(r => setDetail(r.data)) + .catch(() => toast.error('加载详情失败')) + .finally(() => setLoading(false)) + }, [profile]) + if (loading) return
+ if (!detail) return
暂无数据
+ return ( +
+ {detail.dimensions.map((dim, i) => { + const ok = dim.score / dim.max_score >= 0.7 + return ( +
+
+ {dim.name} + + {dim.score}/{dim.max_score} ({dim.weight}) + +
+ {dim.deductions.length > 0 && ( +
+
扣分项:
+ {dim.deductions.map((d, j) =>
\u2022 {d}
)} +
+ )} + {dim.suggestions.length > 0 && ( +
+
修复建议:
+ {dim.suggestions.map((s, j) =>
\u2192 {s}
)} +
+ )} + {dim.deductions.length === 0 &&
\u2713 满分
} +
+ ) + })} +
+ ) +} + +function CompareTable({ data, showDiffOnly, profiles: p }: { + data: CompareResponse; showDiffOnly: boolean; profiles: string[] +}) { + const rows = showDiffOnly ? data.rows.filter(r => r.has_diff) : data.rows + if (rows.length === 0) { + return
没有差异
+ } + return ( +
+ + + + {p.map(n => + + )} + + + {rows.map((row, i) => { + const vals = p.map(n => String(row[n] || '')) + const hasDiff = new Set(vals).size > 1 + return ( + + + {p.map(n => { + const v = String(row[n] || '') + const c = v === '\u2705' ? 'var(--green)' : v === '\u274c' ? 'var(--red)' : 'var(--honey)' + return + })} + + ) + })} + +
字段{n}
{row.field}{v}
+
+ ) +} + +export default function HealthPage() { + const [searchParams, setSearchParams] = useSearchParams() + const { user } = useAuth() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const gradeFilter = searchParams.get('filter') || '' + const [sortAsc, setSortAsc] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const [compareData, setCompareData] = useState(null) + const [compareLoading, setCompareLoading] = useState(false) + const [showCompare, setShowCompare] = useState(false) + const [showDiffOnly, setShowDiffOnly] = useState(false) + const [expandedRow, setExpandedRow] = useState(null) + const [recalcLoading, setRecalcLoading] = useState(false) + + const fetchData = useCallback(async () => { + setLoading(true); setError(null) + try { + const params: Record = { sort: sortAsc ? 'asc' : 'desc' } + if (gradeFilter) params.grade = gradeFilter + const r = await getHealthScores(params) + setData(r.data) + } catch { setError('加载健康分数据失败') } + finally { setLoading(false) } + }, [gradeFilter, sortAsc]) + + useEffect(() => { fetchData() }, [fetchData]) + + const toggleSelect = (name: string) => { + setSelected(prev => { const n = new Set(prev); if (n.has(name)) n.delete(name); else n.add(name); return n }) + } + + const handleCompare = async () => { + if (selected.size < 2) { toast.error('至少选择 2 个 Profile'); return } + setCompareLoading(true) + try { const r = await compareProfiles(Array.from(selected)); setCompareData(r.data); setShowCompare(true) } + catch { toast.error('对比失败') } finally { setCompareLoading(false) } + } + + const handleRecalculate = async () => { + setRecalcLoading(true) + try { await recalculateScores(); toast.success('已重新计算'); fetchData() } + catch { toast.error('重算失败') } finally { setRecalcLoading(false) } + } + + const handleGradeFilter = (g: string) => { + const p = new URLSearchParams(searchParams) + if (g && g !== gradeFilter) p.set('filter', g); else p.delete('filter') + setSearchParams(p) + } + + const columns: TableColumn[] = [ + { key: '_select', label: '', width: 40 }, + { key: 'rank', label: '排名', width: 50 }, + { key: 'name', label: 'Profile' }, + { key: 'total', label: '总分', width: 70 }, + { key: 'grade', label: '等级', width: 60 }, + { key: 'dims', label: '四维度得分', width: 200 }, + { key: 'deduct', label: '主要扣分' }, + ] + + const dist = data?.distribution ?? { A: 0, B: 0, C: 0, D: 0 } + const scores = data?.scores ?? [] + + /* loading */ + if (loading) { + return ( +

健康分

+
+ {['A','B','C','D'].map(g =>
)} +
+ +
+ ) + } + + /* error */ + if (error) { + return ( +

健康分

+
+

{error}

+ +
+
+ ) + } + + /* table rows */ + const tableData = scores.map(s => { + const tc = s.total_score >= 80 ? 'var(--green)' : s.total_score >= 60 ? 'var(--blue)' : s.total_score >= 40 ? 'var(--yellow)' : 'var(--red)' + return { + key: s.name, + _select: toggleSelect(s.name)} className="cursor-pointer" style={{ accentColor: 'var(--honey)' }} />, + rank: {s.rank}, + name: {s.name}, + total: {s.total_score}, + grade: , + dims: ( +
+ G:{s.gateway_score} + C:{s.config_score} + S:{s.skill_score} + R:{s.cron_score} +
+ ), + deduct:
{s.deduct_summary[0] || '-'}
, + } + }) + + return ( +
+

健康分

+ + {/* grade cards */} +
+ {Object.entries(GRADE_META).map(([g]) => + handleGradeFilter(g)} />) + } +
+ + {/* filter bar */} +
+
+ {['', 'A', 'B', 'C', 'D'].map(g => + + )} +
+ + + {user?.role === 'owner' && } + 共 {scores.length} 个 Profile +
+ + {/* table */} + setExpandedRow(expandedRow === record.key ? null : String(record.key))} + rowClassName={(record) => String(record.grade?.props?.grade || '') === 'D' ? 'row-danger' : ''} /> + + {/* expanded detail */} + {expandedRow && ( +
+ +
+ )} + + {/* compare modal */} + setShowCompare(false)} title="Profile \u5bf9\u6bd4" + footer={ + + }> + {compareData && } + + + ) +} +