Files
zhuiguang-ai/src/app/(admin)/admin/tasks/[taskKey]/page.tsx
T

189 lines
9.2 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface TaskLogItem {
id: number;
taskKey: string;
status: string;
startedAt: string;
finishedAt: string | null;
duration: number | null;
result: Record<string, unknown> | null;
error: string | null;
}
const TASK_META: Record<string, { name: string; desc: string; cron: string; metrics: string[] }> = {
"task1-discover-tools": { name: "发现AI工具", desc: "每天从多源搜索新AI工具,过滤去重后入库等待审核", cron: "0 6 * * *", metrics: ["发现工具数", "通过检查数", "入库数"] },
"task3-check-tools": { name: "工具巡检", desc: "检查所有已入库工具的网站可用性、Logo完整性,自动发布合格工具", cron: "0 3 * * *", metrics: ["巡检总数", "网站失效数", "Logo异常数", "自动发布数"] },
"task4-discover-skills": { name: "发现AI技能", desc: "从GitHub搜索热门AI开源项目,获取README并入库等待审核", cron: "0 2 * * *", metrics: ["搜索关键词数", "搜索结果数", "新增入库数"] },
"task5-update-stars": { name: "更新星值", desc: "更新所有GitHub技能的最新Stars数,计算日/周/月变化趋势", cron: "0 1 * * *", metrics: ["总项目数", "成功更新数", "失败数"] },
"task6-review-hot": { name: "评测热门", desc: "对未评测的最热门2个AI技能进行五维深度评测并自动发布", cron: "0 4 * * *", metrics: ["候选技能数", "评测完成数"] },
"daily-news": { name: "AI日报生成", desc: "搜索真实新闻素材,AI整理格式生成当日AI日报", cron: "30 7 * * *", metrics: ["新闻源数", "获取素材数", "最终条目数"] },
};
export default function TaskDetailPage() {
const params = useParams();
const taskKey = params.taskKey as string;
const meta = TASK_META[taskKey] || { name: taskKey, desc: "", cron: "", metrics: [] };
const [logs, setLogs] = useState<TaskLogItem[]>([]);
const [config, setConfig] = useState<{ enabled: boolean; cronExpr: string }>({ enabled: true, cronExpr: meta.cron });
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [lRes, cRes] = await Promise.all([
fetch(`/api/admin/tasks/logs?taskKey=${taskKey}&pageSize=30`),
fetch("/api/admin/tasks/configs"),
]);
if (lRes.ok) setLogs((await lRes.json()).logs || []);
if (cRes.ok) {
const tasks = (await cRes.json()).tasks || [];
const t = tasks.find((x: { taskKey: string }) => x.taskKey === taskKey);
if (t) setConfig({ enabled: t.enabled, cronExpr: t.cronExpr });
}
} catch {}
setLoading(false);
}, [taskKey]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleRun = async () => {
setRunning(true);
try {
const res = await fetch("/api/admin/tasks/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskKey }),
});
const data = await res.json();
alert(`执行${data.status === "completed" ? "完成" : "失败"}\n${(data.output || "").slice(-300)}`);
} catch (e: unknown) {
alert(`错误: ${(e as Error).message}`);
}
setRunning(false);
fetchData();
};
const successCount = logs.filter((l) => l.status === "completed").length;
const failCount = logs.filter((l) => l.status === "failed").length;
return (
<div className="p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-foreground flex items-center gap-3">
<div className={`flex h-9 w-9 items-center justify-center rounded-xl ${config.enabled ? "bg-emerald-100" : "bg-gray-100"}`}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className={config.enabled ? "text-emerald-600" : "text-gray-400"}>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
{meta.name}
<span className={`text-xs px-2 py-0.5 rounded-full ${config.enabled ? "bg-emerald-50 text-emerald-700" : "bg-gray-50 text-gray-500"}`}>
{config.enabled ? "已启用" : "已停用"}
</span>
</h1>
<p className="mt-1 text-sm text-muted-foreground">{meta.desc}</p>
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<code className="bg-muted/50 px-2 py-0.5 rounded">{config.cronExpr || "未配置"}</code>
<button
onClick={handleRun}
disabled={running}
className="px-3 py-1.5 rounded-md bg-blue-600 text-white text-xs font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{running ? "执行中..." : "手动执行"}
</button>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-foreground">{logs.length}</div>
<div className="text-[11px] text-muted-foreground mt-1">总执行次数</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-emerald-600">{successCount}</div>
<div className="text-[11px] text-muted-foreground mt-1">成功</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-red-600">{failCount}</div>
<div className="text-[11px] text-muted-foreground mt-1">失败</div>
</CardContent>
</Card>
<Card className="border-border/60 bg-white shadow-sm">
<CardContent className="p-4 text-center">
<div className="text-2xl font-bold text-blue-600">{Math.round((successCount / Math.max(logs.length, 1)) * 100)}%</div>
<div className="text-[11px] text-muted-foreground mt-1">成功率</div>
</CardContent>
</Card>
</div>
{loading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-16 bg-muted/20 rounded-lg animate-pulse" />
))}
</div>
) : logs.length === 0 ? (
<div className="text-center py-16">
<p className="text-muted-foreground">暂无执行记录,点击"手动执行"触发首次运行</p>
</div>
) : (
<div className="space-y-2">
{logs.map((log) => {
const isSuccess = log.status === "completed";
const isFailed = log.status === "failed";
return (
<details key={log.id} className="group">
<summary className="list-none cursor-pointer">
<Card className="border-border/60 bg-white shadow-sm hover:shadow-md transition-shadow">
<CardContent className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className={`h-2 w-2 rounded-full ${isSuccess ? "bg-emerald-500" : isFailed ? "bg-red-500" : "bg-amber-500"}`} />
<span className="text-xs font-medium text-foreground">
{isSuccess ? "成功" : isFailed ? "失败" : log.status}
</span>
</div>
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
<span>{new Date(log.startedAt).toLocaleString("zh-CN")}</span>
{log.duration && <span>耗时: {(log.duration / 1000).toFixed(1)}s</span>}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="group-open:rotate-180 transition-transform">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
</div>
</CardContent>
</Card>
</summary>
{log.result && (
<div className="mx-1 mt-1 p-3 bg-slate-50 border border-border/30 rounded-lg">
<pre className="text-[11px] text-muted-foreground whitespace-pre-wrap break-all max-h-80 overflow-auto">
{typeof log.result === "string" ? log.result : JSON.stringify(log.result, null, 2)}
</pre>
</div>
)}
{log.error && (
<div className="mx-1 mt-1 p-3 bg-red-50 border border-red-100 rounded-lg">
<p className="text-[11px] text-red-700 whitespace-pre-wrap">{log.error}</p>
</div>
)}
</details>
);
})}
</div>
)}
</div>
);
}