2026-05-25: 等级体系升级(20级)+论坛二级分类+会员空间
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user