369 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|