geo/frontend/app/(dashboard)/dashboard/detection/page.tsx

480 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}