"use client"; import { Pause, Play, RefreshCw, Square } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useCallback, useEffect, useMemo, useState } from "react"; import BackButton from "@/app/components/BackButton"; type VideoTranscript = { id: string; speech_detected: boolean; language: string | null; audio_type: string | null; transcript: string[]; non_speech_summary: string | null; content_tags: string[]; }; type Video = { aweme_id: string; desc: string; cover_url: string; duration_ms: number; content_tags: string[]; manual_content_tags: string[]; author: { nickname: string; }; transcript: VideoTranscript | null; }; type ApiResponse = { videos: Video[]; total: number; }; type Filter = "all" | "transcribed" | "pending" | "tagged" | "missingTags"; type BatchScope = | "missingTags" | "pending" | "transcribed" | "tagged" | "all" | "selected"; type BatchJobStatus = | "queued" | "running" | "paused" | "completed" | "canceled" | "failed"; type BatchItemStatus = | "pending" | "running" | "success" | "failed" | "canceled"; type BatchJobItem = { index: number; awemeId: string; desc: string; status: BatchItemStatus; tags: string[]; error: string | null; startedAt: string | null; finishedAt: string | null; durationMs: number | null; }; type BatchJob = { id: string; status: BatchJobStatus; scope: BatchScope; selection: { sourceCount: number; matchedCount: number; selectedCount: number; rangeStart: number; rangeEnd: number; requestedIdsCount: number; }; progress: { total: number; completed: number; failed: number; canceled: number; pending: number; running: number; processed: number; percent: number; }; currentItem: BatchJobItem | null; pauseRequested: boolean; cancelRequested: boolean; error: string | null; logs: { at: string; message: string; awemeId: string | null }[]; createdAt: string; startedAt: string | null; updatedAt: string; finishedAt: string | null; items?: BatchJobItem[]; }; type JobsResponse = { jobs: BatchJob[]; activeJob: BatchJob | null; }; type JobResponse = { job: BatchJob; }; const scopeOptions: { value: BatchScope; label: string }[] = [ { value: "missingTags", label: "缺标签视频" }, { value: "pending", label: "待转写视频" }, { value: "transcribed", label: "已转写视频" }, { value: "tagged", label: "已有标签视频" }, { value: "all", label: "全部视频" }, { value: "selected", label: "勾选的视频" }, ]; const statusLabels: Record = { queued: "排队中", running: "执行中", paused: "已暂停", completed: "已完成", canceled: "已取消", failed: "失败", }; const itemStatusLabels: Record = { pending: "等待", running: "处理中", success: "成功", failed: "失败", canceled: "已取消", }; const terminalJobStatuses = new Set([ "completed", "canceled", "failed", ]); function hasContentTags(video: Video) { return video.content_tags.length > 0; } function matchVideoScope( video: Video, scope: BatchScope, selectedIds: Set, ) { if (scope === "all") return true; if (scope === "selected") return selectedIds.has(video.aweme_id); if (scope === "missingTags") return !hasContentTags(video); if (scope === "pending") return video.transcript === null; if (scope === "transcribed") return video.transcript !== null; if (scope === "tagged") return hasContentTags(video); return true; } function isTerminalJob(job: BatchJob | null) { return !job || terminalJobStatuses.has(job.status); } function parsePositiveInt(value: string, fallback: number) { const parsed = Number.parseInt(value, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; } async function readApiError(response: Response, fallback: string) { const payload = (await response.json().catch(() => null)) as { error?: string; } | null; return payload?.error ?? fallback; } function formatTime(value: string | null) { if (!value) return "-"; return new Date(value).toLocaleString("zh-CN", { hour12: false }); } export default function AsrVideosPage() { const [videos, setVideos] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [transcribing, setTranscribing] = useState>(new Set()); const [filter, setFilter] = useState("all"); const [selectedIds, setSelectedIds] = useState>(new Set()); const [taskScope, setTaskScope] = useState("missingTags"); const [rangeStart, setRangeStart] = useState("1"); const [rangeEnd, setRangeEnd] = useState(""); const [job, setJob] = useState(null); const [jobHistory, setJobHistory] = useState([]); const [jobLoading, setJobLoading] = useState(false); const [jobError, setJobError] = useState(null); const fetchVideos = useCallback(async (showLoading = true) => { try { if (showLoading) setLoading(true); const response = await fetch("/api/admin/asr/videos"); if (!response.ok) { throw new Error("Failed to fetch videos"); } const data: ApiResponse = await response.json(); setVideos(data.videos); setError(null); } catch (err) { setError(err instanceof Error ? err.message : "Unknown error"); } finally { if (showLoading) setLoading(false); } }, []); const refreshJob = useCallback(async (jobId: string) => { const response = await fetch(`/api/admin/asr/jobs/${jobId}`); if (!response.ok) { throw new Error(await readApiError(response, "获取任务状态失败")); } const data: JobResponse = await response.json(); setJob(data.job); setJobError(null); return data.job; }, []); const fetchJobs = useCallback(async () => { try { const response = await fetch("/api/admin/asr/jobs"); if (!response.ok) { throw new Error(await readApiError(response, "获取任务列表失败")); } const data: JobsResponse = await response.json(); setJobHistory(data.jobs); const latestJob = data.activeJob ?? data.jobs[0] ?? null; if (latestJob) { await refreshJob(latestJob.id); } else { setJob(null); } setJobError(null); } catch (err) { setJobError(err instanceof Error ? err.message : "获取任务列表失败"); } }, [refreshJob]); useEffect(() => { fetchVideos(); fetchJobs(); }, [fetchJobs, fetchVideos]); useEffect(() => { if (!job || terminalJobStatuses.has(job.status)) return; const intervalId = window.setInterval(async () => { try { const nextJob = await refreshJob(job.id); if (terminalJobStatuses.has(nextJob.status)) { await fetchVideos(false); await fetchJobs(); } } catch (err) { setJobError(err instanceof Error ? err.message : "刷新任务状态失败"); } }, 2000); return () => window.clearInterval(intervalId); }, [fetchJobs, fetchVideos, job, refreshJob]); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); type PollOptions = { intervalMs?: number; maxAttempts?: number; }; const pollVideoUntilTranscribed = async ( awemeId: string, { intervalMs = 2000, maxAttempts = 300 }: PollOptions = {}, ): Promise => { for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const resp = await fetch("/api/admin/asr/videos"); if (resp.ok) { const data: ApiResponse = await resp.json(); setVideos(data.videos); const video = data.videos.find((item) => item.aweme_id === awemeId); if ((video?.content_tags.length ?? 0) > 0) { return true; } } } catch { // 网络抖动时继续轮询 } await sleep(intervalMs); } return false; }; const handleTranscribe = async (awemeId: string) => { setTranscribing((prev) => new Set(prev).add(awemeId)); try { const response = await fetch( `/api/asr?awemeId=${encodeURIComponent(awemeId)}&async=1`, ); if (!response.ok) { throw new Error("Failed to transcribe video"); } const done = await pollVideoUntilTranscribed(awemeId); if (!done) await fetchVideos(false); } catch (err) { alert(err instanceof Error ? err.message : "Transcription failed"); } finally { setTranscribing((prev) => { const next = new Set(prev); next.delete(awemeId); return next; }); } }; const filteredVideos = useMemo( () => videos.filter((video) => { if (filter === "transcribed") return video.transcript !== null; if (filter === "pending") return video.transcript === null; if (filter === "tagged") return hasContentTags(video); if (filter === "missingTags") return !hasContentTags(video); return true; }), [filter, videos], ); const stats = useMemo( () => ({ total: videos.length, transcribed: videos.filter((video) => video.transcript !== null).length, pending: videos.filter((video) => video.transcript === null).length, tagged: videos.filter((video) => hasContentTags(video)).length, missingTags: videos.filter((video) => !hasContentTags(video)).length, }), [videos], ); const taskCandidates = useMemo( () => videos.filter((video) => matchVideoScope(video, taskScope, selectedIds)), [selectedIds, taskScope, videos], ); const startIndex = parsePositiveInt(rangeStart, 1); const endIndex = rangeEnd.trim() ? parsePositiveInt(rangeEnd, taskCandidates.length) : taskCandidates.length; const estimatedCount = Math.max( 0, taskCandidates.slice(startIndex - 1, Math.max(startIndex, endIndex)).length, ); const selectedVisible = filteredVideos.length > 0 && filteredVideos.every((video) => selectedIds.has(video.aweme_id)); const activeJobRunning = job ? !isTerminalJob(job) : false; const canCreateJob = !activeJobRunning && estimatedCount > 0 && (taskScope !== "selected" || selectedIds.size > 0); const toggleVideoSelection = useCallback((awemeId: string) => { setSelectedIds((current) => { const next = new Set(current); if (next.has(awemeId)) next.delete(awemeId); else next.add(awemeId); return next; }); }, []); const toggleVisibleSelection = useCallback(() => { setSelectedIds((current) => { const next = new Set(current); if (filteredVideos.every((video) => next.has(video.aweme_id))) { for (const video of filteredVideos) next.delete(video.aweme_id); } else { for (const video of filteredVideos) next.add(video.aweme_id); } return next; }); }, [filteredVideos]); const handleCreateBatchJob = async () => { if (!canCreateJob) return; const scopeLabel = scopeOptions.find((option) => option.value === taskScope)?.label ?? "当前范围"; if ( !confirm( `确定发布后端批量 ASR 任务吗?\n范围:${scopeLabel}\n预计处理:${estimatedCount} 个视频`, ) ) { return; } setJobLoading(true); setJobError(null); try { const response = await fetch("/api/admin/asr/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scope: taskScope, rangeStart: startIndex, ...(rangeEnd.trim() ? { rangeEnd: endIndex } : {}), ...(taskScope === "selected" ? { awemeIds: [...selectedIds] } : {}), }), }); if (!response.ok) { throw new Error(await readApiError(response, "发布任务失败")); } const data: JobResponse = await response.json(); setJob(data.job); setJobHistory((current) => [ data.job, ...current.filter((item) => item.id !== data.job.id), ]); } catch (err) { setJobError(err instanceof Error ? err.message : "发布任务失败"); } finally { setJobLoading(false); } }; const handleControlJob = async (action: "pause" | "resume" | "cancel") => { if (!job) return; if (action === "cancel" && !confirm("确定取消当前批量任务吗?")) return; setJobLoading(true); setJobError(null); try { const response = await fetch(`/api/admin/asr/jobs/${job.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action }), }); if (!response.ok) { throw new Error(await readApiError(response, "操作任务失败")); } const data: JobResponse = await response.json(); setJob(data.job); } catch (err) { setJobError(err instanceof Error ? err.message : "操作任务失败"); } finally { setJobLoading(false); } }; const handleSelectHistoryJob = async (jobId: string) => { if (!jobId) return; setJobLoading(true); try { await refreshJob(jobId); } catch (err) { setJobError(err instanceof Error ? err.message : "获取任务状态失败"); } finally { setJobLoading(false); } }; const recentItems = (job?.items ?? []) .filter((item) => item.status !== "pending") .slice(-6) .reverse(); const recentLogs = (job?.logs ?? []).slice(-8).reverse(); if (loading) { return (

加载中...

); } if (error) { return (

错误: {error}

); } return (

视频语音识别管理

管理视频的语音识别和自动标签状态

总视频数
{stats.total}
已转写
{stats.transcribed}
待转写
{stats.pending}
已生成标签
{stats.tagged}
缺失 {stats.missingTags}
已勾选 {selectedIds.size} 个视频

后端批量任务

发布后由服务端串行执行,页面刷新后仍可查询当前进度。

范围内匹配 {taskCandidates.length} 个 本次预计处理 {estimatedCount} 个 {taskScope === "selected" && selectedIds.size === 0 && ( 请先勾选视频 )}
{jobError && (
{jobError}
)}
{job ? `任务 ${job.id.slice(0, 8)}` : "暂无任务"}
{job && (
创建 {formatTime(job.createdAt)},更新{" "} {formatTime(job.updatedAt)}
)}
{jobHistory.length > 0 && ( )}
{job ? (
{job.cancelRequested ? "取消中" : job.pauseRequested ? "暂停中" : statusLabels[job.status]} {job.progress.processed} / {job.progress.total} 成功 {job.progress.completed},失败 {job.progress.failed} ,取消 {job.progress.canceled}
范围 {job.selection.rangeStart}-{job.selection.rangeEnd} ,匹配 {job.selection.matchedCount},选中{" "} {job.selection.selectedCount}
{job.currentItem && (
当前: {job.currentItem.index}/{job.progress.total}
{job.currentItem.desc || job.currentItem.awemeId}
)}
{recentItems.length > 0 && (
最近结果
{recentItems.map((item) => (
{item.index}. {item.desc || item.awemeId} {itemStatusLabels[item.status]}
))}
)} {recentLogs.length > 0 && (
最近日志
{recentLogs.map((log) => (
{formatTime(log.at)} {log.message}
))}
)}
) : (
还没有批量任务
)}
{filteredVideos.map((video) => ( ))}
视频 时长 识别/标签状态 操作
toggleVideoSelection(video.aweme_id)} className="h-4 w-4 rounded border-gray-300 text-blue-600" aria-label="选择视频" />
{video.desc || "无描述"}

@{video.author.nickname}

{Math.floor(video.duration_ms / 1000)}s {video.transcript ? (
已转写 {video.transcript.language && ( {video.transcript.language} )}
{video.transcript.speech_detected ? (
{video.transcript.transcript.join(" ")}
) : (
{video.transcript.audio_type || "非语音"} {video.transcript.non_speech_summary && ( - {video.transcript.non_speech_summary} )}
)} {video.content_tags.length > 0 && (
{video.content_tags.map((tag) => ( {tag} ))}
)} {video.content_tags.length === 0 && ( 未生成标签 )}
) : (
待转写 {video.content_tags.length > 0 && (
{video.content_tags.map((tag) => ( {tag} ))}
)}
)}
{filteredVideos.length === 0 && (
没有找到视频
)}
); }