354 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|