geo/frontend/app/(dashboard)/dashboard/health-score/page.tsx

354 lines
11 KiB
TypeScript

"use client";
import * as React from "react";
import { useSession } from "next-auth/react";
import { useApi } from "@/lib/hooks/use-api";
import { scoringApi, BrandScore, BrandCompare, ScoreHistory } from "@/lib/api/scoring";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
import { CompetitorRadarChart } from "@/components/charts/CompetitorRadarChart";
import { round, getStatusColor, getProgressBg } from "@/lib/utils/health-score";
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
} from "recharts";
import { Heart } from "lucide-react";
interface BrandsResponse {
items: { id: string; name: string }[];
}
const DIMENSION_LABELS: Record<string, string> = {
mention_rate: "提及率",
recommendation_rank: "推荐排名",
sentiment: "情感倾向",
citation_quality: "引用质量",
competitor_comparison: "竞品对比",
};
function ScoreGauge({ score }: { score: number }) {
const percentage = Math.min(Math.max(score, 0), 100);
const colorClass = getStatusColor(percentage);
const colorMap: Record<string, string> = {
"text-green-500": "#22c55e",
"text-yellow-500": "#eab308",
"text-red-500": "#ef4444",
};
const fillColor = colorMap[colorClass] || "#3b82f6";
const data = [
{ name: "score", value: percentage },
{ name: "remaining", value: 100 - percentage },
];
return (
<div className="relative flex items-center justify-center">
<ResponsiveContainer width={220} height={220}>
<PieChart>
<Pie
data={data}
innerRadius={80}
outerRadius={100}
startAngle={90}
endAngle={-270}
dataKey="value"
stroke="none"
>
<Cell fill={fillColor} />
<Cell fill="hsl(var(--muted))" />
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-bold">{round(percentage, 1)}</span>
<span className="text-sm text-muted-foreground">/100</span>
</div>
</div>
);
}
function DimensionCards({ dimensions }: { dimensions: BrandScore["dimensions"] }) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{dimensions.map((dim) => {
const percentage = dim.max_score > 0 ? (dim.score / dim.max_score) * 100 : 0;
const label = DIMENSION_LABELS[dim.name] || dim.name;
return (
<Card key={dim.name}>
<CardContent className="pt-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{label}</span>
<span className={`text-sm font-semibold ${getStatusColor(percentage)}`}>
{round(percentage, 1)}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
className={`h-full rounded-full transition-all ${getProgressBg(percentage)}`}
style={{ width: `${Math.max(percentage, 2)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{dim.description}
</p>
</CardContent>
</Card>
);
})}
</div>
);
}
function CompetitorTab({ token, brandId }: { token: string; brandId: string }) {
const [data, setData] = React.useState<BrandCompare | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
scoringApi
.getCompare(token, brandId)
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "加载失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [token, brandId]);
if (loading) return <LoadingState rows={2} rowHeight="h-48" />;
if (error) return <ErrorState error={error} />;
if (!data) return <EmptyState message="暂无竞品对比数据" />;
const radarData = data.dimensions.map((dim) => {
const item: Record<string, string | number> = {
label: DIMENSION_LABELS[dim] || dim,
dimension: dim,
};
item[data.brand_name] = 0;
data.competitors.forEach((c) => {
item[c.name] = c.scores[dim] ?? 0;
});
return item;
});
const brandScoreEntry = data.dimensions.reduce<Record<string, number>>(
(acc, dim) => {
acc[dim] = 0;
return acc;
},
{}
);
radarData.forEach((item) => {
const dim = item.dimension as string;
brandScoreEntry[dim] = (item[data.brand_name] as number) || 0;
});
radarData.forEach((item) => {
const dim = item.dimension as string;
item[data.brand_name] = brandScoreEntry[dim];
});
const competitors = data.competitors.map((c, i) => ({
name: c.name,
color: [
"hsl(346.8 77.2% 49.8%)",
"hsl(24.6 95% 53.1%)",
"hsl(142.1 76.2% 36.3%)",
"hsl(262.1 83.3% 57.8%)",
][i % 4],
}));
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<CompetitorRadarChart
data={radarData as any}
brandName={data.brand_name}
competitors={competitors}
/>
</CardContent>
</Card>
);
}
function HistoryTab({ token, brandId }: { token: string; brandId: string }) {
const [data, setData] = React.useState<ScoreHistory | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
scoringApi
.getHistory(token, brandId)
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err) => {
if (!cancelled) setError(err instanceof Error ? err.message : "加载失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [token, brandId]);
if (loading) return <LoadingState rows={2} rowHeight="h-48" />;
if (error) return <ErrorState error={error} />;
if (!data || data.scores.length === 0)
return <EmptyState message="暂无历史趋势数据" />;
const chartData = data.scores.map((entry) => ({
date: entry.date,
score: entry.overall_score,
}));
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(value: string) => {
const date = new Date(value);
return `${date.getMonth() + 1}/${date.getDate()}`;
}}
/>
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
labelFormatter={(value: string) => {
const date = new Date(value);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}}
formatter={(value: number) => [`评分: ${value}`, ""]}
/>
<Line
type="monotone"
dataKey="score"
stroke="hsl(221.2 83.2% 53.3%)"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
export default function HealthScorePage() {
const { data: session } = useSession();
const token = session?.accessToken || "";
const { data: brandsData } = useApi<BrandsResponse>("/api/v1/brands/?limit=1");
const brandId = brandsData?.items?.[0]?.id ?? null;
const [scoreData, setScoreData] = React.useState<BrandScore | null>(null);
const [scoreLoading, setScoreLoading] = React.useState(true);
const [scoreError, setScoreError] = React.useState<string | null>(null);
React.useEffect(() => {
if (!token || !brandId) return;
let cancelled = false;
setScoreLoading(true);
setScoreError(null);
scoringApi
.getScore(token, brandId)
.then((res) => {
if (!cancelled) setScoreData(res);
})
.catch((err) => {
if (!cancelled) setScoreError(err instanceof Error ? err.message : "加载失败");
})
.finally(() => {
if (!cancelled) setScoreLoading(false);
});
return () => {
cancelled = true;
};
}, [token, brandId]);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">AI搜索中的综合健康表现</p>
</div>
{!token || !brandId ? (
<LoadingState rows={3} rowHeight="h-32" />
) : scoreLoading ? (
<LoadingState rows={3} rowHeight="h-32" />
) : scoreError ? (
<ErrorState error={scoreError} />
) : !scoreData ? (
<EmptyState
icon={<Heart className="h-6 w-6 text-muted-foreground" />}
message="暂无健康评分数据"
description="请先完成品牌评分检测"
/>
) : (
<>
<Card>
<CardContent className="pt-6 flex flex-col items-center">
<ScoreGauge score={scoreData.overall_score} />
<p className="mt-2 text-sm text-muted-foreground">
</p>
</CardContent>
</Card>
<DimensionCards dimensions={scoreData.dimensions} />
<Tabs defaultValue="compare">
<TabsList>
<TabsTrigger value="compare"></TabsTrigger>
<TabsTrigger value="history"></TabsTrigger>
</TabsList>
<TabsContent value="compare">
<CompetitorTab token={token} brandId={brandId} />
</TabsContent>
<TabsContent value="history">
<HistoryTab token={token} brandId={brandId} />
</TabsContent>
</Tabs>
</>
)}
</div>
);
}