fix: 合并服务器未推送改动并保留手动视频标签

This commit is contained in:
2026-10-03 22:09:09 +08:00
29 changed files with 1459 additions and 532 deletions
+1 -135
View File
@@ -1,135 +1 @@
"use client";
import Link from "next/link";
import BackButton from "@/app/components/BackButton";
export default function SttAdminPage() {
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>
</div>
<div className="mt-8 grid gap-6">
{/* 视频转写管理 */}
<Link
href="/admin/stt/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
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
视频转写管理
</h2>
<p className="text-gray-600 mt-1">
查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写
</p>
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
进入管理
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</div>
</Link>
{/* 转写设置(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<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
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
转写设置
</h2>
<p className="text-gray-600 mt-1">
配置转写 API、模型参数和其他高级选项
</p>
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
</div>
</div>
</div>
{/* 转写统计(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<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
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
转写统计
</h2>
<p className="text-gray-600 mt-1">
查看转写使用情况、成功率、语言分布等统计数据
</p>
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export { default } from "../asr/page";
+112 -1
View File
@@ -3,6 +3,7 @@
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,
@@ -18,6 +19,7 @@ type Video = {
duration_ms: number;
author: { nickname: string };
transcription: TranscriptionState;
content_tags: string[];
};
const filters: ("all" | TranscriptionStatus)[] = [
"all",
@@ -36,6 +38,10 @@ export default function SttVideosPage() {
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [tag, setTag] = useState<string>(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;
@@ -104,6 +110,28 @@ export default function SttVideosPage() {
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(
@@ -111,7 +139,9 @@ export default function SttVideosPage() {
v.transcription.transcript === null && !isActive(v.transcription.status),
).length;
const shown = videos.filter(
(v) => filter === "all" || v.transcription.status === filter,
(v) =>
(filter === "all" || v.transcription.status === filter) &&
(!tagFilter || v.content_tags.includes(tagFilter)),
);
return (
@@ -155,10 +185,70 @@ export default function SttVideosPage() {
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
<select
aria-label="筛选手动标签"
value={tagFilter}
onChange={(e) => {
setTagFilter(e.target.value);
setSelected(new Set());
}}
className="rounded border p-2"
>
<option value="">全部手动标签</option>
{VIDEO_CONTENT_TAGS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<span>已选 {selected.size} 个(最多 200 个)</span>
<select
aria-label="批量添加的标签"
value={tag}
onChange={(e) => setTag(e.target.value)}
className="rounded border p-2"
>
{VIDEO_CONTENT_TAGS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<button
type="button"
disabled={!selected.size || savingTags}
onClick={() => saveTags()}
className="rounded border px-3 py-2 disabled:opacity-50"
>
添加手动标签
</button>
<button
type="button"
disabled={!selected.size || savingTags}
onClick={() => saveTags(true)}
className="rounded border px-3 py-2 disabled:opacity-50"
>
清空手动标签
</button>
</div>
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
<table className="w-full text-left text-sm">
<thead className="bg-gray-100 text-gray-600">
<tr>
<th className="p-3">
<input
type="checkbox"
aria-label="选择当前列表前200个视频"
checked={
shown.length > 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(),
)
}
/>
</th>
<th className="p-3">视频</th>
<th className="p-3">时长</th>
<th className="p-3">状态与文字</th>
@@ -175,6 +265,24 @@ export default function SttVideosPage() {
key={video.aweme_id}
className="border-t border-gray-100 align-top"
>
<td className="p-3">
<input
type="checkbox"
aria-label={`选择视频 ${video.aweme_id}`}
checked={selected.has(video.aweme_id)}
disabled={
!selected.has(video.aweme_id) && selected.size >= 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;
})
}
/>
</td>
<td className="p-3">
<div className="flex max-w-sm gap-3">
{video.cover_url && (
@@ -197,6 +305,9 @@ export default function SttVideosPage() {
<p className="mt-1 text-gray-500">
@{video.author.nickname}
</p>
<p className="mt-1 text-xs text-blue-700">
{video.content_tags.join("、")}
</p>
</div>
</div>
</td>