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

This commit is contained in:
2026-10-03 22:09:09 +08:00
29 changed files with 1459 additions and 532 deletions
+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>