"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([]); 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 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 (

视频文字转写

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

{error && (

{error}

)} {notice && ( {notice} )}
{filters.map((value) => ( ))}
{shown.map((video) => { const state = video.transcription; const active = isActive(state.status) || submitting.has(video.aweme_id); return ( ); })}
视频 时长 状态与文字 操作
{video.cover_url && ( )}
{video.desc || "无描述"}

@{video.author.nickname}

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

{statusLabels[state.status]}

{state.error && (

{state.error}

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

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

)}
{!shown.length && (

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

)}
); }