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