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

517 lines
18 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 } from "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 { Input } from "@/components/ui/input";
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 { fetchWithAuth } from "@/lib/api/client";
import type { QueryListResponse, ApiQueryItem } from "@/lib/api/queries";
import { PLATFORM_MAP, PLATFORMS } from "@/lib/platforms";
import { useApi } from "@/lib/hooks/use-api";
import { LoadingState, ErrorState, EmptyState } from "@/components/ui/api-states";
import {
Plus,
Pencil,
Trash2,
Play,
Loader2,
Search,
CheckCircle,
} from "lucide-react";
interface QueryFormData {
keyword: string;
target_brand: string;
brand_aliases: string;
platforms: string[];
frequency: string;
}
const FREQUENCY_MAP: Record<string, string> = {
daily: "每日",
weekly: "每周",
};
const emptyForm: QueryFormData = {
keyword: "",
target_brand: "",
brand_aliases: "",
platforms: [],
frequency: "weekly",
};
export default function QueriesPage() {
const { data: queriesResponse, isLoading: loading, error: apiError, refresh: refreshQueries } =
useApi<QueryListResponse>("/api/v1/queries/");
const queries: ApiQueryItem[] = (queriesResponse?.items ?? []);
const error = apiError?.message ?? null;
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [formData, setFormData] = useState<QueryFormData>(emptyForm);
const [saving, setSaving] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [mutationError, setMutationError] = useState<string | null>(null);
function showSuccess(msg: string) {
setSuccessMsg(msg);
setTimeout(() => setSuccessMsg(null), 3000);
}
function openAddDialog() {
setEditingId(null);
setFormData(emptyForm);
setFormErrors({});
setMutationError(null);
setDialogOpen(true);
}
function openEditDialog(item: ApiQueryItem) {
setEditingId(item.id);
setFormErrors({});
setMutationError(null);
setFormData({
keyword: item.keyword,
target_brand: item.target_brand,
brand_aliases: item.brand_aliases?.join(", ") || "",
platforms: item.platforms || [],
frequency: item.frequency,
});
setDialogOpen(true);
}
function validateForm(): boolean {
const errors: Record<string, string> = {};
if (!formData.keyword.trim()) {
errors.keyword = "请输入关键词";
}
if (!formData.target_brand.trim()) {
errors.target_brand = "请输入目标品牌";
}
if (formData.platforms.length === 0) {
errors.platforms = "请至少选择一个平台";
}
setFormErrors(errors);
return Object.keys(errors).length === 0;
}
async function handleSave() {
if (!validateForm()) return;
try {
setSaving(true);
setMutationError(null);
const payload = {
keyword: formData.keyword.trim(),
target_brand: formData.target_brand.trim(),
brand_aliases: formData.brand_aliases
.split(",")
.map((s) => s.trim())
.filter(Boolean),
platforms: formData.platforms,
frequency: formData.frequency,
};
if (editingId) {
await fetchWithAuth(`/api/v1/queries/${editingId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
} else {
await fetchWithAuth("/api/v1/queries/", {
method: "POST",
body: JSON.stringify(payload),
});
}
setDialogOpen(false);
showSuccess(editingId ? "修改成功" : "添加成功");
refreshQueries();
} catch (err) {
setMutationError(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
}
function openDeleteDialog(id: string) {
setDeletingId(id);
setDeleteDialogOpen(true);
}
async function handleDelete() {
if (!deletingId) return;
try {
setDeleting(true);
await fetchWithAuth(`/api/v1/queries/${deletingId}`, { method: "DELETE" });
setDeleteDialogOpen(false);
setDeletingId(null);
showSuccess("删除成功");
refreshQueries();
} catch (err) {
setMutationError(err instanceof Error ? err.message : "删除失败");
} finally {
setDeleting(false);
}
}
async function handleRunQuery(id: string) {
setActionLoading(id);
setMutationError(null);
try {
await fetchWithAuth(`/api/v1/queries/${id}/run-now`, { method: "POST" });
showSuccess("查询已执行");
refreshQueries();
} catch (err) {
setMutationError(err instanceof Error ? err.message : "立即查询失败");
} finally {
setActionLoading(null);
}
}
function togglePlatform(platform: string) {
setFormData((prev) => {
const platforms = prev.platforms.includes(platform)
? prev.platforms.filter((p) => p !== platform)
: [...prev.platforms, platform];
return { ...prev, platforms };
});
}
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"></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"></p>
</div>
</div>
<ErrorState error={error} onRetry={refreshQueries} />
</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"></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>{editingId ? "编辑查询词" : "添加查询词"}</DialogTitle>
<DialogDescription>
{editingId ? "修改查询词配置" : "配置新的关键词查询任务"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="keyword">
<span className="text-destructive">*</span>
</Label>
<Input
id="keyword"
placeholder="例如:个人养老金推荐"
value={formData.keyword}
onChange={(e) => {
setFormData((prev) => ({ ...prev, keyword: e.target.value }));
if (formErrors.keyword) {
setFormErrors((prev) => ({ ...prev, keyword: "" }));
}
}}
/>
{formErrors.keyword && (
<p className="text-xs text-destructive">{formErrors.keyword}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="target_brand">
<span className="text-destructive">*</span>
</Label>
<Input
id="target_brand"
placeholder="例如:中国平安"
value={formData.target_brand}
onChange={(e) => {
setFormData((prev) => ({
...prev,
target_brand: e.target.value,
}));
if (formErrors.target_brand) {
setFormErrors((prev) => ({ ...prev, target_brand: "" }));
}
}}
/>
{formErrors.target_brand && (
<p className="text-xs text-destructive">{formErrors.target_brand}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="brand_aliases"></Label>
<Input
id="brand_aliases"
placeholder="例如:平安保险, Ping An"
value={formData.brand_aliases}
onChange={(e) =>
setFormData((prev) => ({
...prev,
brand_aliases: e.target.value,
}))
}
/>
</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>
</div>
{formErrors.platforms && (
<p className="text-xs text-destructive">{formErrors.platforms}</p>
)}
<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="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={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editingId ? "保存修改" : "添加"}
</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>
)}
{queries.length === 0 ? (
<EmptyState
icon={<Search 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>
{queries.map((item: ApiQueryItem) => (
<TableRow key={item.id}>
<TableCell className="font-medium">
{item.keyword}
</TableCell>
<TableCell>{item.target_brand}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{item.platforms.map((p) => (
<Badge key={p} variant="secondary" className="text-xs">
{PLATFORM_MAP[p] || p}
</Badge>
))}
</div>
</TableCell>
<TableCell>{FREQUENCY_MAP[item.frequency] || item.frequency}</TableCell>
<TableCell>
<Badge
variant={item.status === "active" ? "default" : "secondary"}
className={
item.status === "active"
? "bg-emerald-100 text-emerald-700 hover:bg-emerald-100"
: "bg-amber-100 text-amber-700 hover:bg-amber-100"
}
>
{item.status === "active" ? "运行中" : "已暂停"}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{item.last_queried_at
? new Date(item.last_queried_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={() => handleRunQuery(item.id)}
disabled={actionLoading === item.id}
>
{actionLoading === item.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"
onClick={() => openEditDialog(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => openDeleteDialog(item.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>
);
}