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
+27 -25
View File
@@ -166,50 +166,52 @@ async def get_health_heatmap(
@router.get("/trends", response_model=ApiResponse[list[dict]])
async def get_health_trends(
company_id: str | None = Query(default=None, description="指定企业 ID,不传则汇总"),
company_id: str | None = Query(default=None, description="指定企业 ID,不传则返回全部企业"),
months: int = Query(default=6, ge=1, le=24, description="趋势月数"),
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
):
"""获取健康度趋势对比数据 — 按月汇总评分变化
"""获取健康度趋势数据 — 按企业分组,每家企业一条折线
返回格式:[{ period, avg_score, company_count, dimension_avgs: { dimension: avg } }]
返回格式:[{ company_id, company_name, data: [{ period, score }] }]
"""
query = (
select(HealthScore)
select(HealthScore, Company.name.label("company_name"))
.join(Company, HealthScore.company_id == Company.id)
.where(Company.tenant_id == user.tenant_id)
)
if company_id:
query = query.where(HealthScore.company_id == company_id)
query = query.order_by(HealthScore.calculated_at.desc()).limit(months * 50)
query = query.order_by(HealthScore.calculated_at.asc()).limit(months * 100)
result = await db.execute(query)
scores = result.scalars().all()
# 按分组
monthly: dict[str, list[HealthScore]] = {}
for s in scores:
period = s.calculated_at.strftime("%Y-%m")
monthly.setdefault(period, []).append(s)
# 按企业分组
by_company: dict[str, dict[str, list]] = {}
for row in result.all():
score = row[0]
cname = row[1]
cid = str(score.company_id)
if cid not in by_company:
by_company[cid] = {"company_name": cname, "scores": []}
by_company[cid]["scores"].append(score)
trends = []
for period in sorted(monthly.keys()):
month_scores = monthly[period]
count = len(month_scores)
avg_total = sum(s.total_score for s in month_scores) / count if count else 0
dim_avgs = {}
for dim_key in _DIMENSION_KEYS:
vals = [getattr(s, dim_key) for s in month_scores if getattr(s, dim_key) is not None]
if vals:
dim_avgs[dim_key] = round(sum(vals) / len(vals), 1)
for cid, info in by_company.items():
# 每月取最新一条
monthly: dict[str, float] = {}
for s in info["scores"]:
period = s.calculated_at.strftime("%Y-%m")
monthly[period] = s.total_score
data_points = [
{"period": p, "score": round(v, 1)}
for p, v in sorted(monthly.items())
]
trends.append({
"period": period,
"avg_score": round(avg_total, 1),
"company_count": count,
"dimension_avgs": dim_avgs,
"company_id": cid,
"company_name": info["company_name"],
"data": data_points,
})
return success(data=trends)
@@ -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>
);
}