"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([]); const controllers = useRef>(new Map()); const [openDetails, setOpenDetails] = useState>(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 进行中; if (status === 'pending') return 待开始; if (status === 'success') return 完成; return 失败; }; return (
{/* 背景渐变(明确置于内容层后面) */}

抖音采集任务

粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。

{/* 输入卡片 */}