优化图文页

This commit is contained in:
2026-04-28 17:11:42 +08:00
parent 674c202264
commit 040273586b
69 changed files with 3939 additions and 2302 deletions
+257 -101
View File
@@ -1,7 +1,20 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AlertTriangle, CheckCircle2, Clipboard, Clock, ExternalLink, Link2, Loader2, PlayCircle, Plus, Square, Trash2, X } from "lucide-react";
import {
AlertTriangle,
CheckCircle2,
Clipboard,
Clock,
ExternalLink,
Link2,
Loader2,
PlayCircle,
Plus,
Square,
Trash2,
X,
} from "lucide-react";
type TaskStatus = "pending" | "running" | "success" | "error";
@@ -22,7 +35,7 @@ const extractDouyinLinks = (text: string): string[] => {
const trailing = /[)\]】>。,、!!??\s]+$/; // 去掉常见中文/英文结尾符号
const cleaned = matches
.map((m) => m.replace(trailing, ""))
.map((m) => m.endsWith("/") ? m : m); // 保持原样,通常短链以 / 结尾
.map((m) => (m.endsWith("/") ? m : m)); // 保持原样,通常短链以 / 结尾
// 去重
return Array.from(new Set(cleaned));
};
@@ -43,43 +56,58 @@ export default function TasksPage() {
}, []);
const inProgressUrls = useMemo(
() => new Set(tasks.filter(t => t.status === "pending" || t.status === "running").map(t => t.url)),
[tasks]
() =>
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 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 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 handlePasteAndAdd = useCallback(async () => {
try {
const text = await navigator.clipboard.readText();
const urls = extractDouyinLinks(text);
if (!urls.length) {
alert("剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本");
alert(
"剪贴板中未检测到 Douyin 短链,请复制包含 https://v.douyin.com/... 的文本",
);
return;
}
addTasks(urls);
@@ -94,22 +122,44 @@ export default function TasksPage() {
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));
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 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';
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));
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));
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);
}
@@ -117,17 +167,17 @@ export default function TasksPage() {
// 自动拉起 pending 任务(使用 effect 防止每次 render 重复触发)
useEffect(() => {
const pending = tasks.filter(t => t.status === "pending");
const pending = tasks.filter((t) => t.status === "pending");
pending.forEach((t) => startTask(t));
}, [tasks, startTask]);
// 定时器更新运行中任务的耗时显示
useEffect(() => {
const hasRunningTasks = tasks.some(t => t.status === "running");
const hasRunningTasks = tasks.some((t) => t.status === "running");
if (!hasRunningTasks) return;
const timer = setInterval(() => {
setTick(prev => prev + 1);
setTick((prev) => prev + 1);
}, 1000); // 每秒更新一次
return () => clearInterval(timer);
@@ -140,27 +190,40 @@ export default function TasksPage() {
}, []);
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;
}));
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"));
setTasks((prev) =>
prev.filter((t) => t.status === "pending" || t.status === "running"),
);
}, []);
const toggleOpen = useCallback((id: string) => {
setOpenDetails(prev => {
setOpenDetails((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
})
});
}, []);
const extractedCount = useMemo(() => extractDouyinLinks(input).length, [input]);
const extractedCount = useMemo(
() => extractDouyinLinks(input).length,
[input],
);
const formatDuration = (startTime?: number, endTime?: number) => {
if (!startTime) return "";
@@ -173,11 +236,31 @@ export default function TasksPage() {
};
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>;
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 (
@@ -192,11 +275,16 @@ export default function TasksPage() {
<h1 className="text-3xl font-semibold tracking-tight text-white">
抖音采集任务
</h1>
<p className="mt-2 text-sm text-neutral-400">粘贴任意文本,我会自动提取 Douyin 短链并提交到后端处理。</p>
<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">
<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" />
@@ -210,18 +298,31 @@ export default function TasksPage() {
/>
<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>
<span className="hidden sm:inline">
快捷键:Ctrl / Cmd + Enter 提交
</span>
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap 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">
<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={handlePasteAndAdd} className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40">
<button
type="button"
onClick={handlePasteAndAdd}
className="inline-flex items-center gap-2 rounded-md bg-emerald-600/90 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-400/40"
>
<Clipboard 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">
<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>
@@ -231,64 +332,91 @@ export default function TasksPage() {
<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>
<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">
<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 === "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>
{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">
<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"
{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"/> 查看作品
<ExternalLink className="h-3.5 w-3.5" /> 查看作品
</a>
)}
{t.status === 'error' && (
<button
onClick={() => retryTask(t.id)}
{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"/> 重试
<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"/> 取消
{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
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' && (
{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>
@@ -296,13 +424,17 @@ export default function TasksPage() {
{isOpen && (
<div className="mt-3 rounded-md border border-neutral-800/60 bg-neutral-900/60 p-3">
{t.status === 'error' && t.error && (
{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"/>
<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 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">
@@ -315,34 +447,58 @@ export default function TasksPage() {
</div>
</div>
)}
{typeof t.result !== 'undefined' && (
{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>
<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>
<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>
<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>
查看完整响应数据{" "}
<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>
<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>
)}
{typeof t.result === "undefined" &&
t.status !== "error" && (
<div className="text-xs text-neutral-400">
暂无输出
</div>
)}
</div>
)}
</li>