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:
enzo
2026-07-22 17:14:30 +08:00
co-authored by Claude
parent 6d4a5c955b
commit 8bfefd199c
5 changed files with 893 additions and 3 deletions
+69
View File
@@ -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')
+294 -2
View File
@@ -1,3 +1,295 @@
export default function HealthPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>健康分</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>系统健康评分</p></div>
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<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>
)
}