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

258 lines
9.1 KiB
TypeScript

"use client";
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
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;
};
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 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);
}
}
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,
);
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="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">视频</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">
<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>
</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>
);
}