572 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|