feat: U7 — citation export, agent config panel, trends insight page, schema suggestion page
This commit is contained in:
parent
01e83b3589
commit
f182e166dc
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useMemo } from "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 {
|
||||
Table,
|
||||
|
|
@ -21,8 +21,11 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
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 { 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 FilterStatus = "all" | TaskStatus;
|
||||
|
|
@ -76,6 +79,10 @@ export default function AgentsPage() {
|
|||
const [selectedTask, setSelectedTask] = useState<AgentTask | null>(null);
|
||||
const [taskLogs, setTaskLogs] = useState<TaskLog[]>([]);
|
||||
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(() => {
|
||||
|
|
@ -98,6 +105,11 @@ export default function AgentsPage() {
|
|||
fetchTasks();
|
||||
}, [token, filterStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
agentsApi.list(token).then(setAgents).catch(() => {});
|
||||
}, [token]);
|
||||
|
||||
// 获取任务日志
|
||||
const fetchTaskLogs = async (taskId: string) => {
|
||||
if (!token) return;
|
||||
|
|
@ -147,6 +159,27 @@ export default function AgentsPage() {
|
|||
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 }[] = [
|
||||
{ status: "all", label: "全部" },
|
||||
{ status: "running", label: "运行中" },
|
||||
|
|
@ -187,6 +220,46 @@ export default function AgentsPage() {
|
|||
/>
|
||||
</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">
|
||||
{filterButtons.map(({ status, label }) => (
|
||||
|
|
@ -362,6 +435,42 @@ export default function AgentsPage() {
|
|||
)}
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -22,10 +23,11 @@ import {
|
|||
} 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 { Check, X, Quote, Filter, TrendingUp, MapPin, Hash, FileDown, FileText, Loader2 } 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 { reportsApi } from "@/lib/api/reports";
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
|
|
@ -179,11 +181,14 @@ function TrendLineChart({ data }: { data: { date: string; count: number }[] }) {
|
|||
}
|
||||
|
||||
export default function CitationsPage() {
|
||||
const { data: session } = useSession();
|
||||
const token = (session as { accessToken?: string })?.accessToken;
|
||||
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 [exporting, setExporting] = useState(false);
|
||||
|
||||
const citationsUrl = (() => {
|
||||
const params = new URLSearchParams();
|
||||
|
|
@ -239,6 +244,35 @@ export default function CitationsPage() {
|
|||
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) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -253,9 +287,31 @@ export default function CitationsPage() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">引用记录</h2>
|
||||
<p className="text-muted-foreground">查看各平台的引用检测结果</p>
|
||||
<div className="flex items-center 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={() => 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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,6 +16,8 @@ import {
|
|||
Share2,
|
||||
Heart,
|
||||
ScanSearch,
|
||||
TrendingUp,
|
||||
Code2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
|
|
@ -74,6 +76,18 @@ const NAV_GROUPS: NavGroup[] = [
|
|||
href: "/dashboard/detection",
|
||||
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" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue