348 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}