feat: U3-U6 — onboarding auto-create monitoring, citation stats viz, health score page, detection tasks + dashboard agent activity
This commit is contained in:
parent
37ecd39a60
commit
01e83b3589
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -22,9 +22,22 @@ 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 } from "lucide-react";
|
import { Check, X, Quote, Filter, TrendingUp, MapPin, Hash } 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 {
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
Tooltip as RechartsTooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
} from "recharts";
|
||||||
|
|
||||||
interface CitationItem {
|
interface CitationItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -42,15 +55,136 @@ interface QueryOption {
|
||||||
keyword: string;
|
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() {
|
export default function CitationsPage() {
|
||||||
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);
|
||||||
|
|
||||||
// 构建引用记录查询 URL
|
|
||||||
const citationsUrl = (() => {
|
const citationsUrl = (() => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (selectedQuery && selectedQuery !== "all") params.append("query_id", selectedQuery);
|
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 (startDate) params.append("start_date", startDate);
|
||||||
if (endDate) params.append("end_date", endDate);
|
if (endDate) params.append("end_date", endDate);
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
// filterKey 作为虚拟参数,即使筛选条件不变也允许重新请求
|
|
||||||
return `/api/v1/citations/${qs ? `?${qs}&_k=${filterKey}` : `?_k=${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 {
|
const {
|
||||||
data: citationsData,
|
data: citationsData,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -76,6 +218,12 @@ export default function CitationsPage() {
|
||||||
data: queriesData,
|
data: queriesData,
|
||||||
} = useApi<{ items: QueryOption[] }>("/api/v1/queries/");
|
} = useApi<{ items: QueryOption[] }>("/api/v1/queries/");
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: statsData,
|
||||||
|
isLoading: statsLoading,
|
||||||
|
error: statsError,
|
||||||
|
} = useApi<CitationStats>(statsUrl, { dedupingInterval: 0 });
|
||||||
|
|
||||||
const citations: CitationItem[] = citationsData?.items ?? [];
|
const citations: CitationItem[] = citationsData?.items ?? [];
|
||||||
const queries: QueryOption[] = queriesData?.items ?? [];
|
const queries: QueryOption[] = queriesData?.items ?? [];
|
||||||
|
|
||||||
|
|
@ -110,6 +258,65 @@ export default function CitationsPage() {
|
||||||
<p className="text-muted-foreground">查看各平台的引用检测结果</p>
|
<p className="text-muted-foreground">查看各平台的引用检测结果</p>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { MetricCard, StageProgress } from "@/components/business";
|
import { MetricCard, StageProgress } from "@/components/business";
|
||||||
|
|
@ -16,8 +17,14 @@ import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Zap,
|
Zap,
|
||||||
Lock,
|
Lock,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type GeoProject, type LifecycleStats } from "@/lib/api";
|
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 { useApi } from "@/lib/hooks/use-api";
|
||||||
import {
|
import {
|
||||||
LoadingState,
|
LoadingState,
|
||||||
|
|
@ -87,6 +94,138 @@ function getRecommendation(stage: GeoProject["current_stage"]) {
|
||||||
return map[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 ───────────────────────────────────────────────────────────────*/
|
/* ─── Component ───────────────────────────────────────────────────────────────*/
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
|
@ -391,26 +530,7 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent Activity */}
|
{/* Agent Activity */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<AgentActivity />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Swords,
|
Swords,
|
||||||
Share2,
|
Share2,
|
||||||
|
Heart,
|
||||||
|
ScanSearch,
|
||||||
Settings,
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -54,12 +56,24 @@ const NAV_GROUPS: NavGroup[] = [
|
||||||
href: "/dashboard/competitors",
|
href: "/dashboard/competitors",
|
||||||
icon: <Swords className="h-5 w-5" />,
|
icon: <Swords className="h-5 w-5" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "health-score",
|
||||||
|
label: "健康评分",
|
||||||
|
href: "/dashboard/health-score",
|
||||||
|
icon: <Heart className="h-5 w-5" />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "distribution",
|
id: "distribution",
|
||||||
label: "内容分发",
|
label: "内容分发",
|
||||||
href: "/dashboard/distribution",
|
href: "/dashboard/distribution",
|
||||||
icon: <Share2 className="h-5 w-5" />,
|
icon: <Share2 className="h-5 w-5" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "detection",
|
||||||
|
label: "检测任务",
|
||||||
|
href: "/dashboard/detection",
|
||||||
|
icon: <ScanSearch className="h-5 w-5" />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export default function OnboardingPage() {
|
||||||
createBrand: hookCreateBrand,
|
createBrand: hookCreateBrand,
|
||||||
isCreatingBrand,
|
isCreatingBrand,
|
||||||
mutationError,
|
mutationError,
|
||||||
|
createMonitoringTask,
|
||||||
} = useOnboardingData();
|
} = useOnboardingData();
|
||||||
|
|
||||||
const error = mutationError?.message ?? null;
|
const error = mutationError?.message ?? null;
|
||||||
|
|
@ -117,6 +118,7 @@ export default function OnboardingPage() {
|
||||||
) => {
|
) => {
|
||||||
const brandId = await createBrand();
|
const brandId = await createBrand();
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
|
createMonitoringTask(brandId, platforms, frequency);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
platforms,
|
platforms,
|
||||||
|
|
@ -146,6 +148,7 @@ export default function OnboardingPage() {
|
||||||
];
|
];
|
||||||
const brandId = await createBrand();
|
const brandId = await createBrand();
|
||||||
if (brandId) {
|
if (brandId) {
|
||||||
|
createMonitoringTask(brandId, defaultPlatforms, state.frequency);
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
platforms: defaultPlatforms,
|
platforms: defaultPlatforms,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { fetchWithAuth } from "./client";
|
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 {
|
export interface CitationRecord {
|
||||||
id: string;
|
id: string;
|
||||||
query_id: string;
|
query_id: string;
|
||||||
|
|
@ -18,11 +26,23 @@ export interface CitationListResponse {
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlatformDistribution {
|
||||||
|
platform: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CitationStats {
|
export interface CitationStats {
|
||||||
total_queries: number;
|
total_queries: number;
|
||||||
total_citations: number;
|
total_citations: number;
|
||||||
citation_rate: number;
|
citation_rate: number;
|
||||||
avg_position: number | null;
|
avg_position: number | null;
|
||||||
|
platform_distribution: PlatformDistribution[];
|
||||||
|
trend: TrendPoint[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const citationsApi = {
|
export const citationsApi = {
|
||||||
|
|
@ -32,6 +52,10 @@ export const citationsApi = {
|
||||||
{},
|
{},
|
||||||
token
|
token
|
||||||
) as Promise<CitationListResponse>,
|
) as Promise<CitationListResponse>,
|
||||||
getStats: (token: string) =>
|
getStats: (token: string, brandId?: string, queryId?: string) =>
|
||||||
fetchWithAuth("/api/v1/citations/stats/", {}, token) as Promise<CitationStats>,
|
fetchWithAuth(
|
||||||
|
`/api/v1/citations/stats${buildQuery({ brand_id: brandId, query_id: queryId })}`,
|
||||||
|
{},
|
||||||
|
token
|
||||||
|
) as Promise<CitationStats>,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -8,19 +8,47 @@ function buildQuery(params: Record<string, string | number | boolean | undefined
|
||||||
return qs ? `?${qs}` : "";
|
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 = {
|
export const detectionApi = {
|
||||||
listTasks: (token?: string, params?: { skip?: number; limit?: number; status?: string }) =>
|
listTasks: (token: string, params?: { skip?: number; limit?: number; status?: string }) =>
|
||||||
fetchWithAuth(`/api/v1/detection/tasks${buildQuery(params || {})}`, {}, token),
|
fetchWithAuth(`/api/v1/detection/tasks${buildQuery(params || {})}`, {}, token) as Promise<DetectionTaskList>,
|
||||||
|
|
||||||
createTask: (data: Record<string, unknown>, token?: string) =>
|
createTask: (token: string, data: DetectionTaskCreate) =>
|
||||||
fetchWithAuth("/api/v1/detection/tasks", { method: "POST", body: JSON.stringify(data) }, token),
|
fetchWithAuth("/api/v1/detection/tasks", { method: "POST", body: JSON.stringify(data) }, token) as Promise<DetectionTask>,
|
||||||
|
|
||||||
updateTask: (taskId: string, data: Record<string, unknown>, token?: string) =>
|
updateTask: (token: string, taskId: string, data: DetectionTaskUpdate) =>
|
||||||
fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) }, token),
|
fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "PUT", body: JSON.stringify(data) }, token) as Promise<DetectionTask>,
|
||||||
|
|
||||||
deleteTask: (taskId: string, token?: string) =>
|
deleteTask: (token: string, taskId: string) =>
|
||||||
fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "DELETE" }, token),
|
fetchWithAuth(`/api/v1/detection/tasks/${taskId}`, { method: "DELETE" }, token) as Promise<void>,
|
||||||
|
|
||||||
triggerTask: (taskId: string, token?: string) =>
|
triggerTask: (token: string, taskId: string) =>
|
||||||
fetchWithAuth(`/api/v1/detection/tasks/${taskId}/trigger`, { method: "POST" }, token),
|
fetchWithAuth(`/api/v1/detection/tasks/${taskId}/trigger`, { method: "POST" }, token) as Promise<DetectionTask>,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export { authApi } from "./auth";
|
||||||
export { queriesApi } from "./queries";
|
export { queriesApi } from "./queries";
|
||||||
export type { ApiQueryItem, QueryListResponse, CreateQueryPayload, UpdateQueryPayload } from "./queries";
|
export type { ApiQueryItem, QueryListResponse, CreateQueryPayload, UpdateQueryPayload } from "./queries";
|
||||||
export { citationsApi } from "./citations";
|
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 { reportsApi } from "./reports";
|
||||||
export { subscriptionsApi } from "./subscriptions";
|
export { subscriptionsApi } from "./subscriptions";
|
||||||
export type { SubscriptionInfo } from "./subscriptions";
|
export type { SubscriptionInfo } from "./subscriptions";
|
||||||
|
|
@ -37,6 +37,7 @@ export type {
|
||||||
UpdateMemberRolePayload,
|
UpdateMemberRolePayload,
|
||||||
} from "./organization";
|
} from "./organization";
|
||||||
export { detectionApi } from "./detection";
|
export { detectionApi } from "./detection";
|
||||||
|
export type { DetectionTask, DetectionTaskList, DetectionTaskCreate, DetectionTaskUpdate } from "./detection";
|
||||||
export { strategyApi } from "./strategy";
|
export { strategyApi } from "./strategy";
|
||||||
export { monitoringApi } from "./monitoring";
|
export { monitoringApi } from "./monitoring";
|
||||||
export type {
|
export type {
|
||||||
|
|
@ -80,6 +81,8 @@ export type {
|
||||||
} from "./competitor";
|
} from "./competitor";
|
||||||
export { usageApi } from "./usage";
|
export { usageApi } from "./usage";
|
||||||
export type { UsageQuota, UsageResponse } 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";
|
export type { Agent, AgentRunLog } from "./agents";
|
||||||
|
|
@ -171,6 +174,7 @@ import { schemaAdvisorApi } from "./schema-advisor";
|
||||||
import { trendsApi } from "./trends";
|
import { trendsApi } from "./trends";
|
||||||
import { competitorApi } from "./competitor";
|
import { competitorApi } from "./competitor";
|
||||||
import { usageApi } from "./usage";
|
import { usageApi } from "./usage";
|
||||||
|
import { scoringApi } from "./scoring";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 聚合 API 对象,保持与原 `import { api } from "@/lib/api"` 的向后兼容。
|
* 聚合 API 对象,保持与原 `import { api } from "@/lib/api"` 的向后兼容。
|
||||||
|
|
@ -205,4 +209,5 @@ export const api = {
|
||||||
trends: trendsApi,
|
trends: trendsApi,
|
||||||
competitor: competitorApi,
|
competitor: competitorApi,
|
||||||
usage: usageApi,
|
usage: usageApi,
|
||||||
|
scoring: scoringApi,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
};
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useApi, useApiMutation } from "./use-api";
|
import { useApi, useApiMutation } from "./use-api";
|
||||||
import type { SWRConfiguration } from "swr";
|
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 {
|
interface OnboardingStatusResponse {
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
|
|
@ -42,6 +45,12 @@ export interface UseOnboardingDataReturn {
|
||||||
isCreatingBrand: boolean;
|
isCreatingBrand: boolean;
|
||||||
/** 创建品牌错误 */
|
/** 创建品牌错误 */
|
||||||
mutationError: Error | undefined;
|
mutationError: Error | undefined;
|
||||||
|
/** 创建监控任务 */
|
||||||
|
createMonitoringTask: (
|
||||||
|
brandId: string,
|
||||||
|
platforms: string[],
|
||||||
|
frequency: string
|
||||||
|
) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseOnboardingDataOptions {
|
export interface UseOnboardingDataOptions {
|
||||||
|
|
@ -82,6 +91,30 @@ export function useOnboardingData(
|
||||||
[createBrandTrigger]
|
[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 {
|
return {
|
||||||
onboardingStatus,
|
onboardingStatus,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
|
|
@ -91,5 +124,6 @@ export function useOnboardingData(
|
||||||
createBrand,
|
createBrand,
|
||||||
isCreatingBrand,
|
isCreatingBrand,
|
||||||
mutationError,
|
mutationError,
|
||||||
|
createMonitoringTask,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue