fix: 合并服务器未推送改动并保留手动视频标签
This commit is contained in:
@@ -104,3 +104,9 @@ npx tsx scripts/stt-smoke.ts 7682010237578136858
|
|||||||
```
|
```
|
||||||
|
|
||||||
脚本将供应商任务 ID 和结果保存在 `/tmp/douyin-asr-<视频ID>.json`。已有未完成任务时应查询原任务,不要重复提交。
|
脚本将供应商任务 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 只读。旧内存批处理和分块模型调用已移除。
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "../../stt/videos/page";
|
||||||
+1
-135
@@ -1,135 +1 @@
|
|||||||
"use client";
|
export { default } from "../asr/page";
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { VIDEO_CONTENT_TAGS } from "@/lib/video-tags";
|
||||||
import BackButton from "@/app/components/BackButton";
|
import BackButton from "@/app/components/BackButton";
|
||||||
import {
|
import {
|
||||||
isActive,
|
isActive,
|
||||||
@@ -18,6 +19,7 @@ type Video = {
|
|||||||
duration_ms: number;
|
duration_ms: number;
|
||||||
author: { nickname: string };
|
author: { nickname: string };
|
||||||
transcription: TranscriptionState;
|
transcription: TranscriptionState;
|
||||||
|
content_tags: string[];
|
||||||
};
|
};
|
||||||
const filters: ("all" | TranscriptionStatus)[] = [
|
const filters: ("all" | TranscriptionStatus)[] = [
|
||||||
"all",
|
"all",
|
||||||
@@ -36,6 +38,10 @@ export default function SttVideosPage() {
|
|||||||
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
|
const [submitting, setSubmitting] = useState<Set<string>>(new Set());
|
||||||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||||
const [filter, setFilter] = useState<"all" | TranscriptionStatus>("all");
|
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 fetching = useRef(false);
|
||||||
const fetchVideos = useCallback(async () => {
|
const fetchVideos = useCallback(async () => {
|
||||||
if (fetching.current) return;
|
if (fetching.current) return;
|
||||||
@@ -104,6 +110,28 @@ export default function SttVideosPage() {
|
|||||||
setBatchSubmitting(false);
|
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) =>
|
const count = (status: TranscriptionStatus) =>
|
||||||
videos.filter((v) => v.transcription.status === status).length;
|
videos.filter((v) => v.transcription.status === status).length;
|
||||||
const eligible = videos.filter(
|
const eligible = videos.filter(
|
||||||
@@ -111,7 +139,9 @@ export default function SttVideosPage() {
|
|||||||
v.transcription.transcript === null && !isActive(v.transcription.status),
|
v.transcription.transcript === null && !isActive(v.transcription.status),
|
||||||
).length;
|
).length;
|
||||||
const shown = videos.filter(
|
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 (
|
return (
|
||||||
@@ -155,10 +185,70 @@ export default function SttVideosPage() {
|
|||||||
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
|
{batchSubmitting ? "正在加入队列…" : `批量补转 (${eligible})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="overflow-x-auto rounded border border-gray-200 bg-white">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="bg-gray-100 text-gray-600">
|
<thead className="bg-gray-100 text-gray-600">
|
||||||
<tr>
|
<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>
|
<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}
|
key={video.aweme_id}
|
||||||
className="border-t border-gray-100 align-top"
|
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">
|
<td className="p-3">
|
||||||
<div className="flex max-w-sm gap-3">
|
<div className="flex max-w-sm gap-3">
|
||||||
{video.cover_url && (
|
{video.cover_url && (
|
||||||
@@ -197,6 +305,9 @@ export default function SttVideosPage() {
|
|||||||
<p className="mt-1 text-gray-500">
|
<p className="mt-1 text-gray-500">
|
||||||
@{video.author.nickname}
|
@{video.author.nickname}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-blue-700">
|
||||||
|
{video.content_tags.join("、")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET } from "../../stt/videos/route";
|
||||||
@@ -31,6 +31,7 @@ export async function GET() {
|
|||||||
author: {
|
author: {
|
||||||
nickname: v.author.nickname,
|
nickname: v.author.nickname,
|
||||||
},
|
},
|
||||||
|
content_tags: v.manual_content_tags,
|
||||||
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
transcription: transcriptionState(v.transcript, v.transcriptionTask),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { GET, POST } from "../stt/route";
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
+52
-26
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
||||||
|
import { isVideoContentTag } from "@/lib/video-tags";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
// Contract
|
// Contract
|
||||||
// Inputs: search params { before?: ISOString, limit?: number }
|
// Inputs: search params { before?: ISOString, limit?: number }
|
||||||
@@ -11,24 +12,45 @@ export async function GET(req: NextRequest) {
|
|||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const limitParam = searchParams.get("limit");
|
const limitParam = searchParams.get("limit");
|
||||||
const beforeParam = searchParams.get("before");
|
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 limit = Math.min(Math.max(Number(limitParam ?? "24"), 1), 60); // 1..60
|
||||||
const before = beforeParam ? new Date(beforeParam) : null;
|
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
|
// fetch chunk from both tables
|
||||||
const [videos, posts] = await Promise.all([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
where: before ? { created_at: { lt: before } } : undefined,
|
where: videoWhere,
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: limit,
|
take: limit,
|
||||||
include: { author: true },
|
include: {
|
||||||
}),
|
author: true,
|
||||||
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 } },
|
|
||||||
}),
|
}),
|
||||||
|
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 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const merged: FeedItem[] = [
|
const merged: FeedItem[] = [
|
||||||
@@ -47,29 +69,33 @@ export async function GET(req: NextRequest) {
|
|||||||
sec_uid: v.author.sec_uid,
|
sec_uid: v.author.sec_uid,
|
||||||
},
|
},
|
||||||
likes: Number(v.digg_count),
|
likes: Number(v.digg_count),
|
||||||
|
content_tags: v.manual_content_tags,
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => {
|
||||||
type: "image" as const,
|
const coverPath = p.images?.[0]?.url;
|
||||||
aweme_id: p.aweme_id,
|
return {
|
||||||
created_at: p.created_at,
|
type: "image" as const,
|
||||||
desc: p.desc,
|
aweme_id: p.aweme_id,
|
||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
created_at: p.created_at,
|
||||||
width: p.images?.[0]?.width ?? null,
|
desc: p.desc,
|
||||||
height: p.images?.[0]?.height ?? null,
|
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||||
author: {
|
width: p.images?.[0]?.width ?? null,
|
||||||
nickname: p.author.nickname,
|
height: p.images?.[0]?.height ?? null,
|
||||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
author: {
|
||||||
sec_uid: p.author.sec_uid,
|
nickname: p.author.nickname,
|
||||||
},
|
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||||
likes: Number(p.digg_count),
|
sec_uid: p.author.sec_uid,
|
||||||
})),
|
},
|
||||||
|
likes: Number(p.digg_count),
|
||||||
|
};
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at))
|
||||||
.slice(0, limit);
|
.slice(0, limit);
|
||||||
|
|
||||||
const nextCursor =
|
const nextCursor =
|
||||||
merged.length > 0
|
merged.length > 0
|
||||||
? new Date(merged[merged.length - 1].created_at as any).toISOString()
|
? new Date(merged[merged.length - 1].created_at).toISOString()
|
||||||
: null;
|
: null;
|
||||||
const payload: FeedResponse = { items: merged, nextCursor };
|
const payload: FeedResponse = { items: merged, nextCursor };
|
||||||
return NextResponse.json(payload);
|
return NextResponse.json(payload);
|
||||||
|
|||||||
+133
-80
@@ -1,11 +1,32 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { isVideoContentTag } from "@/lib/video-tags";
|
||||||
import { json } from "@/lib/json";
|
import { json } from "@/lib/json";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
import { getFileUrl } from "@/lib/minio";
|
||||||
import { prisma } from "@/lib/prisma"; // 你的 Prisma 客户端实例
|
import { prisma } from "@/lib/prisma";
|
||||||
import { NextResponse } from "next/server";
|
|
||||||
|
type SearchRow = {
|
||||||
|
id: string;
|
||||||
|
awemeId: string;
|
||||||
|
type: "video" | "image";
|
||||||
|
rank: number;
|
||||||
|
snippet: string;
|
||||||
|
contentTags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeSnippetHtml(snippet: string) {
|
||||||
|
return snippet
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("<mark>", "<mark>")
|
||||||
|
.replaceAll("</mark>", "</mark>");
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: Request) {
|
export async function GET(req: Request) {
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const q = (searchParams.get("q") || "").trim();
|
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 page = Math.max(1, Number(searchParams.get("page") || 1));
|
||||||
const limit = Math.min(
|
const limit = Math.min(
|
||||||
50,
|
50,
|
||||||
@@ -13,7 +34,11 @@ export async function GET(req: Request) {
|
|||||||
);
|
);
|
||||||
const offset = (page - 1) * limit;
|
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 });
|
return NextResponse.json({ results: [], total: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,90 +48,117 @@ export async function GET(req: Request) {
|
|||||||
// - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc
|
// - 合并搜索来源:VideoTranscript.transcript_zh_tsv、Video.desc 与 ImagePost.desc
|
||||||
// - 使用 UNION ALL 合并视频和图文的搜索结果
|
// - 使用 UNION ALL 合并视频和图文的搜索结果
|
||||||
// - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配
|
// - rank 取两者匹配得分的最大值,snippet 优先展示转写匹配,否则展示 desc 匹配
|
||||||
const rows = await prisma.$queryRaw<
|
const rows = q
|
||||||
{
|
? await prisma.$queryRaw<SearchRow[]>`
|
||||||
id: string;
|
WITH tsq AS (
|
||||||
awemeId: string;
|
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||||
type: "video" | "image";
|
)
|
||||||
rank: number;
|
-- 视频搜索结果
|
||||||
snippet: string;
|
SELECT
|
||||||
}[]
|
COALESCE(vt.id, 'desc:' || v.aweme_id) AS id,
|
||||||
>`
|
v.aweme_id AS "awemeId",
|
||||||
WITH tsq AS (
|
'video'::text AS type,
|
||||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
GREATEST(
|
||||||
)
|
COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0),
|
||||||
-- 视频搜索结果
|
COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0)
|
||||||
SELECT
|
) AS rank,
|
||||||
COALESCE(vt.id, 'desc:' || v.aweme_id) AS id,
|
CASE
|
||||||
v.aweme_id AS "awemeId",
|
WHEN vt.transcript_zh_tsv @@ tsq.query THEN
|
||||||
'video'::text AS type,
|
ts_headline(
|
||||||
GREATEST(
|
'zhcfg',
|
||||||
COALESCE(ts_rank(vt.transcript_zh_tsv, tsq.query), 0),
|
array_to_string(vt.transcript, ' '),
|
||||||
COALESCE(ts_rank(to_tsvector('zhcfg', v.desc), tsq.query), 0)
|
tsq.query,
|
||||||
) AS rank,
|
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||||
CASE
|
)
|
||||||
WHEN vt.transcript_zh_tsv @@ tsq.query THEN
|
ELSE
|
||||||
|
ts_headline(
|
||||||
|
'zhcfg',
|
||||||
|
v.desc,
|
||||||
|
tsq.query,
|
||||||
|
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
-- 图文搜索结果
|
||||||
|
SELECT
|
||||||
|
'img_desc:' || ip.aweme_id AS id,
|
||||||
|
ip.aweme_id AS "awemeId",
|
||||||
|
'image'::text AS type,
|
||||||
|
ts_rank(to_tsvector('zhcfg', ip.desc), tsq.query) AS rank,
|
||||||
ts_headline(
|
ts_headline(
|
||||||
'zhcfg',
|
'zhcfg',
|
||||||
array_to_string(vt.transcript, ' '),
|
ip.desc,
|
||||||
tsq.query,
|
tsq.query,
|
||||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
||||||
)
|
) AS snippet,
|
||||||
ELSE
|
ARRAY[]::text[] AS "contentTags"
|
||||||
ts_headline(
|
FROM "ImagePost" ip, tsq
|
||||||
'zhcfg',
|
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,
|
v.desc,
|
||||||
tsq.query,
|
'标签匹配'
|
||||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
) AS snippet,
|
||||||
)
|
v.manual_content_tags AS "contentTags"
|
||||||
END AS snippet
|
FROM "Video" v
|
||||||
FROM "Video" v
|
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id,
|
WHERE v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
tsq
|
ORDER BY v.created_at DESC
|
||||||
WHERE
|
OFFSET ${offset}
|
||||||
(vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
LIMIT ${limit};
|
||||||
|
`;
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
-- 图文搜索结果
|
|
||||||
SELECT
|
|
||||||
'img_desc:' || ip.aweme_id AS id,
|
|
||||||
ip.aweme_id AS "awemeId",
|
|
||||||
'image'::text AS type,
|
|
||||||
ts_rank(to_tsvector('zhcfg', ip.desc), tsq.query) AS rank,
|
|
||||||
ts_headline(
|
|
||||||
'zhcfg',
|
|
||||||
ip.desc,
|
|
||||||
tsq.query,
|
|
||||||
'StartSel=<mark>,StopSel=</mark>,MaxFragments=2,MinWords=2,MaxWords=20'
|
|
||||||
) AS snippet
|
|
||||||
FROM "ImagePost" ip, tsq
|
|
||||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
|
|
||||||
|
|
||||||
ORDER BY rank DESC
|
|
||||||
OFFSET ${offset}
|
|
||||||
LIMIT ${limit};
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 查询总数(参数化,避免注入)
|
// 查询总数(参数化,避免注入)
|
||||||
const totalRows = await prisma.$queryRaw<
|
const totalRows = q
|
||||||
{
|
? await prisma.$queryRaw<{ count: number }[]>`
|
||||||
count: number;
|
WITH tsq AS (
|
||||||
}[]
|
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
||||||
>`
|
)
|
||||||
WITH tsq AS (
|
SELECT (
|
||||||
SELECT websearch_to_tsquery('zhcfg', ${q}) AS query
|
(SELECT COUNT(*)::int FROM "Video" v
|
||||||
)
|
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
||||||
SELECT (
|
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query)
|
||||||
(SELECT COUNT(*)::int FROM "Video" v
|
AND CASE
|
||||||
LEFT JOIN "VideoTranscript" vt ON vt."videoId" = v.aweme_id
|
WHEN ${contentTag}::text IS NULL THEN TRUE
|
||||||
WHERE (vt.transcript_zh_tsv @@ tsq.query OR to_tsvector('zhcfg', v.desc) @@ tsq.query))
|
ELSE v.manual_content_tags @> ARRAY[${contentTag}]::text[]
|
||||||
+
|
END)
|
||||||
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
+
|
||||||
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query)
|
(SELECT COUNT(*)::int FROM "ImagePost" ip
|
||||||
) AS count
|
WHERE to_tsvector('zhcfg', ip.desc) @@ tsq.query
|
||||||
FROM tsq;
|
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
|
// 分离视频和图文ID
|
||||||
const videoIds = rows
|
const videoIds = rows
|
||||||
@@ -176,6 +228,7 @@ export async function GET(req: Request) {
|
|||||||
return json({
|
return json({
|
||||||
results: rows.map((r) => ({
|
results: rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
|
snippet: sanitizeSnippetHtml(r.snippet),
|
||||||
video:
|
video:
|
||||||
r.type === "video"
|
r.type === "video"
|
||||||
? videos.find((v) => v.aweme_id === r.awemeId)
|
? videos.find((v) => v.aweme_id === r.awemeId)
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Play } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
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 { BackgroundCanvas } from "./components/BackgroundCanvas";
|
||||||
import { CommentPanel } from "./components/CommentPanel";
|
import { CommentPanel } from "./components/CommentPanel";
|
||||||
import { ImageCarousel } from "./components/ImageCarousel";
|
import { ImageCarousel } from "./components/ImageCarousel";
|
||||||
@@ -18,15 +11,37 @@ import { MediaControls } from "./components/MediaControls";
|
|||||||
import { NavigationButtons } from "./components/NavigationButtons";
|
import { NavigationButtons } from "./components/NavigationButtons";
|
||||||
import { TranscriptPanel } from "./components/TranscriptPanel";
|
import { TranscriptPanel } from "./components/TranscriptPanel";
|
||||||
import { VideoPlayer } from "./components/VideoPlayer";
|
import { VideoPlayer } from "./components/VideoPlayer";
|
||||||
|
import { VideoTagEditor } from "./components/VideoTagEditor";
|
||||||
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
import { useBackgroundCanvas } from "./hooks/useBackgroundCanvas";
|
||||||
import { useCommentState } from "./hooks/useCommentState";
|
import { useCommentState } from "./hooks/useCommentState";
|
||||||
import { useImageCarousel } from "./hooks/useImageCarousel";
|
import { useImageCarousel } from "./hooks/useImageCarousel";
|
||||||
import { useNavigation } from "./hooks/useNavigation";
|
import { useNavigation } from "./hooks/useNavigation";
|
||||||
import { usePlayerState } from "./hooks/usePlayerState";
|
import { usePlayerState } from "./hooks/usePlayerState";
|
||||||
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
import { useVideoPlayer } from "./hooks/useVideoPlayer";
|
||||||
|
import type {
|
||||||
|
AwemeData,
|
||||||
|
ImageData,
|
||||||
|
Neighbors,
|
||||||
|
VideoData,
|
||||||
|
VideoTranscript,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
const SEGMENT_MS = 4000;
|
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 {
|
interface AwemeDetailClientProps {
|
||||||
data: AwemeData;
|
data: AwemeData;
|
||||||
neighbors: Neighbors;
|
neighbors: Neighbors;
|
||||||
@@ -45,6 +60,9 @@ export default function AwemeDetailClient({
|
|||||||
const playerState = usePlayerState();
|
const playerState = usePlayerState();
|
||||||
const commentState = useCommentState();
|
const commentState = useCommentState();
|
||||||
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
const [transcriptOpen, setTranscriptOpen] = useState(false);
|
||||||
|
const [contentTags, setContentTags] = useState<string[]>(
|
||||||
|
isVideo ? (data as VideoData).content_tags : [],
|
||||||
|
);
|
||||||
|
|
||||||
// 引用
|
// 引用
|
||||||
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
const mediaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -68,6 +86,7 @@ export default function AwemeDetailClient({
|
|||||||
|
|
||||||
// 图文轮播状态
|
// 图文轮播状态
|
||||||
const images = isVideo ? [] : (data as ImageData).images;
|
const images = isVideo ? [] : (data as ImageData).images;
|
||||||
|
const imageCount = images.length;
|
||||||
const imageCarouselState = useImageCarousel({
|
const imageCarouselState = useImageCarousel({
|
||||||
images,
|
images,
|
||||||
isPlaying: playerState.isPlaying,
|
isPlaying: playerState.isPlaying,
|
||||||
@@ -80,40 +99,42 @@ export default function AwemeDetailClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 视频播放器 hooks
|
// 视频播放器 hooks
|
||||||
if (isVideo) {
|
useVideoPlayer({
|
||||||
useVideoPlayer({
|
enabled: isVideo,
|
||||||
awemeId: data.aweme_id,
|
awemeId: data.aweme_id,
|
||||||
videoRef,
|
videoRef,
|
||||||
volume: playerState.volume,
|
volume: playerState.volume,
|
||||||
rate: playerState.rate,
|
rate: playerState.rate,
|
||||||
loopMode: playerState.loopMode,
|
loopMode: playerState.loopMode,
|
||||||
neighbors,
|
neighbors,
|
||||||
progressRestored: playerState.progressRestored,
|
progressRestored: playerState.progressRestored,
|
||||||
setIsPlaying: playerState.setIsPlaying,
|
setIsPlaying: playerState.setIsPlaying,
|
||||||
setProgress: playerState.setProgress,
|
setProgress: playerState.setProgress,
|
||||||
setProgressRestored: playerState.setProgressRestored,
|
setProgressRestored: playerState.setProgressRestored,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// 媒体操作函数
|
// 媒体操作函数
|
||||||
const seekTo = (ratio: number) => {
|
const seekTo = useCallback(
|
||||||
ratio = Math.min(1, Math.max(0, ratio));
|
(ratio: number) => {
|
||||||
if (isVideo) {
|
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||||
const v = videoRef.current;
|
if (isVideo) {
|
||||||
if (!v || !v.duration) return;
|
const v = videoRef.current;
|
||||||
v.currentTime = v.duration * ratio;
|
if (!v || !v.duration) return;
|
||||||
return;
|
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) => {
|
const seekImageByVisualRatio = (ratio: number) => {
|
||||||
if (!images?.length) return;
|
if (!imageCount) return;
|
||||||
|
|
||||||
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
const clampedRatio = Math.min(1, Math.max(0, ratio));
|
||||||
const rawSegment = clampedRatio * images.length;
|
const rawSegment = clampedRatio * imageCount;
|
||||||
const targetIndex = Math.min(images.length - 1, Math.floor(rawSegment));
|
const targetIndex = Math.min(imageCount - 1, Math.floor(rawSegment));
|
||||||
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
|
const segmentProgress = Math.min(1, Math.max(0, rawSegment - targetIndex));
|
||||||
imageCarouselState.goToIndex(targetIndex, segmentProgress);
|
imageCarouselState.goToIndex(targetIndex, segmentProgress);
|
||||||
};
|
};
|
||||||
@@ -152,14 +173,12 @@ export default function AwemeDetailClient({
|
|||||||
document.body.requestFullscreen().catch(() => {});
|
document.body.requestFullscreen().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const vRef = videoRef.current;
|
const vRef = videoRef.current as WebkitVideoElement | null;
|
||||||
if (vRef && vRef.requestFullscreen) {
|
if (vRef?.requestFullscreen) {
|
||||||
vRef.requestFullscreen().catch(() => {});
|
vRef.requestFullscreen().catch(() => {});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// @ts-ignore
|
if (vRef?.webkitEnterFullscreen) {
|
||||||
if (vRef && vRef.webkitEnterFullscreen) {
|
|
||||||
// @ts-ignore
|
|
||||||
vRef.webkitEnterFullscreen();
|
vRef.webkitEnterFullscreen();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -167,20 +186,20 @@ export default function AwemeDetailClient({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const prevImg = () => {
|
const prevImg = useCallback(() => {
|
||||||
if (!images?.length) return;
|
if (!imageCount) return;
|
||||||
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
|
const next = Math.max(0, imageCarouselState.idxRef.current - 1);
|
||||||
imageCarouselState.goToIndex(next);
|
imageCarouselState.goToIndex(next);
|
||||||
};
|
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
|
||||||
|
|
||||||
const nextImg = () => {
|
const nextImg = useCallback(() => {
|
||||||
if (!images?.length) return;
|
if (!imageCount) return;
|
||||||
const next = Math.min(
|
const next = Math.min(
|
||||||
images.length - 1,
|
imageCount - 1,
|
||||||
imageCarouselState.idxRef.current + 1,
|
imageCarouselState.idxRef.current + 1,
|
||||||
);
|
);
|
||||||
imageCarouselState.goToIndex(next);
|
imageCarouselState.goToIndex(next);
|
||||||
};
|
}, [imageCarouselState.goToIndex, imageCarouselState.idxRef, imageCount]);
|
||||||
|
|
||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
@@ -249,7 +268,7 @@ export default function AwemeDetailClient({
|
|||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (!("mediaSession" in navigator)) 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}`;
|
const title = data.desc || `作品 ${data.aweme_id}`;
|
||||||
@@ -282,12 +301,12 @@ export default function AwemeDetailClient({
|
|||||||
|
|
||||||
const updatePosition = () => {
|
const updatePosition = () => {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore: setPositionState 可能不存在
|
|
||||||
if (!ms.setPositionState) return;
|
if (!ms.setPositionState) return;
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v || !isFinite(v.duration) || isNaN(v.duration)) return;
|
if (!v || !Number.isFinite(v.duration) || Number.isNaN(v.duration)) {
|
||||||
// @ts-ignore
|
return;
|
||||||
|
}
|
||||||
ms.setPositionState({
|
ms.setPositionState({
|
||||||
duration: Math.max(0, v.duration),
|
duration: Math.max(0, v.duration),
|
||||||
position: Math.max(0, v.currentTime),
|
position: Math.max(0, v.currentTime),
|
||||||
@@ -300,14 +319,12 @@ export default function AwemeDetailClient({
|
|||||||
0,
|
0,
|
||||||
Math.min(duration, (playerState.progress * totalMs) / 1000),
|
Math.min(duration, (playerState.progress * totalMs) / 1000),
|
||||||
);
|
);
|
||||||
// @ts-ignore
|
|
||||||
ms.setPositionState({ duration, position, playbackRate: 1 });
|
ms.setPositionState({ duration, position, playbackRate: 1 });
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 绑定视频事件以同步状态
|
// 绑定视频事件以同步状态
|
||||||
let timeUpdateTimer: number | null = null;
|
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (isVideo && v) {
|
if (isVideo && v) {
|
||||||
const onPlay = () => {
|
const onPlay = () => {
|
||||||
@@ -331,7 +348,6 @@ export default function AwemeDetailClient({
|
|||||||
updatePosition();
|
updatePosition();
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
timeUpdateTimer = null;
|
|
||||||
return () => {
|
return () => {
|
||||||
v.removeEventListener("play", onPlay);
|
v.removeEventListener("play", onPlay);
|
||||||
v.removeEventListener("pause", onPause);
|
v.removeEventListener("pause", onPause);
|
||||||
@@ -346,15 +362,10 @@ export default function AwemeDetailClient({
|
|||||||
}
|
}
|
||||||
// 依赖:当这些变化时刷新元信息与位置状态
|
// 依赖:当这些变化时刷新元信息与位置状态
|
||||||
}, [
|
}, [
|
||||||
isVideo,
|
data,
|
||||||
data.aweme_id,
|
|
||||||
data.desc,
|
|
||||||
data.author.nickname,
|
|
||||||
data.author.avatar_url,
|
|
||||||
data.created_at,
|
|
||||||
imageCarouselState.idx,
|
|
||||||
imageCarouselState.totalDurationMs,
|
imageCarouselState.totalDurationMs,
|
||||||
images,
|
images,
|
||||||
|
isVideo,
|
||||||
playerState.isPlaying,
|
playerState.isPlaying,
|
||||||
playerState.progress,
|
playerState.progress,
|
||||||
playerState.rate,
|
playerState.rate,
|
||||||
@@ -364,7 +375,7 @@ export default function AwemeDetailClient({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
if (!("mediaSession" in navigator)) return;
|
if (!("mediaSession" in navigator)) return;
|
||||||
const ms = (navigator as any).mediaSession as MediaSession;
|
const ms = navigator.mediaSession;
|
||||||
|
|
||||||
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
|
const getImagesTotalMs = () => imageCarouselState.totalDurationMs;
|
||||||
|
|
||||||
@@ -421,7 +432,7 @@ export default function AwemeDetailClient({
|
|||||||
const handleSeekTo = (seekTimeSec: number) => {
|
const handleSeekTo = (seekTimeSec: number) => {
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
const v = videoRef.current;
|
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));
|
v.currentTime = Math.max(0, Math.min(v.duration, seekTimeSec));
|
||||||
}
|
}
|
||||||
} else if (images?.length) {
|
} else if (images?.length) {
|
||||||
@@ -434,13 +445,13 @@ export default function AwemeDetailClient({
|
|||||||
ms.setActionHandler("play", handlePlay);
|
ms.setActionHandler("play", handlePlay);
|
||||||
ms.setActionHandler("pause", handlePause);
|
ms.setActionHandler("pause", handlePause);
|
||||||
ms.setActionHandler("stop", handleStop);
|
ms.setActionHandler("stop", handleStop);
|
||||||
ms.setActionHandler("seekbackward", (details: any) =>
|
ms.setActionHandler("seekbackward", (details: MediaSessionActionDetails) =>
|
||||||
handleSeekDelta(-((details?.seekOffset as number) || 10)),
|
handleSeekDelta(-((details?.seekOffset as number) || 10)),
|
||||||
);
|
);
|
||||||
ms.setActionHandler("seekforward", (details: any) =>
|
ms.setActionHandler("seekforward", (details: MediaSessionActionDetails) =>
|
||||||
handleSeekDelta((details?.seekOffset as number) || 10),
|
handleSeekDelta((details?.seekOffset as number) || 10),
|
||||||
);
|
);
|
||||||
ms.setActionHandler("seekto", (details: any) => {
|
ms.setActionHandler("seekto", (details: MediaSessionActionDetails) => {
|
||||||
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
|
if (typeof details?.seekTime === "number") handleSeekTo(details.seekTime);
|
||||||
});
|
});
|
||||||
ms.setActionHandler("previoustrack", () => {
|
ms.setActionHandler("previoustrack", () => {
|
||||||
@@ -477,8 +488,12 @@ export default function AwemeDetailClient({
|
|||||||
neighbors.next,
|
neighbors.next,
|
||||||
router,
|
router,
|
||||||
playerState.progress,
|
playerState.progress,
|
||||||
|
playerState.setIsPlaying,
|
||||||
pauseImageMedia,
|
pauseImageMedia,
|
||||||
imageCarouselState.totalDurationMs,
|
imageCarouselState.totalDurationMs,
|
||||||
|
nextImg,
|
||||||
|
prevImg,
|
||||||
|
seekTo,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -537,6 +552,14 @@ export default function AwemeDetailClient({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 媒体控制栏 */}
|
{/* 媒体控制栏 */}
|
||||||
|
{isVideo && (
|
||||||
|
<VideoTagEditor
|
||||||
|
awemeId={data.aweme_id}
|
||||||
|
tags={contentTags}
|
||||||
|
onTagsChange={setContentTags}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<MediaControls
|
<MediaControls
|
||||||
isVideo={isVideo}
|
isVideo={isVideo}
|
||||||
isPlaying={playerState.isPlaying}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { RefObject } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import type { LoopMode, Neighbors } from "../types.ts";
|
import type { LoopMode, Neighbors } from "../types.ts";
|
||||||
|
|
||||||
interface UseVideoPlayerProps {
|
interface UseVideoPlayerProps {
|
||||||
|
enabled?: boolean;
|
||||||
awemeId: string;
|
awemeId: string;
|
||||||
videoRef: RefObject<HTMLVideoElement | null>;
|
videoRef: RefObject<HTMLVideoElement | null>;
|
||||||
volume: number;
|
volume: number;
|
||||||
@@ -17,6 +18,7 @@ interface UseVideoPlayerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useVideoPlayer({
|
export function useVideoPlayer({
|
||||||
|
enabled = true,
|
||||||
awemeId,
|
awemeId,
|
||||||
videoRef,
|
videoRef,
|
||||||
volume,
|
volume,
|
||||||
@@ -32,6 +34,7 @@ export function useVideoPlayer({
|
|||||||
|
|
||||||
// 恢复播放进度
|
// 恢复播放进度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
if (progressRestored) return;
|
if (progressRestored) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
@@ -74,10 +77,11 @@ export function useVideoPlayer({
|
|||||||
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
v.addEventListener("loadedmetadata", onLoadedMetadata, { once: true });
|
||||||
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
return () => v.removeEventListener("loadedmetadata", onLoadedMetadata);
|
||||||
}
|
}
|
||||||
}, [awemeId, progressRestored, videoRef, setProgressRestored]);
|
}, [awemeId, enabled, progressRestored, videoRef, setProgressRestored]);
|
||||||
|
|
||||||
// 保存播放进度
|
// 保存播放进度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
@@ -105,10 +109,11 @@ export function useVideoPlayer({
|
|||||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||||
saveProgress();
|
saveProgress();
|
||||||
};
|
};
|
||||||
}, [awemeId, videoRef]);
|
}, [awemeId, enabled, videoRef]);
|
||||||
|
|
||||||
// 监听播放状态和进度
|
// 监听播放状态和进度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
@@ -137,10 +142,19 @@ export function useVideoPlayer({
|
|||||||
v.removeEventListener("pause", onPause);
|
v.removeEventListener("pause", onPause);
|
||||||
v.removeEventListener("ended", onEnded);
|
v.removeEventListener("ended", onEnded);
|
||||||
};
|
};
|
||||||
}, [loopMode, neighbors?.next, router, videoRef, setIsPlaying, setProgress]);
|
}, [
|
||||||
|
enabled,
|
||||||
|
loopMode,
|
||||||
|
neighbors?.next,
|
||||||
|
router,
|
||||||
|
videoRef,
|
||||||
|
setIsPlaying,
|
||||||
|
setProgress,
|
||||||
|
]);
|
||||||
|
|
||||||
// 自动播放检测
|
// 自动播放检测
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
if (!v) return;
|
||||||
|
|
||||||
@@ -148,7 +162,7 @@ export function useVideoPlayer({
|
|||||||
try {
|
try {
|
||||||
await v.play();
|
await v.play();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.log("自动播放被阻止,需要用户交互");
|
console.log("自动播放被阻止,需要用户交互");
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
@@ -160,16 +174,18 @@ export function useVideoPlayer({
|
|||||||
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
v.addEventListener("loadedmetadata", checkAutoplay, { once: true });
|
||||||
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
return () => v.removeEventListener("loadedmetadata", checkAutoplay);
|
||||||
}
|
}
|
||||||
}, [awemeId, videoRef, setIsPlaying]);
|
}, [enabled, videoRef, setIsPlaying]);
|
||||||
|
|
||||||
// 更新音量和倍速
|
// 更新音量和倍速
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) v.volume = volume;
|
if (v) v.volume = volume;
|
||||||
}, [volume, videoRef]);
|
}, [enabled, volume, videoRef]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (v) v.playbackRate = rate;
|
if (v) v.playbackRate = rate;
|
||||||
}, [rate, videoRef]);
|
}, [enabled, rate, videoRef]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type { Metadata } from "next";
|
||||||
|
import BackButton from "@/app/components/BackButton";
|
||||||
import { getFileUrl } from "@/lib/minio";
|
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({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
@@ -53,7 +53,7 @@ export default async function AwemeDetail({
|
|||||||
const [video, post] = await Promise.all([
|
const [video, post] = await Promise.all([
|
||||||
prisma.video.findUnique({
|
prisma.video.findUnique({
|
||||||
where: { aweme_id: id },
|
where: { aweme_id: id },
|
||||||
include: { author: true },
|
include: { author: true, transcript: true },
|
||||||
}),
|
}),
|
||||||
prisma.imagePost.findUnique({
|
prisma.imagePost.findUnique({
|
||||||
where: { aweme_id: id },
|
where: { aweme_id: id },
|
||||||
@@ -63,65 +63,73 @@ export default async function AwemeDetail({
|
|||||||
|
|
||||||
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
if (!video && !post) return <main className="p-8">找不到该作品</main>;
|
||||||
|
|
||||||
const isVideo = !!video;
|
const isVideo = Boolean(video);
|
||||||
|
|
||||||
// 获取评论总数
|
// 获取评论总数
|
||||||
const commentsCount = await prisma.comment.count({
|
const commentsCount = await prisma.comment.count({
|
||||||
where: isVideo ? { videoId: id } : { imagePostId: id },
|
where: isVideo ? { videoId: id } : { imagePostId: id },
|
||||||
});
|
});
|
||||||
const aweme = isVideo ? video : post;
|
|
||||||
|
|
||||||
const data: AwemeData = {
|
let data: AwemeData;
|
||||||
aweme_id: aweme!.aweme_id,
|
let currentCreatedAt: Date;
|
||||||
desc: aweme!.desc,
|
let transcript: VideoTranscript | null = null;
|
||||||
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"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const transcript: VideoTranscript | null = isVideo
|
if (video) {
|
||||||
? await prisma.videoTranscript.findUnique({
|
transcript = video.transcript ?? null;
|
||||||
where: { videoId: id },
|
currentCreatedAt = video.created_at;
|
||||||
})
|
data = {
|
||||||
: null;
|
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: video.manual_content_tags,
|
||||||
|
manual_content_tags: video.manual_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
|
// 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([
|
const [newerVideo, newerPost, olderVideo, olderPost] = await Promise.all([
|
||||||
prisma.video.findFirst({
|
prisma.video.findFirst({
|
||||||
where: { created_at: { gt: currentCreatedAt } },
|
where: { created_at: { gt: currentCreatedAt } },
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export type VideoData = {
|
|||||||
video_url: string;
|
video_url: string;
|
||||||
width: number | null;
|
width: number | null;
|
||||||
height: number | null;
|
height: number | null;
|
||||||
|
content_tags: string[];
|
||||||
|
manual_content_tags: string[];
|
||||||
author: User;
|
author: User;
|
||||||
commentsCount: number;
|
commentsCount: number;
|
||||||
likesCount: number;
|
likesCount: number;
|
||||||
|
|||||||
+265
-93
@@ -1,9 +1,17 @@
|
|||||||
"use client";
|
"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 Link from "next/link";
|
||||||
import HoverVideo from "./HoverVideo";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { ThumbsUp } from "lucide-react";
|
|
||||||
import type { FeedItem, FeedResponse } from "@/app/types/feed";
|
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 = {
|
type Props = {
|
||||||
initialItems: FeedItem[];
|
initialItems: FeedItem[];
|
||||||
@@ -11,6 +19,13 @@ type Props = {
|
|||||||
fetchUrl?: string;
|
fetchUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TagUpdateResponse = {
|
||||||
|
updated?: {
|
||||||
|
aweme_id: string;
|
||||||
|
content_tags: string[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
export default function FeedMasonry({
|
export default function FeedMasonry({
|
||||||
initialItems,
|
initialItems,
|
||||||
initialCursor,
|
initialCursor,
|
||||||
@@ -20,6 +35,10 @@ export default function FeedMasonry({
|
|||||||
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
const [cursor, setCursor] = useState<string | null>(initialCursor);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [ended, setEnded] = useState(!initialCursor);
|
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 sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -58,7 +77,7 @@ export default function FeedMasonry({
|
|||||||
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
mediaH = (9 / 16) * colWidth; // 常见视频比例
|
||||||
}
|
}
|
||||||
// 文本 + 作者栏的高度粗估
|
// 文本 + 作者栏的高度粗估
|
||||||
const textH = 48; // 标题+标签区域
|
const textH = item.content_tags?.length ? 72 : 48; // 标题+标签区域
|
||||||
const authorH = 48; // 作者行
|
const authorH = 48; // 作者行
|
||||||
const gap = 16; // 卡片内边距/间隙
|
const gap = 16; // 卡片内边距/间隙
|
||||||
return mediaH + textH + authorH + gap;
|
return mediaH + textH + authorH + gap;
|
||||||
@@ -92,8 +111,7 @@ export default function FeedMasonry({
|
|||||||
}
|
}
|
||||||
setColumns(newCols);
|
setColumns(newCols);
|
||||||
setColHeights(newHeights);
|
setColHeights(newHeights);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [columnCount, estimateItemHeight, initialItems]);
|
||||||
}, [columnCount]);
|
|
||||||
|
|
||||||
const fetchMore = useCallback(async () => {
|
const fetchMore = useCallback(async () => {
|
||||||
if (loading || ended) return;
|
if (loading || ended) return;
|
||||||
@@ -136,7 +154,15 @@ export default function FeedMasonry({
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [cursor, ended, loading, columnCount, colHeights, estimateItemHeight]);
|
}, [
|
||||||
|
cursor,
|
||||||
|
ended,
|
||||||
|
loading,
|
||||||
|
columnCount,
|
||||||
|
colHeights,
|
||||||
|
estimateItemHeight,
|
||||||
|
fetchUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = sentinelRef.current;
|
const el = sentinelRef.current;
|
||||||
@@ -154,95 +180,193 @@ export default function FeedMasonry({
|
|||||||
return () => io.disconnect();
|
return () => io.disconnect();
|
||||||
}, [fetchMore]);
|
}, [fetchMore]);
|
||||||
|
|
||||||
const renderCard = useCallback(
|
const toggleSelection = useCallback((awemeId: string) => {
|
||||||
(item: FeedItem) => (
|
setSelectedIds((current) => {
|
||||||
<article
|
const next = new Set(current);
|
||||||
key={item.aweme_id}
|
if (next.has(awemeId)) next.delete(awemeId);
|
||||||
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"
|
else next.add(awemeId);
|
||||||
>
|
return next;
|
||||||
<Link
|
});
|
||||||
href={`/aweme/${item.aweme_id}`}
|
setBatchError(null);
|
||||||
target="_blank"
|
}, []);
|
||||||
className="block relative w-full"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative w-full"
|
|
||||||
style={{
|
|
||||||
aspectRatio:
|
|
||||||
`${item.width && item.height ? `${item.width}/${item.height}` : ""}` as any,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.type === "video" ? (
|
|
||||||
<HoverVideo
|
|
||||||
videoUrl={(item as any).video_url}
|
|
||||||
coverUrl={item.cover_url}
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
loading="lazy"
|
|
||||||
src={item.cover_url || "/placeholder.svg"}
|
|
||||||
alt={item.desc?.slice(0, 20) || "image"}
|
|
||||||
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">
|
|
||||||
<p className="text-white/95 text-sm leading-tight line-clamp-2 drop-shadow">
|
|
||||||
{item.desc}
|
|
||||||
</p>
|
|
||||||
<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">
|
const clearSelection = useCallback(() => {
|
||||||
{item.author.sec_uid ? (
|
setSelectedIds(new Set());
|
||||||
<Link
|
setBatchError(null);
|
||||||
href={`/author/${item.author.sec_uid}`}
|
}, []);
|
||||||
className="flex items-center gap-2 min-w-0 flex-1 hover:opacity-80 transition-opacity"
|
|
||||||
>
|
const applyUpdatedTags = useCallback(
|
||||||
<div className="size-6 rounded-full overflow-hidden bg-zinc-200 shrink-0">
|
(updates: TagUpdateResponse["updated"]) => {
|
||||||
{item.author.avatar_url ? (
|
if (!updates?.length) return;
|
||||||
<img
|
const tagMap = new Map(
|
||||||
src={item.author.avatar_url}
|
updates.map((item) => [item.aweme_id, item.content_tags] as const),
|
||||||
alt="avatar"
|
);
|
||||||
className="w-full h-full object-cover"
|
setColumns((currentColumns) =>
|
||||||
/>
|
currentColumns.map((column) =>
|
||||||
) : null}
|
column.map((item) =>
|
||||||
</div>
|
tagMap.has(item.aweme_id)
|
||||||
<span className="text-sm text-zinc-700 dark:text-zinc-300 truncate">
|
? { ...item, content_tags: tagMap.get(item.aweme_id) ?? [] }
|
||||||
{item.author.nickname}
|
: item,
|
||||||
</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 ? (
|
|
||||||
<img
|
|
||||||
src={item.author.avatar_url}
|
|
||||||
alt="avatar"
|
|
||||||
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 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Masonry(按列渲染,动态分配到最短列) */}
|
{/* Masonry(按列渲染,动态分配到最短列) */}
|
||||||
@@ -254,7 +378,7 @@ export default function FeedMasonry({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{columns.map((col, idx) => (
|
{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))}
|
{col.map((item) => renderCard(item))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -265,6 +389,54 @@ export default function FeedMasonry({
|
|||||||
>
|
>
|
||||||
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
{ended ? "没有更多了" : loading ? "加载中…" : "下拉加载更多"}
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { Search } from "lucide-react";
|
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 [query, setQuery] = useState("");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -12,7 +12,9 @@ export default function SearchBox() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return;
|
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 (
|
return (
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
description: "记录当下时代的精彩瞬间",
|
description: "记录当下时代的精彩瞬间",
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/favicon.png",
|
icon: "/favicon.webp",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+91
-32
@@ -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 { prisma } from "@/lib/prisma";
|
||||||
import FeedMasonry from "./components/FeedMasonry";
|
import FeedMasonry from "./components/FeedMasonry";
|
||||||
import SearchBox from "./components/SearchBox";
|
import SearchBox from "./components/SearchBox";
|
||||||
import type { FeedItem } from "./types/feed";
|
import type { FeedItem } from "./types/feed";
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { getFileUrl } from "@/lib/minio";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,18 +14,38 @@ export const metadata: Metadata = {
|
|||||||
description: "抖歪作品集,记录当下时代的精彩瞬间",
|
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([
|
const [videos, posts] = await Promise.all([
|
||||||
prisma.video.findMany({
|
prisma.video.findMany({
|
||||||
|
where: selectedTag
|
||||||
|
? {
|
||||||
|
manual_content_tags: { has: selectedTag },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
orderBy: { created_at: "desc" },
|
orderBy: { created_at: "desc" },
|
||||||
take: 60,
|
take: 60,
|
||||||
include: { author: true },
|
include: {
|
||||||
}),
|
author: true,
|
||||||
prisma.imagePost.findMany({
|
},
|
||||||
orderBy: { created_at: "desc" },
|
|
||||||
take: 60,
|
|
||||||
include: { author: true, images: { orderBy: { order: "asc" }, take: 1 } },
|
|
||||||
}),
|
}),
|
||||||
|
selectedTag
|
||||||
|
? Promise.resolve([])
|
||||||
|
: prisma.imagePost.findMany({
|
||||||
|
orderBy: { created_at: "desc" },
|
||||||
|
take: 60,
|
||||||
|
include: {
|
||||||
|
author: true,
|
||||||
|
images: { orderBy: { order: "asc" }, take: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const feed: FeedItem[] = [
|
const feed: FeedItem[] = [
|
||||||
@@ -42,22 +64,26 @@ export default async function Home() {
|
|||||||
sec_uid: v.author.sec_uid,
|
sec_uid: v.author.sec_uid,
|
||||||
},
|
},
|
||||||
likes: Number(v.digg_count),
|
likes: Number(v.digg_count),
|
||||||
|
content_tags: v.manual_content_tags,
|
||||||
})),
|
})),
|
||||||
...posts.map((p) => ({
|
...posts.map((p) => {
|
||||||
type: "image" as const,
|
const coverPath = p.images?.[0]?.url;
|
||||||
aweme_id: p.aweme_id,
|
return {
|
||||||
created_at: p.created_at,
|
type: "image" as const,
|
||||||
desc: p.desc,
|
aweme_id: p.aweme_id,
|
||||||
cover_url: getFileUrl(p.images?.[0]?.url ?? null),
|
created_at: p.created_at,
|
||||||
width: p.images?.[0]?.width ?? null,
|
desc: p.desc,
|
||||||
height: p.images?.[0]?.height ?? null,
|
cover_url: coverPath ? getFileUrl(coverPath) : null,
|
||||||
author: {
|
width: p.images?.[0]?.width ?? null,
|
||||||
nickname: p.author.nickname,
|
height: p.images?.[0]?.height ?? null,
|
||||||
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
author: {
|
||||||
sec_uid: p.author.sec_uid,
|
nickname: p.author.nickname,
|
||||||
},
|
avatar_url: getFileUrl(p.author.avatar_url ?? ""),
|
||||||
likes: Number(p.digg_count),
|
sec_uid: p.author.sec_uid,
|
||||||
})),
|
},
|
||||||
|
likes: Number(p.digg_count),
|
||||||
|
};
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
//.sort(() => Math.random() - 0.5)
|
//.sort(() => Math.random() - 0.5)
|
||||||
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
.sort((a, b) => +new Date(b.created_at) - +new Date(a.created_at));
|
||||||
@@ -75,20 +101,53 @@ export default async function Home() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="w-full max-w-xl mb-12">
|
<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>
|
||||||
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{(() => {
|
{(() => {
|
||||||
const initial = feed.slice(0, 24);
|
const initial = feed.slice(0, 24);
|
||||||
const cursor =
|
const lastItem = initial.at(-1);
|
||||||
initial.length > 0
|
const cursor = lastItem
|
||||||
? new Date(
|
? new Date(lastItem.created_at).toISOString()
|
||||||
initial[initial.length - 1].created_at as any,
|
: null;
|
||||||
).toISOString()
|
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<FeedMasonry initialItems={initial} initialCursor={cursor} />
|
<FeedMasonry
|
||||||
|
key={selectedTag ?? "all"}
|
||||||
|
initialItems={initial}
|
||||||
|
initialCursor={cursor}
|
||||||
|
fetchUrl={
|
||||||
|
selectedTag
|
||||||
|
? `/api/feed?tag=${encodeURIComponent(selectedTag)}`
|
||||||
|
: "/api/feed"
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+116
-29
@@ -1,9 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useCallback, useEffect, useState } from "react";
|
import { ArrowLeft, MessageSquare, Search, X } from "lucide-react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
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)
|
// 匹配搜索结果类型(适配后端API)
|
||||||
type SearchResultItem = {
|
type SearchResultItem = {
|
||||||
@@ -12,6 +14,7 @@ type SearchResultItem = {
|
|||||||
type: "video" | "image";
|
type: "video" | "image";
|
||||||
rank: number;
|
rank: number;
|
||||||
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
snippet: string; // 后端返回的高亮片段,已包含<mark>标签
|
||||||
|
contentTags: string[];
|
||||||
video?: {
|
video?: {
|
||||||
aweme_id: string;
|
aweme_id: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
@@ -47,9 +50,20 @@ export default function SearchClient({
|
|||||||
const [results, setResults] = useState<SearchResultItem[]>([]);
|
const [results, setResults] = useState<SearchResultItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
|
|
||||||
const performSearch = useCallback(async (q: string) => {
|
const buildSearchHref = useCallback((q: string, tag: string | null) => {
|
||||||
if (!q.trim()) {
|
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([]);
|
setResults([]);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
return;
|
return;
|
||||||
@@ -57,9 +71,10 @@ export default function SearchClient({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const params = new URLSearchParams({ limit: "60" });
|
||||||
`/api/search?q=${encodeURIComponent(q.trim())}&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");
|
if (!res.ok) throw new Error("Search failed");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setResults(data.results || []);
|
setResults(data.results || []);
|
||||||
@@ -74,32 +89,64 @@ export default function SearchClient({
|
|||||||
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
|
// 初始 & URL 变化时同步(用 searchParams 订阅 URL 更稳妥)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = searchParams.get("q") || "";
|
const q = searchParams.get("q") || "";
|
||||||
|
const tagParam = searchParams.get("tag") || "";
|
||||||
|
const tag = tagParam && isVideoContentTag(tagParam) ? tagParam : null;
|
||||||
setQuery(q);
|
setQuery(q);
|
||||||
if (q) performSearch(q);
|
setSelectedTag(tag);
|
||||||
|
if (q || tag) {
|
||||||
|
performSearch(q, tag);
|
||||||
|
} else {
|
||||||
|
setResults([]);
|
||||||
|
setSearched(false);
|
||||||
|
}
|
||||||
}, [searchParams, performSearch]);
|
}, [searchParams, performSearch]);
|
||||||
|
|
||||||
const handleSearch = (e: FormEvent) => {
|
const handleSearch = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = query.trim();
|
const trimmed = query.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed && !selectedTag) return;
|
||||||
router.push(`/search?q=${encodeURIComponent(trimmed)}`);
|
router.push(buildSearchHref(trimmed, selectedTag));
|
||||||
// 可省略:上面的 push 会触发上面的 effect 从 URL 拉取并搜索
|
};
|
||||||
// performSearch(trimmed);
|
|
||||||
|
const handleTagSelect = (tag: string) => {
|
||||||
|
const nextTag = selectedTag === tag ? null : tag;
|
||||||
|
setSelectedTag(nextTag);
|
||||||
|
router.push(buildSearchHref(query, nextTag));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
|
setSelectedTag(null);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
router.push("/search");
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-950 text-white">
|
<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">
|
<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">
|
<div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-4">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => router.push("/")}
|
onClick={() => router.push("/")}
|
||||||
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
className="p-2 rounded-full hover:bg-white/10 transition-colors"
|
||||||
aria-label="返回首页"
|
aria-label="返回首页"
|
||||||
@@ -118,13 +165,12 @@ export default function SearchClient({
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="搜索视频、图文描述或语音内容..."
|
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"
|
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
|
<Search
|
||||||
size={20}
|
size={20}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/40"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{(query || selectedTag) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
@@ -138,12 +184,30 @@ export default function SearchClient({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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 ? "搜索中..." : "搜索"}
|
{loading ? "搜索中..." : "搜索"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
|
|
||||||
{/* 结果区域 */}
|
{/* 结果区域 */}
|
||||||
@@ -165,7 +229,7 @@ export default function SearchClient({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !searched && !query && (
|
{!loading && !searched && !query && !selectedTag && (
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
<div className="flex flex-col items-center justify-center py-20 text-white/40">
|
||||||
<Search size={64} className="mb-4 opacity-20" />
|
<Search size={64} className="mb-4 opacity-20" />
|
||||||
<p className="text-xl">输入关键词开始搜索</p>
|
<p className="text-xl">输入关键词开始搜索</p>
|
||||||
@@ -207,11 +271,18 @@ export default function SearchClient({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
className="flex-shrink-0 w-64 h-80 sm:w-70 sm:h-96 rounded-xl overflow-hidden bg-zinc-900 group"
|
||||||
>
|
>
|
||||||
<img
|
{content.cover_url ? (
|
||||||
src={content.cover_url || ""}
|
<Image
|
||||||
alt={content.desc}
|
src={content.cover_url}
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
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>
|
</Link>
|
||||||
|
|
||||||
{/* 右侧:信息区 */}
|
{/* 右侧:信息区 */}
|
||||||
@@ -231,6 +302,14 @@ export default function SearchClient({
|
|||||||
<MessageSquare size={12} />
|
<MessageSquare size={12} />
|
||||||
匹配度: {(item.rank * 100).toFixed(1)}%
|
匹配度: {(item.rank * 100).toFixed(1)}%
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 描述 */}
|
{/* 描述 */}
|
||||||
@@ -245,11 +324,18 @@ export default function SearchClient({
|
|||||||
|
|
||||||
{/* 作者信息 */}
|
{/* 作者信息 */}
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<img
|
{content.author.avatar_url ? (
|
||||||
src={content.author.avatar_url || ""}
|
<Image
|
||||||
alt={content.author.nickname}
|
src={content.author.avatar_url}
|
||||||
className="w-6 h-6 rounded-full ring-1 ring-white/20"
|
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">
|
<span className="text-xs text-white/60">
|
||||||
{content.author.nickname}
|
{content.author.nickname}
|
||||||
</span>
|
</span>
|
||||||
@@ -264,11 +350,12 @@ export default function SearchClient({
|
|||||||
</h4>
|
</h4>
|
||||||
<div
|
<div
|
||||||
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
className="text-xs text-white/70 bg-white/5 rounded-lg p-2 leading-relaxed"
|
||||||
dangerouslySetInnerHTML={{ __html: item.snippet }}
|
|
||||||
style={{
|
style={{
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{renderSnippet(item.snippet)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type FeedItem = (
|
|||||||
cover_url: string | null;
|
cover_url: string | null;
|
||||||
width?: number | null;
|
width?: number | null;
|
||||||
height?: number | null;
|
height?: number | null;
|
||||||
|
content_tags?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface FeedResponse {
|
export interface FeedResponse {
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -38,6 +38,7 @@ model Video {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
tags String[]
|
tags String[]
|
||||||
|
manual_content_tags String[] @default([])
|
||||||
video_url String
|
video_url String
|
||||||
height Int?
|
height Int?
|
||||||
width Int?
|
width Int?
|
||||||
@@ -49,6 +50,8 @@ model Video {
|
|||||||
transcript VideoTranscript?
|
transcript VideoTranscript?
|
||||||
transcriptionTask TranscriptionTask?
|
transcriptionTask TranscriptionTask?
|
||||||
|
|
||||||
|
@@index([manual_content_tags], map: "video_manual_content_tags_idx", type: Gin)
|
||||||
|
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@index([created_at])
|
@@index([created_at])
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@@ -16,6 +16,9 @@ import {
|
|||||||
} from "../lib/stt/tasks";
|
} from "../lib/stt/tasks";
|
||||||
import { processTask } from "../lib/stt/worker";
|
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 database = process.env.TEST_DATABASE_URL;
|
||||||
const migration = readFileSync(
|
const migration = readFileSync(
|
||||||
"prisma/migrations/20261003000000_plain_transcription/migration.sql",
|
"prisma/migrations/20261003000000_plain_transcription/migration.sql",
|
||||||
@@ -67,6 +70,12 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
|
|||||||
sql(
|
sql(
|
||||||
readFileSync("prisma/migrations/20251025_baseline/migration.sql", "utf8"),
|
readFileSync("prisma/migrations/20251025_baseline/migration.sql", "utf8"),
|
||||||
);
|
);
|
||||||
|
sql(
|
||||||
|
readFileSync(
|
||||||
|
"prisma/migrations/20260429_add_manual_video_tags/migration.sql",
|
||||||
|
"utf8",
|
||||||
|
),
|
||||||
|
);
|
||||||
await prisma.author.create({
|
await prisma.author.create({
|
||||||
data: {
|
data: {
|
||||||
sec_uid: "test-author",
|
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 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 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();`);
|
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);
|
||||||
sql(migration); // migration DDL is repeatable, historical text is unchanged
|
sql(migration); // migration DDL is repeatable, historical text is unchanged
|
||||||
});
|
});
|
||||||
@@ -120,6 +132,14 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
|
|||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
test("migration preserves historical and empty text and rebuilds search", async () => {
|
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(
|
assert.equal(
|
||||||
(await readTranscription("empty-history"))?.status,
|
(await readTranscription("empty-history"))?.status,
|
||||||
"SUCCEEDED",
|
"SUCCEEDED",
|
||||||
@@ -132,6 +152,42 @@ describe("PostgreSQL transcription lifecycle", { skip: !database }, () => {
|
|||||||
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'original')`;
|
>`SELECT COUNT(*) FROM "VideoTranscript" WHERE transcript_zh_tsv @@ websearch_to_tsquery('zhcfg', 'original')`;
|
||||||
assert.equal(Number(rows[0].count), 1);
|
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 () => {
|
test("concurrent enqueue is idempotent; automatic enqueue never retranscribes history", async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Array.from({ length: 10 }, () => enqueueTranscription("v1", true)),
|
Array.from({ length: 10 }, () => enqueueTranscription("v1", true)),
|
||||||
|
|||||||
Reference in New Issue
Block a user