feat: add manual video tagging
This commit is contained in:
+204
-119
@@ -20,6 +20,8 @@ type Video = {
|
||||
desc: string;
|
||||
cover_url: string;
|
||||
duration_ms: number;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
author: {
|
||||
nickname: string;
|
||||
};
|
||||
@@ -40,11 +42,19 @@ export default function AsrVideosPage() {
|
||||
"all" | "transcribed" | "pending" | "tagged" | "missingTags"
|
||||
>("all");
|
||||
const [batchTranscribing, setBatchTranscribing] = useState(false);
|
||||
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
||||
const [batchProgress, setBatchProgress] = useState({
|
||||
current: 0,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
currentAwemeId: null as string | null,
|
||||
currentDesc: null as string | null,
|
||||
});
|
||||
const [batchMessage, setBatchMessage] = useState<string | null>(null);
|
||||
|
||||
const fetchVideos = useCallback(async () => {
|
||||
const fetchVideos = useCallback(async (showLoading = true) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (showLoading) setLoading(true);
|
||||
const response = await fetch("/api/admin/asr/videos");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch videos");
|
||||
@@ -54,7 +64,7 @@ export default function AsrVideosPage() {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unknown error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (showLoading) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -66,8 +76,7 @@ export default function AsrVideosPage() {
|
||||
const sleep = (ms: number) =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const hasContentTags = (video: Video) =>
|
||||
(video.transcript?.content_tags.length ?? 0) > 0;
|
||||
const hasContentTags = (video: Video) => video.content_tags.length > 0;
|
||||
|
||||
type PollOptions = {
|
||||
intervalMs?: number;
|
||||
@@ -77,7 +86,7 @@ export default function AsrVideosPage() {
|
||||
// 轮询某个视频的识别和标签状态直至完成或超时
|
||||
const pollVideoUntilTranscribed = async (
|
||||
awemeId: string,
|
||||
{ intervalMs = 2000, maxAttempts = 30 }: PollOptions = {},
|
||||
{ intervalMs = 2000, maxAttempts = 300 }: PollOptions = {},
|
||||
): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
@@ -86,7 +95,7 @@ export default function AsrVideosPage() {
|
||||
const data: ApiResponse = await resp.json();
|
||||
setVideos(data.videos);
|
||||
const v = data.videos.find((x) => x.aweme_id === awemeId);
|
||||
if ((v?.transcript?.content_tags.length ?? 0) > 0) {
|
||||
if ((v?.content_tags.length ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -101,7 +110,9 @@ export default function AsrVideosPage() {
|
||||
const handleTranscribe = async (awemeId: string) => {
|
||||
setTranscribing((prev) => new Set(prev).add(awemeId));
|
||||
try {
|
||||
const response = await fetch(`/api/asr?awemeId=${awemeId}`);
|
||||
const response = await fetch(
|
||||
`/api/asr?awemeId=${encodeURIComponent(awemeId)}&async=1`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to transcribe video");
|
||||
}
|
||||
@@ -109,7 +120,7 @@ export default function AsrVideosPage() {
|
||||
const done = await pollVideoUntilTranscribed(awemeId);
|
||||
if (!done) {
|
||||
// 兜底刷新一次
|
||||
await fetchVideos();
|
||||
await fetchVideos(false);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Transcription failed");
|
||||
@@ -138,41 +149,58 @@ export default function AsrVideosPage() {
|
||||
}
|
||||
|
||||
setBatchTranscribing(true);
|
||||
setBatchProgress({ current: 0, total: pendingVideos.length });
|
||||
setBatchMessage(null);
|
||||
setBatchProgress({
|
||||
current: 0,
|
||||
total: pendingVideos.length,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
currentAwemeId: null,
|
||||
currentDesc: null,
|
||||
});
|
||||
|
||||
const pollers: Promise<boolean>[] = [];
|
||||
for (let i = 0; i < pendingVideos.length; i++) {
|
||||
const video = pendingVideos[i];
|
||||
setBatchProgress({ current: i + 1, total: pendingVideos.length });
|
||||
setBatchProgress((prev) => ({
|
||||
...prev,
|
||||
current: i + 1,
|
||||
currentAwemeId: video.aweme_id,
|
||||
currentDesc: video.desc || "无描述",
|
||||
}));
|
||||
setTranscribing((prev) => new Set(prev).add(video.aweme_id));
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/asr?awemeId=${video.aweme_id}`);
|
||||
const response = await fetch(
|
||||
`/api/asr?awemeId=${encodeURIComponent(video.aweme_id)}&async=1`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to transcribe video ${video.aweme_id}`);
|
||||
throw new Error(`Failed to start ASR for ${video.aweme_id}`);
|
||||
}
|
||||
// 为该视频启动后台轮询,不阻塞批处理的顺序执行
|
||||
const p = pollVideoUntilTranscribed(video.aweme_id).finally(() => {
|
||||
setTranscribing((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(video.aweme_id);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
pollers.push(p);
|
||||
|
||||
const done = await pollVideoUntilTranscribed(video.aweme_id);
|
||||
setBatchProgress((prev) => ({
|
||||
...prev,
|
||||
completed: done ? prev.completed + 1 : prev.completed,
|
||||
failed: done ? prev.failed : prev.failed + 1,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(`Error transcribing video ${video.aweme_id}:`, err);
|
||||
setBatchProgress((prev) => ({
|
||||
...prev,
|
||||
failed: prev.failed + 1,
|
||||
}));
|
||||
} finally {
|
||||
setTranscribing((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(video.aweme_id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 等待所有轮询结束(完成或超时),再进行最终刷新
|
||||
if (pollers.length > 0) {
|
||||
await Promise.allSettled(pollers);
|
||||
}
|
||||
await fetchVideos();
|
||||
await fetchVideos(false);
|
||||
setBatchTranscribing(false);
|
||||
setBatchProgress({ current: 0, total: 0 });
|
||||
alert("缺失标签生成完成!");
|
||||
setBatchMessage("缺失标签生成流程已结束");
|
||||
};
|
||||
|
||||
const filteredVideos = videos.filter((v) => {
|
||||
@@ -190,6 +218,11 @@ export default function AsrVideosPage() {
|
||||
tagged: videos.filter((v) => hasContentTags(v)).length,
|
||||
missingTags: videos.filter((v) => !hasContentTags(v)).length,
|
||||
};
|
||||
const processedCount = batchProgress.completed + batchProgress.failed;
|
||||
const progressPercent =
|
||||
batchProgress.total > 0
|
||||
? Math.round((processedCount / batchProgress.total) * 100)
|
||||
: 0;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -212,7 +245,7 @@ export default function AsrVideosPage() {
|
||||
<p className="text-red-600">错误: {error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchVideos}
|
||||
onClick={() => fetchVideos()}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
重试
|
||||
@@ -267,87 +300,121 @@ export default function AsrVideosPage() {
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<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"
|
||||
? "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={`px-4 py-2 rounded-lg 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={`px-4 py-2 rounded-lg 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={`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}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchTranscribe}
|
||||
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>
|
||||
) : (
|
||||
"一键生成缺失标签"
|
||||
<div className="mb-6 rounded-lg bg-white p-4 shadow">
|
||||
<div className="space-y-4">
|
||||
<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="flex items-center gap-4">
|
||||
{batchTranscribing && (
|
||||
<div className="text-sm text-gray-600">
|
||||
正在生成: {batchProgress.current} / {batchProgress.total}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchTranscribe}
|
||||
disabled={batchTranscribing || stats.missingTags === 0}
|
||||
className="rounded-lg bg-purple-600 px-6 py-2 font-medium text-white transition-colors hover:bg-purple-700 disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||
>
|
||||
{batchTranscribing ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="inline-block h-4 w-4 animate-spin rounded-full border-b-2 border-white" />
|
||||
生成中...
|
||||
</span>
|
||||
) : (
|
||||
`一键生成缺失标签 (${stats.missingTags})`
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(batchTranscribing || batchMessage) && (
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50 p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-blue-900">
|
||||
{batchTranscribing ? "正在生成缺失标签" : batchMessage}
|
||||
</span>
|
||||
<span className="text-blue-700">
|
||||
{processedCount} / {batchProgress.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-blue-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-600 transition-all"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-blue-800">
|
||||
<span>
|
||||
已完成 {batchProgress.completed},失败/超时{" "}
|
||||
{batchProgress.failed}
|
||||
</span>
|
||||
{batchTranscribing && batchProgress.currentAwemeId && (
|
||||
<span className="max-w-xl truncate">
|
||||
当前: {batchProgress.currentDesc} (
|
||||
{batchProgress.currentAwemeId})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -429,28 +496,46 @@ export default function AsrVideosPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{video.transcript.content_tags.length > 0 && (
|
||||
{video.content_tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{video.transcript.content_tags.map((tag) => (
|
||||
{video.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"
|
||||
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.transcript.content_tags.length === 0 && (
|
||||
{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>
|
||||
) : (
|
||||
<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>
|
||||
<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">
|
||||
@@ -466,7 +551,7 @@ export default function AsrVideosPage() {
|
||||
<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 ? (
|
||||
) : !hasContentTags(video) ? (
|
||||
"生成标签"
|
||||
) : (
|
||||
"重新识别"
|
||||
|
||||
@@ -27,26 +27,35 @@ export async function GET() {
|
||||
},
|
||||
});
|
||||
|
||||
const formattedVideos = videos.map((v) => ({
|
||||
aweme_id: v.aweme_id,
|
||||
desc: v.desc,
|
||||
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||
duration_ms: v.duration_ms,
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
},
|
||||
transcript: v.transcript
|
||||
? {
|
||||
id: v.transcript.id,
|
||||
speech_detected: v.transcript.speech_detected,
|
||||
language: v.transcript.language,
|
||||
audio_type: v.transcript.audio_type,
|
||||
transcript: v.transcript.transcript,
|
||||
non_speech_summary: v.transcript.non_speech_summary,
|
||||
content_tags: v.transcript.content_tags,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
const formattedVideos = videos.map((v) => {
|
||||
const contentTags =
|
||||
v.manual_content_tags.length > 0
|
||||
? v.manual_content_tags
|
||||
: (v.transcript?.content_tags ?? []);
|
||||
|
||||
return {
|
||||
aweme_id: v.aweme_id,
|
||||
desc: v.desc,
|
||||
cover_url: getFileUrl(v.cover_url ?? ""),
|
||||
duration_ms: v.duration_ms,
|
||||
manual_content_tags: v.manual_content_tags,
|
||||
content_tags: contentTags,
|
||||
author: {
|
||||
nickname: v.author.nickname,
|
||||
},
|
||||
transcript: v.transcript
|
||||
? {
|
||||
id: v.transcript.id,
|
||||
speech_detected: v.transcript.speech_detected,
|
||||
language: v.transcript.language,
|
||||
audio_type: v.transcript.audio_type,
|
||||
transcript: v.transcript.transcript,
|
||||
non_speech_summary: v.transcript.non_speech_summary,
|
||||
content_tags: v.transcript.content_tags,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
videos: formattedVideos,
|
||||
|
||||
+36
-1
@@ -3,6 +3,30 @@ export const runtime = "nodejs";
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { transcriptAweme } from ".";
|
||||
|
||||
const runningJobs = new Map<string, Promise<void>>();
|
||||
|
||||
function startBackgroundAsr(awemeId: string) {
|
||||
if (runningJobs.has(awemeId)) return false;
|
||||
|
||||
const job = transcriptAweme(awemeId, {
|
||||
log: (message) => console.log(`[ASR ${awemeId}] ${message}`),
|
||||
})
|
||||
.then((result) => {
|
||||
console.log(
|
||||
`[ASR ${awemeId}] done tags=${result.content_tags.join("、")}`,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`[ASR ${awemeId}] failed:`, error);
|
||||
})
|
||||
.finally(() => {
|
||||
runningJobs.delete(awemeId);
|
||||
});
|
||||
|
||||
runningJobs.set(awemeId, job);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const awemeId = searchParams.get("awemeId");
|
||||
@@ -12,8 +36,19 @@ export async function GET(req: NextRequest) {
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const asyncMode =
|
||||
searchParams.get("async") === "1" || searchParams.get("mode") === "async";
|
||||
|
||||
if (asyncMode) {
|
||||
const started = startBackgroundAsr(awemeId);
|
||||
return NextResponse.json(
|
||||
{ awemeId, status: started ? "started" : "running" },
|
||||
{ status: 202 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await transcriptAweme(awemeId, /* {log: (msg) => console.log(`[ASR] ${msg}`)} */);
|
||||
const result = await transcriptAweme(awemeId);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
console.error("ASR failed:", error);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
export const runtime = "nodejs";
|
||||
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { isAsrContentTag } from "@/lib/asr-tags";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
type UpdateMode = "set" | "add";
|
||||
|
||||
function uniqueValidTags(tags: unknown[]) {
|
||||
const validTags: string[] = [];
|
||||
for (const tag of tags) {
|
||||
if (typeof tag !== "string" || !isAsrContentTag(tag)) {
|
||||
throw new Error("Invalid tag");
|
||||
}
|
||||
if (!validTags.includes(tag)) validTags.push(tag);
|
||||
}
|
||||
return validTags;
|
||||
}
|
||||
|
||||
function resolveContentTags(manualTags: string[], asrTags: string[]) {
|
||||
return manualTags.length > 0 ? manualTags : asrTags;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request body" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const raw = body as {
|
||||
awemeId?: unknown;
|
||||
awemeIds?: unknown;
|
||||
tag?: unknown;
|
||||
contentTags?: unknown;
|
||||
mode?: unknown;
|
||||
};
|
||||
const awemeIds = Array.isArray(raw.awemeIds)
|
||||
? raw.awemeIds
|
||||
: typeof raw.awemeId === "string"
|
||||
? [raw.awemeId]
|
||||
: [];
|
||||
const normalizedAwemeIds = [...new Set(awemeIds)]
|
||||
.filter(
|
||||
(id): id is string => typeof id === "string" && id.trim().length > 0,
|
||||
)
|
||||
.map((id) => id.trim());
|
||||
|
||||
if (normalizedAwemeIds.length === 0 || normalizedAwemeIds.length > 200) {
|
||||
return NextResponse.json(
|
||||
{ error: "awemeIds must contain 1 to 200 ids" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const mode: UpdateMode = raw.mode === "add" ? "add" : "set";
|
||||
const rawTags = Array.isArray(raw.contentTags)
|
||||
? raw.contentTags
|
||||
: typeof raw.tag === "string"
|
||||
? [raw.tag]
|
||||
: [];
|
||||
|
||||
let contentTags: string[];
|
||||
try {
|
||||
contentTags = uniqueValidTags(rawTags);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (mode === "add" && contentTags.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "add mode requires at least one tag" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const videos = await prisma.video.findMany({
|
||||
where: { aweme_id: { in: normalizedAwemeIds } },
|
||||
select: {
|
||||
aweme_id: true,
|
||||
manual_content_tags: true,
|
||||
transcript: { select: { content_tags: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (videos.length === 0) {
|
||||
return NextResponse.json({ error: "No videos found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const updates = videos.map((video) => {
|
||||
const asrTags = video.transcript?.content_tags ?? [];
|
||||
const baseTags = resolveContentTags(video.manual_content_tags, asrTags);
|
||||
const manualTags =
|
||||
mode === "add"
|
||||
? [...new Set([...baseTags, ...contentTags])]
|
||||
: contentTags;
|
||||
return { awemeId: video.aweme_id, manualTags, asrTags };
|
||||
});
|
||||
|
||||
await prisma.$transaction(
|
||||
updates.map((update) =>
|
||||
prisma.video.update({
|
||||
where: { aweme_id: update.awemeId },
|
||||
data: { manual_content_tags: update.manualTags },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
updated: updates.map((update) => ({
|
||||
aweme_id: update.awemeId,
|
||||
manual_content_tags: update.manualTags,
|
||||
content_tags: resolveContentTags(update.manualTags, update.asrTags),
|
||||
})),
|
||||
missing: normalizedAwemeIds.filter(
|
||||
(id) => !videos.some((video) => video.aweme_id === id),
|
||||
),
|
||||
});
|
||||
}
|
||||
+14
-3
@@ -1,6 +1,6 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { isAsrContentTag } from "@/lib/asr-tags";
|
||||
import { isAsrContentTag, resolveEffectiveContentTags } from "@/lib/asr-tags";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
@@ -24,7 +24,15 @@ export async function GET(req: NextRequest) {
|
||||
const videoWhere = {
|
||||
...(before ? { created_at: { lt: before } } : {}),
|
||||
...(selectedTag
|
||||
? { transcript: { is: { content_tags: { has: selectedTag } } } }
|
||||
? {
|
||||
OR: [
|
||||
{ manual_content_tags: { has: selectedTag } },
|
||||
{
|
||||
manual_content_tags: { isEmpty: true },
|
||||
transcript: { is: { content_tags: { has: selectedTag } } },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -68,7 +76,10 @@ export async function GET(req: NextRequest) {
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
content_tags: v.transcript?.content_tags ?? [],
|
||||
content_tags: resolveEffectiveContentTags(
|
||||
v.manual_content_tags,
|
||||
v.transcript?.content_tags,
|
||||
),
|
||||
})),
|
||||
...posts.map((p) => {
|
||||
const coverPath = p.images?.[0]?.url;
|
||||
|
||||
+25
-10
@@ -78,7 +78,10 @@ export async function GET(req: Request) {
|
||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||
)
|
||||
END AS snippet,
|
||||
COALESCE(vt.content_tags, ARRAY[]::text[]) AS "contentTags"
|
||||
CASE
|
||||
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags
|
||||
ELSE COALESCE(vt.content_tags, ARRAY[]::text[])
|
||||
END AS "contentTags"
|
||||
FROM "Video" v
|
||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id,
|
||||
tsq
|
||||
@@ -86,7 +89,8 @@ export async function GET(req: Request) {
|
||||
(vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
||||
AND CASE
|
||||
WHEN ${contentTag}::text IS NULL THEN TRUE
|
||||
ELSE vt.content_tags @> ARRAY[${contentTag}]::text[]
|
||||
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||
ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[]
|
||||
END
|
||||
|
||||
UNION ALL
|
||||
@@ -114,7 +118,7 @@ export async function GET(req: Request) {
|
||||
`
|
||||
: await prisma.$queryRaw<SearchRow[]>`
|
||||
SELECT
|
||||
vt.id AS id,
|
||||
COALESCE(vt.id, 'tag:' || v.aweme_id) AS id,
|
||||
v.aweme_id AS "awemeId",
|
||||
'video'::text AS type,
|
||||
1.0::double precision AS rank,
|
||||
@@ -124,10 +128,16 @@ export async function GET(req: Request) {
|
||||
v.desc,
|
||||
'标签匹配'
|
||||
) AS snippet,
|
||||
vt.content_tags AS "contentTags"
|
||||
FROM "VideoTranscript" vt
|
||||
JOIN "Video" v ON v.aweme_id = vt."videoId"
|
||||
WHERE vt.content_tags @> ARRAY[${contentTag}]::text[]
|
||||
CASE
|
||||
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags
|
||||
ELSE COALESCE(vt.content_tags, ARRAY[]::text[])
|
||||
END AS "contentTags"
|
||||
FROM "Video" v
|
||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||
WHERE CASE
|
||||
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||
ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[]
|
||||
END
|
||||
ORDER BY v.created_at DESC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${limit};
|
||||
@@ -145,7 +155,8 @@ export async function GET(req: Request) {
|
||||
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
||||
AND CASE
|
||||
WHEN ${contentTag}::text IS NULL THEN TRUE
|
||||
ELSE vt.content_tags @> ARRAY[${contentTag}]::text[]
|
||||
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||
ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[]
|
||||
END)
|
||||
+
|
||||
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
||||
@@ -156,8 +167,12 @@ export async function GET(req: Request) {
|
||||
`
|
||||
: await prisma.$queryRaw<{ count: number }[]>`
|
||||
SELECT COUNT(*)::int AS count
|
||||
FROM "VideoTranscript" vt
|
||||
WHERE vt.content_tags @> ARRAY[${contentTag}]::text[];
|
||||
FROM "Video" v
|
||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||
WHERE CASE
|
||||
WHEN cardinality(v.manual_content_tags) > 0 THEN v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||
ELSE COALESCE(vt.content_tags, ARRAY[]::text[]) @> ARRAY[${contentTag}]::text[]
|
||||
END;
|
||||
`;
|
||||
|
||||
// 分离视频和图文ID
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Play } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
import type {
|
||||
AwemeData,
|
||||
ImageData,
|
||||
Neighbors,
|
||||
VideoData,
|
||||
VideoTranscript,
|
||||
} from "./types.ts";
|
||||
import { BackgroundCanvas } from "./components/BackgroundCanvas";
|
||||
import { CommentPanel } from "./components/CommentPanel";
|
||||
import { ImageCarousel } from "./components/ImageCarousel";
|
||||
@@ -18,15 +11,37 @@ import { MediaControls } from "./components/MediaControls";
|
||||
import { NavigationButtons } from "./components/NavigationButtons";
|
||||
import { TranscriptPanel } from "./components/TranscriptPanel";
|
||||
import { VideoPlayer } from "./components/VideoPlayer";
|
||||
import { VideoTagEditor } from "./components/VideoTagEditor";
|
||||
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
||||
import { useCommentState } from "./hooks/useCommentState";
|
||||
import { useImageCarousel } from "./hooks/useImageCarousel";
|
||||
import { useNavigation } from "./hooks/useNavigation";
|
||||
import { usePlayerState } from "./hooks/usePlayerState";
|
||||
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
||||
import type {
|
||||
AwemeData,
|
||||
ImageData,
|
||||
Neighbors,
|
||||
VideoData,
|
||||
VideoTranscript,
|
||||
} from "./types.ts";
|
||||
|
||||
const SEGMENT_MS = 4000;
|
||||
|
||||
type WebkitVideoElement = HTMLVideoElement & {
|
||||
webkitEnterFullscreen?: () => void;
|
||||
};
|
||||
|
||||
type MediaPositionStateInput = {
|
||||
duration: number;
|
||||
position: number;
|
||||
playbackRate?: number;
|
||||
};
|
||||
|
||||
type MediaSessionWithPositionState = MediaSession & {
|
||||
setPositionState?: (state: MediaPositionStateInput) => void;
|
||||
};
|
||||
|
||||
interface AwemeDetailClientProps {
|
||||
data: AwemeData;
|
||||
neighbors: Neighbors;
|
||||
@@ -45,6 +60,9 @@ export default function AwemeDetailClient({
|
||||
const playerState = usePlayerState();
|
||||
const commentState = useCommentState();
|
||||
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||
const [contentTags, setContentTags] = useState<string[]>(
|
||||
isVideo ? (data as VideoData).content_tags : [],
|
||||
);
|
||||
|
||||
// 引用
|
||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -68,6 +86,7 @@ export default function AwemeDetailClient({
|
||||
|
||||
// 图文轮播状态
|
||||
const images = isVideo ? [] : (data as ImageData).images;
|
||||
const imageCount = images.length;
|
||||
const imageCarouselState = useImageCarousel({
|
||||
images,
|
||||
isPlaying: playerState.isPlaying,
|
||||
@@ -80,40 +99,42 @@ export default function AwemeDetailClient({
|
||||
});
|
||||
|
||||
// 视频播放器 hooks
|
||||
if (isVideo) {
|
||||
useVideoPlayer({
|
||||
awemeId: data.aweme_id,
|
||||
videoRef,
|
||||
volume: playerState.volume,
|
||||
rate: playerState.rate,
|
||||
loopMode: playerState.loopMode,
|
||||
neighbors,
|
||||
progressRestored: playerState.progressRestored,
|
||||
setIsPlaying: playerState.setIsPlaying,
|
||||
setProgress: playerState.setProgress,
|
||||
setProgressRestored: playerState.setProgressRestored,
|
||||
});
|
||||
}
|
||||
useVideoPlayer({
|
||||
enabled: isVideo,
|
||||
awemeId: data.aweme_id,
|
||||
videoRef,
|
||||
volume: playerState.volume,
|
||||
rate: playerState.rate,
|
||||
loopMode: playerState.loopMode,
|
||||
neighbors,
|
||||
progressRestored: playerState.progressRestored,
|
||||
setIsPlaying: playerState.setIsPlaying,
|
||||
setProgress: playerState.setProgress,
|
||||
setProgressRestored: playerState.setProgressRestored,
|
||||
});
|
||||
|
||||
// 媒体操作函数
|
||||
const seekTo = (ratio: number) => {
|
||||
ratio = Math.min(1, Math.max(0, ratio));
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = v.duration * ratio;
|
||||
return;
|
||||
}
|
||||
if (!images?.length) return;
|
||||
imageCarouselState.seekTo(ratio);
|
||||
};
|
||||
const seekTo = useCallback(
|
||||
(ratio: number) => {
|
||||
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !v.duration) return;
|
||||
v.currentTime = v.duration * clampedRatio;
|
||||
return;
|
||||
}
|
||||
if (!imageCount) return;
|
||||
imageCarouselState.seekTo(clampedRatio);
|
||||
},
|
||||
[imageCarouselState.seekTo, imageCount, isVideo],
|
||||
);
|
||||
|
||||
const seekImageByVisualRatio = (ratio: number) => {
|
||||
if (!images?.length) return;
|
||||
if (!imageCount) return;
|
||||
|
||||
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||
const rawSegment = clampedRatio * images.length;
|
||||
const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
|
||||
const rawSegment = clampedRatio * imageCount;
|
||||
const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment));
|
||||
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
|
||||
imageCarouselState.goToIndex(targetIndex, segmentProgress);
|
||||
};
|
||||
@@ -152,14 +173,12 @@ export default function AwemeDetailClient({
|
||||
document.body.requestFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const vRef = videoRef.current;
|
||||
if (vRef && vRef.requestFullscreen) {
|
||||
const vRef = videoRef.current as WebkitVideoElement | null;
|
||||
if (vRef?.requestFullscreen) {
|
||||
vRef.requestFullscreen().catch(() => {});
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (vRef && vRef.webkitEnterFullscreen) {
|
||||
// @ts-ignore
|
||||
if (vRef?.webkitEnterFullscreen) {
|
||||
vRef.webkitEnterFullscreen();
|
||||
}
|
||||
} else {
|
||||
@@ -167,20 +186,20 @@ export default function AwemeDetailClient({
|
||||
}
|
||||
};
|
||||
|
||||
const prevImg = () => {
|
||||
if (!images?.length) return;
|
||||
const prevImg = useCallback(() => {
|
||||
if (!imageCount) return;
|
||||
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
|
||||
imageCarouselState.goToIndex(next);
|
||||
};
|
||||
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
|
||||
|
||||
const nextImg = () => {
|
||||
if (!images?.length) return;
|
||||
const nextImg = useCallback(() => {
|
||||
if (!imageCount) return;
|
||||
const next = Math.min(
|
||||
images.length - 1,
|
||||
imageCount - 1,
|
||||
imageCarouselState.idxRef.current + 1,
|
||||
);
|
||||
imageCarouselState.goToIndex(next);
|
||||
};
|
||||
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
|
||||
|
||||
const handleDownload = () => {
|
||||
if (isVideo) {
|
||||
@@ -249,7 +268,7 @@ export default function AwemeDetailClient({
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
|
||||
const ms = (navigator as any).mediaSession as MediaSession;
|
||||
const ms = navigator.mediaSession as MediaSessionWithPositionState;
|
||||
|
||||
// 元信息:标题、作者、专辑(使用创建时间),封面图(优先当前图片,其次作者头像)
|
||||
const title = data.desc || `作品 ${data.aweme_id}`;
|
||||
@@ -282,12 +301,12 @@ export default function AwemeDetailClient({
|
||||
|
||||
const updatePosition = () => {
|
||||
try {
|
||||
// @ts-ignore: setPositionState 可能不存在
|
||||
if (!ms.setPositionState) return;
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (!v || !isFinite(v.duration) || isNaN(v.duration)) return;
|
||||
// @ts-ignore
|
||||
if (!v || !Number.isFinite(v.duration) || Number.isNaN(v.duration)) {
|
||||
return;
|
||||
}
|
||||
ms.setPositionState({
|
||||
duration: Math.max(0, v.duration),
|
||||
position: Math.max(0, v.currentTime),
|
||||
@@ -300,14 +319,12 @@ export default function AwemeDetailClient({
|
||||
0,
|
||||
Math.min(duration, (playerState.progress * totalMs) / 1000),
|
||||
);
|
||||
// @ts-ignore
|
||||
ms.setPositionState({ duration, position, playbackRate: 1 });
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// 绑定视频事件以同步状态
|
||||
let timeUpdateTimer: number | null = null;
|
||||
const v = videoRef.current;
|
||||
if (isVideo && v) {
|
||||
const onPlay = () => {
|
||||
@@ -331,7 +348,6 @@ export default function AwemeDetailClient({
|
||||
updatePosition();
|
||||
|
||||
// 清理
|
||||
timeUpdateTimer = null;
|
||||
return () => {
|
||||
v.removeEventListener("play", onPlay);
|
||||
v.removeEventListener("pause", onPause);
|
||||
@@ -346,15 +362,10 @@ export default function AwemeDetailClient({
|
||||
}
|
||||
// 依赖:当这些变化时刷新元信息与位置状态
|
||||
}, [
|
||||
isVideo,
|
||||
data.aweme_id,
|
||||
data.desc,
|
||||
data.author.nickname,
|
||||
data.author.avatar_url,
|
||||
data.created_at,
|
||||
imageCarouselState.idx,
|
||||
data,
|
||||
imageCarouselState.totalDurationMs,
|
||||
images,
|
||||
isVideo,
|
||||
playerState.isPlaying,
|
||||
playerState.progress,
|
||||
playerState.rate,
|
||||
@@ -364,7 +375,7 @@ export default function AwemeDetailClient({
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!("mediaSession" in navigator)) return;
|
||||
const ms = (navigator as any).mediaSession as MediaSession;
|
||||
const ms = navigator.mediaSession;
|
||||
|
||||
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
|
||||
|
||||
@@ -421,7 +432,7 @@ export default function AwemeDetailClient({
|
||||
const handleSeekTo = (seekTimeSec: number) => {
|
||||
if (isVideo) {
|
||||
const v = videoRef.current;
|
||||
if (v && isFinite(v.duration)) {
|
||||
if (v && Number.isFinite(v.duration)) {
|
||||
v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec));
|
||||
}
|
||||
} else if (images?.length) {
|
||||
@@ -434,13 +445,13 @@ export default function AwemeDetailClient({
|
||||
ms.setActionHandler("play", handlePlay);
|
||||
ms.setActionHandler("pause", handlePause);
|
||||
ms.setActionHandler("stop", handleStop);
|
||||
ms.setActionHandler("seekbackward", (details: any) =>
|
||||
ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) =>
|
||||
handleSeekDelta(-((details?.seekOffset as number) || 10)),
|
||||
);
|
||||
ms.setActionHandler("seekforward", (details: any) =>
|
||||
ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) =>
|
||||
handleSeekDelta((details?.seekOffset as number) || 10),
|
||||
);
|
||||
ms.setActionHandler("seekto", (details: any) => {
|
||||
ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => {
|
||||
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
|
||||
});
|
||||
ms.setActionHandler("previoustrack", () => {
|
||||
@@ -477,8 +488,12 @@ export default function AwemeDetailClient({
|
||||
neighbors.next,
|
||||
router,
|
||||
playerState.progress,
|
||||
playerState.setIsPlaying,
|
||||
pauseImageMedia,
|
||||
imageCarouselState.totalDurationMs,
|
||||
nextImg,
|
||||
prevImg,
|
||||
seekTo,
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -537,6 +552,14 @@ export default function AwemeDetailClient({
|
||||
)}
|
||||
|
||||
{/* 媒体控制栏 */}
|
||||
{isVideo && (
|
||||
<VideoTagEditor
|
||||
awemeId={data.aweme_id}
|
||||
tags={contentTags}
|
||||
onTagsChange={setContentTags}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MediaControls
|
||||
isVideo={isVideo}
|
||||
isPlaying={playerState.isPlaying}
|
||||
@@ -600,6 +623,7 @@ export default function AwemeDetailClient({
|
||||
open={transcriptOpen}
|
||||
onClose={() => setTranscriptOpen(false)}
|
||||
transcript={transcript}
|
||||
contentTags={contentTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,14 +8,17 @@ interface TranscriptPanelProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
transcript: VideoTranscript | null;
|
||||
contentTags?: string[];
|
||||
}
|
||||
|
||||
export function TranscriptPanel({
|
||||
open,
|
||||
onClose,
|
||||
transcript,
|
||||
contentTags,
|
||||
}: TranscriptPanelProps) {
|
||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
||||
const displayTags = contentTags ?? transcript?.content_tags ?? [];
|
||||
const [copiedAll, setCopiedAll] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
@@ -94,9 +97,9 @@ export function TranscriptPanel({
|
||||
语言: {transcript.language}
|
||||
</p>
|
||||
)}
|
||||
{transcript.content_tags?.length > 0 && (
|
||||
{displayTags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{transcript.content_tags.map((tag) => (
|
||||
{displayTags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded bg-blue-500/15 px-2 py-0.5 text-xs text-blue-200"
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Tag, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ASR_CONTENT_TAGS } from "@/lib/asr-tags";
|
||||
|
||||
type TagUpdateResponse = {
|
||||
updated?: {
|
||||
aweme_id: string;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
type VideoTagEditorProps = {
|
||||
awemeId: string;
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
};
|
||||
|
||||
export function VideoTagEditor({
|
||||
awemeId,
|
||||
tags,
|
||||
onTagsChange,
|
||||
}: VideoTagEditorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draftTags, setDraftTags] = useState<string[]>(tags);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDraftTags(tags);
|
||||
}, [open, tags]);
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setDraftTags((current) =>
|
||||
current.includes(tag)
|
||||
? current.filter((item) => item !== tag)
|
||||
: [...current, tag],
|
||||
);
|
||||
};
|
||||
|
||||
const saveTags = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
awemeId,
|
||||
contentTags: draftTags,
|
||||
mode: "set",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error("保存标签失败");
|
||||
const data: TagUpdateResponse = await response.json();
|
||||
const updated = data.updated?.find((item) => item.aweme_id === awemeId);
|
||||
onTagsChange(updated?.content_tags ?? draftTags);
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "保存标签失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute right-3 top-3 z-40 flex flex-col items-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-full border border-white/20 bg-black/35 px-3 text-sm font-medium text-white shadow-lg backdrop-blur transition-colors hover:bg-black/50"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<Tag size={16} />
|
||||
{tags.length > 0 ? tags.join("、") : "打标签"}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="w-[min(92vw,24rem)] rounded-xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">手动标签</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-full p-1 text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="关闭标签编辑"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ASR_CONTENT_TAGS.map((tag) => {
|
||||
const selected = draftTags.includes(tag);
|
||||
return (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "border-blue-400 bg-blue-500/25 text-blue-100"
|
||||
: "border-white/10 bg-white/5 text-white/70 hover:border-white/25 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{selected && <Check size={13} />}
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-3 text-xs text-rose-300">{error}</div>}
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDraftTags([])}
|
||||
className="rounded-full px-3 py-1.5 text-xs text-white/60 transition-colors hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveTags}
|
||||
disabled={saving}
|
||||
className="rounded-full bg-blue-600 px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||
>
|
||||
{saving ? "保存中..." : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
import type { LoopMode, Neighbors } from "../types.ts";
|
||||
|
||||
interface UseVideoPlayerProps {
|
||||
enabled?: boolean;
|
||||
awemeId: string;
|
||||
videoRef: RefObject<HTMLVideoElement | null>;
|
||||
volume: number;
|
||||
@@ -17,6 +18,7 @@ interface UseVideoPlayerProps {
|
||||
}
|
||||
|
||||
export function useVideoPlayer({
|
||||
enabled = true,
|
||||
awemeId,
|
||||
videoRef,
|
||||
volume,
|
||||
@@ -32,6 +34,7 @@ export function useVideoPlayer({
|
||||
|
||||
// 恢复播放进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (progressRestored) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
@@ -74,10 +77,11 @@ export function useVideoPlayer({
|
||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||
}
|
||||
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
|
||||
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
||||
|
||||
// 保存播放进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -105,10 +109,11 @@ export function useVideoPlayer({
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
saveProgress();
|
||||
};
|
||||
}, [awemeId, videoRef]);
|
||||
}, [awemeId, enabled, videoRef]);
|
||||
|
||||
// 监听播放状态和进度
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -137,10 +142,19 @@ export function useVideoPlayer({
|
||||
v.removeEventListener("pause", onPause);
|
||||
v.removeEventListener("ended", onEnded);
|
||||
};
|
||||
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
|
||||
}, [
|
||||
enabled,
|
||||
loopMode,
|
||||
neighbors?.next,
|
||||
router,
|
||||
videoRef,
|
||||
setIsPlaying,
|
||||
setProgress,
|
||||
]);
|
||||
|
||||
// 自动播放检测
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
|
||||
@@ -148,7 +162,7 @@ export function useVideoPlayer({
|
||||
try {
|
||||
await v.play();
|
||||
setIsPlaying(true);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.log("自动播放被阻止,需要用户交互");
|
||||
setIsPlaying(false);
|
||||
}
|
||||
@@ -160,16 +174,18 @@ export function useVideoPlayer({
|
||||
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
||||
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
||||
}
|
||||
}, [awemeId, videoRef, setIsPlaying]);
|
||||
}, [enabled, videoRef, setIsPlaying]);
|
||||
|
||||
// 更新音量和倍速
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.volume = volume;
|
||||
}, [volume, videoRef]);
|
||||
}, [enabled, volume, videoRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const v = videoRef.current;
|
||||
if (v) v.playbackRate = rate;
|
||||
}, [rate, videoRef]);
|
||||
}, [enabled, rate, videoRef]);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { Metadata } from "next";
|
||||
import BackButton from "@/app/components/BackButton";
|
||||
import { resolveEffectiveContentTags } from "@/lib/asr-tags";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { AwemeData, VideoTranscript } from "./types";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AwemeDetailClient from "./Client";
|
||||
import type { AwemeData, VideoTranscript } from "./types";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -53,7 +54,7 @@ export default async function AwemeDetail({
|
||||
const [video, post] = await Promise.all([
|
||||
prisma.video.findUnique({
|
||||
where: { aweme_id: id },
|
||||
include: { author: true },
|
||||
include: { author: true, transcript: true },
|
||||
}),
|
||||
prisma.imagePost.findUnique({
|
||||
where: { aweme_id: id },
|
||||
@@ -63,65 +64,77 @@ export default async function AwemeDetail({
|
||||
|
||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||
|
||||
const isVideo = !!video;
|
||||
const isVideo = Boolean(video);
|
||||
|
||||
// 获取评论总数
|
||||
const commentsCount = await prisma.comment.count({
|
||||
where: isVideo ? { videoId: id } : { imagePostId: id },
|
||||
});
|
||||
const aweme = isVideo ? video : post;
|
||||
|
||||
const data: AwemeData = {
|
||||
aweme_id: aweme!.aweme_id,
|
||||
desc: aweme!.desc,
|
||||
created_at: aweme!.created_at,
|
||||
likesCount: Number(aweme!.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: aweme!.author.nickname,
|
||||
avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: aweme!.author.sec_uid,
|
||||
},
|
||||
...(() => {
|
||||
if (isVideo) {
|
||||
const aweme = video!;
|
||||
return {
|
||||
type: "video" as const,
|
||||
cover_url: getFileUrl(aweme!.cover_url ?? "default-cover.png"),
|
||||
cover_size: { h: aweme.height ?? 0, w: aweme.width ?? 0 },
|
||||
duration_ms: aweme!.duration_ms,
|
||||
video_url: getFileUrl(aweme!.video_url),
|
||||
width: aweme!.width ?? null,
|
||||
height: aweme!.height ?? null,
|
||||
};
|
||||
} else {
|
||||
const aweme = post!;
|
||||
return {
|
||||
type: "image" as const,
|
||||
cover_url: getFileUrl(aweme!.images[0].url ?? "default-cover.png"),
|
||||
cover_size: {
|
||||
h: aweme!.images[0].height ?? 0,
|
||||
w: aweme!.images[0].width ?? 0,
|
||||
},
|
||||
images: aweme!.images.map((img) => ({
|
||||
...img,
|
||||
url: getFileUrl(img.url),
|
||||
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||
})),
|
||||
music_url: getFileUrl(aweme!.music_url || "default-music.mp3"),
|
||||
};
|
||||
}
|
||||
})(),
|
||||
};
|
||||
let data: AwemeData;
|
||||
let currentCreatedAt: Date;
|
||||
let transcript: VideoTranscript | null = null;
|
||||
|
||||
const transcript: VideoTranscript | null = isVideo
|
||||
? await prisma.videoTranscript.findUnique({
|
||||
where: { videoId: id },
|
||||
})
|
||||
: null;
|
||||
if (video) {
|
||||
transcript = video.transcript ?? null;
|
||||
currentCreatedAt = video.created_at;
|
||||
data = {
|
||||
type: "video",
|
||||
aweme_id: video.aweme_id,
|
||||
desc: video.desc,
|
||||
created_at: video.created_at,
|
||||
likesCount: Number(video.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: video.author.nickname,
|
||||
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: video.author.sec_uid,
|
||||
},
|
||||
cover_url: getFileUrl(video.cover_url ?? "default-cover.png"),
|
||||
cover_size: { h: video.height ?? 0, w: video.width ?? 0 },
|
||||
duration_ms: video.duration_ms,
|
||||
video_url: getFileUrl(video.video_url),
|
||||
width: video.width ?? null,
|
||||
height: video.height ?? null,
|
||||
content_tags: resolveEffectiveContentTags(
|
||||
video.manual_content_tags,
|
||||
transcript?.content_tags,
|
||||
),
|
||||
manual_content_tags: video.manual_content_tags,
|
||||
asr_content_tags: transcript?.content_tags ?? [],
|
||||
};
|
||||
} else if (post) {
|
||||
const coverImage = post.images[0];
|
||||
currentCreatedAt = post.created_at;
|
||||
data = {
|
||||
type: "image",
|
||||
aweme_id: post.aweme_id,
|
||||
desc: post.desc,
|
||||
created_at: post.created_at,
|
||||
likesCount: Number(post.digg_count),
|
||||
commentsCount,
|
||||
author: {
|
||||
nickname: post.author.nickname,
|
||||
avatar_url: getFileUrl(post.author.avatar_url || "default-avatar.png"),
|
||||
sec_uid: post.author.sec_uid,
|
||||
},
|
||||
cover_url: getFileUrl(coverImage?.url ?? "default-cover.png"),
|
||||
cover_size: {
|
||||
h: coverImage?.height ?? 0,
|
||||
w: coverImage?.width ?? 0,
|
||||
},
|
||||
images: post.images.map((img) => ({
|
||||
...img,
|
||||
url: getFileUrl(img.url),
|
||||
animated: img.animated ? getFileUrl(img.animated) : null,
|
||||
})),
|
||||
music_url: getFileUrl(post.music_url || "default-music.mp3"),
|
||||
};
|
||||
} else {
|
||||
return <main className="p-8">找不到该作品</main>;
|
||||
}
|
||||
|
||||
// Compute prev/next neighbors by created_at across videos and image posts
|
||||
const currentCreatedAt = isVideo ? video!.created_at : post!.created_at;
|
||||
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
||||
prisma.video.findFirst({
|
||||
where: { created_at: { gt: currentCreatedAt } },
|
||||
|
||||
@@ -24,6 +24,9 @@ export type VideoData = {
|
||||
video_url: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
content_tags: string[];
|
||||
manual_content_tags: string[];
|
||||
asr_content_tags: string[];
|
||||
author: User;
|
||||
commentsCount: number;
|
||||
likesCount: number;
|
||||
|
||||
+244
-106
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
import { ThumbsUp } from "lucide-react";
|
||||
import { Check, ThumbsUp, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||
import { ASR_CONTENT_TAGS } from "@/lib/asr-tags";
|
||||
import HoverVideo from "./HoverVideo";
|
||||
|
||||
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
|
||||
@@ -18,6 +19,13 @@ type Props = {
|
||||
fetchUrl?: string;
|
||||
};
|
||||
|
||||
type TagUpdateResponse = {
|
||||
updated?: {
|
||||
aweme_id: string;
|
||||
content_tags: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export default function FeedMasonry({
|
||||
initialItems,
|
||||
initialCursor,
|
||||
@@ -27,6 +35,10 @@ export default function FeedMasonry({
|
||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ended, setEnded] = useState(!initialCursor);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [batchTag, setBatchTag] = useState<string>(ASR_CONTENT_TAGS[0]);
|
||||
const [batchSaving, setBatchSaving] = useState(false);
|
||||
const [batchError, setBatchError] = useState<string | null>(null);
|
||||
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -168,115 +180,193 @@ export default function FeedMasonry({
|
||||
return () => io.disconnect();
|
||||
}, [fetchMore]);
|
||||
|
||||
const renderCard = useCallback(
|
||||
(item: FeedItem) => (
|
||||
<article
|
||||
key={item.aweme_id}
|
||||
className="mb-4 group relative overflow-hidden rounded-2xl shadow-sm ring-1 ring-black/5 bg-white/80 dark:bg-zinc-900/60 backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<Link
|
||||
href={`/aweme/${item.aweme_id}`}
|
||||
target="_blank"
|
||||
className="block relative w-full"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
aspectRatio: getAspectRatio(item),
|
||||
}}
|
||||
>
|
||||
{item.type === "video" ? (
|
||||
<HoverVideo
|
||||
videoUrl={item.video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
fill
|
||||
src={item.cover_url || "/placeholder.svg"}
|
||||
alt={item.desc?.slice(0, 20) || "image"}
|
||||
unoptimized
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
{!!item.content_tags?.length && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{item.content_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-blue-500/80 px-2 py-0.5 text-xs text-white shadow"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === "video" ? "视频" : "图文"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
const toggleSelection = useCallback((awemeId: string) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(awemeId)) next.delete(awemeId);
|
||||
else next.add(awemeId);
|
||||
return next;
|
||||
});
|
||||
setBatchError(null);
|
||||
}, []);
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{item.author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${item.author.sec_uid}`}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
||||
{item.likes}{" "}
|
||||
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
),
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
setBatchError(null);
|
||||
}, []);
|
||||
|
||||
const applyUpdatedTags = useCallback(
|
||||
(updates: TagUpdateResponse["updated"]) => {
|
||||
if (!updates?.length) return;
|
||||
const tagMap = new Map(
|
||||
updates.map((item) => [item.aweme_id, item.content_tags] as const),
|
||||
);
|
||||
setColumns((currentColumns) =>
|
||||
currentColumns.map((column) =>
|
||||
column.map((item) =>
|
||||
tagMap.has(item.aweme_id)
|
||||
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
|
||||
: item,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleBatchAddTag = useCallback(async () => {
|
||||
const awemeIds = [...selectedIds];
|
||||
if (awemeIds.length === 0 || batchSaving) return;
|
||||
setBatchSaving(true);
|
||||
setBatchError(null);
|
||||
try {
|
||||
const response = await fetch("/api/asr/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ awemeIds, tag: batchTag, mode: "add" }),
|
||||
});
|
||||
if (!response.ok) throw new Error("批量添加标签失败");
|
||||
const data: TagUpdateResponse = await response.json();
|
||||
applyUpdatedTags(data.updated);
|
||||
clearSelection();
|
||||
} catch (error) {
|
||||
setBatchError(
|
||||
error instanceof Error ? error.message : "批量添加标签失败",
|
||||
);
|
||||
} finally {
|
||||
setBatchSaving(false);
|
||||
}
|
||||
}, [applyUpdatedTags, batchSaving, batchTag, clearSelection, selectedIds]);
|
||||
|
||||
const renderCard = useCallback(
|
||||
(item: FeedItem) => {
|
||||
const selected = selectedIds.has(item.aweme_id);
|
||||
return (
|
||||
<article
|
||||
key={item.aweme_id}
|
||||
className={`mb-4 group relative overflow-hidden rounded-2xl bg-white/80 shadow-sm backdrop-blur-sm transition-transform duration-300 hover:-translate-y-1 dark:bg-zinc-900/60 ${
|
||||
selected ? "ring-2 ring-blue-500" : "ring-1 ring-black/5"
|
||||
}`}
|
||||
>
|
||||
{item.type === "video" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSelection(item.aweme_id)}
|
||||
className={`absolute left-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full border text-white shadow-lg backdrop-blur transition-colors ${
|
||||
selected
|
||||
? "border-blue-300 bg-blue-600"
|
||||
: "border-white/25 bg-black/35 hover:bg-black/55"
|
||||
}`}
|
||||
aria-pressed={selected}
|
||||
aria-label={selected ? "取消选择视频" : "选择视频"}
|
||||
>
|
||||
{selected && <Check size={17} />}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/aweme/${item.aweme_id}`}
|
||||
target="_blank"
|
||||
className="block relative w-full"
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
aspectRatio: getAspectRatio(item),
|
||||
}}
|
||||
>
|
||||
{item.type === "video" ? (
|
||||
<HoverVideo
|
||||
videoUrl={item.video_url}
|
||||
coverUrl={item.cover_url}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
fill
|
||||
src={item.cover_url || "/placeholder.svg"}
|
||||
alt={item.desc?.slice(0, 20) || "image"}
|
||||
unoptimized
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/50 via-black/0 to-black/0 opacity-70" />
|
||||
<div className="absolute left-3 bottom-3 right-3 flex items-end justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
||||
{item.desc}
|
||||
</p>
|
||||
{!!item.content_tags?.length && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{item.content_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full bg-blue-500/80 px-2 py-0.5 text-xs text-white shadow"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="shrink-0 inline-flex items-center gap-2 rounded-full bg-white/85 px-2 py-1 text-xs text-zinc-800">
|
||||
{item.type === "video" ? "视频" : "图文"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
{item.author.sec_uid ? (
|
||||
<Link
|
||||
href={`/author/${item.author.sec_uid}`}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
||||
{item.author.avatar_url ? (
|
||||
<Image
|
||||
src={item.author.avatar_url}
|
||||
alt="avatar"
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
||||
{item.author.nickname}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-zinc-700 dark:text-zinc-300 flex items-center gap-1">
|
||||
{item.likes}{" "}
|
||||
<ThumbsUp size={16} style={{ color: "var(--color-zinc-700)" }} />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
},
|
||||
[selectedIds, toggleSelection],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
||||
@@ -299,6 +389,54 @@ export default function FeedMasonry({
|
||||
>
|
||||
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
||||
</div>
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-4 left-1/2 z-50 w-[min(92vw,36rem)] -translate-x-1/2 rounded-2xl border border-white/15 bg-zinc-950/92 p-3 text-white shadow-2xl backdrop-blur-xl">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
已选择 {selectedIds.size} 个视频
|
||||
</span>
|
||||
{batchError && (
|
||||
<span className="text-xs text-rose-300">{batchError}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={batchTag}
|
||||
onChange={(event) => setBatchTag(event.target.value)}
|
||||
className="h-9 rounded-full border border-white/15 bg-white/10 px-3 text-sm text-white outline-none"
|
||||
aria-label="选择要添加的标签"
|
||||
>
|
||||
{ASR_CONTENT_TAGS.map((tag) => (
|
||||
<option
|
||||
key={tag}
|
||||
value={tag}
|
||||
className="bg-zinc-900 text-white"
|
||||
>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBatchAddTag}
|
||||
disabled={batchSaving}
|
||||
className="h-9 rounded-full bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:bg-zinc-600"
|
||||
>
|
||||
{batchSaving ? "添加中..." : "添加标签"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelection}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-white/15 bg-white/10 text-white/70 transition-colors hover:bg-white/15 hover:text-white"
|
||||
aria-label="清空选择"
|
||||
>
|
||||
<X size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+18
-3
@@ -1,6 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { ASR_CONTENT_TAGS, isAsrContentTag } from "@/lib/asr-tags";
|
||||
import {
|
||||
ASR_CONTENT_TAGS,
|
||||
isAsrContentTag,
|
||||
resolveEffectiveContentTags,
|
||||
} from "@/lib/asr-tags";
|
||||
import { getFileUrl } from "@/lib/minio";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import FeedMasonry from "./components/FeedMasonry";
|
||||
@@ -26,7 +30,15 @@ export default async function Home({
|
||||
const [videos, posts] = await Promise.all([
|
||||
prisma.video.findMany({
|
||||
where: selectedTag
|
||||
? { transcript: { is: { content_tags: { has: selectedTag } } } }
|
||||
? {
|
||||
OR: [
|
||||
{ manual_content_tags: { has: selectedTag } },
|
||||
{
|
||||
manual_content_tags: { isEmpty: true },
|
||||
transcript: { is: { content_tags: { has: selectedTag } } },
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 60,
|
||||
@@ -63,7 +75,10 @@ export default async function Home({
|
||||
sec_uid: v.author.sec_uid,
|
||||
},
|
||||
likes: Number(v.digg_count),
|
||||
content_tags: v.transcript?.content_tags ?? [],
|
||||
content_tags: resolveEffectiveContentTags(
|
||||
v.manual_content_tags,
|
||||
v.transcript?.content_tags,
|
||||
),
|
||||
})),
|
||||
...posts.map((p) => {
|
||||
const coverPath = p.images?.[0]?.url;
|
||||
|
||||
Reference in New Issue
Block a user