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

This commit is contained in:
2026-10-03 22:09:09 +08:00
29 changed files with 1459 additions and 532 deletions
+6
View File
@@ -104,3 +104,9 @@ npx tsx scripts/stt-smoke.ts 7682010237578136858
```
脚本将供应商任务 ID 和结果保存在 `/tmp/douyin-asr-<视频ID>.json`。已有未完成任务时应查询原任务,不要重复提交。
### 手动标签兼容
保留服务器版本的手动标签、首页批量打标签、详情页编辑及标签筛选;标签只来自 `Video.manual_content_tags`,不再回退到模型自动分类。转写成功、失败或重试都不会修改手动标签。后台转写列表支持手动标签筛选与批量添加/清空。
已有 `/admin/asr`、`/admin/asr/videos`、`/api/admin/asr/videos` 地址继续可用;`/api/asr` 与 `/api/stt` 一样,POST 入队、GET 只读。旧内存批处理和分块模型调用已移除。
+139
View File
@@ -0,0 +1,139 @@
"use client";
import Link from "next/link";
import BackButton from "@/app/components/BackButton";
export default function AsrAdminPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-8">
<BackButton />
<div className="mt-8">
<h1 className="text-3xl font-bold text-gray-900">ASR 管理中心</h1>
<p className="text-gray-600 mt-2">管理和配置视频语音识别功能</p>
</div>
<div className="mt-8 grid gap-6">
{/* 视频转写管理 */}
<Link
href="/admin/asr/videos"
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg
aria-hidden="true"
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
视频转写管理
</h2>
<p className="text-gray-600 mt-1">
查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写
</p>
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
进入管理
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</div>
</Link>
{/* 转写设置(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg
aria-hidden="true"
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
转写设置
</h2>
<p className="text-gray-600 mt-1">
配置转写 API、模型参数和其他高级选项
</p>
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
</div>
</div>
</div>
{/* 转写统计(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg
aria-hidden="true"
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
转写统计
</h2>
<p className="text-gray-600 mt-1">
查看转写使用情况、成功率、语言分布等统计数据
</p>
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
export { default } from "../../stt/videos/page";
+1 -135
View File
@@ -1,135 +1 @@
"use client";
import Link from "next/link";
import BackButton from "@/app/components/BackButton";
export default function SttAdminPage() {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto p-8">
<BackButton />
<div className="mt-8">
<h1 className="text-3xl font-bold text-gray-900">STT 管理中心</h1>
<p className="text-gray-600 mt-2">管理和配置视频语音转写功能</p>
</div>
<div className="mt-8 grid gap-6">
{/* 视频转写管理 */}
<Link
href="/admin/stt/videos"
className="block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow p-6 border border-gray-200 hover:border-blue-500"
>
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
视频转写管理
</h2>
<p className="text-gray-600 mt-1">
查看和管理所有视频的转写状态,对待转写的视频进行批量或单个转写
</p>
<div className="mt-4 text-blue-600 font-medium flex items-center gap-1">
进入管理
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</div>
</Link>
{/* 转写设置(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
转写设置
</h2>
<p className="text-gray-600 mt-1">
配置转写 API、模型参数和其他高级选项
</p>
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
</div>
</div>
</div>
{/* 转写统计(预留) */}
<div className="block bg-white rounded-lg shadow-md p-6 border border-gray-200 opacity-60">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div className="flex-1">
<h2 className="text-xl font-semibold text-gray-900">
转写统计
</h2>
<p className="text-gray-600 mt-1">
查看转写使用情况、成功率、语言分布等统计数据
</p>
<div className="mt-4 text-gray-400 font-medium">即将推出</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
export { default } from "../asr/page";
+112 -1
View File
@@ -3,6 +3,7 @@
import Image from "next/image";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
import BackButton from "@/app/components/BackButton";
import {
isActive,
@@ -18,6 +19,7 @@ type Video = {
duration_ms: number;
author: { nickname: string };
transcription: TranscriptionState;
content_tags: string[];
};
const filters: ("all" | TranscriptionStatus)[] = [
"all",
@@ -36,6 +38,10 @@ export default function SttVideosPage() {
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [tag, setTag] = useState<string>(VIDEO_CONTENT_TAGS[0]);
const [tagFilter, setTagFilter] = useState("");
const [savingTags, setSavingTags] = useState(false);
const fetching = useRef(false);
const fetchVideos = useCallback(async () => {
if (fetching.current) return;
@@ -104,6 +110,28 @@ export default function SttVideosPage() {
setBatchSubmitting(false);
}
}
async function saveTags(clear = false) {
setSavingTags(true);
try {
const response = await fetch("/api/asr/tags", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
awemeIds: [...selected],
contentTags: clear ? [] : [tag],
mode: clear ? "set" : "add",
}),
});
if (!response.ok) throw new Error("保存手动标签失败");
setNotice(`已更新 ${selected.size} 个视频的手动标签`);
setSelected(new Set());
await fetchVideos();
} catch (err) {
setNotice(err instanceof Error ? err.message : "保存失败");
} finally {
setSavingTags(false);
}
}
const count = (status: TranscriptionStatus) =>
videos.filter((v) => v.transcription.status === status).length;
const eligible = videos.filter(
@@ -111,7 +139,9 @@ export default function SttVideosPage() {
v.transcription.transcript === null && !isActive(v.transcription.status),
).length;
const shown = videos.filter(
(v) => filter === "all" || v.transcription.status === filter,
(v) =>
(filter === "all" || v.transcription.status === filter) &&
(!tagFilter || v.content_tags.includes(tagFilter)),
);
return (
@@ -155,10 +185,70 @@ export default function SttVideosPage() {
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
</button>
</div>
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700">
<select
aria-label="筛选手动标签"
value={tagFilter}
onChange={(e) => {
setTagFilter(e.target.value);
setSelected(new Set());
}}
className="rounded border p-2"
>
<option value="">全部手动标签</option>
{VIDEO_CONTENT_TAGS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<span>已选 {selected.size} 个(最多 200 个)</span>
<select
aria-label="批量添加的标签"
value={tag}
onChange={(e) => setTag(e.target.value)}
className="rounded border p-2"
>
{VIDEO_CONTENT_TAGS.map((value) => (
<option key={value}>{value}</option>
))}
</select>
<button
type="button"
disabled={!selected.size || savingTags}
onClick={() => saveTags()}
className="rounded border px-3 py-2 disabled:opacity-50"
>
添加手动标签
</button>
<button
type="button"
disabled={!selected.size || savingTags}
onClick={() => saveTags(true)}
className="rounded border px-3 py-2 disabled:opacity-50"
>
清空手动标签
</button>
</div>
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
<table className="w-full text-left text-sm">
<thead className="bg-gray-100 text-gray-600">
<tr>
<th className="p-3">
<input
type="checkbox"
aria-label="选择当前列表前200个视频"
checked={
shown.length > 0 &&
shown.slice(0, 200).every((v) => selected.has(v.aweme_id))
}
onChange={(e) =>
setSelected(
e.target.checked
? new Set(shown.slice(0, 200).map((v) => v.aweme_id))
: new Set(),
)
}
/>
</th>
<th className="p-3">视频</th>
<th className="p-3">时长</th>
<th className="p-3">状态与文字</th>
@@ -175,6 +265,24 @@ export default function SttVideosPage() {
key={video.aweme_id}
className="border-t border-gray-100 align-top"
>
<td className="p-3">
<input
type="checkbox"
aria-label={`选择视频 ${video.aweme_id}`}
checked={selected.has(video.aweme_id)}
disabled={
!selected.has(video.aweme_id) && selected.size >= 200
}
onChange={(e) =>
setSelected((previous) => {
const next = new Set(previous);
if (e.target.checked) next.add(video.aweme_id);
else next.delete(video.aweme_id);
return next;
})
}
/>
</td>
<td className="p-3">
<div className="flex max-w-sm gap-3">
{video.cover_url && (
@@ -197,6 +305,9 @@ export default function SttVideosPage() {
<p className="mt-1 text-gray-500">
@{video.author.nickname}
</p>
<p className="mt-1 text-xs text-blue-700">
{video.content_tags.join("、")}
</p>
</div>
</div>
</td>
+1
View File
@@ -0,0 +1 @@
export { GET } from "../../stt/videos/route";
+1
View File
@@ -31,6 +31,7 @@ export async function GET() {
author: {
nickname: v.author.nickname,
},
content_tags: v.manual_content_tags,
transcription: transcriptionState(v.transcript, v.transcriptionTask),
}));
+1
View File
@@ -0,0 +1 @@
export { GET, POST } from "../stt/route";
+121
View File
@@ -0,0 +1,121 @@
export const runtime = "nodejs";
import { type NextRequest, NextResponse } from "next/server";
import { isVideoContentTag } from "@/lib/video-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" || !isVideoContentTag(tag)) {
throw new Error("Invalid tag");
}
if (!validTags.includes(tag)) validTags.push(tag);
}
return validTags;
}
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,
},
});
if (videos.length === 0) {
return NextResponse.json({ error: "No videos found" }, { status: 404 });
}
const updates = videos.map((video) => {
const baseTags = video.manual_content_tags;
const manualTags =
mode === "add"
? [...new Set([...baseTags, ...contentTags])]
: contentTags;
return { awemeId: video.aweme_id, manualTags };
});
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: update.manualTags,
})),
missing: normalizedAwemeIds.filter(
(id) => !videos.some((video) => video.aweme_id === id),
),
});
}
+36 -10
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { type NextRequest, NextResponse } from "next/server";
import type { FeedItem, FeedResponse } from "@/app/types/feed";
import { isVideoContentTag } from "@/lib/video-tags";
import { getFileUrl } from "@/lib/minio";
import { prisma } from "@/lib/prisma";
// Contract
// Inputs: search params { before?: ISOString, limit?: number }
@@ -11,23 +12,44 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const limitParam = searchParams.get("limit");
const beforeParam = searchParams.get("before");
const rawTag = (searchParams.get("tag") || "").trim();
const selectedTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
if (rawTag && !selectedTag) {
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
}
const limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
const before = beforeParam ? new Date(beforeParam) : null;
const videoWhere = {
...(before ? { created_at: { lt: before } } : {}),
...(selectedTag
? {
manual_content_tags: { has: selectedTag },
}
: {}),
};
// fetch chunk from both tables
const [videos, posts] = await Promise.all([
prisma.video.findMany({
where: before ? { created_at: { lt: before } } : undefined,
where: videoWhere,
orderBy: { created_at: "desc" },
take: limit,
include: { author: true },
include: {
author: true,
},
}),
prisma.imagePost.findMany({
selectedTag
? Promise.resolve([])
: prisma.imagePost.findMany({
where: before ? { created_at: { lt: before } } : undefined,
orderBy: { created_at: "desc" },
take: limit,
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
include: {
author: true,
images: { orderBy: { order: "asc" }, take: 1 },
},
}),
]);
@@ -47,13 +69,16 @@ export async function GET(req: NextRequest) {
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
content_tags: v.manual_content_tags,
})),
...posts.map((p) => ({
...posts.map((p) => {
const coverPath = p.images?.[0]?.url;
return {
type: "image" as const,
aweme_id: p.aweme_id,
created_at: p.created_at,
desc: p.desc,
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
cover_url: coverPath ? getFileUrl(coverPath) : null,
width: p.images?.[0]?.width ?? null,
height: p.images?.[0]?.height ?? null,
author: {
@@ -62,14 +87,15 @@ export async function GET(req: NextRequest) {
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
})),
};
}),
]
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
.slice(0, limit);
const nextCursor =
merged.length > 0
? new Date(merged[merged.length - 1].created_at as any).toISOString()
? new Date(merged[merged.length - 1].created_at).toISOString()
: null;
const payload: FeedResponse = { items: merged, nextCursor };
return NextResponse.json(payload);
+74 -21
View File
@@ -1,11 +1,32 @@
import { NextResponse } from "next/server";
import { isVideoContentTag } from "@/lib/video-tags";
import { json } from "@/lib/json";
import { getFileUrl } from "@/lib/minio";
import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
type SearchRow = {
id: string;
awemeId: string;
type: "video" | "image";
rank: number;
snippet: string;
contentTags: string[];
};
function sanitizeSnippetHtml(snippet: string) {
return snippet
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("&lt;mark&gt;", "<mark>")
.replaceAll("&lt;/mark&gt;", "</mark>");
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const q = (searchParams.get("q") || "").trim();
const rawTag = (searchParams.get("tag") || "").trim();
const contentTag = rawTag && isVideoContentTag(rawTag) ? rawTag : null;
const page = Math.max(1, Number(searchParams.get("page") || 1));
const limit = Math.min(
50,
@@ -13,7 +34,11 @@ export async function GET(req: Request) {
);
const offset = (page - 1) * limit;
if (!q) {
if (rawTag && !contentTag) {
return NextResponse.json({ error: "Invalid tag" }, { status: 400 });
}
if (!q && !contentTag) {
return NextResponse.json({ results: [], total: 0 });
}
@@ -23,15 +48,8 @@ export async function GET(req: Request) {
// - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc
// - 使用 UNION ALL 合并视频和图文的搜索结果
// - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配
const rows = await prisma.$queryRaw<
{
id: string;
awemeId: string;
type: "video" | "image";
rank: number;
snippet: string;
}[]
>`
const rows = q
? await prisma.$queryRaw<SearchRow[]>`
WITH tsq AS (
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
)
@@ -59,12 +77,17 @@ export async function GET(req: Request) {
tsq.query,
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
)
END AS snippet
END AS snippet,
v.manual_content_tags AS "contentTags"
FROM "Video" v
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id,
tsq
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 v.manual_content_tags @> ARRAY[${contentTag}]::text[]
END
UNION ALL
@@ -79,33 +102,62 @@ export async function GET(req: Request) {
ip.desc,
tsq.query,
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
) AS snippet
) AS snippet,
ARRAY[]::text[] AS "contentTags"
FROM "ImagePost" ip, tsq
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
AND ${contentTag}::text IS NULL
ORDER BY rank DESC
OFFSET ${offset}
LIMIT ${limit};
`
: await prisma.$queryRaw<SearchRow[]>`
SELECT
COALESCE(vt.id, 'tag:' || v.aweme_id) AS id,
v.aweme_id AS "awemeId",
'video'::text AS type,
1.0::double precision AS rank,
COALESCE(
NULLIF(array_to_string(vt.transcript[1:2], ' '), ''),
v.desc,
'标签匹配'
) AS snippet,
v.manual_content_tags AS "contentTags"
FROM "Video" v
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[]
ORDER BY v.created_at DESC
OFFSET ${offset}
LIMIT ${limit};
`;
// 查询总数(参数化,避免注入)
const totalRows = await prisma.$queryRaw<
{
count: number;
}[]
>`
const totalRows = q
? await prisma.$queryRaw<{ count: number }[]>`
WITH tsq AS (
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
)
SELECT (
(SELECT COUNT(*)::int FROM "Video" v
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query))
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 v.manual_content_tags @> ARRAY[${contentTag}]::text[]
END)
+
(SELECT COUNT(*)::int FROM "ImagePost" ip
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query)
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
AND ${contentTag}::text IS NULL)
) AS count
FROM tsq;
`
: await prisma.$queryRaw<{ count: number }[]>`
SELECT COUNT(*)::int AS count
FROM "Video" v
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[];
`;
// 分离视频和图文ID
@@ -176,6 +228,7 @@ export async function GET(req: Request) {
return json({
results: rows.map((r) => ({
...r,
snippet: sanitizeSnippetHtml(r.snippet),
video:
r.type === "video"
? videos.find((v) => v.aweme_id === r.awemeId)
+73 -50
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,8 +99,8 @@ export default function AwemeDetailClient({
});
// 视频播放器 hooks
if (isVideo) {
useVideoPlayer({
enabled: isVideo,
awemeId: data.aweme_id,
videoRef,
volume: playerState.volume,
@@ -93,27 +112,29 @@ export default function AwemeDetailClient({
setProgress: playerState.setProgress,
setProgressRestored: playerState.setProgressRestored,
});
}
// 媒体操作函数
const seekTo = (ratio: number) => {
ratio = Math.min(1, Math.max(0, 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 * ratio;
v.currentTime = v.duration * clampedRatio;
return;
}
if (!images?.length) return;
imageCarouselState.seekTo(ratio);
};
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}
@@ -0,0 +1,138 @@
"use client";
import { Check, Tag, X } from "lucide-react";
import { useEffect, useState } from "react";
import { VIDEO_CONTENT_TAGS } from "@/lib/video-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">
{VIDEO_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]);
}
+53 -45
View File
@@ -1,9 +1,9 @@
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 { 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 +53,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 +63,73 @@ 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),
let data: AwemeData;
let currentCreatedAt: Date;
let transcript: VideoTranscript | null = 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: aweme!.author.nickname,
avatar_url: getFileUrl(aweme!.author.avatar_url || "default-avatar.png"),
sec_uid: aweme!.author.sec_uid,
nickname: video.author.nickname,
avatar_url: getFileUrl(video.author.avatar_url || "default-avatar.png"),
sec_uid: video.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,
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: video.manual_content_tags,
manual_content_tags: video.manual_content_tags,
};
} 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,
} 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,
},
images: aweme!.images.map((img) => ({
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(aweme!.music_url || "default-music.mp3"),
music_url: getFileUrl(post.music_url || "default-music.mp3"),
};
} else {
return <main className="p-8">找不到该作品</main>;
}
})(),
};
const transcript: VideoTranscript | null = isVideo
? await prisma.videoTranscript.findUnique({
where: { videoId: id },
})
: null;
// 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 } },
+2
View File
@@ -24,6 +24,8 @@ export type VideoData = {
video_url: string;
width: number | null;
height: number | null;
content_tags: string[];
manual_content_tags: string[];
author: User;
commentsCount: number;
likesCount: number;
+191 -19
View File
@@ -1,9 +1,17 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Check, ThumbsUp, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import HoverVideo from "./HoverVideo";
import { ThumbsUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { FeedItem, FeedResponse } from "@/app/types/feed";
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
import HoverVideo from "./HoverVideo";
const COLUMN_KEYS = ["feed-col-0", "feed-col-1", "feed-col-2", "feed-col-3"];
function getAspectRatio(item: FeedItem) {
return item.width && item.height ? `${item.width}/${item.height}` : undefined;
}
type Props = {
initialItems: FeedItem[];
@@ -11,6 +19,13 @@ type Props = {
fetchUrl?: string;
};
type TagUpdateResponse = {
updated?: {
aweme_id: string;
content_tags: string[];
}[];
};
export default function FeedMasonry({
initialItems,
initialCursor,
@@ -20,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>(VIDEO_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);
@@ -58,7 +77,7 @@ export default function FeedMasonry({
mediaH = (9 / 16) * colWidth; // 常见视频比例
}
// 文本 + 作者栏的高度粗估
const textH = 48; // 标题+标签区域
const textH = item.content_tags?.length ? 72 : 48; // 标题+标签区域
const authorH = 48; // 作者行
const gap = 16; // 卡片内边距/间隙
return mediaH + textH + authorH + gap;
@@ -92,8 +111,7 @@ export default function FeedMasonry({
}
setColumns(newCols);
setColHeights(newHeights);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnCount]);
}, [columnCount, estimateItemHeight, initialItems]);
const fetchMore = useCallback(async () => {
if (loading || ended) return;
@@ -136,7 +154,15 @@ export default function FeedMasonry({
} finally {
setLoading(false);
}
}, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]);
}, [
cursor,
ended,
loading,
columnCount,
colHeights,
estimateItemHeight,
fetchUrl,
]);
useEffect(() => {
const el = sentinelRef.current;
@@ -154,12 +180,89 @@ export default function FeedMasonry({
return () => io.disconnect();
}, [fetchMore]);
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);
}, []);
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) => (
(item: FeedItem) => {
const selected = selectedIds.has(item.aweme_id);
return (
<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"
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"
@@ -168,29 +271,43 @@ export default function FeedMasonry({
<div
className="relative w-full"
style={{
aspectRatio:
`${item.width && item.height ? `${item.width}/${item.height}` : ""}` as any,
aspectRatio: getAspectRatio(item),
}}
>
{item.type === "video" ? (
<HoverVideo
videoUrl={(item as any).video_url}
videoUrl={item.video_url}
coverUrl={item.cover_url}
className="absolute inset-0 w-full h-full"
/>
) : (
<img
loading="lazy"
<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>
@@ -206,9 +323,12 @@ export default function FeedMasonry({
>
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
{item.author.avatar_url ? (
<img
<Image
src={item.author.avatar_url}
alt="avatar"
width={24}
height={24}
unoptimized
className="w-full h-full object-cover"
/>
) : null}
@@ -221,9 +341,12 @@ export default function FeedMasonry({
<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 ? (
<img
<Image
src={item.author.avatar_url}
alt="avatar"
width={24}
height={24}
unoptimized
className="w-full h-full object-cover"
/>
) : null}
@@ -239,8 +362,9 @@ export default function FeedMasonry({
</span>
</div>
</article>
),
[],
);
},
[selectedIds, toggleSelection],
);
return (
@@ -254,7 +378,7 @@ export default function FeedMasonry({
}}
>
{columns.map((col, idx) => (
<div key={idx} className="flex flex-col">
<div key={COLUMN_KEYS[idx]} className="flex flex-col">
{col.map((item) => renderCard(item))}
</div>
))}
@@ -265,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="选择要添加的标签"
>
{VIDEO_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>
)}
</>
);
}
+6 -4
View File
@@ -1,10 +1,10 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Search } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export default function SearchBox() {
export default function SearchBox({ tag }: { tag?: string | null }) {
const [query, setQuery] = useState("");
const router = useRouter();
@@ -12,7 +12,9 @@ export default function SearchBox() {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
const params = new URLSearchParams({ q: trimmed });
if (tag) params.set("tag", tag);
router.push(`/search?${params.toString()}`);
};
return (
+1 -1
View File
@@ -8,7 +8,7 @@ export const metadata: Metadata = {
},
description: "记录当下时代的精彩瞬间",
icons: {
icon: "/favicon.png",
icon: "/favicon.webp",
},
};
+75 -16
View File
@@ -1,9 +1,11 @@
import type { Metadata } from "next";
import Link from "next/link";
import { VIDEO_CONTENT_TAGS, isVideoContentTag } from "@/lib/video-tags";
import { getFileUrl } from "@/lib/minio";
import { prisma } from "@/lib/prisma";
import FeedMasonry from "./components/FeedMasonry";
import SearchBox from "./components/SearchBox";
import type { FeedItem } from "./types/feed";
import type { Metadata } from "next";
import { getFileUrl } from "@/lib/minio";
export const dynamic = "force-dynamic";
@@ -12,17 +14,37 @@ export const metadata: Metadata = {
description: "抖歪作品集,记录当下时代的精彩瞬间",
};
export default async function Home() {
export default async function Home({
searchParams,
}: {
searchParams?: { tag?: string };
}) {
const requestedTag = searchParams?.tag?.trim() || "";
const selectedTag =
requestedTag && isVideoContentTag(requestedTag) ? requestedTag : null;
const [videos, posts] = await Promise.all([
prisma.video.findMany({
where: selectedTag
? {
manual_content_tags: { has: selectedTag },
}
: undefined,
orderBy: { created_at: "desc" },
take: 60,
include: { author: true },
include: {
author: true,
},
}),
prisma.imagePost.findMany({
selectedTag
? Promise.resolve([])
: prisma.imagePost.findMany({
orderBy: { created_at: "desc" },
take: 60,
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
include: {
author: true,
images: { orderBy: { order: "asc" }, take: 1 },
},
}),
]);
@@ -42,13 +64,16 @@ export default async function Home() {
sec_uid: v.author.sec_uid,
},
likes: Number(v.digg_count),
content_tags: v.manual_content_tags,
})),
...posts.map((p) => ({
...posts.map((p) => {
const coverPath = p.images?.[0]?.url;
return {
type: "image" as const,
aweme_id: p.aweme_id,
created_at: p.created_at,
desc: p.desc,
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
cover_url: coverPath ? getFileUrl(coverPath) : null,
width: p.images?.[0]?.width ?? null,
height: p.images?.[0]?.height ?? null,
author: {
@@ -57,7 +82,8 @@ export default async function Home() {
sec_uid: p.author.sec_uid,
},
likes: Number(p.digg_count),
})),
};
}),
]
//.sort(() => Math.random() - 0.5)
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
@@ -75,20 +101,53 @@ export default async function Home() {
</h1>
<div className="w-full max-w-xl mb-12">
<SearchBox />
<SearchBox tag={selectedTag} />
</div>
<div className="mb-8 flex w-full max-w-4xl flex-wrap justify-center gap-2">
<Link
href="/"
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
selectedTag
? "border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300"
: "border-blue-500 bg-blue-600 text-white"
}`}
>
全部
</Link>
{VIDEO_CONTENT_TAGS.map((tag) => (
<Link
key={tag}
href={`/?tag=${encodeURIComponent(tag)}`}
className={`rounded-full border px-3 py-1.5 text-sm font-medium transition-colors ${
selectedTag === tag
? "border-blue-500 bg-blue-600 text-white"
: "border-zinc-200 bg-white/70 text-zinc-600 hover:border-zinc-300 dark:border-white/10 dark:bg-white/5 dark:text-zinc-300 dark:hover:text-white"
}`}
>
{tag}
</Link>
))}
</div>
<div className="w-full">
{(() => {
const initial = feed.slice(0, 24);
const cursor =
initial.length > 0
? new Date(
initial[initial.length - 1].created_at as any,
).toISOString()
const lastItem = initial.at(-1);
const cursor = lastItem
? new Date(lastItem.created_at).toISOString()
: null;
return (
<FeedMasonry initialItems={initial} initialCursor={cursor} />
<FeedMasonry
key={selectedTag ?? "all"}
initialItems={initial}
initialCursor={cursor}
fetchUrl={
selectedTag
? `/api/feed?tag=${encodeURIComponent(selectedTag)}`
: "/api/feed"
}
/>
);
})()}
</div>
+110 -23
View File
@@ -1,9 +1,11 @@
"use client";
import { FormEvent, useCallback, useEffect, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { ArrowLeft, MessageSquare, Search, X } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Search, ArrowLeft, X, MessageSquare } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { VIDEO_CONTENT_TAGS, isVideoContentTag } from "@/lib/video-tags";
// 匹配搜索结果类型(适配后端API)
type SearchResultItem = {
@@ -12,6 +14,7 @@ type SearchResultItem = {
type: "video" | "image";
rank: number;
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
contentTags: string[];
video?: {
aweme_id: string;
desc: string;
@@ -47,9 +50,20 @@ export default function SearchClient({
const [results, setResults] = useState<SearchResultItem[]>([]);
const [loading, setLoading] = useState(false);
const [searched, setSearched] = useState(false);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const performSearch = useCallback(async (q: string) => {
if (!q.trim()) {
const buildSearchHref = useCallback((q: string, tag: string | null) => {
const params = new URLSearchParams();
const trimmed = q.trim();
if (trimmed) params.set("q", trimmed);
if (tag) params.set("tag", tag);
const queryString = params.toString();
return queryString ? `/search?${queryString}` : "/search";
}, []);
const performSearch = useCallback(async (q: string, tag: string | null) => {
const trimmed = q.trim();
if (!trimmed && !tag) {
setResults([]);
setSearched(false);
return;
@@ -57,9 +71,10 @@ export default function SearchClient({
setLoading(true);
setSearched(true);
try {
const res = await fetch(
`/api/search?q=${encodeURIComponent(q.trim())}&limit=60`,
);
const params = new URLSearchParams({ limit: "60" });
if (trimmed) params.set("q", trimmed);
if (tag) params.set("tag", tag);
const res = await fetch(`/api/search?${params.toString()}`);
if (!res.ok) throw new Error("Search failed");
const data = await res.json();
setResults(data.results || []);
@@ -74,32 +89,64 @@ export default function SearchClient({
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
useEffect(() => {
const q = searchParams.get("q") || "";
const tagParam = searchParams.get("tag") || "";
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
setQuery(q);
if (q) performSearch(q);
setSelectedTag(tag);
if (q || tag) {
performSearch(q, tag);
} else {
setResults([]);
setSearched(false);
}
}, [searchParams, performSearch]);
const handleSearch = (e: FormEvent) => {
e.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
// 可省略:上面的 push 会触发上面的 effect 从 URL 拉取并搜索
// performSearch(trimmed);
if (!trimmed && !selectedTag) return;
router.push(buildSearchHref(trimmed, selectedTag));
};
const handleTagSelect = (tag: string) => {
const nextTag = selectedTag === tag ? null : tag;
setSelectedTag(nextTag);
router.push(buildSearchHref(query, nextTag));
};
const handleClear = () => {
setQuery("");
setSelectedTag(null);
setResults([]);
setSearched(false);
router.push("/search");
};
const renderSnippet = (snippet: string) => {
let highlighted = false;
let offset = 0;
return snippet.split(/(<mark>|<\/mark>)/g).map((part) => {
offset += part.length;
if (part === "<mark>") {
highlighted = true;
return null;
}
if (part === "</mark>") {
highlighted = false;
return null;
}
if (!part) return null;
return highlighted ? <mark key={`${offset}-${part}`}>{part}</mark> : part;
});
};
return (
<div className="min-h-screen bg-zinc-950 text-white">
{/* 顶部搜索栏 */}
<header className="sticky top-0 z-50 bg-zinc-900/80 backdrop-blur-xl border-b border-white/10">
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
<button
type="button"
onClick={() => router.push("/")}
className="p-2 rounded-full hover:bg-white/10 transition-colors"
aria-label="返回首页"
@@ -118,13 +165,12 @@ export default function SearchClient({
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索视频、图文描述或语音内容..."
className="w-full bg-white/5 border border-white/10 rounded-full px-5 py-3 pl-12 pr-12 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all"
autoFocus
/>
<Search
size={20}
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
/>
{query && (
{(query || selectedTag) && (
<button
type="button"
onClick={handleClear}
@@ -138,12 +184,30 @@ export default function SearchClient({
<button
type="submit"
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-full font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || !query.trim()}
disabled={loading || (!query.trim() && !selectedTag)}
>
{loading ? "搜索中..." : "搜索"}
</button>
</form>
</div>
<div className="max-w-7xl mx-auto px-4 pb-4">
<div className="flex gap-2 overflow-x-auto">
{VIDEO_CONTENT_TAGS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => handleTagSelect(tag)}
className={`flex-shrink-0 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
selectedTag === tag
? "border-blue-400 bg-blue-500/20 text-blue-200"
: "border-white/10 bg-white/5 text-white/60 hover:border-white/20 hover:text-white"
}`}
>
{tag}
</button>
))}
</div>
</div>
</header>
{/* 结果区域 */}
@@ -165,7 +229,7 @@ export default function SearchClient({
</div>
)}
{!loading && !searched && !query && (
{!loading && !searched && !query && !selectedTag && (
<div className="flex flex-col items-center justify-center py-20 text-white/40">
<Search size={64} className="mb-4 opacity-20" />
<p className="text-xl">输入关键词开始搜索</p>
@@ -207,11 +271,18 @@ export default function SearchClient({
target="_blank"
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
>
<img
src={content.cover_url || ""}
{content.cover_url ? (
<Image
src={content.cover_url}
alt={content.desc}
width={280}
height={384}
unoptimized
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
) : (
<div className="h-full w-full bg-zinc-800" />
)}
</Link>
{/* 右侧:信息区 */}
@@ -231,6 +302,14 @@ export default function SearchClient({
<MessageSquare size={12} />
匹配度: {(item.rank * 100).toFixed(1)}%
</span>
{item.contentTags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 text-xs rounded bg-blue-500/15 text-blue-200"
>
{tag}
</span>
))}
</div>
{/* 描述 */}
@@ -245,11 +324,18 @@ export default function SearchClient({
{/* 作者信息 */}
<div className="flex items-center gap-2 mb-4">
<img
src={content.author.avatar_url || ""}
{content.author.avatar_url ? (
<Image
src={content.author.avatar_url}
alt={content.author.nickname}
width={24}
height={24}
unoptimized
className="w-6 h-6 rounded-full ring-1 ring-white/20"
/>
) : (
<div className="h-6 w-6 rounded-full bg-white/10 ring-1 ring-white/20" />
)}
<span className="text-xs text-white/60">
{content.author.nickname}
</span>
@@ -264,11 +350,12 @@ export default function SearchClient({
</h4>
<div
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
dangerouslySetInnerHTML={{ __html: item.snippet }}
style={{
wordBreak: "break-word",
}}
/>
>
{renderSnippet(item.snippet)}
</div>
</div>
</div>
</div>
+1
View File
@@ -15,6 +15,7 @@ export type FeedItem = (
cover_url: string | null;
width?: number | null;
height?: number | null;
content_tags?: string[];
};
export interface FeedResponse {
+24
View File
@@ -0,0 +1,24 @@
export const VIDEO_CONTENT_TAGS = [
"美女",
"游戏",
"时政",
"美食",
"旅行",
"体育",
"科技",
"教育",
"宠物",
"音乐",
"影视",
"财经",
"生活",
"其他",
] as const;
export type VideoContentTag = (typeof VIDEO_CONTENT_TAGS)[number];
const videoContentTagSet = new Set<string>(VIDEO_CONTENT_TAGS);
export function isVideoContentTag(value: string): value is VideoContentTag {
return videoContentTagSet.has(value);
}
@@ -0,0 +1,5 @@
ALTER TABLE "VideoTranscript"
ADD COLUMN "content_tags" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
CREATE INDEX "video_transcript_content_tags_idx"
ON "VideoTranscript" USING GIN ("content_tags");
@@ -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");
+3
View File
@@ -38,6 +38,7 @@ model Video {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tags String[]
manual_content_tags String[] @default([])
video_url String
height Int?
width Int?
@@ -49,6 +50,8 @@ model Video {
transcript VideoTranscript?
transcriptionTask TranscriptionTask?
@@index([manual_content_tags], map: "video_manual_content_tags_idx", type: Gin)
@@index([authorId])
@@index([created_at])
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

+56
View File
@@ -16,6 +16,9 @@ import {
} from "../lib/stt/tasks";
import { processTask } from "../lib/stt/worker";
import { POST as updateTags } from "../app/api/asr/tags/route";
import { GET as searchVideos } from "../app/api/search/route";
const database = process.env.TEST_DATABASE_URL;
const migration = readFileSync(
"prisma/migrations/20261003000000_plain_transcription/migration.sql",
@@ -67,6 +70,12 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
sql(
readFileSync("prisma/migrations/20251025_baseline/migration.sql", "utf8"),
);
sql(
readFileSync(
"prisma/migrations/20260429_add_manual_video_tags/migration.sql",
"utf8",
),
);
await prisma.author.create({
data: {
sec_uid: "test-author",
@@ -104,6 +113,9 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
CREATE TEXT SEARCH CONFIGURATION public.zhcfg (COPY = pg_catalog.simple);
CREATE FUNCTION public.vt_update_transcript_zh_tsv() RETURNS trigger AS $$ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql;
CREATE TRIGGER trg_vt_transcript_zh_tsv BEFORE INSERT OR UPDATE OF transcript ON "VideoTranscript" FOR EACH ROW EXECUTE FUNCTION public.vt_update_transcript_zh_tsv();`);
sql(
`UPDATE "Video" SET manual_content_tags = ARRAY['科技'] WHERE aweme_id = 'historical';`,
);
sql(migration);
sql(migration); // migration DDL is repeatable, historical text is unchanged
});
@@ -120,6 +132,14 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
await prisma.$disconnect();
});
test("migration preserves historical and empty text and rebuilds search", async () => {
assert.deepEqual(
(
await prisma.video.findUniqueOrThrow({
where: { aweme_id: "historical" },
})
).manual_content_tags,
["科技"],
);
assert.equal(
(await readTranscription("empty-history"))?.status,
"SUCCEEDED",
@@ -132,6 +152,42 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'original')`;
assert.equal(Number(rows[0].count), 1);
});
test("manual tags survive migration and remain editable and searchable without ASR tags", async () => {
const result = await updateTags(
request("http://local/api/asr/tags", {
awemeId: "v1",
mode: "add",
contentTags: ["科技"],
}),
);
assert.equal(result.status, 200);
assert.deepEqual((await result.json()).updated[0].content_tags, ["科技"]);
const search = await searchVideos(
new Request("http://local/api/search?tag=" + encodeURIComponent("科技")),
);
assert.equal(search.status, 200);
const found = await search.json();
assert.ok(
found.results.some((r: { awemeId: string }) => r.awemeId === "v1"),
);
assert.ok(
found.results.some(
(r: { awemeId: string }) => r.awemeId === "historical",
),
);
await updateTags(
request("http://local/api/asr/tags", {
awemeId: "v1",
mode: "set",
contentTags: [],
}),
);
assert.deepEqual(
(await prisma.video.findUniqueOrThrow({ where: { aweme_id: "v1" } }))
.manual_content_tags,
[],
);
});
test("concurrent enqueue is idempotent; automatic enqueue never retranscribes history", async () => {
await Promise.all(
Array.from({ length: 10 }, () => enqueueTranscription("v1", true)),