258 lines
12 KiB
TypeScript
258 lines
12 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 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>
|
||
);
|
||
}
|