geo/frontend/app/(dashboard)/compare/page.tsx

572 lines
22 KiB
TypeScript

"use client";
import { useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { ScoreCard } from "@/components/dashboard/ScoreCard";
import { CompetitorRadarChart } from "@/components/charts/CompetitorRadarChart";
import { Star, TrendingUp, TrendingDown, Minus, BarChart3 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { CompareItem } from "@/types/brand";
import { NextActionCard } from "@/components/dashboard/NextActionCard";
import type { ActionContext } from "@/types/next-action";
import { useCompareData } from "@/lib/hooks/use-compare-data";
// 趋势图标组件
function TrendIcon({ trend, value }: { trend: string; value: number }) {
if (trend === "up") {
return (
<span className="inline-flex items-center gap-0.5 text-emerald-600">
<TrendingUp className="h-3 w-3" />
{value > 0 && <span className="text-xs">+{value.toFixed(1)}</span>}
</span>
);
}
if (trend === "down") {
return (
<span className="inline-flex items-center gap-0.5 text-red-600">
<TrendingDown className="h-3 w-3" />
{value < 0 && <span className="text-xs">{value.toFixed(1)}</span>}
</span>
);
}
return (
<span className="inline-flex items-center gap-0.5 text-muted-foreground">
<Minus className="h-3 w-3" />
</span>
);
}
// V1兼容指标配置
const METRICS = [
{ key: "overall_score", label: "综合评分", description: "综合表现得分" },
{
key: "mention_rate_score",
label: "提及率得分",
description: "品牌被提及的频率",
},
{ key: "sov_score", label: "SOV得分", description: "品牌声量占比" },
{
key: "quality_score",
label: "引用质量得分",
description: "引用文本的质量",
},
{ key: "citation_count", label: "引用次数", description: "被引用的总次数" },
] as const;
export default function ComparePage() {
const {
brands,
selectedBrandId,
setSelectedBrandId,
compareData,
isLoading: loading,
error: apiError,
} = useCompareData();
// 品牌列表加载完成后自动选择第一个品牌
useEffect(() => {
if (brands.length > 0 && !selectedBrandId) {
setSelectedBrandId(brands[0].id);
}
}, [brands, selectedBrandId, setSelectedBrandId]);
const error = apiError?.message ?? null;
if (loading) {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<Skeleton className="h-4 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
))}
</div>
<Card>
<CardHeader>
<Skeleton className="h-4 w-32" />
</CardHeader>
<CardContent>
<Skeleton className="h-[400px] w-full" />
</CardContent>
</Card>
</div>
);
}
if (error) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-2">
<p className="text-destructive">{error}</p>
</div>
);
}
const brandItem = compareData?.items.find(
(item) => item.entity_type === "brand",
);
const competitorItems =
compareData?.items.filter((item) => item.entity_type === "competitor") ||
[];
// 判断某项指标是否优于品牌(用于竞品行)
const isBetterThanBrand = (
competitorItem: CompareItem,
metricKey: string,
) => {
if (!brandItem) return false;
const brandValue = brandItem[metricKey as keyof CompareItem] as number;
const competitorValue = competitorItem[
metricKey as keyof CompareItem
] as number;
return competitorValue > brandValue;
};
// 判断某项指标是否弱于品牌(用于竞品行)
const isWorseThanBrand = (competitorItem: CompareItem, metricKey: string) => {
if (!brandItem) return false;
const brandValue = brandItem[metricKey as keyof CompareItem] as number;
const competitorValue = competitorItem[
metricKey as keyof CompareItem
] as number;
return competitorValue < brandValue;
};
// 判断品牌某项指标是否优于所有竞品
const isBrandBest = (metricKey: string) => {
if (!brandItem || competitorItems.length === 0) return false;
return competitorItems.every((comp) => isBetterThanBrand(comp, metricKey));
};
// 判断品牌某项指标是否弱于所有竞品
const isBrandWorst = (metricKey: string) => {
if (!brandItem || competitorItems.length === 0) return false;
return competitorItems.every((comp) => isWorseThanBrand(comp, metricKey));
};
// 构建雷达图竞品配置
const radarCompetitors = competitorItems.map((comp, index) => ({
name: comp.entity_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%)",
][index % 4],
}));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-muted-foreground" />
<Select value={selectedBrandId} onValueChange={setSelectedBrandId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="选择品牌" />
</SelectTrigger>
<SelectContent>
{brands.map((brand) => (
<SelectItem key={brand.id} value={brand.id}>
{brand.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 评分概览 */}
{brandItem && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<ScoreCard
title="综合评分"
score={brandItem.overall_score}
icon={<BarChart3 className="h-4 w-4" />}
/>
<ScoreCard
title="提及率得分"
score={brandItem.mention_rate_score}
icon={<TrendingUp className="h-4 w-4" />}
/>
<ScoreCard
title="SOV得分"
score={brandItem.sov_score}
icon={<BarChart3 className="h-4 w-4" />}
/>
<ScoreCard
title="引用次数"
score={brandItem.citation_count}
icon={<Star className="h-4 w-4" />}
/>
</div>
)}
{/* 雷达图 */}
{compareData &&
compareData.radar_data &&
compareData.radar_data.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<CompetitorRadarChart
data={compareData.radar_data}
brandName={brandItem?.entity_name || ""}
competitors={radarCompetitors}
/>
</CardContent>
</Card>
)}
{/* V2 五维度详细对比表 */}
{compareData &&
compareData.items.length > 0 &&
compareData.items[0].dimensions &&
compareData.items[0].dimensions.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">/</TableHead>
{compareData.items[0].dimensions.map((dim) => (
<TableHead
key={dim.name}
className="text-right min-w-[120px]"
>
<div className="flex flex-col items-end">
<span>{dim.name}</span>
<span className="text-xs font-normal text-muted-foreground">
{dim.max_score}
</span>
</div>
</TableHead>
))}
<TableHead className="text-right min-w-[100px]">
<div className="flex flex-col items-end">
<span></span>
<span className="text-xs font-normal text-muted-foreground">
100
</span>
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{compareData.items.map((item) => (
<TableRow
key={item.entity_id}
className={
item.entity_type === "brand" ? "bg-primary/5" : ""
}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{item.entity_type === "brand" && (
<span className="flex h-2 w-2 rounded-full bg-primary" />
)}
{item.entity_name}
{item.entity_type === "brand" && (
<span className="ml-1 text-xs text-muted-foreground">
()
</span>
)}
</div>
</TableCell>
{item.dimensions.map((dim) => {
// 判断该维度是否最优
const allDimScores = compareData.items
.map((i) => {
const d = i.dimensions?.find(
(dd) => dd.name === dim.name,
);
return d?.score ?? 0;
})
.filter((s) => s > 0);
const isMax =
allDimScores.length > 0 &&
dim.score === Math.max(...allDimScores) &&
dim.score > 0;
const isMin =
allDimScores.length > 0 &&
dim.score === Math.min(...allDimScores) &&
dim.score > 0 &&
allDimScores.length > 1;
return (
<TableCell key={dim.name} className="text-right">
<div className="flex items-center justify-end gap-1">
<span
className={`font-medium ${
isMax
? "text-emerald-600"
: isMin
? "text-red-600"
: ""
}`}
>
{dim.score.toFixed(1)}
</span>
<TrendIcon
trend={dim.trend}
value={dim.trend_value}
/>
</div>
<div className="mt-0.5 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
isMax
? "bg-emerald-500"
: isMin
? "bg-red-400"
: "bg-primary/60"
}`}
style={{
width: `${dim.percentage}%`,
}}
/>
</div>
</TableCell>
);
})}
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<span className="font-bold text-base">
{item.overall_score.toFixed(1)}
</span>
<TrendIcon
trend={item.overall_trend}
value={item.overall_trend_value}
/>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 图例说明 */}
<div className="mt-4 flex flex-wrap gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="flex h-3 w-3 rounded-full bg-primary" />
<span></span>
</div>
<div className="flex items-center gap-1">
<TrendingUp className="h-3 w-3 text-emerald-600" />
<span className="text-emerald-600"></span>
</div>
<div className="flex items-center gap-1">
<TrendingDown className="h-3 w-3 text-red-600" />
<span className="text-red-600"></span>
</div>
<div className="flex items-center gap-1">
<Minus className="h-3 w-3 text-muted-foreground" />
<span></span>
</div>
</div>
</CardContent>
</Card>
)}
{/* V1兼容对比表格 */}
{compareData && compareData.items.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[140px]">/</TableHead>
{METRICS.map((metric) => (
<TableHead
key={metric.key}
className="text-right min-w-[100px]"
>
<div className="flex flex-col items-end">
<span>{metric.label}</span>
<span className="text-xs font-normal text-muted-foreground">
{metric.description}
</span>
</div>
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{compareData.items.map((item) => (
<TableRow
key={item.entity_id}
className={
item.entity_type === "brand" ? "bg-primary/5" : ""
}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{item.entity_type === "brand" && (
<span className="flex h-2 w-2 rounded-full bg-primary" />
)}
{item.entity_name}
{item.entity_type === "brand" && (
<span className="ml-1 text-xs text-muted-foreground">
()
</span>
)}
<TrendIcon
trend={item.overall_trend}
value={item.overall_trend_value}
/>
</div>
</TableCell>
{METRICS.map((metric) => {
const value = item[
metric.key as keyof CompareItem
] as number;
const isBest =
item.entity_type === "brand" &&
isBrandBest(metric.key);
const isWorst =
item.entity_type === "brand" &&
isBrandWorst(metric.key);
const isBetter =
item.entity_type === "competitor" &&
isBetterThanBrand(item, metric.key);
const isWorse =
item.entity_type === "competitor" &&
isWorseThanBrand(item, metric.key);
let cellClass = "text-right font-medium";
if (isBest) {
cellClass += " text-emerald-600 bg-emerald-50";
} else if (isWorst) {
cellClass += " text-red-600 bg-red-50";
} else if (isBetter) {
cellClass += " text-emerald-600";
} else if (isWorse) {
cellClass += " text-red-600";
}
return (
<TableCell key={metric.key} className={cellClass}>
<div className="flex items-center justify-end gap-1">
{isBest && (
<TrendingUp className="h-3 w-3 text-emerald-600" />
)}
{isWorst && (
<TrendingDown className="h-3 w-3 text-red-600" />
)}
{metric.key === "overall_score" ||
metric.key === "mention_rate_score" ||
metric.key === "sov_score" ||
metric.key === "quality_score"
? value.toFixed(1)
: value}
</div>
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* 图例说明 */}
<div className="mt-4 flex flex-wrap gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="flex h-3 w-3 rounded-full bg-primary" />
<span></span>
</div>
<div className="flex items-center gap-1">
<TrendingUp className="h-3 w-3 text-emerald-600" />
<span className="text-emerald-600"></span>
</div>
<div className="flex items-center gap-1">
<TrendingDown className="h-3 w-3 text-red-600" />
<span className="text-red-600"></span>
</div>
</div>
</CardContent>
</Card>
)}
{/* 无数据提示 */}
{compareData && compareData.items.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">
</p>
</CardContent>
</Card>
)}
{/* 无品牌提示 */}
{brands.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<p className="text-muted-foreground"></p>
<p className="mt-2 text-sm text-muted-foreground">
</p>
</CardContent>
</Card>
)}
{/* 下一步行动建议 */}
<NextActionCard
context={
{
hasData: !!compareData,
hasBrands: brands.length > 0,
brandCount: brands.length,
overallScore: brandItem?.overall_score ?? 0,
scoreChange: brandItem?.overall_trend_value ?? 0,
competitorCount: competitorItems.length,
hasQueryHistory: true,
currentPage: "compare",
brandId: selectedBrandId,
} as ActionContext
}
/>
</div>
);
}