feat: U3-U6 — onboarding auto-create monitoring, citation stats viz, health score page, detection tasks + dashboard agent activity

This commit is contained in:
chiguyong 2026-06-02 07:59:08 +08:00
parent 37ecd39a60
commit 01e83b3589
11 changed files with 1351 additions and 38 deletions

View File

@ -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 (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{value}
{suffix && <span className="text-sm font-normal text-muted-foreground ml-1">{suffix}</span>}
</div>
</CardContent>
</Card>
);
}
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 (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={2}
dataKey="value"
nameKey="name"
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={getChartColor(index)} />
))}
</Pie>
<RechartsTooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
formatter={(value: number) => [value, "引用数"]}
/>
</PieChart>
</ResponsiveContainer>
);
}
function TrendLineChart({ data }: { data: { date: string; count: number }[] }) {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(value: string) => {
const date = new Date(value);
return `${date.getMonth() + 1}/${date.getDate()}`;
}}
/>
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<RechartsTooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
labelFormatter={(value: string) => {
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}`, ""]}
/>
<Line
type="monotone"
dataKey="count"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
);
}
export default function CitationsPage() {
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);
// 构建引用记录查询 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<CitationStats>(statsUrl, { dedupingInterval: 0 });
const citations: CitationItem[] = citationsData?.items ?? [];
const queries: QueryOption[] = queriesData?.items ?? [];
@ -110,6 +258,65 @@ export default function CitationsPage() {
<p className="text-muted-foreground"></p>
</div>
{!statsError && (
<div className="space-y-4">
{statsLoading ? (
<LoadingState rows={2} rowHeight="h-32" />
) : statsData ? (
<>
<div className="grid gap-4 sm:grid-cols-3">
<StatCard
title="引用率"
value={statsData.citation_rate != null ? `${(statsData.citation_rate * 100).toFixed(1)}` : "—"}
icon={TrendingUp}
suffix={statsData.citation_rate != null ? "%" : undefined}
/>
<StatCard
title="平均位置"
value={statsData.avg_position != null ? statsData.avg_position.toFixed(1) : "—"}
icon={MapPin}
/>
<StatCard
title="总引用数"
value={statsData.total_citations ?? 0}
icon={Hash}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{statsData.platform_distribution?.length > 0 ? (
<PlatformPieChart data={statsData.platform_distribution} />
) : (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">30</CardTitle>
</CardHeader>
<CardContent>
{statsData.trend?.length > 0 ? (
<TrendLineChart data={statsData.trend} />
) : (
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
</div>
)}
</CardContent>
</Card>
</div>
</>
) : null}
</div>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">

View File

@ -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<string, string> = {
daily: "每日",
weekly: "每周",
hourly: "每小时",
};
const STATUS_CONFIG: Record<string, { label: string; className: string }> = {
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<DetectionTask[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [queries, setQueries] = useState<ApiQueryItem[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
const [formData, setFormData] = useState<CreateFormData>(emptyForm);
const [saving, setSaving] = useState(false);
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [mutationError, setMutationError] = useState<string | null>(null);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(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<string, string> = {};
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">AI搜索检测任务</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"></h2>
<p className="text-muted-foreground">AI搜索检测任务</p>
</div>
</div>
<ErrorState error={error} onRetry={loadTasks} />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight"></h2>
<p className="text-muted-foreground">AI搜索检测任务</p>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogTrigger asChild>
<Button onClick={openAddDialog}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
AI搜索检测任务
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={formData.query_id}
onValueChange={(value) => {
setFormData((prev) => ({ ...prev, query_id: value }));
if (formErrors.query_id) {
setFormErrors((prev) => ({ ...prev, query_id: "" }));
}
}}
>
<SelectTrigger>
<SelectValue placeholder="选择查询词" />
</SelectTrigger>
<SelectContent>
{queries.map((q) => (
<SelectItem key={q.id} value={q.id}>
{q.keyword} {q.target_brand}
</SelectItem>
))}
</SelectContent>
</Select>
{formErrors.query_id && (
<p className="text-xs text-destructive">{formErrors.query_id}</p>
)}
</div>
<div className="space-y-2">
<Label>
<span className="text-destructive">*</span>
</Label>
<div className="grid grid-cols-2 gap-2">
{PLATFORMS.map((p) => (
<label
key={p.key}
className="flex cursor-pointer items-center space-x-2 rounded-md border p-2 hover:bg-muted"
>
<input
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary"
checked={formData.platforms.includes(p.key)}
onChange={() => togglePlatform(p.key)}
/>
<span className="text-sm">{p.label}</span>
</label>
))}
</div>
{formErrors.platforms && (
<p className="text-xs text-destructive">{formErrors.platforms}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="frequency"></Label>
<Select
value={formData.frequency}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, frequency: value }))
}
>
<SelectTrigger id="frequency">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly"></SelectItem>
<SelectItem value="daily"></SelectItem>
<SelectItem value="weekly"></SelectItem>
</SelectContent>
</Select>
</div>
{mutationError && (
<p className="text-xs text-destructive">{mutationError}</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
</Button>
<Button onClick={handleCreate} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{successMsg && (
<div className="flex items-center gap-2 rounded-md bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
<CheckCircle className="h-4 w-4 shrink-0" />
{successMsg}
</div>
)}
{mutationError && !dialogOpen && (
<div className="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
{mutationError}
</div>
)}
{tasks.length === 0 ? (
<EmptyState
icon={<ScanSearch className="h-6 w-6 text-gray-400" />}
message="暂无检测任务"
description="点击右上角按钮创建您的第一个检测任务"
/>
) : (
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={task.id}>
<TableCell className="font-medium">
{matchedQuery?.keyword ?? task.query_id}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{task.platforms.map((p) => (
<Badge key={p} variant="secondary" className="text-xs">
{PLATFORM_MAP[p] || p}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{FREQUENCY_MAP[task.frequency] || task.frequency}
</TableCell>
<TableCell>
<Badge variant="secondary" className={statusCfg.className}>
{statusCfg.label}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{task.last_run_at
? new Date(task.last_run_at).toLocaleString("zh-CN")
: "从未"}
</TableCell>
<TableCell className="text-muted-foreground">
{task.next_run_at
? new Date(task.next_run_at).toLocaleString("zh-CN")
: "—"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleTrigger(task.id)}
disabled={actionLoading === task.id}
>
{actionLoading === task.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => openDeleteDialog(task.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={deleting}
>
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -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<string, string> = {
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<string, string> = {
"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 (
<div className="relative flex items-center justify-center">
<ResponsiveContainer width={220} height={220}>
<PieChart>
<Pie
data={data}
innerRadius={80}
outerRadius={100}
startAngle={90}
endAngle={-270}
dataKey="value"
stroke="none"
>
<Cell fill={fillColor} />
<Cell fill="hsl(var(--muted))" />
</Pie>
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-4xl font-bold">{round(percentage, 1)}</span>
<span className="text-sm text-muted-foreground">/100</span>
</div>
</div>
);
}
function DimensionCards({ dimensions }: { dimensions: BrandScore["dimensions"] }) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{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 (
<Card key={dim.name}>
<CardContent className="pt-5 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{label}</span>
<span className={`text-sm font-semibold ${getStatusColor(percentage)}`}>
{round(percentage, 1)}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-gray-100">
<div
className={`h-full rounded-full transition-all ${getProgressBg(percentage)}`}
style={{ width: `${Math.max(percentage, 2)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{dim.description}
</p>
</CardContent>
</Card>
);
})}
</div>
);
}
function CompetitorTab({ token, brandId }: { token: string; brandId: string }) {
const [data, setData] = React.useState<BrandCompare | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 <LoadingState rows={2} rowHeight="h-48" />;
if (error) return <ErrorState error={error} />;
if (!data) return <EmptyState message="暂无竞品对比数据" />;
const radarData = data.dimensions.map((dim) => {
const item: Record<string, string | number> = {
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<Record<string, number>>(
(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 (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<CompetitorRadarChart
data={radarData as any}
brandName={data.brand_name}
competitors={competitors}
/>
</CardContent>
</Card>
);
}
function HistoryTab({ token, brandId }: { token: string; brandId: string }) {
const [data, setData] = React.useState<ScoreHistory | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 <LoadingState rows={2} rowHeight="h-48" />;
if (error) return <ErrorState error={error} />;
if (!data || data.scores.length === 0)
return <EmptyState message="暂无历史趋势数据" />;
const chartData = data.scores.map((entry) => ({
date: entry.date,
score: entry.overall_score,
}));
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={350}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="date"
tick={{ fontSize: 12 }}
tickFormatter={(value: string) => {
const date = new Date(value);
return `${date.getMonth() + 1}/${date.getDate()}`;
}}
/>
<YAxis domain={[0, 100]} tick={{ fontSize: 12 }} />
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "var(--radius)",
}}
labelFormatter={(value: string) => {
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}`, ""]}
/>
<Line
type="monotone"
dataKey="score"
stroke="hsl(221.2 83.2% 53.3%)"
strokeWidth={2}
dot={{ r: 3 }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
export default function HealthScorePage() {
const { data: session } = useSession();
const token = session?.accessToken || "";
const { data: brandsData } = useApi<BrandsResponse>("/api/v1/brands/?limit=1");
const brandId = brandsData?.items?.[0]?.id ?? null;
const [scoreData, setScoreData] = React.useState<BrandScore | null>(null);
const [scoreLoading, setScoreLoading] = React.useState(true);
const [scoreError, setScoreError] = React.useState<string | null>(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 (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground">AI搜索中的综合健康表现</p>
</div>
{!token || !brandId ? (
<LoadingState rows={3} rowHeight="h-32" />
) : scoreLoading ? (
<LoadingState rows={3} rowHeight="h-32" />
) : scoreError ? (
<ErrorState error={scoreError} />
) : !scoreData ? (
<EmptyState
icon={<Heart className="h-6 w-6 text-muted-foreground" />}
message="暂无健康评分数据"
description="请先完成品牌评分检测"
/>
) : (
<>
<Card>
<CardContent className="pt-6 flex flex-col items-center">
<ScoreGauge score={scoreData.overall_score} />
<p className="mt-2 text-sm text-muted-foreground">
</p>
</CardContent>
</Card>
<DimensionCards dimensions={scoreData.dimensions} />
<Tabs defaultValue="compare">
<TabsList>
<TabsTrigger value="compare"></TabsTrigger>
<TabsTrigger value="history"></TabsTrigger>
</TabsList>
<TabsContent value="compare">
<CompetitorTab token={token} brandId={brandId} />
</TabsContent>
<TabsContent value="history">
<HistoryTab token={token} brandId={brandId} />
</TabsContent>
</Tabs>
</>
)}
</div>
);
}

View File

@ -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: <Clock className="h-3 w-3" />,
color: "bg-gray-100 text-gray-600",
},
running: {
label: "运行中",
icon: <Loader2 className="h-3 w-3 animate-spin" />,
color: "bg-blue-100 text-blue-600",
},
completed: {
label: "已完成",
icon: <CheckCircle2 className="h-3 w-3" />,
color: "bg-emerald-100 text-emerald-600",
},
failed: {
label: "失败",
icon: <XCircle className="h-3 w-3" />,
color: "bg-red-100 text-red-600",
},
cancelled: {
label: "已取消",
icon: <Clock className="h-3 w-3" />,
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<AgentTask[]>([]);
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 (
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-gray-500">Agent活动</p>
<Link
href="/dashboard/agents"
className="text-xs text-primary hover:underline"
>
</Link>
</div>
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : tasks.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<Zap className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium text-muted-foreground">
</p>
</div>
) : (
<div className="space-y-2">
{tasks.map((task) => {
const cfg = TASK_STATUS_CONFIG[task.status] ?? {
label: task.status,
icon: <Clock className="h-3 w-3" />,
color: "bg-gray-100 text-gray-600",
};
return (
<div
key={task.id}
className="flex items-center gap-3 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2"
>
<span className="text-sm font-medium text-gray-700 truncate flex-1 min-w-0">
{task.task_type}
</span>
<Badge
variant="secondary"
className={`text-xs shrink-0 ${cfg.color}`}
>
{cfg.icon}
<span className="ml-1">{cfg.label}</span>
</Badge>
<span className="text-xs text-gray-400 shrink-0">
{formatDuration(task.started_at, task.completed_at)}
</span>
<span className="text-xs text-gray-400 shrink-0">
{task.created_at ? formatRelativeTime(task.created_at) : ""}
</span>
</div>
);
})}
</div>
)}
</div>
);
}
/* ─── Component ───────────────────────────────────────────────────────────────*/
export default function DashboardPage() {
@ -391,26 +530,7 @@ export default function DashboardPage() {
</div>
{/* Agent Activity */}
<div className="bg-white rounded-xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-medium text-gray-500">Agent活动</p>
<Link
href="/dashboard/agents"
className="text-xs text-primary hover:underline"
>
</Link>
</div>
<div className="flex flex-col items-center justify-center py-8 text-center">
<Zap className="h-8 w-8 text-muted-foreground mb-3" />
<p className="text-sm font-medium text-muted-foreground">
</p>
<p className="text-xs text-muted-foreground mt-1">
Agent状态监控即将上线
</p>
</div>
</div>
<AgentActivity />
</div>
</div>
);

View File

@ -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: <Swords className="h-5 w-5" />,
},
{
id: "health-score",
label: "健康评分",
href: "/dashboard/health-score",
icon: <Heart className="h-5 w-5" />,
},
{
id: "distribution",
label: "内容分发",
href: "/dashboard/distribution",
icon: <Share2 className="h-5 w-5" />,
},
{
id: "detection",
label: "检测任务",
href: "/dashboard/detection",
icon: <ScanSearch className="h-5 w-5" />,
},
],
},
{

View File

@ -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,

View File

@ -1,5 +1,13 @@
import { fetchWithAuth } from "./client";
function buildQuery(params: Record<string, string | number | boolean | undefined>): 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<CitationListResponse>,
getStats: (token: string) =>
fetchWithAuth("/api/v1/citations/stats/", {}, token) as Promise<CitationStats>,
getStats: (token: string, brandId?: string, queryId?: string) =>
fetchWithAuth(
`/api/v1/citations/stats${buildQuery({ brand_id: brandId, query_id: queryId })}`,
{},
token
) as Promise<CitationStats>,
};

View File

@ -8,19 +8,47 @@ function buildQuery(params: Record<string, string | number | boolean | undefined
return qs ? `?${qs}` : "";
}
export interface DetectionTask {
id: string;
query_id: string;
platforms: string[];
frequency: string;
status: string;
last_run_at: string | null;
next_run_at: string | null;
created_at: string;
}
export interface DetectionTaskList {
items: DetectionTask[];
total: number;
}
export interface DetectionTaskCreate {
query_id: string;
platforms: string[];
frequency: string;
}
export interface DetectionTaskUpdate {
platforms?: string[];
frequency?: string;
status?: string;
}
export const detectionApi = {
listTasks: (token?: string, params?: { skip?: number; limit?: number; status?: string }) =>
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<DetectionTaskList>,
createTask: (data: Record<string, unknown>, 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<DetectionTask>,
updateTask: (taskId: string, data: Record<string, unknown>, 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<DetectionTask>,
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<void>,
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<DetectionTask>,
};

View File

@ -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,
};

View File

@ -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<string, number>;
}
export interface BrandCompare {
brand_name: string;
competitors: BrandCompareCompetitor[];
dimensions: string[];
}
export interface ScoreHistoryEntry {
date: string;
overall_score: number;
dimension_scores: Record<string, number>;
}
export interface ScoreHistory {
scores: ScoreHistoryEntry[];
}
export const scoringApi = {
getScore: (token: string, brandId: string) =>
fetchWithAuth(`/api/v1/brands/${brandId}/score/`, {}, token) as Promise<BrandScore>,
getHistory: (token: string, brandId: string) =>
fetchWithAuth(`/api/v1/brands/${brandId}/score/history/`, {}, token) as Promise<ScoreHistory>,
getCompare: (token: string, brandId: string) =>
fetchWithAuth(`/api/v1/brands/${brandId}/compare/`, {}, token) as Promise<BrandCompare>,
};

View File

@ -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<void>;
}
export interface UseOnboardingDataOptions {
@ -82,6 +91,30 @@ export function useOnboardingData(
[createBrandTrigger]
);
const createMonitoringTask = useCallback(
async (
brandId: string,
platforms: string[],
frequency: string
): Promise<void> => {
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,
};
}