This commit is contained in:
2025-10-20 13:06:06 +08:00
commit 9b45c4e3e8
48 changed files with 5843 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
"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 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 next: Task[] = [...prev];
for (const url of notDuplicated) {
const id = `${now}-${Math.random().toString(36).slice(2, 8)}`;
next.push({ id, url, status: "pending" });
}
return next;
});
}, [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() } : t));
try {
const res = await fetch(`/fetcher?url=${encodeURIComponent(task.url)}`, { signal: ctrl.signal, method: "GET" });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `请求失败: ${res.status}`);
}
const data = await res.json().catch(() => undefined);
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]);
const cancelTask = useCallback((id: string) => {
const ctrl = controllers.current.get(id);
if (ctrl) ctrl.abort();
controllers.current.delete(id);
}, []);
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 = (t?: number) => {
if (!t) return "";
const secs = Math.max(0, Math.round((Date.now() - t) / 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">用时 {t.startedAt ? formatDuration(t.startedAt) : '--'}</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="shrink-0 space-x-2">
{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' && (
<div className="mb-2 inline-flex items-center gap-2 rounded border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200">
<AlertTriangle className="h-4 w-4"/> {t.error}
</div>
)}
{typeof t.result !== 'undefined' && (
<pre className="max-h-64 overflow-auto rounded bg-neutral-950 p-2 text-xs text-neutral-300">{JSON.stringify(t.result, null, 2)}</pre>
)}
{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>
);
}