From a24ccd01c790a5dcbd5c37f498b3e8c690cd13bf Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sun, 19 Jul 2026 21:47:19 +0800 Subject: [PATCH] fix(dashboard): trends per-company lines instead of meaningless average --- backend/app/routers/dashboard.py | 52 ++-- .../components/dashboard/HealthHeatmap.tsx | 256 ++++++++++-------- 2 files changed, 166 insertions(+), 142 deletions(-) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 377f786..0d8d1a9 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -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) diff --git a/frontend/src/components/dashboard/HealthHeatmap.tsx b/frontend/src/components/dashboard/HealthHeatmap.tsx index 8351947..4cf38e2 100644 --- a/frontend/src/components/dashboard/HealthHeatmap.tsx +++ b/frontend/src/components/dashboard/HealthHeatmap.tsx @@ -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; } -/** 趋势数据点。 */ -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([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const path = companyId + ? `/dashboard/trends?company_id=${companyId}&months=6` + : "/dashboard/trends?months=6"; + apiFetch(path) + .then((res) => setData((res.data as CompanyTrend[]) || [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [companyId]); + + if (loading) return ; + if (!data.length) return ; + + // 收集所有月份作为 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 ( +
+

健康度趋势 — 按企业

+ + {/* Y 轴刻度 */} + {[0, 25, 50, 75, 100].map((v) => ( + + + + {v} + + + ))} + {/* X 轴月份标签 */} + {allPeriods.map((period, i) => ( + + {period} + + ))} + {/* 每家企业一条折线 */} + {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 ( + + + {points.map((p, i) => ( + + + + {p.score.toFixed(0)} + + + ))} + + ); + })} + + {/* 图例 */} +
+ {data.map((company, ci) => ( +
+ + {company.company_name} +
+ ))} +
+
+ ); +} export function HealthHeatmap() { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); @@ -92,114 +225,3 @@ export function HealthHeatmap() { ); } -/** 健康度趋势对比图 — 按月汇总评分变化折线图。 */ -export function HealthTrends({ companyId }: { companyId?: string }) { - const [data, setData] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const path = companyId - ? `/dashboard/trends?company_id=${companyId}&months=6` - : "/dashboard/trends?months=6"; - apiFetch(path) - .then((res) => setData((res.data as TrendDataPoint[]) || [])) - .catch(() => {}) - .finally(() => setLoading(false)); - }, [companyId]); - - if (loading) return ; - if (!data.length) return ; - - 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 ( -
-
-

健康度趋势

-
- - {latestScore?.toFixed(1)} - - -
-
- - {/* Y 轴刻度 */} - {[0, 25, 50, 75, 100].map((v) => ( - - - - {v} - - - ))} - {/* 折线 */} - - {/* 数据点 */} - {scores.map((s, i) => ( - - - - {s.toFixed(1)} - - - {periods[i]} - - - ))} - -
- ); -}