348 lines
17 KiB
TypeScript
348 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { AlertTriangle, CheckCircle2, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react";
|
||
|
||
type TaskStatus = "pending" | "running" | "success" | "error";
|
||
|
||
type Task = {
|
||
id: string;
|
||
url: string;
|
||
status: TaskStatus;
|
||
startedAt?: number;
|
||
finishedAt?: number;
|
||
error?: string;
|
||
result?: any;
|
||
};
|
||
|
||
const extractDouyinLinks = (text: string): string[] => {
|
||
if (!text) return [];
|
||
const regex = /https?:\/\/v\.douyin\.com\/\S+/g; // 粗略匹配直到空白
|
||
const matches = text.match(regex) ?? [];
|
||
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
|
||
const cleaned = matches
|
||
.map((m) => m.replace(trailing, ""))
|
||
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾
|
||
// 去重
|
||
return Array.from(new Set(cleaned));
|
||
};
|
||
|
||
export default function TasksPage() {
|
||
const [input, setInput] = useState("");
|
||
const [tasks, setTasks] = useState<Task[]>([]);
|
||
const controllers = useRef<Map<string, AbortController>>(new Map());
|
||
const [openDetails, setOpenDetails] = useState<Set<string>>(new Set());
|
||
const [, setTick] = useState(0); // 用于强制更新计时显示
|
||
|
||
// 设置页面标题
|
||
useEffect(() => {
|
||
document.title = "任务管理 - 抖歪";
|
||
return () => {
|
||
document.title = "抖歪 - 记录当下时代";
|
||
};
|
||
}, []);
|
||
|
||
const inProgressUrls = useMemo(
|
||
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
|
||
[tasks]
|
||
);
|
||
|
||
const addTasks = useCallback((urls: string[]) => {
|
||
if (!urls.length) return;
|
||
const now = Date.now();
|
||
setTasks((prev) => {
|
||
const existing = new Set(prev.map((t) => t.id));
|
||
const notDuplicated = urls.filter(u => !inProgressUrls.has(u));
|
||
const newTasks: Task[] = [];
|
||
for (const url of notDuplicated) {
|
||
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
|
||
newTasks.push({ id, url, status: "pending" });
|
||
}
|
||
// 新任务添加到最前面
|
||
return [...newTasks, ...prev];
|
||
});
|
||
}, [inProgressUrls]);
|
||
|
||
const handleSubmit = useCallback((e?: React.FormEvent) => {
|
||
e?.preventDefault();
|
||
const urls = extractDouyinLinks(input);
|
||
if (!urls.length) {
|
||
alert("未检测到 Douyin 短链,请粘贴包含 https://v.douyin.com/... 的文本");
|
||
return;
|
||
}
|
||
addTasks(urls);
|
||
setInput("");
|
||
}, [input, addTasks]);
|
||
|
||
const startTask = useCallback(async (task: Task) => {
|
||
// 若已存在控制器,避免重复启动
|
||
if (controllers.current.has(task.id)) return;
|
||
const ctrl = new AbortController();
|
||
controllers.current.set(task.id, ctrl);
|
||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "running", startedAt: Date.now(), error: undefined } : t));
|
||
try {
|
||
const res = await fetch(`/api/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
|
||
const data = await res.json().catch(() => null);
|
||
|
||
if (!res.ok) {
|
||
// 使用后端返回的结构化错误信息
|
||
const errorMsg = data?.error || `请求失败: ${res.status}`;
|
||
const errorCode = data?.code || 'UNKNOWN';
|
||
throw new Error(`${errorMsg} (${errorCode})`);
|
||
}
|
||
|
||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "success", finishedAt: Date.now(), result: data } : t));
|
||
} catch (err: any) {
|
||
const msg = err?.name === 'AbortError' ? '已取消' : (err?.message || String(err));
|
||
setTasks(prev => prev.map(t => t.id === task.id ? { ...t, status: "error", finishedAt: Date.now(), error: msg } : t));
|
||
} finally {
|
||
controllers.current.delete(task.id);
|
||
}
|
||
}, []);
|
||
|
||
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
|
||
useEffect(() => {
|
||
const pending = tasks.filter(t => t.status === "pending");
|
||
pending.forEach((t) => startTask(t));
|
||
}, [tasks, startTask]);
|
||
|
||
// 定时器更新运行中任务的耗时显示
|
||
useEffect(() => {
|
||
const hasRunningTasks = tasks.some(t => t.status === "running");
|
||
if (!hasRunningTasks) return;
|
||
|
||
const timer = setInterval(() => {
|
||
setTick(prev => prev + 1);
|
||
}, 1000); // 每秒更新一次
|
||
|
||
return () => clearInterval(timer);
|
||
}, [tasks]);
|
||
|
||
const cancelTask = useCallback((id: string) => {
|
||
const ctrl = controllers.current.get(id);
|
||
if (ctrl) ctrl.abort();
|
||
controllers.current.delete(id);
|
||
}, []);
|
||
|
||
const retryTask = useCallback((taskId: string) => {
|
||
setTasks(prev => prev.map(t => {
|
||
if (t.id === taskId) {
|
||
return { ...t, status: "pending" as TaskStatus, error: undefined, result: undefined };
|
||
}
|
||
return t;
|
||
}));
|
||
}, []);
|
||
|
||
const clearFinished = useCallback(() => {
|
||
setTasks(prev => prev.filter(t => t.status === "pending" || t.status === "running"));
|
||
}, []);
|
||
|
||
const toggleOpen = useCallback((id: string) => {
|
||
setOpenDetails(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
})
|
||
}, []);
|
||
|
||
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]);
|
||
|
||
const formatDuration = (startTime?: number, endTime?: number) => {
|
||
if (!startTime) return "";
|
||
const end = endTime || Date.now();
|
||
const secs = Math.max(0, Math.round((end - startTime) / 1000));
|
||
if (secs < 60) return `${secs}s`;
|
||
const m = Math.floor(secs / 60);
|
||
const s = secs % 60;
|
||
return `${m}m ${s}s`;
|
||
};
|
||
|
||
const StatusBadge = ({ status }: { status: TaskStatus }) => {
|
||
const base = "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium";
|
||
if (status === 'running') return <span className={`${base} bg-indigo-500/15 text-indigo-300`}><Loader2 className="h-3.5 w-3.5 animate-spin"/> 进行中</span>;
|
||
if (status === 'pending') return <span className={`${base} bg-yellow-500/15 text-yellow-300`}><Clock className="h-3.5 w-3.5"/> 待开始</span>;
|
||
if (status === 'success') return <span className={`${base} bg-emerald-500/15 text-emerald-300`}><CheckCircle2 className="h-3.5 w-3.5"/> 完成</span>;
|
||
return <span className={`${base} bg-red-500/15 text-red-300`}><AlertTriangle className="h-3.5 w-3.5"/> 失败</span>;
|
||
};
|
||
|
||
return (
|
||
<div className="relative min-h-dvh overflow-hidden bg-neutral-950 text-neutral-100">
|
||
{/* 背景渐变(明确置于内容层后面) */}
|
||
<div className="pointer-events-none absolute inset-0 -z-10 opacity-40 [mask-image:radial-gradient(60%_60%_at_50%_0%,#000_30%,transparent_70%)]">
|
||
<div className="absolute -top-1/2 left-1/2 h-[120vh] w-[120vh] -translate-x-1/2 rounded-full bg-[conic-gradient(at_50%_50%,#312e81_0deg,#0ea5e9_120deg,#10b981_240deg,#312e81_360deg)] blur-3xl" />
|
||
</div>
|
||
|
||
<div className="relative z-10 mx-auto max-w-4xl px-5 py-12">
|
||
<div className="mb-8">
|
||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||
抖音采集任务
|
||
</h1>
|
||
<p className="mt-2 text-sm text-neutral-400">粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。</p>
|
||
</div>
|
||
|
||
{/* 输入卡片 */}
|
||
<form onSubmit={handleSubmit} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur">
|
||
<div className="flex items-start gap-3">
|
||
<div className="mt-1 rounded-md bg-neutral-900/60 p-2 ring-1 ring-white/5">
|
||
<Link2 className="h-5 w-5 text-neutral-400" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<textarea
|
||
className="w-full min-h-28 resize-y rounded-md border border-neutral-800/60 bg-neutral-900/60 px-3 py-2 text-sm placeholder:text-neutral-500 focus:border-indigo-700/50 focus:outline-none focus:ring-2 focus:ring-indigo-700/30"
|
||
placeholder="例如:2.51 05/08 f@b.AT EUy:/ 无意之中... https://v.douyin.com/dP22IOH8uAI/ 复制此链接..."
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
/>
|
||
<div className="mt-2 flex items-center justify-between text-xs text-neutral-500">
|
||
<span>已提取 {extractedCount} 个短链</span>
|
||
<span className="hidden sm:inline">快捷键:Ctrl / Cmd + Enter 提交</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 flex items-center gap-3">
|
||
<button type="submit" className="inline-flex items-center gap-2 rounded-md bg-indigo-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-400/40">
|
||
<Plus className="h-4 w-4" /> 添加任务
|
||
</button>
|
||
<button type="button" onClick={clearFinished} className="inline-flex items-center gap-2 rounded-md bg-neutral-800 px-3 py-2 text-sm text-neutral-200 transition-colors hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-white/10">
|
||
<Trash2 className="h-4 w-4" /> 清除已完成
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
{/* 任务列表 */}
|
||
<section className="mt-8">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h2 className="text-lg font-medium">进行中的任务</h2>
|
||
<span className="text-xs text-neutral-400">共 {tasks.length} 个</span>
|
||
</div>
|
||
|
||
<ul className="space-y-3">
|
||
{tasks.map((t) => {
|
||
const isOpen = openDetails.has(t.id);
|
||
return (
|
||
<li key={t.id} className="rounded-xl border border-white/10 bg-white/5 p-4 shadow-[0_0_0_1px_rgba(255,255,255,0.03)] backdrop-blur">
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<StatusBadge status={t.status} />
|
||
{t.status === 'running' && (
|
||
<span className="text-xs text-neutral-400">已耗时 {formatDuration(t.startedAt)}</span>
|
||
)}
|
||
{t.status === 'success' && (
|
||
<span className="text-xs text-neutral-400">用时 {formatDuration(t.startedAt, t.finishedAt)}</span>
|
||
)}
|
||
</div>
|
||
<div className="mt-1 flex items-center gap-2 text-sm text-neutral-200">
|
||
<span className="truncate" title={t.url}>{t.url}</span>
|
||
<a className="shrink-0 text-neutral-400 hover:text-neutral-200" href={t.url} target="_blank" rel="noreferrer">
|
||
<ExternalLink className="h-4 w-4" />
|
||
</a>
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{t.status === 'success' && t.result?.data?.aweme_id && (
|
||
<a
|
||
href={`/aweme/${t.result.data.aweme_id}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1 rounded-md bg-emerald-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-emerald-600"
|
||
>
|
||
<ExternalLink className="h-3.5 w-3.5"/> 查看作品
|
||
</a>
|
||
)}
|
||
{t.status === 'error' && (
|
||
<button
|
||
onClick={() => retryTask(t.id)}
|
||
className="inline-flex items-center gap-1 rounded-md bg-amber-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-amber-600"
|
||
>
|
||
<PlayCircle className="h-3.5 w-3.5"/> 重试
|
||
</button>
|
||
)}
|
||
{t.status === 'running' && (
|
||
<button onClick={() => cancelTask(t.id)} className="inline-flex items-center gap-1 rounded-md bg-red-600/80 px-2 py-1 text-xs text-white transition-colors hover:bg-red-600">
|
||
<Square className="h-3.5 w-3.5"/> 取消
|
||
</button>
|
||
)}
|
||
<button onClick={() => toggleOpen(t.id)} className="inline-flex items-center gap-1 rounded-md bg-neutral-800 px-2 py-1 text-xs text-neutral-200 transition-colors hover:bg-neutral-700">
|
||
{isOpen ? <X className="h-3.5 w-3.5"/> : <PlayCircle className="h-3.5 w-3.5" />} {isOpen ? '收起' : '详情'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 进度条 */}
|
||
{t.status === 'running' && (
|
||
<div className="mt-3 h-1 w-full overflow-hidden rounded bg-neutral-800">
|
||
<div className="h-full w-1/3 animate-[progress_1.2s_ease_infinite] rounded bg-indigo-500/70" />
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && (
|
||
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
|
||
{t.status === 'error' && t.error && (
|
||
<div className="mb-3 space-y-2">
|
||
<div className="flex items-start gap-2 rounded-md border border-red-500/30 bg-red-500/10 p-3">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-red-400"/>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="text-sm font-medium text-red-200">爬取失败</div>
|
||
<div className="mt-1 text-xs text-red-300/90">{t.error}</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-neutral-400">
|
||
<strong>常见原因:</strong>
|
||
<ul className="ml-4 mt-1 list-disc space-y-0.5">
|
||
<li>视频已被删除或设为私密</li>
|
||
<li>网络连接问题或请求超时</li>
|
||
<li>抖音接口限流或反爬虫</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{typeof t.result !== 'undefined' && (
|
||
<div>
|
||
{t.result?.data?.aweme && (
|
||
<div className="mb-3 space-y-2 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs">
|
||
<div className="flex items-center gap-2">
|
||
<CheckCircle2 className="h-4 w-4 text-emerald-400"/>
|
||
<span className="font-medium text-emerald-200">作品信息</span>
|
||
</div>
|
||
<div className="space-y-1 text-neutral-300">
|
||
<div><span className="text-neutral-400">ID:</span> {t.result.data.aweme.aweme_id}</div>
|
||
<div><span className="text-neutral-400">描述:</span> {t.result.data.aweme.desc || '(无)'}</div>
|
||
{t.result.data.aweme.author && (
|
||
<div><span className="text-neutral-400">作者:</span> {t.result.data.aweme.author.nickname}</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<details className="group">
|
||
<summary className="cursor-pointer text-xs text-neutral-400 hover:text-neutral-300">
|
||
查看完整响应数据 <span className="group-open:hidden">▶</span><span className="hidden group-open:inline">▼</span>
|
||
</summary>
|
||
<pre className="mt-2 max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
|
||
</details>
|
||
</div>
|
||
)}
|
||
{typeof t.result === 'undefined' && t.status !== 'error' && (
|
||
<div className="text-xs text-neutral-400">暂无输出</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</li>
|
||
);
|
||
})}
|
||
|
||
{tasks.length === 0 && (
|
||
<li className="rounded-xl border border-dashed border-white/10 bg-white/[0.03] p-8 text-center text-sm text-neutral-400">
|
||
暂无任务,粘贴包含 Douyin 短链的文本后点击“添加任务”。
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
|
||
{/* 进度条动画 keyframes */}
|
||
<style>{`@keyframes progress{0%{transform:translateX(-100%)}50%{transform:translateX(10%)}100%{transform:translateX(120%)}}`}</style>
|
||
</div>
|
||
);
|
||
}
|