From 01e83b3589eb9c8da26c5771cdb23b1a0e5f988b Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 2 Jun 2026 07:59:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20U3-U6=20=E2=80=94=20onboarding=20auto-c?= =?UTF-8?q?reate=20monitoring,=20citation=20stats=20viz,=20health=20score?= =?UTF-8?q?=20page,=20detection=20tasks=20+=20dashboard=20agent=20activity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(dashboard)/dashboard/citations/page.tsx | 217 +++++++- .../(dashboard)/dashboard/detection/page.tsx | 479 ++++++++++++++++++ .../dashboard/health-score/page.tsx | 353 +++++++++++++ frontend/app/(dashboard)/dashboard/page.tsx | 160 +++++- frontend/app/(dashboard)/layout.tsx | 14 + frontend/app/(dashboard)/onboarding/page.tsx | 3 + frontend/lib/api/citations.ts | 28 +- frontend/lib/api/detection.ts | 48 +- frontend/lib/api/index.ts | 7 +- frontend/lib/api/scoring.ts | 46 ++ frontend/lib/hooks/use-onboarding-data.ts | 34 ++ 11 files changed, 1351 insertions(+), 38 deletions(-) create mode 100644 frontend/app/(dashboard)/dashboard/detection/page.tsx create mode 100644 frontend/app/(dashboard)/dashboard/health-score/page.tsx create mode 100644 frontend/lib/api/scoring.ts diff --git a/frontend/app/(dashboard)/dashboard/citations/page.tsx b/frontend/app/(dashboard)/dashboard/citations/page.tsx index 7e3df9d..be7a317 100644 --- a/frontend/app/(dashboard)/dashboard/citations/page.tsx +++ b/frontend/app/(dashboard)/dashboard/citations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Table, TableBody, @@ -22,9 +22,22 @@ import { } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { PLATFORM_MAP } from "@/lib/platforms"; -import { Check, X, Quote, Filter } from "lucide-react"; +import { Check, X, Quote, Filter, TrendingUp, MapPin, Hash } 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 { + PieChart, + Pie, + Cell, + Tooltip as RechartsTooltip, + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; interface CitationItem { id: string; @@ -42,15 +55,136 @@ interface QueryOption { keyword: string; } +const PIE_FALLBACK_COLORS = [ + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", +]; + +function getChartColor(index: number): string { + if (typeof document !== "undefined") { + const style = getComputedStyle(document.documentElement); + const cssVar = style.getPropertyValue(`--chart-${index + 1}`).trim(); + if (cssVar) return `hsl(var(--chart-${index + 1}))`; + } + return PIE_FALLBACK_COLORS[index % PIE_FALLBACK_COLORS.length]; +} + +function StatCard({ + title, + value, + icon: Icon, + suffix, +}: { + title: string; + value: string | number; + icon: React.ElementType; + suffix?: string; +}) { + return ( + + + {title} + + + +
+ {value} + {suffix && {suffix}} +
+
+
+ ); +} + +function PlatformPieChart({ data }: { data: { platform: string; count: number }[] }) { + const chartData = useMemo( + () => + data.map((d) => ({ + name: PLATFORM_MAP[d.platform] || d.platform, + value: d.count, + })), + [data] + ); + + return ( + + + + {chartData.map((_, index) => ( + + ))} + + [value, "引用数"]} + /> + + + ); +} + +function TrendLineChart({ data }: { data: { date: string; count: number }[] }) { + return ( + + + + { + const date = new Date(value); + return `${date.getMonth() + 1}/${date.getDate()}`; + }} + /> + + { + const date = new Date(value); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + }} + formatter={(value: number) => [`引用次数: ${value}`, ""]} + /> + + + + ); +} + export default function CitationsPage() { const [selectedQuery, setSelectedQuery] = useState("all"); const [selectedPlatform, setSelectedPlatform] = useState("all"); const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - // 用于手动触发筛选 const [filterKey, setFilterKey] = useState(0); - // 构建引用记录查询 URL const citationsUrl = (() => { const params = new URLSearchParams(); if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery); @@ -58,10 +192,18 @@ export default function CitationsPage() { if (startDate) params.append("start_date", startDate); if (endDate) params.append("end_date", endDate); const qs = params.toString(); - // filterKey 作为虚拟参数,即使筛选条件不变也允许重新请求 return `/api/v1/citations/${qs ? `?${qs}&_k=${filterKey}` : `?_k=${filterKey}`}`; })(); + const statsUrl = useMemo(() => { + const params = new URLSearchParams(); + if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery); + if (startDate) params.append("start_date", startDate); + if (endDate) params.append("end_date", endDate); + const qs = params.toString(); + return `/api/v1/citations/stats${qs ? `?${qs}&_k=${filterKey}` : `?_k=${filterKey}`}`; + }, [selectedQuery, startDate, endDate, filterKey]); + const { data: citationsData, isLoading, @@ -76,6 +218,12 @@ export default function CitationsPage() { data: queriesData, } = useApi<{ items: QueryOption[] }>("/api/v1/queries/"); + const { + data: statsData, + isLoading: statsLoading, + error: statsError, + } = useApi(statsUrl, { dedupingInterval: 0 }); + const citations: CitationItem[] = citationsData?.items ?? []; const queries: QueryOption[] = queriesData?.items ?? []; @@ -110,6 +258,65 @@ export default function CitationsPage() {

查看各平台的引用检测结果

+ {!statsError && ( +
+ {statsLoading ? ( + + ) : statsData ? ( + <> +
+ + + +
+
+ + + 平台分布 + + + {statsData.platform_distribution?.length > 0 ? ( + + ) : ( +
+ 暂无平台分布数据 +
+ )} +
+
+ + + 30天趋势 + + + {statsData.trend?.length > 0 ? ( + + ) : ( +
+ 暂无趋势数据 +
+ )} +
+
+
+ + ) : null} +
+ )} + diff --git a/frontend/app/(dashboard)/dashboard/detection/page.tsx b/frontend/app/(dashboard)/dashboard/detection/page.tsx new file mode 100644 index 0000000..4576900 --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/detection/page.tsx @@ -0,0 +1,479 @@ +"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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { detectionApi, type DetectionTask } from "@/lib/api/detection"; +import type { QueryListResponse, ApiQueryItem } from "@/lib/api/queries"; +import { fetchWithAuth } from "@/lib/api/client"; +import { PLATFORM_MAP, PLATFORMS } from "@/lib/platforms"; +import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states"; +import { + Plus, + Trash2, + Play, + Loader2, + ScanSearch, + CheckCircle, +} from "lucide-react"; + +const FREQUENCY_MAP: Record = { + daily: "每日", + weekly: "每周", + hourly: "每小时", +}; + +const STATUS_CONFIG: Record = { + active: { label: "运行中", className: "bg-emerald-100 text-emerald-700 hover:bg-emerald-100" }, + paused: { label: "已暂停", className: "bg-amber-100 text-amber-700 hover:bg-amber-100" }, + completed: { label: "已完成", className: "bg-blue-100 text-blue-700 hover:bg-blue-100" }, +}; + +interface CreateFormData { + query_id: string; + platforms: string[]; + frequency: string; +} + +const emptyForm: CreateFormData = { + query_id: "", + platforms: [], + frequency: "weekly", +}; + +export default function DetectionPage() { + const { data: session } = useSession(); + const token = (session as { accessToken?: string })?.accessToken; + + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [queries, setQueries] = useState([]); + const [dialogOpen, setDialogOpen] = useState(false); + const [formData, setFormData] = useState(emptyForm); + const [saving, setSaving] = useState(false); + const [formErrors, setFormErrors] = useState>({}); + const [mutationError, setMutationError] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + const [actionLoading, setActionLoading] = useState(null); + + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingId, setDeletingId] = useState(null); + const [deleting, setDeleting] = useState(false); + + async function loadTasks() { + if (!token) return; + try { + setLoading(true); + setError(null); + const result = await detectionApi.listTasks(token); + setTasks(result.items ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : "获取检测任务失败"); + } finally { + setLoading(false); + } + } + + async function loadQueries() { + try { + const result = await fetchWithAuth("/api/v1/queries/") as QueryListResponse; + setQueries(result.items ?? []); + } catch { + // ignore + } + } + + useEffect(() => { + if (token) loadTasks(); + }, [token]); + + function showSuccess(msg: string) { + setSuccessMsg(msg); + setTimeout(() => setSuccessMsg(null), 3000); + } + + function openAddDialog() { + setFormData(emptyForm); + setFormErrors({}); + setMutationError(null); + setDialogOpen(true); + loadQueries(); + } + + function togglePlatform(platform: string) { + setFormData((prev) => { + const platforms = prev.platforms.includes(platform) + ? prev.platforms.filter((p) => p !== platform) + : [...prev.platforms, platform]; + return { ...prev, platforms }; + }); + } + + function validateForm(): boolean { + const errors: Record = {}; + if (!formData.query_id) errors.query_id = "请选择查询词"; + if (formData.platforms.length === 0) errors.platforms = "请至少选择一个平台"; + setFormErrors(errors); + return Object.keys(errors).length === 0; + } + + async function handleCreate() { + if (!validateForm() || !token) return; + try { + setSaving(true); + setMutationError(null); + await detectionApi.createTask(token, { + query_id: formData.query_id, + platforms: formData.platforms, + frequency: formData.frequency, + }); + setDialogOpen(false); + showSuccess("创建成功"); + loadTasks(); + } catch (err) { + setMutationError(err instanceof Error ? err.message : "创建失败"); + } finally { + setSaving(false); + } + } + + function openDeleteDialog(id: string) { + setDeletingId(id); + setDeleteDialogOpen(true); + } + + async function handleDelete() { + if (!deletingId || !token) return; + try { + setDeleting(true); + await detectionApi.deleteTask(token, deletingId); + setDeleteDialogOpen(false); + setDeletingId(null); + showSuccess("删除成功"); + loadTasks(); + } catch (err) { + setMutationError(err instanceof Error ? err.message : "删除失败"); + } finally { + setDeleting(false); + } + } + + async function handleTrigger(taskId: string) { + if (!token) return; + setActionLoading(taskId); + setMutationError(null); + try { + await detectionApi.triggerTask(token, taskId); + showSuccess("检测已触发"); + loadTasks(); + } catch (err) { + setMutationError(err instanceof Error ? err.message : "触发检测失败"); + } finally { + setActionLoading(null); + } + } + + if (loading) { + return ( +
+
+
+

检测任务

+

管理AI搜索检测任务

+
+
+ +
+ ); + } + + if (error) { + return ( +
+
+
+

检测任务

+

管理AI搜索检测任务

+
+
+ +
+ ); + } + + return ( +
+
+
+

检测任务

+

管理AI搜索检测任务

+
+ + + + + + + 新建检测任务 + + 配置新的AI搜索检测任务 + + +
+
+ + + {formErrors.query_id && ( +

{formErrors.query_id}

+ )} +
+
+ +
+ {PLATFORMS.map((p) => ( + + ))} +
+ {formErrors.platforms && ( +

{formErrors.platforms}

+ )} +
+
+ + +
+ {mutationError && ( +

{mutationError}

+ )} +
+ + + + +
+
+
+ + {successMsg && ( +
+ + {successMsg} +
+ )} + + {mutationError && !dialogOpen && ( +
+ {mutationError} +
+ )} + + {tasks.length === 0 ? ( + } + message="暂无检测任务" + description="点击右上角按钮创建您的第一个检测任务" + /> + ) : ( + + + 检测任务列表 + + +
+ + + + 查询词 + 平台 + 频率 + 状态 + 上次运行 + 下次运行 + 操作 + + + + {tasks.map((task) => { + const matchedQuery = queries.find((q) => q.id === task.query_id); + const statusCfg = STATUS_CONFIG[task.status] ?? { + label: task.status, + className: "bg-gray-100 text-gray-600", + }; + return ( + + + {matchedQuery?.keyword ?? task.query_id} + + +
+ {task.platforms.map((p) => ( + + {PLATFORM_MAP[p] || p} + + ))} +
+
+ + {FREQUENCY_MAP[task.frequency] || task.frequency} + + + + {statusCfg.label} + + + + {task.last_run_at + ? new Date(task.last_run_at).toLocaleString("zh-CN") + : "从未"} + + + {task.next_run_at + ? new Date(task.next_run_at).toLocaleString("zh-CN") + : "—"} + + +
+ + +
+
+
+ ); + })} +
+
+
+
+
+ )} + + + + + 确认删除 + + 删除后无法恢复,确定要删除这个检测任务吗? + + + + + + + + +
+ ); +} diff --git a/frontend/app/(dashboard)/dashboard/health-score/page.tsx b/frontend/app/(dashboard)/dashboard/health-score/page.tsx new file mode 100644 index 0000000..ce57303 --- /dev/null +++ b/frontend/app/(dashboard)/dashboard/health-score/page.tsx @@ -0,0 +1,353 @@ +"use client"; + +import * as React from "react"; +import { useSession } from "next-auth/react"; +import { useApi } from "@/lib/hooks/use-api"; +import { scoringApi, BrandScore, BrandCompare, ScoreHistory } from "@/lib/api/scoring"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states"; +import { CompetitorRadarChart } from "@/components/charts/CompetitorRadarChart"; +import { round, getStatusColor, getProgressBg } from "@/lib/utils/health-score"; +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from "recharts"; +import { Heart } from "lucide-react"; + +interface BrandsResponse { + items: { id: string; name: string }[]; +} + +const DIMENSION_LABELS: Record = { + mention_rate: "提及率", + recommendation_rank: "推荐排名", + sentiment: "情感倾向", + citation_quality: "引用质量", + competitor_comparison: "竞品对比", +}; + +function ScoreGauge({ score }: { score: number }) { + const percentage = Math.min(Math.max(score, 0), 100); + const colorClass = getStatusColor(percentage); + const colorMap: Record = { + "text-green-500": "#22c55e", + "text-yellow-500": "#eab308", + "text-red-500": "#ef4444", + }; + const fillColor = colorMap[colorClass] || "#3b82f6"; + const data = [ + { name: "score", value: percentage }, + { name: "remaining", value: 100 - percentage }, + ]; + + return ( +
+ + + + + + + + +
+ {round(percentage, 1)} + /100 +
+
+ ); +} + +function DimensionCards({ dimensions }: { dimensions: BrandScore["dimensions"] }) { + return ( +
+ {dimensions.map((dim) => { + const percentage = dim.max_score > 0 ? (dim.score / dim.max_score) * 100 : 0; + const label = DIMENSION_LABELS[dim.name] || dim.name; + return ( + + +
+ {label} + + {round(percentage, 1)}% + +
+
+
+
+

+ {dim.description} +

+ + + ); + })} +
+ ); +} + +function CompetitorTab({ token, brandId }: { token: string; brandId: string }) { + const [data, setData] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + scoringApi + .getCompare(token, brandId) + .then((res) => { + if (!cancelled) setData(res); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "加载失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [token, brandId]); + + if (loading) return ; + if (error) return ; + if (!data) return ; + + const radarData = data.dimensions.map((dim) => { + const item: Record = { + label: DIMENSION_LABELS[dim] || dim, + dimension: dim, + }; + item[data.brand_name] = 0; + data.competitors.forEach((c) => { + item[c.name] = c.scores[dim] ?? 0; + }); + return item; + }); + + const brandScoreEntry = data.dimensions.reduce>( + (acc, dim) => { + acc[dim] = 0; + return acc; + }, + {} + ); + radarData.forEach((item) => { + const dim = item.dimension as string; + brandScoreEntry[dim] = (item[data.brand_name] as number) || 0; + }); + radarData.forEach((item) => { + const dim = item.dimension as string; + item[data.brand_name] = brandScoreEntry[dim]; + }); + + const competitors = data.competitors.map((c, i) => ({ + name: c.name, + color: [ + "hsl(346.8 77.2% 49.8%)", + "hsl(24.6 95% 53.1%)", + "hsl(142.1 76.2% 36.3%)", + "hsl(262.1 83.3% 57.8%)", + ][i % 4], + })); + + return ( + + + 竞品对比雷达图 + + + + + + ); +} + +function HistoryTab({ token, brandId }: { token: string; brandId: string }) { + const [data, setData] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + scoringApi + .getHistory(token, brandId) + .then((res) => { + if (!cancelled) setData(res); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "加载失败"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [token, brandId]); + + if (loading) return ; + if (error) return ; + if (!data || data.scores.length === 0) + return ; + + const chartData = data.scores.map((entry) => ({ + date: entry.date, + score: entry.overall_score, + })); + + return ( + + + 历史趋势 + + + + + + { + const date = new Date(value); + return `${date.getMonth() + 1}/${date.getDate()}`; + }} + /> + + { + const date = new Date(value); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + }} + formatter={(value: number) => [`评分: ${value}`, ""]} + /> + + + + + + ); +} + +export default function HealthScorePage() { + const { data: session } = useSession(); + const token = session?.accessToken || ""; + + const { data: brandsData } = useApi("/api/v1/brands/?limit=1"); + const brandId = brandsData?.items?.[0]?.id ?? null; + + const [scoreData, setScoreData] = React.useState(null); + const [scoreLoading, setScoreLoading] = React.useState(true); + const [scoreError, setScoreError] = React.useState(null); + + React.useEffect(() => { + if (!token || !brandId) return; + let cancelled = false; + setScoreLoading(true); + setScoreError(null); + scoringApi + .getScore(token, brandId) + .then((res) => { + if (!cancelled) setScoreData(res); + }) + .catch((err) => { + if (!cancelled) setScoreError(err instanceof Error ? err.message : "加载失败"); + }) + .finally(() => { + if (!cancelled) setScoreLoading(false); + }); + return () => { + cancelled = true; + }; + }, [token, brandId]); + + return ( +
+
+

健康评分

+

品牌在AI搜索中的综合健康表现

+
+ + {!token || !brandId ? ( + + ) : scoreLoading ? ( + + ) : scoreError ? ( + + ) : !scoreData ? ( + } + message="暂无健康评分数据" + description="请先完成品牌评分检测" + /> + ) : ( + <> + + + +

+ 综合健康评分 +

+
+
+ + + + + + 竞品对比 + 历史趋势 + + + + + + + + + + )} +
+ ); +} diff --git a/frontend/app/(dashboard)/dashboard/page.tsx b/frontend/app/(dashboard)/dashboard/page.tsx index b4e3c03..6ae4d7c 100644 --- a/frontend/app/(dashboard)/dashboard/page.tsx +++ b/frontend/app/(dashboard)/dashboard/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState, useEffect } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { MetricCard, StageProgress } from "@/components/business"; @@ -16,8 +17,14 @@ import { ArrowRight, Zap, Lock, + Loader2, + CheckCircle2, + XCircle, + Clock, } from "lucide-react"; import { type GeoProject, type LifecycleStats } from "@/lib/api"; +import { agentsApi, type AgentTask } from "@/lib/api/agents"; +import { useSession } from "next-auth/react"; import { useApi } from "@/lib/hooks/use-api"; import { LoadingState, @@ -87,6 +94,138 @@ function getRecommendation(stage: GeoProject["current_stage"]) { return map[stage]; } +/* ─── Agent Activity Component ───────────────────────────────────────────────*/ + +const TASK_STATUS_CONFIG: Record< + string, + { label: string; icon: React.ReactNode; color: string } +> = { + pending: { + label: "等待中", + icon: , + color: "bg-gray-100 text-gray-600", + }, + running: { + label: "运行中", + icon: , + color: "bg-blue-100 text-blue-600", + }, + completed: { + label: "已完成", + icon: , + color: "bg-emerald-100 text-emerald-600", + }, + failed: { + label: "失败", + icon: , + color: "bg-red-100 text-red-600", + }, + cancelled: { + label: "已取消", + icon: , + color: "bg-yellow-100 text-yellow-600", + }, +}; + +function formatDuration(startedAt?: string, completedAt?: string): string { + if (!startedAt) return "-"; + const start = new Date(startedAt).getTime(); + const end = completedAt ? new Date(completedAt).getTime() : Date.now(); + const seconds = Math.round((end - start) / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}m ${remainingSeconds}s`; +} + +function formatRelativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return "刚刚"; + if (diffMin < 60) return `${diffMin}分钟前`; + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}小时前`; + const diffDay = Math.floor(diffHour / 24); + return `${diffDay}天前`; +} + +function AgentActivity() { + const { data: session } = useSession(); + const token = (session as { accessToken?: string })?.accessToken; + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!token) return; + agentsApi + .listTasks(token, { limit: 5 }) + .then((result) => setTasks(result.items ?? [])) + .catch(() => setTasks([])) + .finally(() => setLoading(false)); + }, [token]); + + return ( +
+
+

Agent活动

+ + 查看全部 + +
+ {loading ? ( +
+ +
+ ) : tasks.length === 0 ? ( +
+ +

+ 暂无执行记录 +

+
+ ) : ( +
+ {tasks.map((task) => { + const cfg = TASK_STATUS_CONFIG[task.status] ?? { + label: task.status, + icon: , + color: "bg-gray-100 text-gray-600", + }; + return ( +
+ + {task.task_type} + + + {cfg.icon} + {cfg.label} + + + {formatDuration(task.started_at, task.completed_at)} + + + {task.created_at ? formatRelativeTime(task.created_at) : ""} + +
+ ); + })} +
+ )} +
+ ); +} + /* ─── Component ───────────────────────────────────────────────────────────────*/ export default function DashboardPage() { @@ -391,26 +530,7 @@ export default function DashboardPage() {
{/* Agent Activity */} -
-
-

Agent活动

- - 查看全部 - -
-
- -

- 功能开发中 -

-

- Agent状态监控即将上线 -

-
-
+ ); diff --git a/frontend/app/(dashboard)/layout.tsx b/frontend/app/(dashboard)/layout.tsx index 77e14c9..fbe03a3 100644 --- a/frontend/app/(dashboard)/layout.tsx +++ b/frontend/app/(dashboard)/layout.tsx @@ -14,6 +14,8 @@ import { BarChart3, Swords, Share2, + Heart, + ScanSearch, Settings, } from "lucide-react"; @@ -54,12 +56,24 @@ const NAV_GROUPS: NavGroup[] = [ href: "/dashboard/competitors", icon: , }, + { + id: "health-score", + label: "健康评分", + href: "/dashboard/health-score", + icon: , + }, { id: "distribution", label: "内容分发", href: "/dashboard/distribution", icon: , }, + { + id: "detection", + label: "检测任务", + href: "/dashboard/detection", + icon: , + }, ], }, { diff --git a/frontend/app/(dashboard)/onboarding/page.tsx b/frontend/app/(dashboard)/onboarding/page.tsx index c66631e..3ea265d 100644 --- a/frontend/app/(dashboard)/onboarding/page.tsx +++ b/frontend/app/(dashboard)/onboarding/page.tsx @@ -37,6 +37,7 @@ export default function OnboardingPage() { createBrand: hookCreateBrand, isCreatingBrand, mutationError, + createMonitoringTask, } = useOnboardingData(); const error = mutationError?.message ?? null; @@ -117,6 +118,7 @@ export default function OnboardingPage() { ) => { const brandId = await createBrand(); if (brandId) { + createMonitoringTask(brandId, platforms, frequency); setState((prev) => ({ ...prev, platforms, @@ -146,6 +148,7 @@ export default function OnboardingPage() { ]; const brandId = await createBrand(); if (brandId) { + createMonitoringTask(brandId, defaultPlatforms, state.frequency); setState((prev) => ({ ...prev, platforms: defaultPlatforms, diff --git a/frontend/lib/api/citations.ts b/frontend/lib/api/citations.ts index e7b7c3f..d5bb9ba 100644 --- a/frontend/lib/api/citations.ts +++ b/frontend/lib/api/citations.ts @@ -1,5 +1,13 @@ import { fetchWithAuth } from "./client"; +function buildQuery(params: Record): string { + const qs = Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join("&"); + return qs ? `?${qs}` : ""; +} + export interface CitationRecord { id: string; query_id: string; @@ -18,11 +26,23 @@ export interface CitationListResponse { total: number; } +export interface PlatformDistribution { + platform: string; + count: number; +} + +export interface TrendPoint { + date: string; + count: number; +} + export interface CitationStats { total_queries: number; total_citations: number; citation_rate: number; avg_position: number | null; + platform_distribution: PlatformDistribution[]; + trend: TrendPoint[]; } export const citationsApi = { @@ -32,6 +52,10 @@ export const citationsApi = { {}, token ) as Promise, - getStats: (token: string) => - fetchWithAuth("/api/v1/citations/stats/", {}, token) as Promise, + getStats: (token: string, brandId?: string, queryId?: string) => + fetchWithAuth( + `/api/v1/citations/stats${buildQuery({ brand_id: brandId, query_id: queryId })}`, + {}, + token + ) as Promise, }; diff --git a/frontend/lib/api/detection.ts b/frontend/lib/api/detection.ts index dc88941..b2e022b 100644 --- a/frontend/lib/api/detection.ts +++ b/frontend/lib/api/detection.ts @@ -8,19 +8,47 @@ function buildQuery(params: Record - fetchWithAuth(`/api/v1/detection/tasks${buildQuery(params || {})}`, {}, token), + listTasks: (token: string, params?: { skip?: number; limit?: number; status?: string }) => + fetchWithAuth(`/api/v1/detection/tasks${buildQuery(params || {})}`, {}, token) as Promise, - createTask: (data: Record, token?: string) => - fetchWithAuth("/api/v1/detection/tasks", { method: "POST", body: JSON.stringify(data) }, token), + createTask: (token: string, data: DetectionTaskCreate) => + fetchWithAuth("/api/v1/detection/tasks", { method: "POST", body: JSON.stringify(data) }, token) as Promise, - updateTask: (taskId: string, data: Record, token?: string) => - fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) }, token), + updateTask: (token: string, taskId: string, data: DetectionTaskUpdate) => + fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) }, token) as Promise, - deleteTask: (taskId: string, token?: string) => - fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "DELETE" }, token), + deleteTask: (token: string, taskId: string) => + fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "DELETE" }, token) as Promise, - triggerTask: (taskId: string, token?: string) => - fetchWithAuth(`/api/v1/detection/tasks/${taskId}/trigger`, { method: "POST" }, token), + triggerTask: (token: string, taskId: string) => + fetchWithAuth(`/api/v1/detection/tasks/${taskId}/trigger`, { method: "POST" }, token) as Promise, }; diff --git a/frontend/lib/api/index.ts b/frontend/lib/api/index.ts index e0d9ec8..d67ef27 100644 --- a/frontend/lib/api/index.ts +++ b/frontend/lib/api/index.ts @@ -6,7 +6,7 @@ export { authApi } from "./auth"; export { queriesApi } from "./queries"; export type { ApiQueryItem, QueryListResponse, CreateQueryPayload, UpdateQueryPayload } from "./queries"; export { citationsApi } from "./citations"; -export type { CitationRecord, CitationListResponse, CitationStats } from "./citations"; +export type { CitationRecord, CitationListResponse, CitationStats, PlatformDistribution, TrendPoint } from "./citations"; export { reportsApi } from "./reports"; export { subscriptionsApi } from "./subscriptions"; export type { SubscriptionInfo } from "./subscriptions"; @@ -37,6 +37,7 @@ export type { UpdateMemberRolePayload, } from "./organization"; export { detectionApi } from "./detection"; +export type { DetectionTask, DetectionTaskList, DetectionTaskCreate, DetectionTaskUpdate } from "./detection"; export { strategyApi } from "./strategy"; export { monitoringApi } from "./monitoring"; export type { @@ -80,6 +81,8 @@ export type { } from "./competitor"; export { usageApi } from "./usage"; export type { UsageQuota, UsageResponse } from "./usage"; +export { scoringApi } from "./scoring"; +export type { BrandScore, BrandScoreDimension, BrandCompare, BrandCompareCompetitor, ScoreHistory, ScoreHistoryEntry } from "./scoring"; // ── 类型导出 ─────────────────────────────────────────────────────────────────── export type { Agent, AgentRunLog } from "./agents"; @@ -171,6 +174,7 @@ import { schemaAdvisorApi } from "./schema-advisor"; import { trendsApi } from "./trends"; import { competitorApi } from "./competitor"; import { usageApi } from "./usage"; +import { scoringApi } from "./scoring"; /** * 聚合 API 对象,保持与原 `import { api } from "@/lib/api"` 的向后兼容。 @@ -205,4 +209,5 @@ export const api = { trends: trendsApi, competitor: competitorApi, usage: usageApi, + scoring: scoringApi, }; diff --git a/frontend/lib/api/scoring.ts b/frontend/lib/api/scoring.ts new file mode 100644 index 0000000..f47e0ca --- /dev/null +++ b/frontend/lib/api/scoring.ts @@ -0,0 +1,46 @@ +import { fetchWithAuth } from "./client"; + +export interface BrandScoreDimension { + name: string; + score: number; + max_score: number; + description: string; +} + +export interface BrandScore { + overall_score: number; + dimensions: BrandScoreDimension[]; + generated_at: string; +} + +export interface BrandCompareCompetitor { + name: string; + scores: Record; +} + +export interface BrandCompare { + brand_name: string; + competitors: BrandCompareCompetitor[]; + dimensions: string[]; +} + +export interface ScoreHistoryEntry { + date: string; + overall_score: number; + dimension_scores: Record; +} + +export interface ScoreHistory { + scores: ScoreHistoryEntry[]; +} + +export const scoringApi = { + getScore: (token: string, brandId: string) => + fetchWithAuth(`/api/v1/brands/${brandId}/score/`, {}, token) as Promise, + + getHistory: (token: string, brandId: string) => + fetchWithAuth(`/api/v1/brands/${brandId}/score/history/`, {}, token) as Promise, + + getCompare: (token: string, brandId: string) => + fetchWithAuth(`/api/v1/brands/${brandId}/compare/`, {}, token) as Promise, +}; diff --git a/frontend/lib/hooks/use-onboarding-data.ts b/frontend/lib/hooks/use-onboarding-data.ts index 7975c48..3cbfdbb 100644 --- a/frontend/lib/hooks/use-onboarding-data.ts +++ b/frontend/lib/hooks/use-onboarding-data.ts @@ -9,6 +9,9 @@ import { useCallback } from "react"; import { useApi, useApiMutation } from "./use-api"; import type { SWRConfiguration } from "swr"; +import { getSession } from "next-auth/react"; +import type { Session } from "next-auth"; +import { monitoringApi } from "@/lib/api/monitoring"; interface OnboardingStatusResponse { completed: boolean; @@ -42,6 +45,12 @@ export interface UseOnboardingDataReturn { isCreatingBrand: boolean; /** 创建品牌错误 */ mutationError: Error | undefined; + /** 创建监控任务 */ + createMonitoringTask: ( + brandId: string, + platforms: string[], + frequency: string + ) => Promise; } export interface UseOnboardingDataOptions { @@ -82,6 +91,30 @@ export function useOnboardingData( [createBrandTrigger] ); + const createMonitoringTask = useCallback( + async ( + brandId: string, + platforms: string[], + frequency: string + ): Promise => { + try { + const session = await getSession(); + const token = (session as Session)?.accessToken; + if (!token) return; + for (const platform of platforms) { + await monitoringApi.createTask(token, { + brand_id: brandId, + platform, + query_keywords: frequency, + }); + } + } catch { + // silent + } + }, + [] + ); + return { onboardingStatus, isCompleted, @@ -91,5 +124,6 @@ export function useOnboardingData( createBrand, isCreatingBrand, mutationError, + createMonitoringTask, }; }