fix(dashboard): trends per-company lines instead of meaningless average
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user