@@ -3,26 +3,27 @@
|
||||
import Link from "next/link";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
|
||||
export default function SttAdminPage() {
|
||||
export default function AsrAdminPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-4xl mx-auto p-8">
|
||||
<BackButton />
|
||||
|
||||
<div className="mt-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">STT 管理中心</h1>
|
||||
<p className="text-gray-600 mt-2">管理和配置视频语音转写功能</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">ASR 管理中心</h1>
|
||||
<p className="text-gray-600 mt-2">管理和配置视频语音识别功能</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6">
|
||||
{/* 视频转写管理 */}
|
||||
<Link
|
||||
href="/admin/stt/videos"
|
||||
href="/admin/asr/videos"
|
||||
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-blue-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -46,6 +47,7 @@ export default function SttAdminPage() {
|
||||
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
|
||||
进入管理
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -68,6 +70,7 @@ export default function SttAdminPage() {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -104,6 +107,7 @@ export default function SttAdminPage() {
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-6 h-6 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
|
||||
type VideoTranscript = {
|
||||
@@ -9,8 +10,9 @@ type VideoTranscript = {
|
||||
speech_detected: boolean;
|
||||
language: string | null;
|
||||
audio_type: string | null;
|
||||
transcript: string | null;
|
||||
transcript: string[];
|
||||
non_speech_summary: string | null;
|
||||
content_tags: string[];
|
||||
};
|
||||
|
||||
type Video = {
|
||||
@@ -29,25 +31,21 @@ type ApiResponse = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export default function SttVideosPage() {
|
||||
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<"all" | "transcribed" | "pending">(
|
||||
"all",
|
||||
);
|
||||
const [filter, setFilter] = useState<
|
||||
"all" | "transcribed" | "pending" | "tagged" | "missingTags"
|
||||
>("all");
|
||||
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
const fetchVideos = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch("/api/admin/stt/videos");
|
||||
const response = await fetch("/api/admin/asr/videos");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch videos");
|
||||
}
|
||||
@@ -58,34 +56,41 @@ export default function SttVideosPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, [fetchVideos]);
|
||||
|
||||
// 简单的 sleep 工具
|
||||
const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const hasContentTags = (video: Video) =>
|
||||
(video.transcript?.content_tags.length ?? 0) > 0;
|
||||
|
||||
type PollOptions = {
|
||||
intervalMs?: number;
|
||||
maxAttempts?: number;
|
||||
};
|
||||
|
||||
// 轮询某个视频的转写状态直至完成或超时
|
||||
// 轮询某个视频的识别和标签状态直至完成或超时
|
||||
const pollVideoUntilTranscribed = async (
|
||||
awemeId: string,
|
||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
|
||||
): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const resp = await fetch("/api/admin/stt/videos");
|
||||
const resp = await fetch("/api/admin/asr/videos");
|
||||
if (resp.ok) {
|
||||
const data: ApiResponse = await resp.json();
|
||||
setVideos(data.videos);
|
||||
const v = data.videos.find((x) => x.aweme_id === awemeId);
|
||||
if (v && v.transcript) {
|
||||
if ((v?.transcript?.content_tags.length ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// 忽略网络错误,继续轮询
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
@@ -96,7 +101,7 @@ export default function SttVideosPage() {
|
||||
const handleTranscribe = async (awemeId: string) => {
|
||||
setTranscribing((prev) => new Set(prev).add(awemeId));
|
||||
try {
|
||||
const response = await fetch(`/api/stt?awemeId=${awemeId}`);
|
||||
const response = await fetch(`/api/asr?awemeId=${awemeId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to transcribe video");
|
||||
}
|
||||
@@ -118,15 +123,15 @@ export default function SttVideosPage() {
|
||||
};
|
||||
|
||||
const handleBatchTranscribe = async () => {
|
||||
const pendingVideos = videos.filter((v) => v.transcript === null);
|
||||
const pendingVideos = videos.filter((v) => !hasContentTags(v));
|
||||
if (pendingVideos.length === 0) {
|
||||
alert("没有待转写的视频");
|
||||
alert("没有缺失标签的视频");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!confirm(
|
||||
`确定要转写 ${pendingVideos.length} 个视频吗?这可能需要较长时间。`,
|
||||
`确定要为 ${pendingVideos.length} 个视频生成 ASR 标签吗?这会重新执行识别流程,可能需要较长时间。`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -142,7 +147,7 @@ export default function SttVideosPage() {
|
||||
setTranscribing((prev) => new Set(prev).add(video.aweme_id));
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/stt?awemeId=${video.aweme_id}`);
|
||||
const response = await fetch(`/api/asr?awemeId=${video.aweme_id}`);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to transcribe video ${video.aweme_id}`);
|
||||
}
|
||||
@@ -167,12 +172,14 @@ export default function SttVideosPage() {
|
||||
await fetchVideos();
|
||||
setBatchTranscribing(false);
|
||||
setBatchProgress({ current: 0, total: 0 });
|
||||
alert("批量转写完成!");
|
||||
alert("缺失标签生成完成!");
|
||||
};
|
||||
|
||||
const filteredVideos = videos.filter((v) => {
|
||||
if (filter === "transcribed") return v.transcript !== null;
|
||||
if (filter === "pending") return v.transcript === null;
|
||||
if (filter === "tagged") return hasContentTags(v);
|
||||
if (filter === "missingTags") return !hasContentTags(v);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -180,6 +187,8 @@ export default function SttVideosPage() {
|
||||
total: videos.length,
|
||||
transcribed: videos.filter((v) => v.transcript !== null).length,
|
||||
pending: videos.filter((v) => v.transcript === null).length,
|
||||
tagged: videos.filter((v) => hasContentTags(v)).length,
|
||||
missingTags: videos.filter((v) => !hasContentTags(v)).length,
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -202,6 +211,7 @@ export default function SttVideosPage() {
|
||||
<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"
|
||||
>
|
||||
@@ -220,13 +230,13 @@ export default function SttVideosPage() {
|
||||
<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>
|
||||
<p className="text-gray-600 mt-2">管理视频的语音识别和自动标签状态</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<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">
|
||||
@@ -245,6 +255,15 @@ export default function SttVideosPage() {
|
||||
{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>
|
||||
|
||||
{/* Filter */}
|
||||
@@ -252,6 +271,7 @@ export default function SttVideosPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("all")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === "all"
|
||||
@@ -262,6 +282,7 @@ export default function SttVideosPage() {
|
||||
全部 ({stats.total})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("transcribed")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === "transcribed"
|
||||
@@ -272,6 +293,7 @@ export default function SttVideosPage() {
|
||||
已转写 ({stats.transcribed})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("pending")}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${
|
||||
filter === "pending"
|
||||
@@ -281,25 +303,48 @@ export default function SttVideosPage() {
|
||||
>
|
||||
待转写 ({stats.pending})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFilter("tagged")}
|
||||
className={`px-4 py-2 rounded-lg 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={`px-4 py-2 rounded-lg 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="flex items-center gap-4">
|
||||
{batchTranscribing && (
|
||||
<div className="text-sm text-gray-600">
|
||||
正在转写: {batchProgress.current} / {batchProgress.total}
|
||||
正在生成: {batchProgress.current} / {batchProgress.total}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchTranscribe}
|
||||
disabled={batchTranscribing || stats.pending === 0}
|
||||
disabled={batchTranscribing || stats.missingTags === 0}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||
>
|
||||
{batchTranscribing ? (
|
||||
<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>
|
||||
批量转写中...
|
||||
生成中...
|
||||
</span>
|
||||
) : (
|
||||
"全部转写"
|
||||
"一键生成缺失标签"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -319,7 +364,7 @@ export default function SttVideosPage() {
|
||||
时长
|
||||
</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">
|
||||
操作
|
||||
@@ -331,9 +376,12 @@ export default function SttVideosPage() {
|
||||
<tr key={video.aweme_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
<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">
|
||||
@@ -367,7 +415,7 @@ export default function SttVideosPage() {
|
||||
</div>
|
||||
{video.transcript.speech_detected ? (
|
||||
<div className="text-xs text-gray-600 line-clamp-5 max-w-md">
|
||||
{video.transcript.transcript}
|
||||
{video.transcript.transcript.join(" ")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500">
|
||||
@@ -381,6 +429,23 @@ export default function SttVideosPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{video.transcript.content_tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{video.transcript.content_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{video.transcript.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>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800">
|
||||
@@ -391,6 +456,7 @@ export default function SttVideosPage() {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{video.transcript ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTranscribe(video.aweme_id)}
|
||||
disabled={transcribing.has(video.aweme_id)}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
@@ -398,14 +464,17 @@ export default function SttVideosPage() {
|
||||
{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>
|
||||
重新转写...
|
||||
生成中...
|
||||
</span>
|
||||
) : video.transcript.content_tags.length === 0 ? (
|
||||
"生成标签"
|
||||
) : (
|
||||
"重新转写"
|
||||
"重新识别"
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTranscribe(video.aweme_id)}
|
||||
disabled={transcribing.has(video.aweme_id)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-blue-400 disabled:cursor-not-allowed transition-colors"
|
||||
@@ -413,10 +482,10 @@ export default function SttVideosPage() {
|
||||
{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>
|
||||
转写中...
|
||||
识别中...
|
||||
</span>
|
||||
) : (
|
||||
"开始转写"
|
||||
"开始识别"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
Reference in New Issue
Block a user