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 { 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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,
|
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" />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue