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]])
|
@router.get("/trends", response_model=ApiResponse[list[dict]])
|
||||||
async def get_health_trends(
|
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="趋势月数"),
|
months: int = Query(default=6, ge=1, le=24, description="趋势月数"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
user: User = Depends(get_current_user),
|
user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""获取健康度趋势对比数据 — 按月汇总评分变化。
|
"""获取健康度趋势数据 — 按企业分组,每家企业一条折线。
|
||||||
|
|
||||||
返回格式:[{ period, avg_score, company_count, dimension_avgs: { dimension: avg } }]
|
返回格式:[{ company_id, company_name, data: [{ period, score }] }]
|
||||||
"""
|
"""
|
||||||
query = (
|
query = (
|
||||||
select(HealthScore)
|
select(HealthScore, Company.name.label("company_name"))
|
||||||
.join(Company, HealthScore.company_id == Company.id)
|
.join(Company, HealthScore.company_id == Company.id)
|
||||||
.where(Company.tenant_id == user.tenant_id)
|
.where(Company.tenant_id == user.tenant_id)
|
||||||
)
|
)
|
||||||
if company_id:
|
if company_id:
|
||||||
query = query.where(HealthScore.company_id == 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)
|
result = await db.execute(query)
|
||||||
scores = result.scalars().all()
|
|
||||||
|
|
||||||
# 按月分组
|
# 按企业分组
|
||||||
monthly: dict[str, list[HealthScore]] = {}
|
by_company: dict[str, dict[str, list]] = {}
|
||||||
for s in scores:
|
for row in result.all():
|
||||||
period = s.calculated_at.strftime("%Y-%m")
|
score = row[0]
|
||||||
monthly.setdefault(period, []).append(s)
|
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 = []
|
trends = []
|
||||||
for period in sorted(monthly.keys()):
|
for cid, info in by_company.items():
|
||||||
month_scores = monthly[period]
|
# 每月取最新一条
|
||||||
count = len(month_scores)
|
monthly: dict[str, float] = {}
|
||||||
avg_total = sum(s.total_score for s in month_scores) / count if count else 0
|
for s in info["scores"]:
|
||||||
|
period = s.calculated_at.strftime("%Y-%m")
|
||||||
dim_avgs = {}
|
monthly[period] = s.total_score
|
||||||
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)
|
|
||||||
|
|
||||||
|
data_points = [
|
||||||
|
{"period": p, "score": round(v, 1)}
|
||||||
|
for p, v in sorted(monthly.items())
|
||||||
|
]
|
||||||
trends.append({
|
trends.append({
|
||||||
"period": period,
|
"company_id": cid,
|
||||||
"avg_score": round(avg_total, 1),
|
"company_name": info["company_name"],
|
||||||
"company_count": count,
|
"data": data_points,
|
||||||
"dimension_avgs": dim_avgs,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return success(data=trends)
|
return success(data=trends)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { apiFetch } from "@/lib/api";
|
|||||||
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
|
||||||
import { EmptyState } from "@/components/shared/EmptyState";
|
import { EmptyState } from "@/components/shared/EmptyState";
|
||||||
import { DIMENSIONS_14 } from "@/components/health/HealthRadar";
|
import { DIMENSIONS_14 } from "@/components/health/HealthRadar";
|
||||||
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
|
|
||||||
|
|
||||||
/** 热力图行数据。 */
|
/** 热力图行数据。 */
|
||||||
interface HeatmapRow {
|
interface HeatmapRow {
|
||||||
@@ -15,13 +14,147 @@ interface HeatmapRow {
|
|||||||
scores: Record<string, number | null | undefined>;
|
scores: Record<string, number | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 趋势数据点。 */
|
/** 企业趋势数据。 */
|
||||||
interface TrendDataPoint {
|
interface CompanyTrend {
|
||||||
period: string;
|
company_id: string;
|
||||||
avg_score: number;
|
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() {
|
export function HealthHeatmap() {
|
||||||
const [data, setData] = useState<HeatmapRow[]>([]);
|
const [data, setData] = useState<HeatmapRow[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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