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

347 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { reportsApi } from "@/lib/api/reports";
import type { QueryListResponse } from "@/lib/api/queries";
import type { CitationListResponse, CitationStats } from "@/lib/api/citations";
import { useApi } from "@/lib/hooks/use-api";
import { clsx } from "clsx";
import {
Loader2,
FileDown,
FileText,
Info,
CheckCircle,
AlertTriangle,
Search,
Quote,
Percent,
BarChart3,
} from "lucide-react";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export default function ReportsPage() {
const { data: session } = useSession();
const [selectedQuery, setSelectedQuery] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const { data: queriesData } = useApi<QueryListResponse>("/api/v1/queries/");
const { data: statsData, isLoading: statsLoading } = useApi<CitationStats>("/api/v1/citations/stats/");
const { data: citationsData, isLoading: citationsLoading } =
useApi<CitationListResponse>("/api/v1/citations/?limit=10");
const queries = queriesData?.items ?? [];
const recentCitations = citationsData?.items ?? [];
async function handleExportCSV() {
if (!session?.accessToken) return;
if (!selectedQuery) {
setError("请先选择要导出的查询词");
return;
}
await handleExport("csv");
}
async function handleExportPDF() {
if (!session?.accessToken) return;
if (!selectedQuery) {
setError("请先选择要导出的查询词");
return;
}
await handleExport("pdf");
}
async function handleExport(format: "csv" | "pdf") {
if (!session?.accessToken) return;
try {
setLoading(true);
setError(null);
setSuccess(false);
const queryId = selectedQuery;
let blob: Blob;
let filename: string;
if (format === "csv") {
const query = `?query_id=${queryId}`;
const url = `${API_BASE}/api/v1/reports/export/csv${query}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${session.accessToken}` },
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({ detail: "导出失败" }));
throw new Error(errorData.detail || `HTTP ${res.status}`);
}
blob = await res.blob();
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.csv`;
} else {
blob = await reportsApi.exportPDF(session.accessToken, queryId);
filename = `report_${queryId}_${new Date().toISOString().split("T")[0]}.pdf`;
}
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = downloadUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(downloadUrl);
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (err) {
setError(err instanceof Error ? err.message : "导出失败");
} finally {
setLoading(false);
}
}
const statCards = [
{
title: "总查询",
value: statsData?.total_queries ?? 0,
icon: Search,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "引用次数",
value: statsData?.total_citations ?? 0,
icon: Quote,
color: "text-emerald-600",
bg: "bg-emerald-50",
},
{
title: "引用率",
value: statsData ? `${statsData.citation_rate.toFixed(1)}%` : "0%",
icon: Percent,
color: "text-violet-600",
bg: "bg-violet-50",
},
{
title: "平均位置",
value: statsData?.avg_position ? statsData.avg_position.toFixed(1) : "-",
icon: BarChart3,
color: "text-amber-600",
bg: "bg-amber-50",
},
];
function formatDate(dateStr: string) {
if (!dateStr) return "-";
const d = new Date(dateStr);
return d.toLocaleDateString("zh-CN") + " " + d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
}
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">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileDown className="h-4 w-4" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="query-select"></Label>
<Select value={selectedQuery} onValueChange={setSelectedQuery}>
<SelectTrigger id="query-select">
<SelectValue placeholder="请选择查询词" />
</SelectTrigger>
<SelectContent>
{queries.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.keyword}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
)}
{success && (
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
<CheckCircle className="h-4 w-4 shrink-0" />
<span></span>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleExportCSV}
disabled={loading}
className="flex-1"
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileDown className="mr-2 h-4 w-4" />
)}
CSV
</Button>
<Button
onClick={handleExportPDF}
disabled={loading}
variant="outline"
className="flex-1"
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<FileText className="mr-2 h-4 w-4" />
)}
PDF
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Info className="h-4 w-4" />
使
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>1. </p>
<p>2. CSV PDF </p>
<p>3. </p>
<ul className="ml-4 list-disc space-y-1">
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<p>4. </p>
</CardContent>
</Card>
</div>
{/* Stats Preview */}
<div>
<h3 className="mb-4 text-lg font-semibold"></h3>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{statCards.map((card) => (
<Card key={card.title}>
<CardContent className="flex items-center gap-4 p-6">
<div className={clsx("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
<card.icon className={clsx("h-6 w-6", card.color)} />
</div>
<div>
<p className="text-sm text-muted-foreground">{card.title}</p>
<p className="text-2xl font-bold">
{statsLoading ? <Loader2 className="h-5 w-5 animate-spin" /> : card.value}
</p>
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Recent Citations */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription> 10 </CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{citationsLoading ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
<Loader2 className="mx-auto h-5 w-5 animate-spin" />
</TableCell>
</TableRow>
) : recentCitations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
recentCitations.map((c) => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.platform}</TableCell>
<TableCell>
{c.cited ? (
<Badge variant="default" className="bg-emerald-500 hover:bg-emerald-600">
</Badge>
) : (
<Badge variant="secondary"></Badge>
)}
</TableCell>
<TableCell>{c.citation_position ?? "-"}</TableCell>
<TableCell className="max-w-xs truncate" title={c.citation_text || ""}>
{c.citation_text || "-"}
</TableCell>
<TableCell>
{c.competitor_brands?.length > 0 ? c.competitor_brands.join(", ") : "-"}
</TableCell>
<TableCell className="text-muted-foreground">{formatDate(c.queried_at)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
);
}