feat: Profile health score system (scoring engine, API, frontend)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||||
|
}
|
||||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.database import engine, Base
|
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
|
from app.middleware.audit import AuditMiddleware
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ app.include_router(users.router)
|
|||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
app.include_router(dashboard.router, prefix="/api/v1")
|
app.include_router(dashboard.router, prefix="/api/v1")
|
||||||
app.include_router(gateway.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")
|
@app.get("/api/v1/health")
|
||||||
|
|||||||
@@ -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("健康分缓存已清除")
|
||||||
@@ -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<ScoreListResponse>('/health/scores', { params })
|
||||||
|
|
||||||
|
export const getProfileScore = (profile: string) =>
|
||||||
|
client.get<ProfileScoreDetail>(`/health/scores/${encodeURIComponent(profile)}`)
|
||||||
|
|
||||||
|
export const compareProfiles = (profile_names: string[]) =>
|
||||||
|
client.post<CompareResponse>('/health/compare', { profile_names })
|
||||||
|
|
||||||
|
export const recalculateScores = () =>
|
||||||
|
client.post<CalculateResponse>('/health/calculate')
|
||||||
@@ -1,3 +1,295 @@
|
|||||||
export default function HealthPage() {
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>系统健康评分</p></div>
|
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<string, { color: string; bg: string; label: string }> = {
|
||||||
|
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 <Badge variant="outline">{grade}</Badge>
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 px-[8px] py-[2px] rounded-[10px] text-[11px] font-semibold"
|
||||||
|
style={{ backgroundColor: m.bg, color: m.color }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: '50%', backgroundColor: m.color, display: 'inline-block' }} />
|
||||||
|
{grade}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div onClick={onClick} className="rounded-[var(--radius)] p-4 cursor-pointer transition-all duration-150"
|
||||||
|
style={{ background: m.bg, ...as }}>
|
||||||
|
<div className="text-[12px] uppercase tracking-wider" style={{ color: m.color }}>{grade}</div>
|
||||||
|
<div className="text-[28px] font-bold leading-tight mt-1" style={{ color: m.color }}>{count}</div>
|
||||||
|
<div className="text-[12px] mt-1" style={{ color: m.color, opacity: 0.7 }}>{m.label}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanel({ profile }: { profile: string }) {
|
||||||
|
const [detail, setDetail] = useState<ProfileScoreDetail | null>(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 <div className="p-4"><Skeleton variant="text" count={4} /></div>
|
||||||
|
if (!detail) return <div className="p-4 text-[var(--text-dim)]">暂无数据</div>
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{detail.dimensions.map((dim, i) => {
|
||||||
|
const ok = dim.score / dim.max_score >= 0.7
|
||||||
|
return (
|
||||||
|
<div key={i} className="rounded-[var(--radius-sm)] p-3" style={{ background: "var(--bg-hover)" }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[13px] font-semibold" style={{ color: "var(--text)" }}>{dim.name}</span>
|
||||||
|
<span className="text-[12px]" style={{ color: ok ? "var(--green)" : "var(--red)" }}>
|
||||||
|
{dim.score}/{dim.max_score} ({dim.weight})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{dim.deductions.length > 0 && (
|
||||||
|
<div className="space-y-1 mb-2">
|
||||||
|
<div className="text-[11px] font-semibold" style={{ color: "var(--red)" }}>扣分项:</div>
|
||||||
|
{dim.deductions.map((d, j) => <div key={j} className="text-[12px] pl-2" style={{ color: "var(--text-dim)" }}>\u2022 {d}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dim.suggestions.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[11px] font-semibold" style={{ color: "var(--honey)" }}>修复建议:</div>
|
||||||
|
{dim.suggestions.map((s, j) => <div key={j} className="text-[12px] pl-2" style={{ color: "var(--text-dim)" }}>\u2192 {s}</div>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dim.deductions.length === 0 && <div className="text-[12px]" style={{ color: "var(--green)" }}>\u2713 满分</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="p-4 text-center text-[var(--text-dim)]">没有差异</div>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-x-auto rounded-[var(--radius)] border border-[var(--border)]">
|
||||||
|
<table className="w-full border-collapse text-[12px]">
|
||||||
|
<thead><tr>
|
||||||
|
<th className="sticky top-0 z-10 bg-[var(--bg-nav)] text-left px-3 h-[36px] uppercase text-[var(--text-dim)] font-semibold">字段</th>
|
||||||
|
{p.map(n =>
|
||||||
|
<th key={n} className="sticky top-0 z-10 bg-[var(--bg-nav)] text-center px-3 h-[36px] text-[var(--text-dim)] font-semibold">{n}</th>
|
||||||
|
)}
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => {
|
||||||
|
const vals = p.map(n => String(row[n] || ''))
|
||||||
|
const hasDiff = new Set(vals).size > 1
|
||||||
|
return (
|
||||||
|
<tr key={i} className="border-t border-[var(--border)]" style={hasDiff ? { background: "var(--bg-warn)" } : {}}>
|
||||||
|
<td className="px-3 font-medium h-[36px]" style={{ color: "var(--text)" }}>{row.field}</td>
|
||||||
|
{p.map(n => {
|
||||||
|
const v = String(row[n] || '')
|
||||||
|
const c = v === '\u2705' ? 'var(--green)' : v === '\u274c' ? 'var(--red)' : 'var(--honey)'
|
||||||
|
return <td key={n} className="px-3 text-center" style={{ color: c }}>{v}</td>
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HealthPage() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const { user } = useAuth()
|
||||||
|
const [data, setData] = useState<ScoreListResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const gradeFilter = searchParams.get('filter') || ''
|
||||||
|
const [sortAsc, setSortAsc] = useState(false)
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||||
|
const [compareData, setCompareData] = useState<CompareResponse | null>(null)
|
||||||
|
const [compareLoading, setCompareLoading] = useState(false)
|
||||||
|
const [showCompare, setShowCompare] = useState(false)
|
||||||
|
const [showDiffOnly, setShowDiffOnly] = useState(false)
|
||||||
|
const [expandedRow, setExpandedRow] = useState<string | null>(null)
|
||||||
|
const [recalcLoading, setRecalcLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setLoading(true); setError(null)
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = { 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 (
|
||||||
|
<div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||||
|
{['A','B','C','D'].map(g => <div key={g} className="rounded-[var(--radius)] p-4" style={{ background: 'var(--bg-card)' }}><Skeleton variant="text" count={2} /></div>)}
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="table" count={8} className="mt-4" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* error */
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||||
|
<div className="mt-4 p-5 rounded-[var(--radius)] text-center" style={{ background: "var(--bg-card)" }}>
|
||||||
|
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
||||||
|
<Button onClick={fetchData}>重试</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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: <input type="checkbox" checked={selected.has(s.name)} onChange={() => toggleSelect(s.name)} className="cursor-pointer" style={{ accentColor: 'var(--honey)' }} />,
|
||||||
|
rank: <span style={{ color: 'var(--text-dim)' }}>{s.rank}</span>,
|
||||||
|
name: <span style={{ color: 'var(--text)', fontWeight: 500 }}>{s.name}</span>,
|
||||||
|
total: <span style={{ color: tc, fontWeight: 700, fontSize: 15 }}>{s.total_score}</span>,
|
||||||
|
grade: <GradeBadge grade={s.grade} />,
|
||||||
|
dims: (
|
||||||
|
<div className="flex gap-3 text-[12px]">
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>G:<span style={{ color: 'var(--green)' }}>{s.gateway_score}</span></span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>C:<span style={{ color: 'var(--blue)' }}>{s.config_score}</span></span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>S:<span style={{ color: 'var(--honey)' }}>{s.skill_score}</span></span>
|
||||||
|
<span style={{ color: 'var(--text-dim)' }}>R:<span style={{ color: 'var(--yellow)' }}>{s.cron_score}</span></span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
deduct: <div className="text-[12px] truncate max-w-[200px]" style={{ color: 'var(--text-dim)' }}>{s.deduct_summary[0] || '-'}</div>,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2>
|
||||||
|
|
||||||
|
{/* grade cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||||
|
{Object.entries(GRADE_META).map(([g]) =>
|
||||||
|
<GradeCard key={g} grade={g} count={dist[g as keyof typeof dist]} active={gradeFilter === g} onClick={() => handleGradeFilter(g)} />)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* filter bar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-4 mb-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{['', 'A', 'B', 'C', 'D'].map(g =>
|
||||||
|
<button key={g} onClick={() => handleGradeFilter(g)}
|
||||||
|
className="px-3 py-1.5 text-[12px] rounded-[var(--radius-sm)] font-semibold cursor-pointer border-none transition-colors"
|
||||||
|
style={{ background: gradeFilter === g ? 'var(--honey)' : 'var(--bg-hover)', color: gradeFilter === g ? '#000' : 'var(--text-dim)' }}>
|
||||||
|
{g || '全部'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" disabled={selected.size < 2} loading={compareLoading} onClick={handleCompare}>对比选中 ({selected.size})</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setSortAsc(!sortAsc) }}>{sortAsc ? '\u2191 低到高' : '\u2193 高到低'}</Button>
|
||||||
|
{user?.role === 'owner' && <Button size="sm" variant="outline" loading={recalcLoading} onClick={handleRecalculate}>重新计算</Button>}
|
||||||
|
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>共 {scores.length} 个 Profile</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* table */}
|
||||||
|
<Table columns={columns} data={tableData as any} rowKey="key"
|
||||||
|
onRowClick={(record) => setExpandedRow(expandedRow === record.key ? null : String(record.key))}
|
||||||
|
rowClassName={(record) => String(record.grade?.props?.grade || '') === 'D' ? 'row-danger' : ''} />
|
||||||
|
|
||||||
|
{/* expanded detail */}
|
||||||
|
{expandedRow && (
|
||||||
|
<div className="mt-2 mb-4 rounded-[var(--radius)] border border-[var(--border)] overflow-hidden"
|
||||||
|
style={{ background: "var(--bg-card)" }}>
|
||||||
|
<DetailPanel profile={expandedRow} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* compare modal */}
|
||||||
|
<Modal open={showCompare} onClose={() => setShowCompare(false)} title="Profile \u5bf9\u6bd4"
|
||||||
|
footer={
|
||||||
|
<label className="flex items-center gap-2 text-[13px] cursor-pointer" style={{ color: "var(--text-dim)" }}>
|
||||||
|
<input type="checkbox" checked={showDiffOnly} onChange={() => setShowDiffOnly(!showDiffOnly)} style={{ accentColor: 'var(--honey)' }} />
|
||||||
|
只看差异
|
||||||
|
</label>
|
||||||
|
}>
|
||||||
|
{compareData && <CompareTable data={compareData} showDiffOnly={showDiffOnly} profiles={Array.from(selected)} />}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user