1080 lines
40 KiB
TypeScript
1080 lines
40 KiB
TypeScript
"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<BatchJobStatus, string> = {
|
|
queued: "排队中",
|
|
running: "执行中",
|
|
paused: "已暂停",
|
|
completed: "已完成",
|
|
canceled: "已取消",
|
|
failed: "失败",
|
|
};
|
|
|
|
const itemStatusLabels: Record<BatchItemStatus, string> = {
|
|
pending: "等待",
|
|
running: "处理中",
|
|
success: "成功",
|
|
failed: "失败",
|
|
canceled: "已取消",
|
|
};
|
|
|
|
const terminalJobStatuses = new Set<BatchJobStatus>([
|
|
"completed",
|
|
"canceled",
|
|
"failed",
|
|
]);
|
|
|
|
function hasContentTags(video: Video) {
|
|
return video.content_tags.length > 0;
|
|
}
|
|
|
|
function matchVideoScope(
|
|
video: Video,
|
|
scope: BatchScope,
|
|
selectedIds: Set<string>,
|
|
) {
|
|
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<Video[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [transcribing, setTranscribing] = useState<Set<string>>(new Set());
|
|
const [filter, setFilter] = useState<Filter>("all");
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
|
const [taskScope, setTaskScope] = useState<BatchScope>("missingTags");
|
|
const [rangeStart, setRangeStart] = useState("1");
|
|
const [rangeEnd, setRangeEnd] = useState("");
|
|
const [job, setJob] = useState<BatchJob | null>(null);
|
|
const [jobHistory, setJobHistory] = useState<BatchJob[]>([]);
|
|
const [jobLoading, setJobLoading] = useState(false);
|
|
const [jobError, setJobError] = useState<string | null>(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<boolean> => {
|
|
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 (
|
|
<div className="min-h-screen bg-gray-50 p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="text-center py-12">
|
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900" />
|
|
<p className="mt-4 text-gray-600">加载中...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 p-8">
|
|
<div className="max-w-7xl mx-auto">
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
|
<p className="text-red-600">错误: {error}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchVideos()}
|
|
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
|
>
|
|
重试
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<div className="max-w-7xl mx-auto p-8">
|
|
<div className="mb-8">
|
|
<BackButton />
|
|
<h1 className="text-3xl font-bold text-gray-900 mt-4">
|
|
视频语音识别管理
|
|
</h1>
|
|
<p className="text-gray-600 mt-2">管理视频的语音识别和自动标签状态</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-gray-600">总视频数</div>
|
|
<div className="text-3xl font-bold text-gray-900 mt-2">
|
|
{stats.total}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-gray-600">已转写</div>
|
|
<div className="text-3xl font-bold text-green-600 mt-2">
|
|
{stats.transcribed}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-gray-600">待转写</div>
|
|
<div className="text-3xl font-bold text-orange-600 mt-2">
|
|
{stats.pending}
|
|
</div>
|
|
</div>
|
|
<div className="bg-white rounded-lg shadow p-6">
|
|
<div className="text-sm text-gray-600">已生成标签</div>
|
|
<div className="text-3xl font-bold text-blue-600 mt-2">
|
|
{stats.tagged}
|
|
</div>
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
缺失 {stats.missingTags}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-6 rounded-lg bg-white p-4 shadow">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter("all")}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
filter === "all"
|
|
? "bg-blue-600 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
全部 ({stats.total})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter("transcribed")}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
filter === "transcribed"
|
|
? "bg-green-600 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
已转写 ({stats.transcribed})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter("pending")}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
filter === "pending"
|
|
? "bg-orange-600 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
待转写 ({stats.pending})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter("tagged")}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
filter === "tagged"
|
|
? "bg-blue-600 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
已生成标签 ({stats.tagged})
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setFilter("missingTags")}
|
|
className={`rounded-lg px-4 py-2 font-medium transition-colors ${
|
|
filter === "missingTags"
|
|
? "bg-rose-600 text-white"
|
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
|
}`}
|
|
>
|
|
缺标签 ({stats.missingTags})
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-sm text-gray-600">
|
|
已勾选 {selectedIds.size} 个视频
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mb-6 rounded-lg bg-white p-4 shadow">
|
|
<div className="grid gap-6 lg:grid-cols-[1.1fr_1.4fr]">
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900">
|
|
后端批量任务
|
|
</h2>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
发布后由服务端串行执行,页面刷新后仍可查询当前进度。
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<label className="space-y-1 text-sm">
|
|
<span className="font-medium text-gray-700">任务范围</span>
|
|
<select
|
|
value={taskScope}
|
|
onChange={(event) =>
|
|
setTaskScope(event.target.value as BatchScope)
|
|
}
|
|
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500"
|
|
>
|
|
{scopeOptions.map((option) => (
|
|
<option key={option.value} value={option.value}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="space-y-1 text-sm">
|
|
<span className="font-medium text-gray-700">起始序号</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={rangeStart}
|
|
onChange={(event) => setRangeStart(event.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500"
|
|
/>
|
|
</label>
|
|
<label className="space-y-1 text-sm">
|
|
<span className="font-medium text-gray-700">结束序号</span>
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
value={rangeEnd}
|
|
placeholder="到末尾"
|
|
onChange={(event) => setRangeEnd(event.target.value)}
|
|
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500"
|
|
/>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-600">
|
|
<span>范围内匹配 {taskCandidates.length} 个</span>
|
|
<span>本次预计处理 {estimatedCount} 个</span>
|
|
{taskScope === "selected" && selectedIds.size === 0 && (
|
|
<span className="text-rose-600">请先勾选视频</span>
|
|
)}
|
|
</div>
|
|
|
|
{jobError && (
|
|
<div className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
|
{jobError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleCreateBatchJob}
|
|
disabled={!canCreateJob || jobLoading}
|
|
className="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
|
>
|
|
<Play size={16} />
|
|
发布后端任务
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
fetchVideos(false);
|
|
fetchJobs();
|
|
}}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
|
|
>
|
|
<RefreshCw size={16} />
|
|
刷新
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-sm font-semibold text-gray-900">
|
|
{job ? `任务 ${job.id.slice(0, 8)}` : "暂无任务"}
|
|
</div>
|
|
{job && (
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
创建 {formatTime(job.createdAt)},更新{" "}
|
|
{formatTime(job.updatedAt)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{jobHistory.length > 0 && (
|
|
<select
|
|
value={job?.id ?? ""}
|
|
onChange={(event) =>
|
|
handleSelectHistoryJob(event.target.value)
|
|
}
|
|
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm outline-none focus:border-blue-500"
|
|
aria-label="选择历史任务"
|
|
>
|
|
{jobHistory.map((item) => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.id.slice(0, 8)} · {statusLabels[item.status]} ·{" "}
|
|
{item.progress.total}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
|
|
{job ? (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
|
<span
|
|
className={`rounded-full px-2.5 py-1 font-medium ${
|
|
job.status === "running"
|
|
? "bg-blue-100 text-blue-700"
|
|
: job.status === "paused"
|
|
? "bg-amber-100 text-amber-700"
|
|
: terminalJobStatuses.has(job.status)
|
|
? "bg-gray-200 text-gray-700"
|
|
: "bg-purple-100 text-purple-700"
|
|
}`}
|
|
>
|
|
{job.cancelRequested
|
|
? "取消中"
|
|
: job.pauseRequested
|
|
? "暂停中"
|
|
: statusLabels[job.status]}
|
|
</span>
|
|
<span className="text-gray-600">
|
|
{job.progress.processed} / {job.progress.total}
|
|
</span>
|
|
<span className="text-gray-500">
|
|
成功 {job.progress.completed},失败 {job.progress.failed}
|
|
,取消 {job.progress.canceled}
|
|
</span>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="h-2 overflow-hidden rounded-full bg-gray-200">
|
|
<div
|
|
className="h-full rounded-full bg-blue-600 transition-all"
|
|
style={{ width: `${job.progress.percent}%` }}
|
|
/>
|
|
</div>
|
|
<div className="mt-1 text-xs text-gray-500">
|
|
范围 {job.selection.rangeStart}-{job.selection.rangeEnd}
|
|
,匹配 {job.selection.matchedCount},选中{" "}
|
|
{job.selection.selectedCount}
|
|
</div>
|
|
</div>
|
|
|
|
{job.currentItem && (
|
|
<div className="rounded-lg bg-white p-3 text-sm max-w-200">
|
|
<div className="font-medium text-gray-900">
|
|
当前: {job.currentItem.index}/{job.progress.total}
|
|
</div>
|
|
<div className="mt-1 truncate text-gray-600">
|
|
{job.currentItem.desc || job.currentItem.awemeId}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleControlJob("pause")}
|
|
disabled={
|
|
jobLoading ||
|
|
job.status !== "running" ||
|
|
job.pauseRequested ||
|
|
job.cancelRequested
|
|
}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<Pause size={15} />
|
|
暂停
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleControlJob("resume")}
|
|
disabled={
|
|
jobLoading ||
|
|
terminalJobStatuses.has(job.status) ||
|
|
job.cancelRequested ||
|
|
(!job.pauseRequested && job.status !== "paused")
|
|
}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<Play size={15} />
|
|
继续
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleControlJob("cancel")}
|
|
disabled={
|
|
jobLoading ||
|
|
terminalJobStatuses.has(job.status) ||
|
|
job.cancelRequested
|
|
}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-rose-200 bg-white px-3 py-2 text-sm font-medium text-rose-700 transition-colors hover:bg-rose-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<Square size={15} />
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => refreshJob(job.id)}
|
|
disabled={jobLoading}
|
|
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<RefreshCw size={15} />
|
|
查询进度
|
|
</button>
|
|
</div>
|
|
|
|
{recentItems.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-gray-500">
|
|
最近结果
|
|
</div>
|
|
<div className="space-y-1 max-w-200">
|
|
{recentItems.map((item) => (
|
|
<div
|
|
key={`${item.awemeId}-${item.index}`}
|
|
className="flex items-center justify-between gap-3 rounded bg-white px-3 py-2 text-xs"
|
|
>
|
|
<span className="min-w-0 truncate text-gray-700">
|
|
{item.index}. {item.desc || item.awemeId}
|
|
</span>
|
|
<span
|
|
className={`shrink-0 rounded px-2 py-0.5 ${
|
|
item.status === "success"
|
|
? "bg-green-50 text-green-700"
|
|
: item.status === "failed"
|
|
? "bg-rose-50 text-rose-700"
|
|
: "bg-gray-100 text-gray-600"
|
|
}`}
|
|
>
|
|
{itemStatusLabels[item.status]}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{recentLogs.length > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-gray-500">
|
|
最近日志
|
|
</div>
|
|
<div className="max-h-36 space-y-1 overflow-auto rounded bg-white p-2 text-xs text-gray-600">
|
|
{recentLogs.map((log) => (
|
|
<div
|
|
key={`${log.at}-${log.message}`}
|
|
className="truncate"
|
|
>
|
|
{formatTime(log.at)} {log.message}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg bg-white p-6 text-center text-sm text-gray-500">
|
|
还没有批量任务
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full divide-y divide-gray-200">
|
|
<thead className="bg-gray-50">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
<button
|
|
type="button"
|
|
onClick={toggleVisibleSelection}
|
|
className="rounded border border-gray-300 px-2 py-1 text-gray-700 hover:bg-gray-100"
|
|
>
|
|
{selectedVisible ? "取消" : "全选"}
|
|
</button>
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
视频
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
时长
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
识别/标签状态
|
|
</th>
|
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
操作
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-white divide-y divide-gray-200">
|
|
{filteredVideos.map((video) => (
|
|
<tr key={video.aweme_id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-4 align-top">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedIds.has(video.aweme_id)}
|
|
onChange={() => toggleVideoSelection(video.aweme_id)}
|
|
className="h-4 w-4 rounded border-gray-300 text-blue-600"
|
|
aria-label="选择视频"
|
|
/>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-4">
|
|
<Image
|
|
src={video.cover_url}
|
|
alt=""
|
|
width={80}
|
|
height={112}
|
|
unoptimized
|
|
className="w-20 h-28 object-cover rounded"
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<Link
|
|
href={`/aweme/${video.aweme_id}`}
|
|
className="text-sm font-medium text-blue-600 hover:text-blue-800 line-clamp-2"
|
|
>
|
|
{video.desc || "无描述"}
|
|
</Link>
|
|
<p className="text-sm text-gray-500 mt-1">
|
|
@{video.author.nickname}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
{Math.floor(video.duration_ms / 1000)}s
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
{video.transcript ? (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
已转写
|
|
</span>
|
|
{video.transcript.language && (
|
|
<span className="text-xs text-gray-500">
|
|
{video.transcript.language}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{video.transcript.speech_detected ? (
|
|
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
|
|
{video.transcript.transcript.join(" ")}
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-gray-500">
|
|
<span className="font-medium">
|
|
{video.transcript.audio_type || "非语音"}
|
|
</span>
|
|
{video.transcript.non_speech_summary && (
|
|
<span className="ml-1">
|
|
- {video.transcript.non_speech_summary}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{video.content_tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1 pt-1">
|
|
{video.content_tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${
|
|
video.manual_content_tags.includes(tag)
|
|
? "bg-purple-50 text-purple-700"
|
|
: "bg-blue-50 text-blue-700"
|
|
}`}
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
{video.content_tags.length === 0 && (
|
|
<span className="inline-flex items-center rounded bg-rose-50 px-2 py-0.5 text-xs font-medium text-rose-700">
|
|
未生成标签
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
|
待转写
|
|
</span>
|
|
{video.content_tags.length > 0 && (
|
|
<div className="flex flex-wrap gap-1">
|
|
{video.content_tags.map((tag) => (
|
|
<span
|
|
key={tag}
|
|
className="inline-flex items-center rounded bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-700"
|
|
>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleTranscribe(video.aweme_id)}
|
|
disabled={
|
|
transcribing.has(video.aweme_id) || activeJobRunning
|
|
}
|
|
className={`px-4 py-2 text-white rounded-lg disabled:cursor-not-allowed transition-colors ${
|
|
video.transcript
|
|
? "bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400"
|
|
: "bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400"
|
|
}`}
|
|
>
|
|
{transcribing.has(video.aweme_id) ? (
|
|
<span className="flex items-center gap-2">
|
|
<span className="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-white" />
|
|
生成中...
|
|
</span>
|
|
) : video.transcript ? (
|
|
hasContentTags(video) ? (
|
|
"重新识别"
|
|
) : (
|
|
"生成标签"
|
|
)
|
|
) : (
|
|
"开始识别"
|
|
)}
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{filteredVideos.length === 0 && (
|
|
<div className="text-center py-12 text-gray-500">没有找到视频</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|