402 lines
13 KiB
TypeScript
402 lines
13 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, 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 { api } from "@/lib/api";
|
||
import {
|
||
Loader2,
|
||
FileDown,
|
||
FileText,
|
||
Info,
|
||
CheckCircle,
|
||
AlertTriangle,
|
||
Search,
|
||
Quote,
|
||
Percent,
|
||
BarChart3,
|
||
} from "lucide-react";
|
||
|
||
interface QueryOption {
|
||
id: string;
|
||
keyword: string;
|
||
}
|
||
|
||
interface CitationStats {
|
||
total_queries: number;
|
||
total_citations: number;
|
||
citation_rate: number;
|
||
avg_position: number | null;
|
||
}
|
||
|
||
interface CitationRecord {
|
||
id: string;
|
||
query_id: string;
|
||
platform: string;
|
||
cited: boolean;
|
||
citation_position: number | null;
|
||
citation_text: string | null;
|
||
competitor_brands: string[];
|
||
confidence: number | null;
|
||
match_type: string | null;
|
||
queried_at: string;
|
||
}
|
||
|
||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
|
||
|
||
export default function ReportsPage() {
|
||
const { data: session } = useSession();
|
||
const [queries, setQueries] = useState<QueryOption[]>([]);
|
||
const [selectedQuery, setSelectedQuery] = useState<string>("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [success, setSuccess] = useState(false);
|
||
|
||
const [stats, setStats] = useState<CitationStats | null>(null);
|
||
const [recentCitations, setRecentCitations] = useState<CitationRecord[]>([]);
|
||
const [previewLoading, setPreviewLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (!session?.accessToken) return;
|
||
loadQueries();
|
||
loadPreview();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [session?.accessToken]);
|
||
|
||
async function loadQueries() {
|
||
try {
|
||
const data = await api.queries.list(session!.accessToken);
|
||
setQueries(data.items || []);
|
||
setError(null);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "加载查询词列表失败");
|
||
}
|
||
}
|
||
|
||
async function loadPreview() {
|
||
if (!session?.accessToken) return;
|
||
setPreviewLoading(true);
|
||
try {
|
||
const statsData = await api.citations.getStats(session.accessToken);
|
||
setStats(statsData);
|
||
const listData = await api.citations.list(session.accessToken, "limit=10");
|
||
setRecentCitations(listData.items || []);
|
||
} catch (err) {
|
||
// 预览数据加载失败不影响主功能
|
||
console.error("预览数据加载失败", err);
|
||
} finally {
|
||
setPreviewLoading(false);
|
||
}
|
||
}
|
||
|
||
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 api.reports.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: stats?.total_queries ?? 0,
|
||
icon: Search,
|
||
color: "text-blue-600",
|
||
bg: "bg-blue-50",
|
||
},
|
||
{
|
||
title: "引用次数",
|
||
value: stats?.total_citations ?? 0,
|
||
icon: Quote,
|
||
color: "text-emerald-600",
|
||
bg: "bg-emerald-50",
|
||
},
|
||
{
|
||
title: "引用率",
|
||
value: stats ? `${stats.citation_rate.toFixed(1)}%` : "0%",
|
||
icon: Percent,
|
||
color: "text-violet-600",
|
||
bg: "bg-violet-50",
|
||
},
|
||
{
|
||
title: "平均位置",
|
||
value: stats?.avg_position ? stats.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={cn("flex h-12 w-12 items-center justify-center rounded-lg", card.bg)}>
|
||
<card.icon className={cn("h-6 w-6", card.color)} />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-muted-foreground">{card.title}</p>
|
||
<p className="text-2xl font-bold">
|
||
{previewLoading ? <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>
|
||
{previewLoading ? (
|
||
<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>
|
||
);
|
||
}
|
||
|
||
function cn(...classes: (string | undefined | false)[]) {
|
||
return classes.filter(Boolean).join(" ");
|
||
}
|