Files
douyin-archive/app/admin/stt/videos/page.tsx
T

369 lines
13 KiB
TypeScript

"use client";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
import BackButton from "@/app/components/BackButton";
import {
isActive,
statusLabels,
type TranscriptionState,
type TranscriptionStatus,
} from "@/lib/stt/types";
type Video = {
aweme_id: string;
desc: string;
cover_url: string;
duration_ms: number;
author: { nickname: string };
transcription: TranscriptionState;
content_tags: string[];
};
const filters: ("all" | TranscriptionStatus)[] = [
"all",
"PENDING",
"QUEUED",
"PROCESSING",
"SUCCEEDED",
"FAILED",
];
export default function SttVideosPage() {
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState("");
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [tag, setTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
const [tagFilter, setTagFilter] = useState("");
const [savingTags, setSavingTags] = useState(false);
const fetching = useRef(false);
const fetchVideos = useCallback(async () => {
if (fetching.current) return;
fetching.current = true;
try {
const response = await fetch("/api/admin/stt/videos", {
cache: "no-store",
});
if (!response.ok) throw new Error("读取转写列表失败");
const data: { videos: Video[] } = await response.json();
setVideos(data.videos);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "读取失败");
} finally {
setLoading(false);
fetching.current = false;
}
}, []);
useEffect(() => {
void fetchVideos();
const timer = setInterval(() => {
void fetchVideos();
}, 5000);
return () => clearInterval(timer);
}, [fetchVideos]);
async function handleTranscribe(awemeId: string) {
setSubmitting((prev) => new Set(prev).add(awemeId));
try {
const response = await fetch("/api/stt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ awemeId }),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "任务入队失败");
setVideos((prev) =>
prev.map((video) =>
video.aweme_id === awemeId
? { ...video, transcription: data }
: video,
),
);
} catch (err) {
setNotice(err instanceof Error ? err.message : "任务入队失败");
} finally {
setSubmitting((prev) => {
const next = new Set(prev);
next.delete(awemeId);
return next;
});
}
}
async function handleBatch() {
setBatchSubmitting(true);
try {
const response = await fetch("/api/stt/batch", { method: "POST" });
const data = await response.json();
if (!response.ok) throw new Error(data.error || "批量入队失败");
setNotice(`已加入 ${data.queued} 个转写任务;关闭页面后仍会继续处理。`);
await fetchVideos();
} catch (err) {
setNotice(err instanceof Error ? err.message : "批量入队失败");
} finally {
setBatchSubmitting(false);
}
}
async function saveTags(clear = false) {
setSavingTags(true);
try {
const response = await fetch("/api/asr/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
awemeIds: [...selected],
contentTags: clear ? [] : [tag],
mode: clear ? "set" : "add",
}),
});
if (!response.ok) throw new Error("保存手动标签失败");
setNotice(`已更新 ${selected.size} 个视频的手动标签`);
setSelected(new Set());
await fetchVideos();
} catch (err) {
setNotice(err instanceof Error ? err.message : "保存失败");
} finally {
setSavingTags(false);
}
}
const count = (status: TranscriptionStatus) =>
videos.filter((v) => v.transcription.status === status).length;
const eligible = videos.filter(
(v) =>
v.transcription.transcript === null && !isActive(v.transcription.status),
).length;
const shown = videos.filter(
(v) =>
(filter === "all" || v.transcription.status === filter) &&
(!tagFilter || v.content_tags.includes(tagFilter)),
);
return (
<main className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-7xl space-y-4">
<BackButton />
<h1 className="text-2xl font-semibold text-gray-900">视频文字转写</h1>
<p className="text-sm text-gray-600">
已完成 {count("SUCCEEDED")} · 处理中 {count("PROCESSING")} · 排队中{" "}
{count("QUEUED")} · 失败 {count("FAILED")} · 待转写 {count("PENDING")}
</p>
{error && (
<p role="alert" className="text-red-700">
{error}
</p>
)}
{notice && (
<output className="block text-sm text-gray-700">{notice}</output>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
{filters.map((value) => (
<button
type="button"
key={value}
onClick={() => setFilter(value)}
className={`rounded px-3 py-2 text-sm ${filter === value ? "bg-blue-600 text-white" : "bg-white text-gray-700"}`}
>
{value === "all"
? `全部 (${videos.length})`
: `${statusLabels[value]} (${count(value)})`}
</button>
))}
</div>
<button
type="button"
onClick={handleBatch}
disabled={batchSubmitting || !eligible}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
<select
aria-label="筛选手动标签"
value={tagFilter}
onChange={(e) => {
setTagFilter(e.target.value);
setSelected(new Set());
}}
className="rounded border p-2"
>
<option value="">全部手动标签</option>
{VIDEO_CONTENT_TAGS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<span>已选 {selected.size} 个(最多 200 个)</span>
<select
aria-label="批量添加的标签"
value={tag}
onChange={(e) => setTag(e.target.value)}
className="rounded border p-2"
>
{VIDEO_CONTENT_TAGS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<button
type="button"
disabled={!selected.size || savingTags}
onClick={() => saveTags()}
className="rounded border px-3 py-2 disabled:opacity-50"
>
添加手动标签
</button>
<button
type="button"
disabled={!selected.size || savingTags}
onClick={() => saveTags(true)}
className="rounded border px-3 py-2 disabled:opacity-50"
>
清空手动标签
</button>
</div>
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
<table className="w-full text-left text-sm">
<thead className="bg-gray-100 text-gray-600">
<tr>
<th className="p-3">
<input
type="checkbox"
aria-label="选择当前列表前200个视频"
checked={
shown.length > 0 &&
shown.slice(0, 200).every((v) => selected.has(v.aweme_id))
}
onChange={(e) =>
setSelected(
e.target.checked
? new Set(shown.slice(0, 200).map((v) => v.aweme_id))
: new Set(),
)
}
/>
</th>
<th className="p-3">视频</th>
<th className="p-3">时长</th>
<th className="p-3">状态与文字</th>
<th className="p-3">操作</th>
</tr>
</thead>
<tbody>
{shown.map((video) => {
const state = video.transcription;
const active =
isActive(state.status) || submitting.has(video.aweme_id);
return (
<tr
key={video.aweme_id}
className="border-t border-gray-100 align-top"
>
<td className="p-3">
<input
type="checkbox"
aria-label={`选择视频 ${video.aweme_id}`}
checked={selected.has(video.aweme_id)}
disabled={
!selected.has(video.aweme_id) && selected.size >= 200
}
onChange={(e) =>
setSelected((previous) => {
const next = new Set(previous);
if (e.target.checked) next.add(video.aweme_id);
else next.delete(video.aweme_id);
return next;
})
}
/>
</td>
<td className="p-3">
<div className="flex max-w-sm gap-3">
{video.cover_url && (
<Image
src={video.cover_url}
alt=""
width={56}
height={80}
unoptimized
className="h-20 w-14 rounded object-cover"
/>
)}
<div>
<Link
className="line-clamp-2 text-blue-700"
href={`/aweme/${video.aweme_id}`}
>
{video.desc || "无描述"}
</Link>
<p className="mt-1 text-gray-500">
@{video.author.nickname}
</p>
<p className="mt-1 text-xs text-blue-700">
{video.content_tags.join("、")}
</p>
</div>
</div>
</td>
<td className="whitespace-nowrap p-3 text-gray-700">
{Math.floor(video.duration_ms / 1000)} 秒
</td>
<td className="p-3">
<p
className={
state.status === "FAILED"
? "text-red-700"
: "text-gray-700"
}
>
{statusLabels[state.status]}
</p>
{state.error && (
<p className="mt-1 max-w-lg text-xs text-red-700">
{state.error}
</p>
)}
{state.transcript !== null && (
<p className="mt-1 max-w-lg whitespace-pre-wrap text-gray-600">
{state.transcript.join("\n") || "未识别到文字"}
</p>
)}
</td>
<td className="whitespace-nowrap p-3">
<button
type="button"
disabled={active}
onClick={() => handleTranscribe(video.aweme_id)}
className="rounded bg-gray-100 px-3 py-2 text-gray-800 disabled:opacity-50"
>
{active
? "处理中…"
: state.status === "FAILED"
? "重试"
: state.transcript !== null
? "重新转写"
: "开始转写"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{!shown.length && (
<p className="p-8 text-center text-gray-500">
{loading ? "加载中…" : "没有找到视频"}
</p>
)}
</div>
</div>
</main>
);
}