347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|