feat: add manual video tagging

This commit is contained in:
2026-04-29 21:20:21 +08:00
parent 02d37f3953
commit eac6ad2343
17 changed files with 1066 additions and 418 deletions
+204 -119
View File
@@ -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) ? (
"生成标签"
) : (
"重新识别"
+29 -20
View File
@@ -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
View File
@@ -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);
+127
View File
@@ -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
View File
@@ -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
View File
@@ -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
+91 -67
View File
@@ -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>
);
}
+25 -9
View File
@@ -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]);
}
+68 -55
View File
@@ -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 } },
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+9
View File
@@ -38,3 +38,12 @@ export function normalizeAsrContentTags(tags: unknown): AsrContentTag[] {
const uniqueTags = [...new Set(normalized)].slice(0, 3);
return uniqueTags.length > 0 ? uniqueTags : ["其他"];
}
export function resolveEffectiveContentTags(
manualTags: readonly string[] | null | undefined,
asrTags: readonly string[] | null | undefined,
) {
return manualTags && manualTags.length > 0
? [...manualTags]
: [...(asrTags ?? [])];
}
@@ -0,0 +1,5 @@
ALTER TABLE "Video"
ADD COLUMN "manual_content_tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
CREATE INDEX "video_manual_content_tags_idx"
ON "Video" USING GIN ("manual_content_tags");
+25 -23
View File
@@ -24,32 +24,34 @@ model Author {
}
model Video {
aweme_id String @id
desc String
preview_title String?
duration_ms Int
created_at DateTime
share_url String
digg_count BigInt @default(0)
comment_count BigInt @default(0)
share_count BigInt @default(0)
collect_count BigInt @default(0)
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags String[]
video_url String
height Int?
width Int?
cover_url String?
fps Int?
raw_json Json?
comments Comment[]
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
transcript VideoTranscript?
aweme_id String @id
desc String
preview_title String?
duration_ms Int
created_at DateTime
share_url String
digg_count BigInt @default(0)
comment_count BigInt @default(0)
share_count BigInt @default(0)
collect_count BigInt @default(0)
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags String[]
manual_content_tags String[] @default([])
video_url String
height Int?
width Int?
cover_url String?
fps Int?
raw_json Json?
comments Comment[]
author Author @relation(fields: [authorId], references: [sec_uid], onDelete: Cascade)
transcript VideoTranscript?
@@index([authorId])
@@index([created_at])
@@index([manual_content_tags], map: "video_manual_content_tags_idx", type: Gin)
}
model CommentUser {