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

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>
);
}