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,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() {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user