feat: U7 — citation export, agent config panel, trends insight page, schema suggestion page

This commit is contained in:
chiguyong 2026-06-02 08:11:43 +08:00
parent 01e83b3589
commit f182e166dc
5 changed files with 987 additions and 6 deletions

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { agentsApi, type AgentTask, type TaskLog } from "@/lib/api/agents"; import { agentsApi, type AgentTask, type TaskLog, type Agent } from "@/lib/api/agents";
import { MetricCard } from "@/components/business/metric-card"; import { MetricCard } from "@/components/business/metric-card";
import { import {
Table, Table,
@ -21,8 +21,11 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Bot, CheckCircle2, XCircle, Clock, Loader2, AlertCircle } from "lucide-react"; import { Bot, CheckCircle2, XCircle, Clock, Loader2, AlertCircle, Settings2, Save } from "lucide-react";
type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled";
type FilterStatus = "all" | TaskStatus; type FilterStatus = "all" | TaskStatus;
@ -76,6 +79,10 @@ export default function AgentsPage() {
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null); const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null);
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]); const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
const [loadingLogs, setLoadingLogs] = useState(false); const [loadingLogs, setLoadingLogs] = useState(false);
const [agents, setAgents] = useState<Agent[]>([]);
const [configAgent, setConfigAgent] = useState<Agent | null>(null);
const [configJson, setConfigJson] = useState("");
const [savingConfig, setSavingConfig] = useState(false);
// 获取执行记录 // 获取执行记录
useEffect(() => { useEffect(() => {
@ -98,6 +105,11 @@ export default function AgentsPage() {
fetchTasks(); fetchTasks();
}, [token, filterStatus]); }, [token, filterStatus]);
useEffect(() => {
if (!token) return;
agentsApi.list(token).then(setAgents).catch(() => {});
}, [token]);
// 获取任务日志 // 获取任务日志
const fetchTaskLogs = async (taskId: string) => { const fetchTaskLogs = async (taskId: string) => {
if (!token) return; if (!token) return;
@ -147,6 +159,27 @@ export default function AgentsPage() {
await fetchTaskLogs(task.id); await fetchTaskLogs(task.id);
}; };
const openConfig = (agent: Agent) => {
setConfigAgent(agent);
setConfigJson(JSON.stringify(agent.config ?? {}, null, 2));
};
const saveConfig = async () => {
if (!token || !configAgent) return;
try {
setSavingConfig(true);
const parsed = JSON.parse(configJson);
await agentsApi.updateConfig(token, configAgent.id, parsed);
setConfigAgent(null);
const updated = await agentsApi.list(token);
setAgents(updated);
} catch (err) {
console.error("保存配置失败:", err);
} finally {
setSavingConfig(false);
}
};
const filterButtons: { status: FilterStatus; label: string }[] = [ const filterButtons: { status: FilterStatus; label: string }[] = [
{ status: "all", label: "全部" }, { status: "all", label: "全部" },
{ status: "running", label: "运行中" }, { status: "running", label: "运行中" },
@ -187,6 +220,46 @@ export default function AgentsPage() {
/> />
</div> </div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Settings2 className="h-4 w-4" />
Agent
</CardTitle>
</CardHeader>
<CardContent>
{agents.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center"> Agent</p>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => (
<div key={agent.id} className="flex items-center justify-between rounded-lg border p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{agent.name}</p>
<p className="text-xs text-muted-foreground">{agent.type}</p>
</div>
<Badge
variant="secondary"
className={cn(
"ml-2 shrink-0",
agent.status === "running" && "bg-emerald-100 text-emerald-700",
agent.status === "error" && "bg-red-100 text-red-700",
agent.status === "disabled" && "bg-gray-100 text-gray-600",
agent.status === "idle" && "bg-blue-100 text-blue-700"
)}
>
{agent.status}
</Badge>
<Button variant="ghost" size="sm" className="ml-2 shrink-0" onClick={() => openConfig(agent)}>
<Settings2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* 状态筛选 */} {/* 状态筛选 */}
<div className="flex gap-2"> <div className="flex gap-2">
{filterButtons.map(({ status, label }) => ( {filterButtons.map(({ status, label }) => (
@ -362,6 +435,42 @@ export default function AgentsPage() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={!!configAgent} onOpenChange={() => setConfigAgent(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="h-5 w-5" />
{configAgent?.name}
</DialogTitle>
</DialogHeader>
{configAgent && (
<div className="space-y-4">
<div className="space-y-2">
<Label> (JSON)</Label>
<textarea
className="w-full min-h-[200px] rounded-md border bg-gray-50 p-3 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={configJson}
onChange={(e) => setConfigJson(e.target.value)}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setConfigAgent(null)}>
</Button>
<Button onClick={saveConfig} disabled={savingConfig}>
{savingConfig ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useSession } from "next-auth/react";
import { import {
Table, Table,
TableBody, TableBody,
@ -22,10 +23,11 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PLATFORM_MAP } from "@/lib/platforms"; import { PLATFORM_MAP } from "@/lib/platforms";
import { Check, X, Quote, Filter, TrendingUp, MapPin, Hash } from "lucide-react"; import { Check, X, Quote, Filter, TrendingUp, MapPin, Hash, FileDown, FileText, Loader2 } from "lucide-react";
import { useApi } from "@/lib/hooks/use-api"; import { useApi } from "@/lib/hooks/use-api";
import { LoadingState } from "@/components/ui/api-states"; import { LoadingState } from "@/components/ui/api-states";
import { type CitationStats } from "@/lib/api/citations"; import { type CitationStats } from "@/lib/api/citations";
import { reportsApi } from "@/lib/api/reports";
import { import {
PieChart, PieChart,
Pie, Pie,
@ -179,11 +181,14 @@ function TrendLineChart({ data }: { data: { date: string; count: number }[] }) {
} }
export default function CitationsPage() { export default function CitationsPage() {
const { data: session } = useSession();
const token = (session as { accessToken?: string })?.accessToken;
const [selectedQuery, setSelectedQuery] = useState<string>("all"); const [selectedQuery, setSelectedQuery] = useState<string>("all");
const [selectedPlatform, setSelectedPlatform] = useState<string>("all"); const [selectedPlatform, setSelectedPlatform] = useState<string>("all");
const [startDate, setStartDate] = useState<string>(""); const [startDate, setStartDate] = useState<string>("");
const [endDate, setEndDate] = useState<string>(""); const [endDate, setEndDate] = useState<string>("");
const [filterKey, setFilterKey] = useState(0); const [filterKey, setFilterKey] = useState(0);
const [exporting, setExporting] = useState(false);
const citationsUrl = (() => { const citationsUrl = (() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
@ -239,6 +244,35 @@ export default function CitationsPage() {
setFilterKey((k) => k + 1); setFilterKey((k) => k + 1);
} }
async function handleExport(format: "csv" | "pdf") {
if (!token) return;
const queryId = selectedQuery !== "all" ? selectedQuery : undefined;
try {
setExporting(true);
let blob: Blob;
let filename: string;
if (format === "csv") {
blob = await reportsApi.exportCSV(token, queryId) as unknown as Blob;
filename = `citations_${new Date().toISOString().split("T")[0]}.csv`;
} else {
blob = await reportsApi.exportPDF(token, queryId) as unknown as Blob;
filename = `citations_${new Date().toISOString().split("T")[0]}.pdf`;
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
console.error("导出失败:", err);
} finally {
setExporting(false);
}
}
if (isLoading && citations.length === 0) { if (isLoading && citations.length === 0) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -253,10 +287,32 @@ export default function CitationsPage() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight"></h2> <h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p> <p className="text-muted-foreground"></p>
</div> </div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleExport("csv")}
disabled={exporting || !token}
>
{exporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FileDown className="mr-2 h-4 w-4" />}
CSV
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("pdf")}
disabled={exporting || !token}
>
{exporting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <FileText className="mr-2 h-4 w-4" />}
PDF
</Button>
</div>
</div>
{!statsError && ( {!statsError && (
<div className="space-y-4"> <div className="space-y-4">

View File

@ -0,0 +1,400 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { schemaAdvisorApi, type SchemaSuggestion } from "@/lib/api/schema-advisor";
import { useApi } from "@/lib/hooks/use-api";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
import {
Code2,
CheckCircle2,
XCircle,
AlertTriangle,
RefreshCw,
Eye,
FileJson,
Loader2,
} from "lucide-react";
const VALIDATION_BADGE: Record<string, { label: string; className: string }> = {
valid: { label: "有效", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
invalid: { label: "无效", className: "bg-red-100 text-red-700 hover:bg-red-100" },
pending: { label: "待验证", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" },
};
const STATUS_BADGE: Record<string, { label: string; className: string }> = {
pending: { label: "待处理", className: "bg-gray-100 text-gray-700 hover:bg-gray-100" },
applied: { label: "已应用", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" },
dismissed: { label: "已忽略", className: "bg-red-100 text-red-700 hover:bg-red-100" },
};
export default function SchemaPage() {
const { data: session } = useSession();
const token = (session as { accessToken?: string })?.accessToken;
const [suggestions, setSuggestions] = useState<SchemaSuggestion[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [adviseDialogOpen, setAdviseDialogOpen] = useState(false);
const [targetUrl, setTargetUrl] = useState("");
const [advising, setAdvising] = useState(false);
const [adviseError, setAdviseError] = useState<string | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const [selectedSuggestion, setSelectedSuggestion] = useState<SchemaSuggestion | null>(null);
const [statusUpdating, setStatusUpdating] = useState(false);
const [statusError, setStatusError] = useState<string | null>(null);
const { data: brandsData } = useApi<{ items: { id: string; name: string }[] }>("/api/v1/brands/");
const brandId = brandsData?.items?.[0]?.id ?? "";
async function loadSuggestions() {
if (!token || !brandId) return;
try {
setLoading(true);
setError(null);
const result = await schemaAdvisorApi.getBrandSuggestions(token, brandId);
setSuggestions(result.suggestions ?? []);
} catch (err) {
setError(err instanceof Error ? err.message : "获取 Schema 建议失败");
} finally {
setLoading(false);
}
}
useEffect(() => {
if (token && brandId) loadSuggestions();
}, [token, brandId]);
async function handleAdvise() {
if (!token || !brandId) return;
try {
setAdvising(true);
setAdviseError(null);
await schemaAdvisorApi.advise(token, {
brand_id: brandId,
target_url: targetUrl || undefined,
});
setAdviseDialogOpen(false);
setTargetUrl("");
loadSuggestions();
} catch (err) {
setAdviseError(err instanceof Error ? err.message : "生成建议失败");
} finally {
setAdvising(false);
}
}
function openDetail(suggestion: SchemaSuggestion) {
setSelectedSuggestion(suggestion);
setStatusError(null);
setDetailDialogOpen(true);
}
async function handleUpdateStatus(suggestionId: string, status: string) {
if (!token) return;
try {
setStatusUpdating(true);
setStatusError(null);
await schemaAdvisorApi.updateStatus(token, suggestionId, status);
setDetailDialogOpen(false);
setSelectedSuggestion(null);
loadSuggestions();
} catch (err) {
setStatusError(err instanceof Error ? err.message : "状态更新失败");
} finally {
setStatusUpdating(false);
}
}
if (loading) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schema </h2>
<p className="text-muted-foreground"></p>
</div>
</div>
<LoadingState rows={5} rowHeight="h-14" />
</div>
);
}
if (error) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schema </h2>
<p className="text-muted-foreground"></p>
</div>
</div>
<ErrorState error={error} onRetry={loadSuggestions} />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Schema </h2>
<p className="text-muted-foreground"> AI </p>
</div>
<Button onClick={() => { setTargetUrl(""); setAdviseError(null); setAdviseDialogOpen(true); }} disabled={!brandId}>
<Code2 className="mr-2 h-4 w-4" />
</Button>
</div>
{suggestions.length === 0 ? (
<EmptyState
icon={<FileJson className="h-6 w-6 text-gray-400" />}
message="暂无 Schema 建议"
description="点击右上角按钮生成结构化数据优化建议"
action={
<Button onClick={() => { setTargetUrl(""); setAdviseError(null); setAdviseDialogOpen(true); }} disabled={!brandId}>
<Code2 className="mr-2 h-4 w-4" />
</Button>
}
/>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Schema </TableHead>
<TableHead> URL</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((s) => {
const vCfg = VALIDATION_BADGE[s.validation_status ?? "pending"] ?? {
label: s.validation_status ?? "未知",
className: "bg-gray-100 text-gray-600",
};
const sCfg = STATUS_BADGE[s.status] ?? {
label: s.status,
className: "bg-gray-100 text-gray-600",
};
return (
<TableRow
key={s.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => openDetail(s)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<FileJson className="h-4 w-4 text-muted-foreground" />
{s.schema_type}
</div>
</TableCell>
<TableCell className="max-w-[200px] truncate text-muted-foreground">
{s.target_url ?? "—"}
</TableCell>
<TableCell>
<Badge variant="secondary" className={vCfg.className}>
{s.validation_status === "valid" && <CheckCircle2 className="mr-1 h-3 w-3" />}
{s.validation_status === "invalid" && <XCircle className="mr-1 h-3 w-3" />}
{s.validation_status === "pending" && <AlertTriangle className="mr-1 h-3 w-3" />}
{vCfg.label}
</Badge>
</TableCell>
<TableCell>
{s.priority !== null ? (
<Badge variant="outline">{s.priority}</Badge>
) : "—"}
</TableCell>
<TableCell>
<Badge variant="secondary" className={sCfg.className}>
{sCfg.label}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(s.created_at).toLocaleString("zh-CN")}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); openDetail(s); }}
>
<Eye className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
<Dialog open={adviseDialogOpen} onOpenChange={setAdviseDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle> Schema </DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="target-url"> URL</Label>
<Input
id="target-url"
placeholder="https://example.com/page"
value={targetUrl}
onChange={(e) => setTargetUrl(e.target.value)}
/>
</div>
{adviseError && (
<p className="text-xs text-destructive">{adviseError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAdviseDialogOpen(false)}>
</Button>
<Button onClick={handleAdvise} disabled={advising}>
{advising && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileJson className="h-5 w-5" />
{selectedSuggestion?.schema_type}
</DialogTitle>
</DialogHeader>
{selectedSuggestion && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground"> URL</span>
<span className="font-medium">{selectedSuggestion.target_url ?? "—"}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<span className="font-medium">{selectedSuggestion.priority ?? "—"}</span>
</div>
<div>
<span className="text-muted-foreground"></span>
<Badge
variant="secondary"
className={
VALIDATION_BADGE[selectedSuggestion.validation_status ?? "pending"]
?.className ?? "bg-gray-100 text-gray-600"
}
>
{VALIDATION_BADGE[selectedSuggestion.validation_status ?? "pending"]?.label ?? "未知"}
</Badge>
</div>
<div>
<span className="text-muted-foreground"></span>
<Badge
variant="secondary"
className={
STATUS_BADGE[selectedSuggestion.status]?.className ?? "bg-gray-100 text-gray-600"
}
>
{STATUS_BADGE[selectedSuggestion.status]?.label ?? selectedSuggestion.status}
</Badge>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
<Code2 className="h-4 w-4" />
JSON-LD
</div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-4 text-xs text-gray-100">
{JSON.stringify(selectedSuggestion.json_ld, null, 2)}
</pre>
</div>
{selectedSuggestion.validation_errors && selectedSuggestion.validation_errors.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm font-medium text-red-600">
<XCircle className="h-4 w-4" />
</div>
<ul className="space-y-1 rounded-lg border border-red-200 bg-red-50 p-3">
{selectedSuggestion.validation_errors.map((err, i) => (
<li key={i} className="text-xs text-red-700">
{err}
</li>
))}
</ul>
</div>
)}
{statusError && (
<p className="text-xs text-destructive">{statusError}</p>
)}
{selectedSuggestion.status === "pending" && (
<div className="flex gap-2 pt-2">
<Button
onClick={() => handleUpdateStatus(selectedSuggestion.id, "applied")}
disabled={statusUpdating}
>
{statusUpdating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckCircle2 className="mr-2 h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => handleUpdateStatus(selectedSuggestion.id, "dismissed")}
disabled={statusUpdating}
>
<XCircle className="mr-2 h-4 w-4" />
</Button>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -0,0 +1,402 @@
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { useApi } from "@/lib/hooks/use-api";
import { trendsApi, type TrendInsight, type TrendSummary } from "@/lib/api/trends";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
import { TrendingUp, TrendingDown, RefreshCw, Eye, Clock, BarChart3, Loader2 } from "lucide-react";
interface BrandsResponse {
items: { id: string; name: string }[];
}
function TrendDirectionIcon({ direction }: { direction: string }) {
if (direction === "up" || direction === "rising") {
return <TrendingUp className="h-5 w-5 text-emerald-600" />;
}
if (direction === "down" || direction === "declining") {
return <TrendingDown className="h-5 w-5 text-red-500" />;
}
return <BarChart3 className="h-5 w-5 text-amber-500" />;
}
function TrendDirectionBadge({ direction }: { direction: string }) {
const config: Record<string, { variant: "default" | "destructive" | "secondary" | "outline"; label: string }> = {
up: { variant: "default", label: "上升" },
rising: { variant: "default", label: "上升" },
down: { variant: "destructive", label: "下降" },
declining: { variant: "destructive", label: "下降" },
stable: { variant: "secondary", label: "平稳" },
flat: { variant: "secondary", label: "平稳" },
};
const c = config[direction] || { variant: "outline" as const, label: direction };
return <Badge variant={c.variant} className="text-xs">{c.label}</Badge>;
}
function InsightTypeBadge({ type }: { type: string }) {
const typeMap: Record<string, string> = {
keyword_trend: "关键词趋势",
sentiment: "情感分析",
platform_comparison: "平台对比",
competitor_movement: "竞品动态",
content_gap: "内容缺口",
};
return <Badge variant="outline" className="text-xs">{typeMap[type] || type}</Badge>;
}
function JsonViewer({ data }: { data: Record<string, unknown> }) {
return (
<pre className="rounded-lg border bg-muted/50 p-4 text-xs leading-relaxed overflow-auto max-h-[400px]">
{JSON.stringify(data, null, 2)}
</pre>
);
}
export default function TrendsPage() {
const { data: session } = useSession();
const token = (session as { accessToken?: string })?.accessToken;
const { data: brandsData } = useApi<BrandsResponse>("/api/v1/brands/");
const brandId = brandsData?.items?.[0]?.id ?? null;
const [insights, setInsights] = useState<TrendInsight[]>([]);
const [insightsLoading, setInsightsLoading] = useState(true);
const [insightsError, setInsightsError] = useState<Error | null>(null);
const [summary, setSummary] = useState<TrendSummary | null>(null);
const [summaryLoading, setSummaryLoading] = useState(true);
const [summaryError, setSummaryError] = useState<Error | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedInsight, setSelectedInsight] = useState<TrendInsight | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [periodDays, setPeriodDays] = useState<string>("7");
const [creating, setCreating] = useState(false);
useEffect(() => {
if (!token || !brandId) return;
setInsightsLoading(true);
setInsightsError(null);
trendsApi
.getBrandInsights(token, brandId)
.then((res) => setInsights(res.items ?? []))
.catch((err) => setInsightsError(err instanceof Error ? err : new Error(String(err))))
.finally(() => setInsightsLoading(false));
setSummaryLoading(true);
setSummaryError(null);
trendsApi
.getSummary(token, brandId)
.then((res) => setSummary(res))
.catch((err) => setSummaryError(err instanceof Error ? err : new Error(String(err))))
.finally(() => setSummaryLoading(false));
}, [token, brandId]);
async function handleRefresh() {
if (!token || !brandId) return;
setInsightsLoading(true);
setInsightsError(null);
trendsApi
.getBrandInsights(token, brandId)
.then((res) => setInsights(res.items ?? []))
.catch((err) => setInsightsError(err instanceof Error ? err : new Error(String(err))))
.finally(() => setInsightsLoading(false));
setSummaryLoading(true);
setSummaryError(null);
trendsApi
.getSummary(token, brandId)
.then((res) => setSummary(res))
.catch((err) => setSummaryError(err instanceof Error ? err : new Error(String(err))))
.finally(() => setSummaryLoading(false));
}
async function handleViewDetail(insightId: string) {
if (!token) return;
setDetailLoading(true);
setSelectedInsight(null);
setDetailOpen(true);
try {
const result = await trendsApi.getInsight(token, insightId);
setSelectedInsight(result);
} catch {
setSelectedInsight(null);
} finally {
setDetailLoading(false);
}
}
async function handleCreateInsight() {
if (!token || !brandId) return;
setCreating(true);
try {
await trendsApi.createInsight(token, {
brand_id: brandId,
period_days: Number(periodDays),
});
setCreateOpen(false);
handleRefresh();
} catch {
} finally {
setCreating(false);
}
}
if (!token || !brandId) {
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-24" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={insightsLoading}>
<RefreshCw className={`mr-2 h-4 w-4 ${insightsLoading ? "animate-spin" : ""}`} />
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<TrendingUp className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{summaryError ? (
<ErrorState error={summaryError} onRetry={handleRefresh} />
) : summaryLoading ? (
<LoadingState rows={1} rowHeight="h-32" />
) : summary ? (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<BarChart3 className="h-4 w-4" />
</CardTitle>
<Badge variant="outline" className="text-xs">
{summary.period_days}
</Badge>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-8">
<div className="flex items-center gap-3">
<TrendDirectionIcon direction={summary.trend_direction} />
<div>
<p className="text-sm text-muted-foreground"></p>
<TrendDirectionBadge direction={summary.trend_direction} />
</div>
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground mb-2"></p>
<div className="flex flex-wrap gap-1.5">
{summary.hotspot_keywords?.length > 0 ? (
summary.hotspot_keywords.map((kw) => (
<Badge key={kw} variant="secondary" className="text-xs">
{kw}
</Badge>
))
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
) : null}
{insightsError ? (
<ErrorState error={insightsError} onRetry={handleRefresh} />
) : insightsLoading ? (
<LoadingState rows={5} rowHeight="h-12" />
) : insights.length === 0 ? (
<EmptyState
icon={<TrendingUp className="h-6 w-6 text-muted-foreground" />}
message="暂无洞察记录"
description="点击「生成洞察」按钮创建趋势洞察分析"
action={
<Button size="sm" onClick={() => setCreateOpen(true)}>
<TrendingUp className="mr-2 h-4 w-4" />
</Button>
}
/>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{insights.map((insight) => (
<TableRow key={insight.id}>
<TableCell>
<InsightTypeBadge type={insight.insight_type} />
</TableCell>
<TableCell className="text-muted-foreground">
{insight.period_start && insight.period_end ? (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(insight.period_start).toLocaleDateString("zh-CN")} ~ {new Date(insight.period_end).toLocaleDateString("zh-CN")}
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="text-muted-foreground">
{new Date(insight.created_at).toLocaleString("zh-CN")}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
onClick={() => handleViewDetail(insight.id)}
>
<Eye className="mr-1 h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
{detailLoading ? (
<LoadingState rows={3} rowHeight="h-16" />
) : selectedInsight ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<InsightTypeBadge type={selectedInsight.insight_type} />
{selectedInsight.period_start && selectedInsight.period_end && (
<span className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{new Date(selectedInsight.period_start).toLocaleDateString("zh-CN")} ~ {new Date(selectedInsight.period_end).toLocaleDateString("zh-CN")}
</span>
)}
</div>
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground"></p>
<JsonViewer data={selectedInsight.data} />
</div>
{selectedInsight.recommendations && selectedInsight.recommendations.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground"></p>
<ul className="space-y-1.5">
{selectedInsight.recommendations.map((rec, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-blue-500 shrink-0" />
<span>{rec}</span>
</li>
))}
</ul>
</div>
)}
</div>
) : (
<div className="py-8 text-center text-muted-foreground"></div>
)}
</DialogContent>
</Dialog>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium"></p>
<Select value={periodDays} onValueChange={setPeriodDays}>
<SelectTrigger>
<SelectValue placeholder="选择分析周期" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7"> 7 </SelectItem>
<SelectItem value="14"> 14 </SelectItem>
<SelectItem value="30"> 30 </SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setCreateOpen(false)} disabled={creating}>
</Button>
<Button onClick={handleCreateInsight} disabled={creating}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -16,6 +16,8 @@ import {
Share2, Share2,
Heart, Heart,
ScanSearch, ScanSearch,
TrendingUp,
Code2,
Settings, Settings,
} from "lucide-react"; } from "lucide-react";
@ -74,6 +76,18 @@ const NAV_GROUPS: NavGroup[] = [
href: "/dashboard/detection", href: "/dashboard/detection",
icon: <ScanSearch className="h-5 w-5" />, icon: <ScanSearch className="h-5 w-5" />,
}, },
{
id: "trends",
label: "趋势洞察",
href: "/dashboard/trends",
icon: <TrendingUp className="h-5 w-5" />,
},
{
id: "schema",
label: "Schema 建议",
href: "/dashboard/schema",
icon: <Code2 className="h-5 w-5" />,
},
], ],
}, },
{ {