475 lines
16 KiB
TypeScript
475 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
import { PLATFORM_MAP } from "@/lib/platforms";
|
|
import { Check, X, Quote, Filter, TrendingUp, MapPin, Hash } from "lucide-react";
|
|
import { useApi } from "@/lib/hooks/use-api";
|
|
import { LoadingState } from "@/components/ui/api-states";
|
|
import { type CitationStats } from "@/lib/api/citations";
|
|
import {
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
Tooltip as RechartsTooltip,
|
|
ResponsiveContainer,
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
} from "recharts";
|
|
|
|
interface CitationItem {
|
|
id: string;
|
|
query_id: string;
|
|
platform: string;
|
|
cited: boolean;
|
|
citation_position: number | null;
|
|
citation_text: string | null;
|
|
competitor_brands: string[];
|
|
queried_at: string;
|
|
}
|
|
|
|
interface QueryOption {
|
|
id: string;
|
|
keyword: string;
|
|
}
|
|
|
|
const PIE_FALLBACK_COLORS = [
|
|
"#3b82f6",
|
|
"#10b981",
|
|
"#f59e0b",
|
|
"#ef4444",
|
|
"#8b5cf6",
|
|
];
|
|
|
|
function getChartColor(index: number): string {
|
|
if (typeof document !== "undefined") {
|
|
const style = getComputedStyle(document.documentElement);
|
|
const cssVar = style.getPropertyValue(`--chart-${index + 1}`).trim();
|
|
if (cssVar) return `hsl(var(--chart-${index + 1}))`;
|
|
}
|
|
return PIE_FALLBACK_COLORS[index % PIE_FALLBACK_COLORS.length];
|
|
}
|
|
|
|
function StatCard({
|
|
title,
|
|
value,
|
|
icon: Icon,
|
|
suffix,
|
|
}: {
|
|
title: string;
|
|
value: string | number;
|
|
icon: React.ElementType;
|
|
suffix?: string;
|
|
}) {
|
|
return (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{value}
|
|
{suffix && <span className="text-sm font-normal text-muted-foreground ml-1">{suffix}</span>}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function PlatformPieChart({ data }: { data: { platform: string; count: number }[] }) {
|
|
const chartData = useMemo(
|
|
() =>
|
|
data.map((d) => ({
|
|
name: PLATFORM_MAP[d.platform] || d.platform,
|
|
value: d.count,
|
|
})),
|
|
[data]
|
|
);
|
|
|
|
return (
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<PieChart>
|
|
<Pie
|
|
data={chartData}
|
|
cx="50%"
|
|
cy="50%"
|
|
innerRadius={60}
|
|
outerRadius={100}
|
|
paddingAngle={2}
|
|
dataKey="value"
|
|
nameKey="name"
|
|
>
|
|
{chartData.map((_, index) => (
|
|
<Cell key={`cell-${index}`} fill={getChartColor(index)} />
|
|
))}
|
|
</Pie>
|
|
<RechartsTooltip
|
|
contentStyle={{
|
|
backgroundColor: "hsl(var(--card))",
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: "var(--radius)",
|
|
}}
|
|
formatter={(value: number) => [value, "引用数"]}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
}
|
|
|
|
function TrendLineChart({ data }: { data: { date: string; count: number }[] }) {
|
|
return (
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<LineChart data={data} 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 tick={{ fontSize: 12 }} allowDecimals={false} />
|
|
<RechartsTooltip
|
|
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="count"
|
|
stroke="hsl(var(--primary))"
|
|
strokeWidth={2}
|
|
dot={{ r: 3 }}
|
|
activeDot={{ r: 5 }}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
);
|
|
}
|
|
|
|
export default function CitationsPage() {
|
|
const [selectedQuery, setSelectedQuery] = useState<string>("all");
|
|
const [selectedPlatform, setSelectedPlatform] = useState<string>("all");
|
|
const [startDate, setStartDate] = useState<string>("");
|
|
const [endDate, setEndDate] = useState<string>("");
|
|
const [filterKey, setFilterKey] = useState(0);
|
|
|
|
const citationsUrl = (() => {
|
|
const params = new URLSearchParams();
|
|
if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery);
|
|
if (selectedPlatform && selectedPlatform !== "all") params.append("platform", selectedPlatform);
|
|
if (startDate) params.append("start_date", startDate);
|
|
if (endDate) params.append("end_date", endDate);
|
|
const qs = params.toString();
|
|
return `/api/v1/citations/${qs ? `?${qs}&_k=${filterKey}` : `?_k=${filterKey}`}`;
|
|
})();
|
|
|
|
const statsUrl = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery);
|
|
if (startDate) params.append("start_date", startDate);
|
|
if (endDate) params.append("end_date", endDate);
|
|
const qs = params.toString();
|
|
return `/api/v1/citations/stats${qs ? `?${qs}&_k=${filterKey}` : `?_k=${filterKey}`}`;
|
|
}, [selectedQuery, startDate, endDate, filterKey]);
|
|
|
|
const {
|
|
data: citationsData,
|
|
isLoading,
|
|
error: citationsError,
|
|
refresh: refreshCitations,
|
|
} = useApi<{ items: CitationItem[] }>(
|
|
citationsUrl,
|
|
{ dedupingInterval: 0 }
|
|
);
|
|
|
|
const {
|
|
data: queriesData,
|
|
} = useApi<{ items: QueryOption[] }>("/api/v1/queries/");
|
|
|
|
const {
|
|
data: statsData,
|
|
isLoading: statsLoading,
|
|
error: statsError,
|
|
} = useApi<CitationStats>(statsUrl, { dedupingInterval: 0 });
|
|
|
|
const citations: CitationItem[] = citationsData?.items ?? [];
|
|
const queries: QueryOption[] = queriesData?.items ?? [];
|
|
|
|
function handleFilter() {
|
|
setFilterKey((k) => k + 1);
|
|
}
|
|
|
|
function handleReset() {
|
|
setSelectedQuery("all");
|
|
setSelectedPlatform("all");
|
|
setStartDate("");
|
|
setEndDate("");
|
|
setFilterKey((k) => k + 1);
|
|
}
|
|
|
|
if (isLoading && citations.length === 0) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold tracking-tight">引用记录</h2>
|
|
<p className="text-muted-foreground">查看各平台的引用检测结果</p>
|
|
</div>
|
|
<LoadingState rows={4} rowHeight="h-12" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-2xl font-bold tracking-tight">引用记录</h2>
|
|
<p className="text-muted-foreground">查看各平台的引用检测结果</p>
|
|
</div>
|
|
|
|
{!statsError && (
|
|
<div className="space-y-4">
|
|
{statsLoading ? (
|
|
<LoadingState rows={2} rowHeight="h-32" />
|
|
) : statsData ? (
|
|
<>
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<StatCard
|
|
title="引用率"
|
|
value={statsData.citation_rate != null ? `${(statsData.citation_rate * 100).toFixed(1)}` : "—"}
|
|
icon={TrendingUp}
|
|
suffix={statsData.citation_rate != null ? "%" : undefined}
|
|
/>
|
|
<StatCard
|
|
title="平均位置"
|
|
value={statsData.avg_position != null ? statsData.avg_position.toFixed(1) : "—"}
|
|
icon={MapPin}
|
|
/>
|
|
<StatCard
|
|
title="总引用数"
|
|
value={statsData.total_citations ?? 0}
|
|
icon={Hash}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">平台分布</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{statsData.platform_distribution?.length > 0 ? (
|
|
<PlatformPieChart data={statsData.platform_distribution} />
|
|
) : (
|
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
|
暂无平台分布数据
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">30天趋势</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{statsData.trend?.length > 0 ? (
|
|
<TrendLineChart data={statsData.trend} />
|
|
) : (
|
|
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
|
暂无趋势数据
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Filter className="h-4 w-4" />
|
|
筛选条件
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="query-filter">查询词</Label>
|
|
<Select value={selectedQuery} onValueChange={setSelectedQuery}>
|
|
<SelectTrigger id="query-filter">
|
|
<SelectValue placeholder="全部查询词" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部查询词</SelectItem>
|
|
{queries.map((q) => (
|
|
<SelectItem key={q.id} value={q.id}>
|
|
{q.keyword}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="platform-filter">平台</Label>
|
|
<Select value={selectedPlatform} onValueChange={setSelectedPlatform}>
|
|
<SelectTrigger id="platform-filter">
|
|
<SelectValue placeholder="全部平台" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">全部平台</SelectItem>
|
|
{Object.entries(PLATFORM_MAP).map(([key, label]) => (
|
|
<SelectItem key={key} value={key}>
|
|
{label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="start-date">开始日期</Label>
|
|
<Input
|
|
id="start-date"
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="end-date">结束日期</Label>
|
|
<Input
|
|
id="end-date"
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<Button onClick={handleFilter} className="flex-1">
|
|
筛选
|
|
</Button>
|
|
<Button variant="outline" onClick={handleReset} className="flex-1">
|
|
重置
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{citationsError && (
|
|
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
|
{citationsError.message}
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">引用记录列表</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{citations.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<Quote className="mb-4 h-12 w-12 opacity-20" />
|
|
<p>暂无引用记录</p>
|
|
<p className="text-sm">添加查询词并执行查询后将在此显示结果</p>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>平台</TableHead>
|
|
<TableHead>是否引用</TableHead>
|
|
<TableHead>引用位置</TableHead>
|
|
<TableHead>引用文本片段</TableHead>
|
|
<TableHead>竞争品牌</TableHead>
|
|
<TableHead>查询时间</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{citations.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="font-medium">
|
|
{PLATFORM_MAP[item.platform] || item.platform}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.cited ? (
|
|
<div className="flex items-center gap-1 text-emerald-600">
|
|
<Check className="h-4 w-4" />
|
|
<span className="text-sm">已引用</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1 text-red-500">
|
|
<X className="h-4 w-4" />
|
|
<span className="text-sm">未引用</span>
|
|
</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{item.citation_position !== null
|
|
? `第 ${item.citation_position} 位`
|
|
: "—"}
|
|
</TableCell>
|
|
<TableCell className="max-w-xs truncate">
|
|
{item.citation_text || "—"}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-wrap gap-1">
|
|
{item.competitor_brands?.length > 0 ? (
|
|
item.competitor_brands.map((brand) => (
|
|
<Badge key={brand} variant="secondary" className="text-xs">
|
|
{brand}
|
|
</Badge>
|
|
))
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{new Date(item.queried_at).toLocaleString("zh-CN")}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|