fix(dashboard): trends per-company lines instead of meaningless average

This commit is contained in:
selfrelease
2026-07-19 21:47:19 +08:00
parent 7b0b538b37
commit a24ccd01c7
2 changed files with 166 additions and 142 deletions
@@ -5,7 +5,6 @@ import { apiFetch } from "@/lib/api";
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
import { EmptyState } from "@/components/shared/EmptyState";
import { DIMENSIONS_14 } from "@/components/health/HealthRadar";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
/** 热力图行数据。 */
interface HeatmapRow {
@@ -15,13 +14,147 @@ interface HeatmapRow {
scores: Record<string, number | null | undefined>;
}
/** 趋势数据。 */
interface TrendDataPoint {
period: string;
avg_score: number;
/** 企业趋势数据。 */
interface CompanyTrend {
company_id: string;
company_name: string;
data: Array<{ period: string; score: number }>;
}
/** 健康度热力图 — 企业 × 维度评分矩阵。 */
/** 折线颜色池。 */
const LINE_COLORS = [
"#6366f1", "#10b981", "#f59e0b", "#f43f5e",
"#8b5cf6", "#06b6d4", "#ec4899", "#84cc16",
];
/** 健康度趋势对比图 — 按企业分组,每家企业一条折线。 */
export function HealthTrends({ companyId }: { companyId?: string }) {
const [data, setData] = useState<CompanyTrend[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const path = companyId
? `/dashboard/trends?company_id=${companyId}&months=6`
: "/dashboard/trends?months=6";
apiFetch<CompanyTrend[]>(path)
.then((res) => setData((res.data as CompanyTrend[]) || []))
.catch(() => {})
.finally(() => setLoading(false));
}, [companyId]);
if (loading) return <LoadingSpinner />;
if (!data.length) return <EmptyState title="暂无趋势数据" />;
// 收集所有月份作为 X 轴
const allPeriods = Array.from(
new Set(data.flatMap((c) => c.data.map((d) => d.period))),
).sort();
const width = 700;
const height = 280;
const padding = { top: 20, right: 20, bottom: 40, left: 40 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const xStep = allPeriods.length > 1 ? chartW / (allPeriods.length - 1) : 0;
const yScale = (val: number) => chartH - (val / 100) * chartH;
/** 根据企业索引取颜色。 */
const getColor = (idx: number) => LINE_COLORS[idx % LINE_COLORS.length];
return (
<div className="space-y-2">
<h3 className="text-sm font-medium"> </h3>
<svg width={width} height={height} role="img" aria-label="健康度趋势图(按企业)">
{/* Y 轴刻度 */}
{[0, 25, 50, 75, 100].map((v) => (
<g key={v}>
<line
x1={padding.left}
y1={padding.top + yScale(v)}
x2={width - padding.right}
y2={padding.top + yScale(v)}
stroke="var(--border)"
strokeWidth={1}
strokeDasharray={v === 0 ? "none" : "2,2"}
/>
<text
x={padding.left - 8}
y={padding.top + yScale(v) + 4}
textAnchor="end"
className="text-[10px] fill-muted-foreground"
>
{v}
</text>
</g>
))}
{/* X 轴月份标签 */}
{allPeriods.map((period, i) => (
<text
key={period}
x={padding.left + i * xStep}
y={height - padding.bottom + 16}
textAnchor="middle"
className="text-[10px] fill-muted-foreground"
>
{period}
</text>
))}
{/* 每家企业一条折线 */}
{data.map((company, ci) => {
const color = getColor(ci);
const points = company.data
.map((d) => {
const xi = allPeriods.indexOf(d.period);
if (xi < 0) return null;
const x = padding.left + xi * xStep;
const y = padding.top + yScale(d.score);
return { x, y, score: d.score };
})
.filter((p): p is { x: number; y: number; score: number } => p !== null);
if (points.length === 0) return null;
const path = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`)
.join(" ");
return (
<g key={company.company_id}>
<path d={path} fill="none" stroke={color} strokeWidth={2} />
{points.map((p, i) => (
<g key={i}>
<circle cx={p.x} cy={p.y} r={3} fill={color} />
<text
x={p.x}
y={p.y - 8}
textAnchor="middle"
className="text-[9px] font-medium"
fill={color}
>
{p.score.toFixed(0)}
</text>
</g>
))}
</g>
);
})}
</svg>
{/* 图例 */}
<div className="flex flex-wrap gap-3">
{data.map((company, ci) => (
<div key={company.company_id} className="flex items-center gap-1.5">
<span
className="h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: getColor(ci) }}
/>
<span className="text-xs text-muted-foreground">{company.company_name}</span>
</div>
))}
</div>
</div>
);
}
export function HealthHeatmap() {
const [data, setData] = useState<HeatmapRow[]>([]);
const [loading, setLoading] = useState(true);
@@ -92,114 +225,3 @@ export function HealthHeatmap() {
);
}
/** 健康度趋势对比图 — 按月汇总评分变化折线图。 */
export function HealthTrends({ companyId }: { companyId?: string }) {
const [data, setData] = useState<TrendDataPoint[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const path = companyId
? `/dashboard/trends?company_id=${companyId}&months=6`
: "/dashboard/trends?months=6";
apiFetch<TrendDataPoint[]>(path)
.then((res) => setData((res.data as TrendDataPoint[]) || []))
.catch(() => {})
.finally(() => setLoading(false));
}, [companyId]);
if (loading) return <LoadingSpinner />;
if (!data.length) return <EmptyState title="暂无趋势数据" />;
const width = 600;
const height = 240;
const padding = { top: 20, right: 20, bottom: 40, left: 40 };
const chartW = width - padding.left - padding.right;
const chartH = height - padding.top - padding.bottom;
const periods = data.map((d) => d.period);
const scores = data.map((d) => d.avg_score);
const maxScore = 100;
const minScore = 0;
const xStep = periods.length > 1 ? chartW / (periods.length - 1) : 0;
const yScale = (val: number) => chartH - ((val - minScore) / (maxScore - minScore)) * chartH;
const linePath = scores
.map((s, i) => `${i === 0 ? "M" : "L"} ${padding.left + i * xStep} ${padding.top + yScale(s)}`)
.join(" ");
const prevScore = scores.length > 1 ? scores[scores.length - 2] : null;
const latestScore = scores[scores.length - 1];
const trendDir = prevScore != null
? latestScore > prevScore + 2 ? "up" : latestScore < prevScore - 2 ? "down" : "stable"
: "stable";
const TrendIcon = trendDir === "up" ? TrendingUp : trendDir === "down" ? TrendingDown : Minus;
const trendColor = trendDir === "up" ? "text-emerald-600" : trendDir === "down" ? "text-rose-600" : "text-muted-foreground";
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"></h3>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold text-[var(--investor-primary)]">
{latestScore?.toFixed(1)}
</span>
<TrendIcon size={18} className={trendColor} />
</div>
</div>
<svg width={width} height={height} role="img" aria-label="健康度趋势图">
{/* Y 轴刻度 */}
{[0, 25, 50, 75, 100].map((v) => (
<g key={v}>
<line
x1={padding.left}
y1={padding.top + yScale(v)}
x2={width - padding.right}
y2={padding.top + yScale(v)}
stroke="var(--border)"
strokeWidth={1}
strokeDasharray={v === 0 ? "none" : "2,2"}
/>
<text
x={padding.left - 8}
y={padding.top + yScale(v) + 4}
textAnchor="end"
className="text-[10px] fill-muted-foreground"
>
{v}
</text>
</g>
))}
{/* 折线 */}
<path d={linePath} fill="none" stroke="var(--investor-primary)" strokeWidth={2} />
{/* 数据点 */}
{scores.map((s, i) => (
<g key={i}>
<circle
cx={padding.left + i * xStep}
cy={padding.top + yScale(s)}
r={4}
fill="var(--investor-primary)"
/>
<text
x={padding.left + i * xStep}
y={padding.top + yScale(s) - 10}
textAnchor="middle"
className="text-[10px] fill-foreground font-medium"
>
{s.toFixed(1)}
</text>
<text
x={padding.left + i * xStep}
y={height - padding.bottom + 16}
textAnchor="middle"
className="text-[10px] fill-muted-foreground"
>
{periods[i]}
</text>
</g>
))}
</svg>
</div>
);
}