"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([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [notice, setNotice] = useState(""); const [submitting, setSubmitting] = useState>(new Set()); const [batchSubmitting, setBatchSubmitting] = useState(false); const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all"); const [selected, setSelected] = useState>(new Set()); const [tag, setTag] = useState(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 (

视频文字转写

已完成 {count("SUCCEEDED")} · 处理中 {count("PROCESSING")} · 排队中{" "} {count("QUEUED")} · 失败 {count("FAILED")} · 待转写 {count("PENDING")}

{error && (

{error}

)} {notice && ( {notice} )}
{filters.map((value) => ( ))}
已选 {selected.size} 个(最多 200 个)
{shown.map((video) => { const state = video.transcription; const active = isActive(state.status) || submitting.has(video.aweme_id); return ( ); })}
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(), ) } /> 视频 时长 状态与文字 操作
= 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; }) } />
{video.cover_url && ( )}
{video.desc || "无描述"}

@{video.author.nickname}

{video.content_tags.join("、")}

{Math.floor(video.duration_ms / 1000)} 秒

{statusLabels[state.status]}

{state.error && (

{state.error}

)} {state.transcript !== null && (

{state.transcript.join("\n") || "未识别到文字"}

)}
{!shown.length && (

{loading ? "加载中…" : "没有找到视频"}

)}
); }